mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Merge origin/main into isolate-ai-vault-process
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-07",
|
||||
"updatedAt": "2026-08-09",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -10,103 +10,6 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "ai-vault.live-session-delete-authority",
|
||||
"title": "AI Vault deletion fails closed against owning-runtime session liveness",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "ai-vault-runtime",
|
||||
"layer": "main-process-filesystem-mutation",
|
||||
"surfaces": [
|
||||
"AI Vault transcript deletion",
|
||||
"local and WSL agent sessions",
|
||||
"paired-client-owned host sessions"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "wsl", "ssh", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos", "linux", "windows"],
|
||||
"coveredProviders": ["local", "wsl", "ssh", "paired-runtime"],
|
||||
"coverageNotes": "Platform-independent contracts cover local and WSL authority, paired-owned and unmanaged live sessions absent from renderer or managed-PTY state, identity retention across UI dismissal, inventory and foreground-inspection ordering, SSH exclusion, unavailable and ambiguous inventory, bounded inspection concurrency, oversized inventories, returned rejection, and real temporary-file survival. Live headed/headless paired-runtime, physical Windows/WSL, and Docker SSH journeys remain uncollected.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"],
|
||||
"invariant": "Main may delete a local AI transcript only after the owning runtime freshly proves that no live local or WSL provider-session identity exists, including exact hook identities absent from managed PTY inventory; live and unknown liveness preserve the transcript regardless of renderer freshness.",
|
||||
"oracle": "Open confirmation against a stale empty renderer snapshot, transition the authoritative session to live before main authorization and during a controlled foreground-inspection barrier, and separately model paired and unmanaged local owners absent from renderer or managed-PTY state. Dismiss a live row and require its identity-only authority to remain. Require session-live or session-liveness-unknown rejection and the real temporary transcript to survive. Make process inventory unavailable or ambiguous and require unknown with the file preserved; prove known other sessions permit deletion and inspection concurrency stays at or below eight.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ai-vault/session-delete-liveness.repro.test.ts",
|
||||
"src/main/ai-vault/session-liveness.test.ts",
|
||||
"src/main/agent-hooks/server-ai-vault-liveness.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/ai-vault/session-delete-liveness.repro.test.ts",
|
||||
"assertions": [
|
||||
"confirmation TOCTOU returns session-live and preserves the real transcript",
|
||||
"paired ownership missing from the renderer snapshot returns session-live and preserves the real transcript",
|
||||
"exact live local hook identity missing from managed PTY inventory returns unknown and preserves the real transcript",
|
||||
"dismissed live status retained as identity-only authority returns unknown and preserves the real transcript",
|
||||
"unknown authoritative liveness fails closed and preserves the real transcript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ai-vault/session-liveness.test.ts",
|
||||
"assertions": [
|
||||
"local, WSL, paired-runtime, and SSH host partitions resolve without local fallback",
|
||||
"renderer session identity must match the identity parsed from the validated transcript",
|
||||
"hook identity is captured after the controlled process-inventory barrier",
|
||||
"hook identity is captured after the controlled foreground-inspection barrier",
|
||||
"missing identity, unavailable inventory, unattributed ownership, and oversized inventory remain unknown",
|
||||
"foreground inspection concurrency never exceeds eight and stops scheduling at the shared deadline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/agent-hooks/server-ai-vault-liveness.test.ts",
|
||||
"assertions": [
|
||||
"user dismissal retains provider identity without retaining a visible turn-status row",
|
||||
"restart hydration preserves dismissed identity as unconfirmed liveness-only authority"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1,
|
||||
"summary": "Twenty deterministic deletion, identity-binding, inventory/inspection-ordering, dismissal/restart-retention, unmanaged-ownership, unknown-state, filesystem-survival, host-partition, deadline, and bounded-concurrency assertions passed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 2,
|
||||
"scope": "focused main-process liveness and temporary-filesystem contracts"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "One local deterministic run is recorded; CI and soak history have not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical two-scenario oracle failed on v1.4.177-rc.0 (9e948fbdf4) and latest main (7a867f12aa): both returned deleted and both real transcripts were removed. On PR commit 8e2fcfd681, an exact live local hook identity absent from empty managed-PTY inventory returned deleted and removed the real transcript. Independent review then proved a session-other to session-live transition during foreground inspection returned not-live, and live-row dismissal erased the only external identity. The corrected candidate passed all 20 assertions, including restart hydration of dismissed identity; temporarily disabling only the original liveness decision made all three original deletion-safety tests red again."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Deletion parses only the validated target through the incremental parse cache, performs one local-provider process listing, makes no SSH or paired-runtime request, caps inventory at 512 PTYs, bounds foreground inspections to eight concurrent calls under one shared three-second fail-closed deadline, and adds no polling or background work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect CI soak history for the deterministic gate.",
|
||||
"Collect headed and headless paired-runtime deletion-refusal evidence.",
|
||||
"Collect physical Windows/WSL and Docker SSH host-partition evidence."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live headed/headless paired-runtime journey was run.",
|
||||
"No physical Windows/WSL or Docker SSH journey was run.",
|
||||
"The authorization check cannot make an external provider process start atomic with filesystem trashing; it closes the reported renderer-confirmation window at the main mutation boundary."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if live or unknown ownership can reach trashing, remote ownership falls back locally, process inspection becomes unbounded, or the deterministic filesystem oracle flakes without a product or harness defect."
|
||||
},
|
||||
{
|
||||
"id": "editor.restored-sibling-owner-reparent",
|
||||
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
|
||||
|
||||
@@ -157,28 +157,12 @@ const RESET_SNAPSHOT = {
|
||||
}
|
||||
} as const
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderAccountsRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AccountsScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AccountsScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('Accounts route did not render')
|
||||
}
|
||||
@@ -229,7 +213,6 @@ describe('accounts route Codex reset credit', () => {
|
||||
let storedValues: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
storedValues = new Map()
|
||||
dependencies.alert.mockReset()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CodexResetCreditAction } from './CodexResetCreditAction'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -19,35 +19,19 @@ const summary = {
|
||||
expiryLabel: 'Expires in 5d'
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(CodexResetCreditAction, {
|
||||
summary,
|
||||
scopeLabel: 'dev@example.com on the host',
|
||||
busy,
|
||||
disabled,
|
||||
onPress: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(CodexResetCreditAction, {
|
||||
summary,
|
||||
scopeLabel: 'dev@example.com on the host',
|
||||
busy,
|
||||
disabled,
|
||||
onPress: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('Reset action did not render')
|
||||
}
|
||||
@@ -55,10 +39,6 @@ function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
|
||||
}
|
||||
|
||||
describe('CodexResetCreditAction', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes a 44pt touch target and enabled accessibility state', () => {
|
||||
const renderer = renderAction(false, false)
|
||||
const button = renderer.root.findByType('Pressable')
|
||||
|
||||
@@ -78,7 +78,6 @@ describe('HostProtocolGate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
nativeTestState.openUrl.mockClear()
|
||||
nativeTestState.platform.OS = 'ios'
|
||||
probeMounts.count = 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostProfile } from '../transport/types'
|
||||
import { MobileHomeQuickActions } from './MobileHomeQuickActions'
|
||||
|
||||
@@ -37,10 +37,6 @@ function host(id: string, name: string, endpoint: string): HostProfile {
|
||||
describe('MobileHomeQuickActions', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileHostCard } from './MobileHostCard'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -30,10 +30,6 @@ function suppressRendererDeprecation() {
|
||||
describe('MobileHostCard', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types'
|
||||
@@ -39,10 +39,6 @@ const loaded: HostWorktreeInfo = {
|
||||
describe('MobileHostCard', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -38,7 +38,6 @@ describe('MobileMarkdown file links', () => {
|
||||
const onOpenFile = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
onOpenFile.mockClear()
|
||||
openURL.mockClear()
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Linking } from 'react-native'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { MobilePrComposeForm, type PrComposePrefill } from './pr-sidebar/MobilePrComposeForm'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
worktreeId: string
|
||||
prefill: PrComposePrefill
|
||||
// Head branch — enables the base≠head guard and the "from <branch>" hint.
|
||||
head?: string | null
|
||||
onClose: () => void
|
||||
onCreated: (url: string, warning?: string) => void
|
||||
}
|
||||
|
||||
// BottomDrawer wrapper around the inline compose form, for full-screen roots
|
||||
// (source-control modals). The PR sidebar empty-state renders MobilePrComposeForm
|
||||
// inline instead, since a BottomDrawer overlay nested in a ScrollView clips it.
|
||||
export function MobilePrComposeSheet({
|
||||
visible,
|
||||
client,
|
||||
worktreeId,
|
||||
prefill,
|
||||
head,
|
||||
onClose,
|
||||
onCreated
|
||||
}: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
{/* Why: key on visible + prefill fields so reopening (or a new prefill)
|
||||
remounts the form with fresh initial values; the previous sheet reset
|
||||
its fields via an effect on the same signals. */}
|
||||
<MobilePrComposeForm
|
||||
key={`${visible}:${prefill.title}:${prefill.base}:${prefill.body}`}
|
||||
client={client}
|
||||
worktreeId={worktreeId}
|
||||
prefill={prefill}
|
||||
head={head}
|
||||
onCancel={onClose}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
export function openMobilePrUrl(url: string): void {
|
||||
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
|
||||
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
|
||||
// (in promise) Error: Unable to open URL…" over the PR screen.
|
||||
void Linking.openURL(url).catch(() => {})
|
||||
}
|
||||
@@ -58,7 +58,6 @@ describe('NewWorktreeModal repo list', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
setCachedRepos('host-1', repos)
|
||||
})
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ describe('PickerModal accessibility', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
|
||||
throw new Error(String(args[0]))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { WorktreeAgentList } from './WorktreeAgentList'
|
||||
|
||||
@@ -39,10 +39,6 @@ function agent(paneKey: string, parentPaneKey: string | null = null): RuntimeWor
|
||||
describe('WorktreeAgentList', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -127,7 +127,6 @@ describe('memoized worktree rows', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
agentSpinnerRender.mockClear()
|
||||
agentStateDotRender.mockClear()
|
||||
})
|
||||
|
||||
@@ -51,14 +51,12 @@ function mountedDrawer(renderer: ReactTestRenderer) {
|
||||
|
||||
describe('BottomDrawer close lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const originalConsoleError = console.error
|
||||
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const message = args[0]
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
(message.includes('react-test-renderer is deprecated') ||
|
||||
message.includes('The current testing environment is not configured to support act'))
|
||||
message.includes('The current testing environment is not configured to support act')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -35,10 +35,6 @@ vi.mock('./AgentIcons', () => ({
|
||||
describe('MobileAgentIcon OMP gradient', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Linking } from 'react-native'
|
||||
|
||||
export function openMobilePrUrl(url: string): void {
|
||||
// Prevent unhandled rejections when no app accepts the URL.
|
||||
void Linking.openURL(url).catch(() => {})
|
||||
}
|
||||
@@ -38,11 +38,7 @@ type Props = {
|
||||
onCreated: (url: string, warning?: string) => void
|
||||
}
|
||||
|
||||
// PR compose form body: title/body/base/draft with AI prefill (git.generate
|
||||
// PullRequestFields), submitting via createMobilePr. Renders a plain View so it
|
||||
// can sit inline inside the PR sidebar's existing ScrollView (a BottomDrawer
|
||||
// overlay trapped in a ScrollView clips the form). The BottomDrawer wrapper
|
||||
// MobilePrComposeSheet reuses this body at full-screen roots.
|
||||
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
|
||||
export function MobilePrComposeForm({
|
||||
client,
|
||||
worktreeId,
|
||||
|
||||
@@ -32,18 +32,6 @@ vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} }))
|
||||
vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} }))
|
||||
vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } }))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function comment(id: number): PRComment {
|
||||
return {
|
||||
id,
|
||||
@@ -65,14 +53,9 @@ function detailsWithComments(count: number): GitHubWorkItemDetails {
|
||||
|
||||
async function renderComments(details: GitHubWorkItemDetails): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('PRCommentsSection did not render')
|
||||
}
|
||||
@@ -107,7 +90,6 @@ describe('PRCommentsSection', () => {
|
||||
afterEach(() => {
|
||||
renderer?.unmount()
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resets pagination only when the user chooses a different audience filter', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-acti
|
||||
import { prStateBadge } from './pr-checks-presentation'
|
||||
import { statusColor } from './pr-sidebar-status-color'
|
||||
import { canEditPRTitle } from '../../session/pr-title-edit'
|
||||
import { openMobilePrUrl } from '../MobilePrComposeSheet'
|
||||
import { openMobilePrUrl } from '../mobile-pr-url'
|
||||
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
|
||||
import { prCommentComposerStyles as composerStyles } from './pr-comment-composer-styles'
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
runMobileHostedReviewCreateIntent
|
||||
} from '../../source-control/mobile-hosted-review-create-intent-runner'
|
||||
import { fetchWorktreeLinkedPR } from '../../source-control/mobile-pr-link'
|
||||
import { openMobilePrUrl } from '../MobilePrComposeSheet'
|
||||
import { openMobilePrUrl } from '../mobile-pr-url'
|
||||
import { MobileLinkPrForm } from './MobileLinkPrForm'
|
||||
import { prCreateEmptyStateStyles as styles } from './pr-create-empty-state-styles'
|
||||
|
||||
|
||||
@@ -83,18 +83,6 @@ vi.mock('../transport/client-context', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function entry(name: string, isDirectory = false): MobileDirEntry {
|
||||
return { name, isDirectory }
|
||||
}
|
||||
@@ -113,21 +101,16 @@ function createMockClient(entriesByPath: Record<string, MobileDirEntry[]>): Mock
|
||||
|
||||
async function renderExplorer(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileFileExplorerPanel, {
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'worktree-a',
|
||||
name: 'Example Worktree',
|
||||
embedded: true
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileFileExplorerPanel, {
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'worktree-a',
|
||||
name: 'Example Worktree',
|
||||
embedded: true
|
||||
})
|
||||
)
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('MobileFileExplorerPanel did not render')
|
||||
}
|
||||
|
||||
@@ -33,28 +33,11 @@ vi.mock('./mobile-file-preview-styles', () => ({
|
||||
|
||||
type PreviewProps = Parameters<typeof MobileFileMarkdownPreview>[0]
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderPreview(props: PreviewProps): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(MobileFileMarkdownPreview, props))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(MobileFileMarkdownPreview, props))
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('MobileFileMarkdownPreview did not render')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const appState = vi.hoisted(() => ({
|
||||
current: 'active',
|
||||
@@ -25,7 +25,6 @@ import { useNow } from './use-now'
|
||||
describe('useNow', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let latest = 0
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
function Harness({ enabled = true }: { enabled?: boolean }): null {
|
||||
latest = useNow(1_000, enabled)
|
||||
@@ -42,18 +41,10 @@ describe('useNow', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
appState.current = 'active'
|
||||
appState.listener = null
|
||||
appState.remove.mockClear()
|
||||
latest = 0
|
||||
const original = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
@@ -63,7 +54,6 @@ describe('useNow', () => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ticks while active, pauses in the background, and refreshes immediately on resume', () => {
|
||||
|
||||
@@ -46,29 +46,12 @@ vi.mock('./transport/client-context', () => ({
|
||||
usePrimeHosts: () => dependencies.primeHosts
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('Edit host route did not render')
|
||||
}
|
||||
@@ -77,7 +60,6 @@ async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
||||
|
||||
describe('edit host route accessibility', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.loadHosts.mockReset().mockResolvedValue([
|
||||
{
|
||||
id: 'host-1',
|
||||
|
||||
@@ -56,29 +56,12 @@ const HOST_FIXTURE = {
|
||||
lastConnected: 1
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('Edit host route did not render')
|
||||
}
|
||||
@@ -135,7 +118,6 @@ function findText(renderer: ReactTestRenderer, match: string): boolean {
|
||||
|
||||
describe('edit host handleSave', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.hostId = 'host-1'
|
||||
dependencies.back.mockReset()
|
||||
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
|
||||
@@ -285,7 +267,6 @@ describe('edit host handleSave', () => {
|
||||
|
||||
describe('edit host load() error states', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.hostId = 'host-1'
|
||||
dependencies.back.mockReset()
|
||||
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileOnboardingPage } from './MobileOnboardingPage'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
@@ -24,10 +24,6 @@ vi.mock('lucide-react-native', () => ({
|
||||
describe('MobileOnboardingPage', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -56,7 +56,6 @@ describe('MobileOnboardingScreen', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' }
|
||||
mocks.replace.mockReset()
|
||||
mocks.reducedMotionEnabled = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { radii, spacing } from '../theme/mobile-theme'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
|
||||
@@ -43,24 +43,9 @@ vi.mock('../components/BottomDrawer', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('MobileNativeChatComposer', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
@@ -71,21 +56,16 @@ describe('MobileNativeChatComposer', () => {
|
||||
onChangeText: () => void,
|
||||
isAttaching = false
|
||||
) {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: ' hello ',
|
||||
onChangeText,
|
||||
onSend,
|
||||
isAttaching
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: ' hello ',
|
||||
onChangeText,
|
||||
onSend,
|
||||
isAttaching
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function sendButton(): { props: { onPress: () => Promise<void> } } {
|
||||
@@ -130,20 +110,15 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(true)
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: ' /clear is prose ',
|
||||
onChangeText: vi.fn(),
|
||||
onSend
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: ' /clear is prose ',
|
||||
onChangeText: vi.fn(),
|
||||
onSend
|
||||
})
|
||||
)
|
||||
})
|
||||
await act(async () => sendButton().props.onPress())
|
||||
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
|
||||
})
|
||||
@@ -181,67 +156,57 @@ describe('MobileNativeChatComposer', () => {
|
||||
invokeAction: vi.fn(),
|
||||
recordCommand: vi.fn()
|
||||
}
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'run the tests',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sessionOptions: { isWorking: false, controller }
|
||||
})
|
||||
)
|
||||
})
|
||||
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
|
||||
renderer!.root.find(
|
||||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
|
||||
) as { props: { accessibilityState: { disabled: boolean } } }
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||||
// Start the send but don't await it — it stays in flight on purpose.
|
||||
let pressed!: Promise<void>
|
||||
await act(async () => {
|
||||
pressed = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
|
||||
await act(async () => {
|
||||
releaseSend?.(true)
|
||||
await pressed
|
||||
})
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'run the tests',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sessionOptions: { isWorking: false, controller }
|
||||
})
|
||||
)
|
||||
})
|
||||
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
|
||||
renderer!.root.find(
|
||||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
|
||||
) as { props: { accessibilityState: { disabled: boolean } } }
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||||
// Start the send but don't await it — it stays in flight on purpose.
|
||||
let pressed!: Promise<void>
|
||||
await act(async () => {
|
||||
pressed = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
|
||||
await act(async () => {
|
||||
releaseSend?.(true)
|
||||
await pressed
|
||||
})
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||||
})
|
||||
|
||||
it('blocks composer submission while a session-option command is pending', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(true)
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'hello',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sessionOptions: {
|
||||
isWorking: false,
|
||||
controller: {
|
||||
snapshot: [],
|
||||
pendingId: 'model',
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
recordCommand: vi.fn()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'hello',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sessionOptions: {
|
||||
isWorking: false,
|
||||
controller: {
|
||||
snapshot: [],
|
||||
pendingId: 'model',
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
recordCommand: vi.fn()
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||||
await act(async () => sendButton().props.onPress())
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
@@ -268,21 +233,16 @@ describe('MobileNativeChatComposer', () => {
|
||||
})
|
||||
|
||||
it('keeps the text input editable while the send is locked', async () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'half-typed',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
disabled: true
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'half-typed',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
disabled: true
|
||||
})
|
||||
)
|
||||
})
|
||||
// Revoking `editable` on a focused field resigns first responder on iOS and
|
||||
// yanks the keyboard mid-typing (#10681) — the lock may only gate sending.
|
||||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||||
@@ -294,25 +254,20 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
it('renders a removable thumbnail for each pending image attachment', async () => {
|
||||
const onRemoveAttachment = vi.fn()
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
attachments: [
|
||||
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
|
||||
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
|
||||
],
|
||||
onRemoveAttachment
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
attachments: [
|
||||
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
|
||||
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
|
||||
],
|
||||
onRemoveAttachment
|
||||
})
|
||||
)
|
||||
})
|
||||
const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{
|
||||
props: { source: { uri: string } }
|
||||
}>
|
||||
@@ -327,42 +282,32 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
it('enables send with an attached image even when the text is empty', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(true)
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(sendButton().props).toMatchObject({ disabled: false })
|
||||
await act(async () => sendButton().props.onPress())
|
||||
expect(onSend).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/c',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
agent: 'claude'
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/c',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
agent: 'claude'
|
||||
})
|
||||
)
|
||||
})
|
||||
const input = () =>
|
||||
renderer!.root.find((node) => node.type === 'TextInput') as {
|
||||
props: {
|
||||
@@ -390,21 +335,16 @@ describe('MobileNativeChatComposer', () => {
|
||||
})
|
||||
|
||||
it('serves the active agent’s shared command catalog with descriptions', async () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
agent: 'codex'
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
agent: 'codex'
|
||||
})
|
||||
)
|
||||
})
|
||||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||||
props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void }
|
||||
}
|
||||
@@ -428,45 +368,40 @@ describe('MobileNativeChatComposer', () => {
|
||||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
|
||||
) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } }
|
||||
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
onMicPress,
|
||||
dictationMode: 'hold',
|
||||
onMicPressIn,
|
||||
onMicPressOut
|
||||
})
|
||||
)
|
||||
})
|
||||
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
|
||||
expect(mic().props.onPress).toBeUndefined()
|
||||
expect(mic().props.onPressIn).toBe(onMicPressIn)
|
||||
expect(mic().props.onPressOut).toBe(onMicPressOut)
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
onMicPress,
|
||||
dictationMode: 'hold',
|
||||
onMicPressIn,
|
||||
onMicPressOut
|
||||
})
|
||||
)
|
||||
})
|
||||
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
|
||||
expect(mic().props.onPress).toBeUndefined()
|
||||
expect(mic().props.onPressIn).toBe(onMicPressIn)
|
||||
expect(mic().props.onPressOut).toBe(onMicPressOut)
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
onMicPress,
|
||||
dictationMode: 'toggle',
|
||||
onMicPressIn,
|
||||
onMicPressOut
|
||||
})
|
||||
)
|
||||
})
|
||||
// Toggle mode: tap drives dictation, press-in/out inert.
|
||||
expect(mic().props.onPress).toBe(onMicPress)
|
||||
expect(mic().props.onPressIn).toBeUndefined()
|
||||
expect(mic().props.onPressOut).toBeUndefined()
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
onMicPress,
|
||||
dictationMode: 'toggle',
|
||||
onMicPressIn,
|
||||
onMicPressOut
|
||||
})
|
||||
)
|
||||
})
|
||||
// Toggle mode: tap drives dictation, press-in/out inert.
|
||||
expect(mic().props.onPress).toBe(onMicPress)
|
||||
expect(mic().props.onPressIn).toBeUndefined()
|
||||
expect(mic().props.onPressOut).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
|
||||
|
||||
@@ -38,9 +38,6 @@ function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||||
describe('MobileNativeChatMessage', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
@@ -50,20 +47,9 @@ describe('MobileNativeChatMessage', () => {
|
||||
message: NativeChatMessage,
|
||||
props: { toolsExpanded?: boolean } = {}
|
||||
): ReactTestRenderer {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
return renderer!
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
|
||||
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
|
||||
@@ -16,17 +16,6 @@ function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
/** One render of the route: chat visible or not, the transcript it currently
|
||||
* holds, and the agent-status stream behind it. */
|
||||
type Tick = {
|
||||
@@ -69,24 +58,15 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
||||
describe('MobileNativeChatOverlay streaming gate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function render(tick: Tick): Promise<void> {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(overlayElement(tick))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(overlayElement(tick))
|
||||
})
|
||||
}
|
||||
|
||||
async function update(tick: Tick): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -15,10 +15,6 @@ vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' }))
|
||||
describe('MobileNativeChatPermission', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
@@ -28,25 +24,14 @@ describe('MobileNativeChatPermission', () => {
|
||||
let resolveResponse: (accepted: boolean) => void = () => {}
|
||||
const response = new Promise<boolean>((resolve) => (resolveResponse = resolve))
|
||||
const onRespond = vi.fn(() => response)
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatPermission, {
|
||||
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
|
||||
onRespond
|
||||
})
|
||||
)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatPermission, {
|
||||
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
|
||||
onRespond
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
const button = renderer.root.findByType('Pressable')
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -149,7 +149,6 @@ describe('MobileNativeChatSessionOptionPickers', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
setOption.mockReset()
|
||||
setOption.mockResolvedValue(true)
|
||||
invokeAction.mockReset()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatView } from './MobileNativeChatView'
|
||||
|
||||
@@ -73,17 +73,6 @@ type Overrides = {
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
@@ -105,24 +94,15 @@ function chatViewElement(overrides: Overrides): ReturnType<typeof createElement>
|
||||
describe('MobileNativeChatView', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function render(overrides: Overrides = {}): Promise<void> {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(chatViewElement(overrides))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(chatViewElement(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
async function update(overrides: Overrides = {}): Promise<void> {
|
||||
|
||||
@@ -29,9 +29,7 @@ vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentI
|
||||
describe('QuickCommandsList search', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
beforeEach(() => {})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands'
|
||||
@@ -65,26 +65,15 @@ function deferred<T>() {
|
||||
|
||||
describe('QuickCommandsSheet', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
mocks.alert.mockReset()
|
||||
mocks.commands = []
|
||||
mocks.persist.mockReset()
|
||||
const originalConsoleError = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the sheet open when a launch is rejected', async () => {
|
||||
|
||||
@@ -104,31 +104,6 @@ export function mergeMobileDiffReviewState(
|
||||
}
|
||||
}
|
||||
|
||||
export function markMobileDiffReviewFileOpened(
|
||||
state: MobileDiffReviewState,
|
||||
descriptor: MobileDiffReviewFileDescriptor,
|
||||
now: number
|
||||
): MobileDiffReviewState {
|
||||
const previous = state.files[descriptor.key]
|
||||
return {
|
||||
...state,
|
||||
updatedAt: now,
|
||||
files: {
|
||||
...state.files,
|
||||
[descriptor.key]: {
|
||||
key: descriptor.key,
|
||||
filePath: descriptor.filePath,
|
||||
oldPath: descriptor.oldPath,
|
||||
scope: descriptor.scope,
|
||||
reviewedAt: previous?.reviewedAt,
|
||||
reviewDiffIdentity: previous?.reviewDiffIdentity,
|
||||
lastOpenedAt: now,
|
||||
lastSeenDiffIdentity: descriptor.diffIdentity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markMobileDiffReviewFileReviewed(
|
||||
state: MobileDiffReviewState,
|
||||
descriptor: MobileDiffReviewFileDescriptor,
|
||||
|
||||
@@ -67,7 +67,6 @@ describe('useMobileNativeChatPermissionSend', () => {
|
||||
let respond: ((text: string) => Promise<boolean>) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
|
||||
@@ -52,7 +52,6 @@ describe('useInitialSessionTerminalAutoCreate', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
stateRef = {
|
||||
current: { autoCreatedForWorktree: null, sawSessionTabs: false }
|
||||
}
|
||||
|
||||
@@ -13,18 +13,6 @@ vi.mock('expo-router', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
describe('useLiveWorktreeName request volume', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let eventListener: ((payload: unknown) => void) | null = null
|
||||
@@ -54,15 +42,10 @@ describe('useLiveWorktreeName request volume', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function emitEvent(payload: unknown): Promise<void> {
|
||||
@@ -75,7 +58,6 @@ describe('useLiveWorktreeName request volume', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
eventListener = null
|
||||
sendRequest.mockClear().mockResolvedValue({
|
||||
id: 'worktree-show',
|
||||
@@ -179,15 +161,10 @@ describe('useLiveWorktreeName request volume', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(FloatingHarness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(FloatingHarness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
@@ -214,7 +191,6 @@ describe('useLiveWorktreeName request volume', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
@@ -241,7 +217,6 @@ describe('useLiveWorktreeName request volume', () => {
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
|
||||
@@ -263,15 +238,10 @@ describe('useLiveWorktreeName request volume', () => {
|
||||
return null
|
||||
}
|
||||
const mount = async (): Promise<void> => {
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(VerdictHarness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(VerdictHarness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
await mount()
|
||||
|
||||
@@ -27,7 +27,6 @@ describe('useMissingWorktreeBounce', () => {
|
||||
const bounce = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
bounce.mockReset()
|
||||
renderer = null
|
||||
})
|
||||
|
||||
@@ -7,16 +7,7 @@ type Gate = (targetHandle: string) => Promise<boolean>
|
||||
|
||||
describe('useMobileAttachmentInputLeaseGate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let errorSpy: ReturnType<typeof vi.spyOn> | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
const original = console.error
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...(args as Parameters<typeof console.error>))
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
@@ -24,7 +15,6 @@ describe('useMobileAttachmentInputLeaseGate', () => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
errorSpy?.mockRestore()
|
||||
})
|
||||
|
||||
function renderGate(args: {
|
||||
|
||||
@@ -30,7 +30,6 @@ describe('useMobileDefaultSessionViewPreference', () => {
|
||||
let preference: MobileDefaultSessionViewPreference | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal')
|
||||
vi.mocked(saveDefaultSessionView).mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
@@ -63,7 +63,6 @@ describe('useMobileDiffReviewController', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
loadSnapshot.mockReset()
|
||||
})
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ describe('useMobileDiffReviewDiffLoading', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
loadDiff.mockReset()
|
||||
})
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ describe('useMobileDiffReviewSendActions', () => {
|
||||
let saveCommentsAndReviewState: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
setActionError = vi.fn()
|
||||
setSendSheet = vi.fn()
|
||||
@@ -81,20 +80,9 @@ describe('useMobileDiffReviewSendActions', () => {
|
||||
|
||||
async function mount(client: RpcClient): Promise<void> {
|
||||
mountedClient = client
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it('heals a marked terminal BEFORE submitting the notes', async () => {
|
||||
|
||||
@@ -19,7 +19,6 @@ describe('useMobileFileTapHandlers', () => {
|
||||
let handlers: Handlers | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
push.mockClear()
|
||||
})
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
beforeEach(() => {
|
||||
onAccepted = vi.fn()
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
@@ -93,20 +92,9 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
mountedClient = client
|
||||
mountedOnSendError = onSendError
|
||||
mountedAgent = agent
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { enabled: true }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { enabled: true }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(enabled: boolean): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AskPrompt } from './mobile-native-chat-ask'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
|
||||
@@ -10,7 +10,6 @@ describe('useMobileNativeChatAskDismiss', () => {
|
||||
let renders = 0
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
renders = 0
|
||||
})
|
||||
|
||||
@@ -45,20 +44,9 @@ describe('useMobileNativeChatAskDismiss', () => {
|
||||
}
|
||||
|
||||
async function mount(props: Parameters<typeof Harness>[0]): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, props))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, props))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
async function update(props: Parameters<typeof Harness>[0]): Promise<void> {
|
||||
|
||||
@@ -127,24 +127,12 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.clearAllMocks()
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
captureSendOrigin.mockReturnValue(ORIGIN)
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
@@ -364,24 +352,12 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
}
|
||||
|
||||
function render(tab: unknown): void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { tab }))
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { tab }))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
draftsArgs.length = 0
|
||||
viewMode.isTabChatView = () => true
|
||||
sessionState.messages = []
|
||||
@@ -487,25 +463,13 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
viewMode.isTabChatView = () => true
|
||||
setTranscript('ready')
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -757,22 +721,10 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
viewMode.isTabChatView = () => true
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
|
||||
@@ -23,10 +23,6 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: DraftState | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
@@ -65,20 +61,9 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
}
|
||||
|
||||
async function mount(tabId: string): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { tabId }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { tabId }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it('prefills the composer from a host launch draft exactly once', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
|
||||
@@ -30,10 +30,6 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: DraftState | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
@@ -69,20 +65,9 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
}
|
||||
|
||||
async function mount(tabId: string): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { tabId }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { tabId }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTo(tabId: string): Promise<void> {
|
||||
|
||||
@@ -15,17 +15,6 @@ function rpcSuccess(files: string[]): Awaited<ReturnType<RpcClient['sendRequest'
|
||||
}
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatFileSearch', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: SearchState | null = null
|
||||
@@ -35,19 +24,13 @@ describe('useMobileNativeChatFileSearch', () => {
|
||||
state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' })
|
||||
return null
|
||||
}
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
state = null
|
||||
})
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
pick.mockReset()
|
||||
// Stale markers and write locks live at module scope (they outlive the
|
||||
// screen), so they also outlive a test.
|
||||
@@ -101,20 +100,9 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
function mount(args: HookArgs): void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { args }))
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { args }))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
function update(args: HookArgs): void {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease'
|
||||
|
||||
type Lease = ReturnType<typeof useMobileNativeChatInputLease>
|
||||
@@ -9,10 +9,6 @@ describe('useMobileNativeChatInputLease', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let lease: Lease | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
@@ -25,65 +21,43 @@ describe('useMobileNativeChatInputLease', () => {
|
||||
}
|
||||
|
||||
it('unlocks only after acknowledgement and clears on disconnect', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { connected: true }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { connected: true }))
|
||||
})
|
||||
expect(lease?.ready).toBe(false)
|
||||
expect(lease?.lockReason).toBe('waiting')
|
||||
act(() => lease?.markReady('terminal'))
|
||||
expect(lease?.ready).toBe(true)
|
||||
expect(lease?.lockReason).toBeNull()
|
||||
expect(lease?.ready).toBe(false)
|
||||
expect(lease?.lockReason).toBe('waiting')
|
||||
act(() => lease?.markReady('terminal'))
|
||||
expect(lease?.ready).toBe(true)
|
||||
expect(lease?.lockReason).toBeNull()
|
||||
|
||||
act(() => lease?.clear())
|
||||
expect(lease?.ready).toBe(false)
|
||||
act(() => lease?.markReady('terminal'))
|
||||
expect(lease?.ready).toBe(true)
|
||||
act(() => lease?.clear())
|
||||
expect(lease?.ready).toBe(false)
|
||||
act(() => lease?.markReady('terminal'))
|
||||
expect(lease?.ready).toBe(true)
|
||||
|
||||
await act(async () => renderer?.update(createElement(Harness, { connected: false })))
|
||||
expect(lease?.ready).toBe(false)
|
||||
expect(lease?.lockReason).toBe('disconnected')
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
await act(async () => renderer?.update(createElement(Harness, { connected: false })))
|
||||
expect(lease?.ready).toBe(false)
|
||||
expect(lease?.lockReason).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('reports whether a clear actually dropped a lease', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { connected: true }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { connected: true }))
|
||||
})
|
||||
// The route reads this to tell a real teardown from one React never sees.
|
||||
expect(lease?.clear('terminal')).toBe(false)
|
||||
expect(lease?.clear()).toBe(false)
|
||||
// The route reads this to tell a real teardown from one React never sees.
|
||||
expect(lease?.clear('terminal')).toBe(false)
|
||||
expect(lease?.clear()).toBe(false)
|
||||
|
||||
act(() => lease?.markReady('terminal'))
|
||||
let dropped: boolean | undefined
|
||||
act(() => {
|
||||
dropped = lease?.clear('terminal')
|
||||
})
|
||||
expect(dropped).toBe(true)
|
||||
expect(lease?.ready).toBe(false)
|
||||
expect(lease?.clear('terminal')).toBe(false)
|
||||
act(() => lease?.markReady('terminal'))
|
||||
let dropped: boolean | undefined
|
||||
act(() => {
|
||||
dropped = lease?.clear('terminal')
|
||||
})
|
||||
expect(dropped).toBe(true)
|
||||
expect(lease?.ready).toBe(false)
|
||||
expect(lease?.clear('terminal')).toBe(false)
|
||||
|
||||
act(() => lease?.markReady('other'))
|
||||
expect(lease?.clear()).toBe(true)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
act(() => lease?.markReady('other'))
|
||||
expect(lease?.clear()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,7 +80,6 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
(clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string }
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
sendWithOutcome.mockReset()
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
|
||||
@@ -10,7 +10,6 @@ describe('useMobileNativeChatReadability', () => {
|
||||
let readable = false
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
readable = false
|
||||
})
|
||||
|
||||
@@ -34,21 +33,10 @@ describe('useMobileNativeChatReadability', () => {
|
||||
readable = useMobileNativeChatReadability(client, worktreeId)
|
||||
return null
|
||||
}
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
return sendRequest
|
||||
}
|
||||
|
||||
@@ -89,32 +77,21 @@ describe('useMobileNativeChatReadability', () => {
|
||||
readable = useMobileNativeChatReadability(client, worktreeId)
|
||||
return null
|
||||
}
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(readable).toBe(true)
|
||||
expect(readable).toBe(true)
|
||||
|
||||
act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' })))
|
||||
expect(readable).toBe(false)
|
||||
await act(async () => {
|
||||
resolveNext({
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] }
|
||||
})
|
||||
await Promise.resolve()
|
||||
act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' })))
|
||||
expect(readable).toBe(false)
|
||||
await act(async () => {
|
||||
resolveNext({
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] }
|
||||
})
|
||||
expect(readable).toBe(false)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(readable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,31 +31,13 @@ describe('useMobileNativeChatSendError', () => {
|
||||
return apiRef.current
|
||||
}
|
||||
|
||||
/** react-test-renderer logs a deprecation notice on every render; keep real errors. */
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function render(scopeKey: string | null = 'terminal-1'): Promise<void> {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { scopeKey }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { scopeKey }))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
apiRef.current = null
|
||||
showToast.mockClear()
|
||||
vi.useFakeTimers()
|
||||
@@ -117,26 +99,16 @@ describe('useMobileNativeChatSendError', () => {
|
||||
await act(async () => api().show('a'))
|
||||
expect(api().message).toBe('a')
|
||||
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' }))
|
||||
})
|
||||
expect(api().message).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the toast when the banner is not mounted', async () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false }))
|
||||
})
|
||||
// A deferred failure landing after the user left chat must still be seen.
|
||||
await act(async () => api().show('Delivery unconfirmed'))
|
||||
|
||||
@@ -149,14 +121,9 @@ describe('useMobileNativeChatSendError', () => {
|
||||
// Captured while tab A was live; a 20s unconfirmed send resolves much later.
|
||||
const showFromTabA = api().show
|
||||
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' }))
|
||||
})
|
||||
await act(async () => showFromTabA('Message not sent'))
|
||||
|
||||
// The banner belongs to terminal-2 now, so A's failure must not paint there.
|
||||
@@ -168,14 +135,9 @@ describe('useMobileNativeChatSendError', () => {
|
||||
await render('terminal-1')
|
||||
const clearFromTabA = api().clear
|
||||
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' }))
|
||||
})
|
||||
await act(async () => api().show('b'))
|
||||
// An accepted card action from tab A resolving late must not retire B's warning.
|
||||
await act(async () => clearFromTabA())
|
||||
|
||||
@@ -44,7 +44,6 @@ describe('useMobileNativeChatSessionOptions', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
clearMobileSessionOptionRecordsForTests()
|
||||
dispatchCommand.mockReset()
|
||||
dispatchCommand.mockResolvedValue('accepted')
|
||||
|
||||
@@ -24,7 +24,6 @@ describe('useMobileNativeChatSession', () => {
|
||||
let emit: (frame: unknown) => void = () => {}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
state = null
|
||||
})
|
||||
|
||||
@@ -45,20 +44,9 @@ describe('useMobileNativeChatSession', () => {
|
||||
}
|
||||
|
||||
async function mount(client: RpcClient): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it('drops an older-page response captured before transcript replacement', async () => {
|
||||
@@ -445,7 +433,6 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
}[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
renders.length = 0
|
||||
})
|
||||
|
||||
@@ -482,20 +469,9 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
}
|
||||
|
||||
async function mountAt(client: RpcClient | null, sessionId: string | null): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client, sessionId }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client, sessionId }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it('reports loading on the very first render, before the subscription effect runs', async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ describe('useMobileNativeChatStop', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
sendRequest.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { send: { accepted: true } }
|
||||
|
||||
@@ -17,7 +17,6 @@ describe('useMobileNativeChatTerminalStream', () => {
|
||||
const hasTabsRecoveryNeedRef = { current: (): boolean => false }
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
subscriptionsRef.current = new Map([['terminal-1', () => {}]])
|
||||
subscribingRef.current = new Set()
|
||||
webReadyRef.current = new Set(['terminal-1'])
|
||||
@@ -85,113 +84,73 @@ describe('useMobileNativeChatTerminalStream', () => {
|
||||
}
|
||||
|
||||
it('replaces output with a lease-only stream while covered, then restores output', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: false }))
|
||||
})
|
||||
await act(async () => {
|
||||
notifyWebReadyRef.current('terminal-1', false)
|
||||
})
|
||||
expect(harnessRenderCount).toBe(1)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: false }))
|
||||
})
|
||||
await act(async () => {
|
||||
notifyWebReadyRef.current('terminal-1', false)
|
||||
})
|
||||
expect(harnessRenderCount).toBe(1)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
|
||||
expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1')
|
||||
expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1')
|
||||
expect(initializedRef.current.has('terminal-1')).toBe(false)
|
||||
expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1')
|
||||
expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1')
|
||||
expect(initializedRef.current.has('terminal-1')).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: false }))
|
||||
})
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: false }))
|
||||
})
|
||||
|
||||
expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
})
|
||||
|
||||
it('re-subscribes a covered stream torn down under chat (#10681)', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
subscribe.mockClear()
|
||||
unsubscribe.mockClear()
|
||||
// What a terminal.list prune / `end` frame does to the lease-only stream:
|
||||
// the subscription is gone and the lease drops with it.
|
||||
subscriptionsRef.current.delete('terminal-1')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
subscribe.mockClear()
|
||||
unsubscribe.mockClear()
|
||||
// What a terminal.list prune / `end` frame does to the lease-only stream:
|
||||
// the subscription is gone and the lease drops with it.
|
||||
subscriptionsRef.current.delete('terminal-1')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false }))
|
||||
})
|
||||
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
expect(subscribe).toHaveBeenCalledWith('terminal-1')
|
||||
// Pins the rearm branch specifically: without it the action falls through to
|
||||
// the resume tail, which also subscribes — but drops coverage on the way.
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
expect(subscribe).toHaveBeenCalledWith('terminal-1')
|
||||
// Pins the rearm branch specifically: without it the action falls through to
|
||||
// the resume tail, which also subscribes — but drops coverage on the way.
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops rearming a handle whose stream never comes back (#10681)', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
try {
|
||||
subscribe.mockClear()
|
||||
// A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is
|
||||
// gone again on each pass. Unbounded, that is a ~10s resubscribe loop.
|
||||
for (let revision = 1; revision <= 6; revision += 1) {
|
||||
subscriptionsRef.current.delete('terminal-1')
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
showNativeChat: true,
|
||||
leaseReady: false,
|
||||
streamRevision: revision
|
||||
})
|
||||
)
|
||||
})
|
||||
subscribe.mockClear()
|
||||
// A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is
|
||||
// gone again on each pass. Unbounded, that is a ~10s resubscribe loop.
|
||||
for (let revision = 1; revision <= 6; revision += 1) {
|
||||
subscriptionsRef.current.delete('terminal-1')
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
showNativeChat: true,
|
||||
leaseReady: false,
|
||||
streamRevision: revision
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('does not charge the rearm budget for a subscribe its own gates turned away', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
@@ -218,89 +177,66 @@ describe('useMobileNativeChatTerminalStream', () => {
|
||||
subscribe.mockImplementation((handle: string) =>
|
||||
subscriptionsRef.current.set(handle, () => {})
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('refills the rearm budget when terminal.list reports the handle again (#10681)', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
for (let revision = 1; revision <= 5; revision += 1) {
|
||||
subscriptionsRef.current.delete('terminal-1')
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
showNativeChat: true,
|
||||
leaseReady: false,
|
||||
streamRevision: revision
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
subscribe.mockClear()
|
||||
// Budget is spent, so a further teardown signal alone changes nothing.
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 })
|
||||
)
|
||||
})
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
|
||||
// A dead-but-listed handle must not buy a new budget on every list refresh,
|
||||
// or the resubscribe loop this bound exists to stop comes straight back.
|
||||
await act(async () => {
|
||||
notifyListedHandlesRef.current(new Set(['terminal-1']))
|
||||
notifyListedHandlesRef.current(new Set(['terminal-1']))
|
||||
})
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
|
||||
// The handle actually went away and came back: it may have a live PTY once more.
|
||||
await act(async () => {
|
||||
notifyListedHandlesRef.current(new Set())
|
||||
notifyListedHandlesRef.current(new Set(['terminal-1']))
|
||||
})
|
||||
|
||||
expect(subscribe).toHaveBeenCalledWith('terminal-1')
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rearms on a teardown the lease cannot report (#10681)', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
subscribe.mockClear()
|
||||
// `end` with no preceding `subscribed` leaves the lease untouched, so
|
||||
// `leaseReady` holds its value and only the revision bump can re-run us.
|
||||
for (let revision = 1; revision <= 5; revision += 1) {
|
||||
subscriptionsRef.current.delete('terminal-1')
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 })
|
||||
createElement(Harness, {
|
||||
showNativeChat: true,
|
||||
leaseReady: false,
|
||||
streamRevision: revision
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(subscribe).toHaveBeenCalledWith('terminal-1')
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
subscribe.mockClear()
|
||||
// Budget is spent, so a further teardown signal alone changes nothing.
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 })
|
||||
)
|
||||
})
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
|
||||
// A dead-but-listed handle must not buy a new budget on every list refresh,
|
||||
// or the resubscribe loop this bound exists to stop comes straight back.
|
||||
await act(async () => {
|
||||
notifyListedHandlesRef.current(new Set(['terminal-1']))
|
||||
notifyListedHandlesRef.current(new Set(['terminal-1']))
|
||||
})
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
|
||||
// The handle actually went away and came back: it may have a live PTY once more.
|
||||
await act(async () => {
|
||||
notifyListedHandlesRef.current(new Set())
|
||||
notifyListedHandlesRef.current(new Set(['terminal-1']))
|
||||
})
|
||||
|
||||
expect(subscribe).toHaveBeenCalledWith('terminal-1')
|
||||
})
|
||||
|
||||
it('rearms on a teardown the lease cannot report (#10681)', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
subscribe.mockClear()
|
||||
// `end` with no preceding `subscribed` leaves the lease untouched, so
|
||||
// `leaseReady` holds its value and only the revision bump can re-run us.
|
||||
subscriptionsRef.current.delete('terminal-1')
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 })
|
||||
)
|
||||
})
|
||||
|
||||
expect(subscribe).toHaveBeenCalledWith('terminal-1')
|
||||
})
|
||||
|
||||
it('does not let a dead PTY buy a new budget with its own `subscribed` ack', async () => {
|
||||
@@ -411,34 +347,23 @@ describe('useMobileNativeChatTerminalStream', () => {
|
||||
})
|
||||
|
||||
it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
webReadyRef.current.clear()
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: false }))
|
||||
})
|
||||
try {
|
||||
webReadyRef.current.clear()
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { showNativeChat: true }))
|
||||
})
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { showNativeChat: false }))
|
||||
})
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
|
||||
webReadyRef.current.add('terminal-1')
|
||||
await act(async () => {
|
||||
notifyWebReadyRef.current('terminal-1', false)
|
||||
})
|
||||
webReadyRef.current.add('terminal-1')
|
||||
await act(async () => {
|
||||
notifyWebReadyRef.current('terminal-1', false)
|
||||
})
|
||||
|
||||
expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,8 +112,6 @@ async function setAppState(state: string): Promise<void> {
|
||||
|
||||
describe('useMobileSessionTabsReconciliation', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
async function mount(): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
@@ -124,14 +122,6 @@ describe('useMobileSessionTabsReconciliation', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const originalConsoleError = console.error
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
lifecycle.appState = 'active'
|
||||
lifecycle.focused = true
|
||||
lifecycle.listeners.clear()
|
||||
@@ -157,7 +147,6 @@ describe('useMobileSessionTabsReconciliation', () => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
streamListener = null
|
||||
consoleErrorSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ describe('useMobileSessionViewMode', () => {
|
||||
let controller: MobileSessionViewModeController | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal')
|
||||
vi.mocked(readSessionViewOverridesPreference)
|
||||
.mockReset()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
|
||||
/** Wraps a chat card action so an accepted write also retires the route's held
|
||||
* failure banner.
|
||||
@@ -23,15 +22,3 @@ export function useNativeChatAcceptedAction<Params extends unknown[]>(
|
||||
[action, onAccepted]
|
||||
)
|
||||
}
|
||||
|
||||
/** Boolean surface for callers with no pre-pasted input: 'unknown' stays true
|
||||
* (the send usually landed; the optimistic echo is already held unconfirmed). */
|
||||
export function useNativeChatSentFlag(
|
||||
send: (text: string, images?: string[]) => Promise<MobileNativeChatSendOutcome>
|
||||
): (text: string, images?: string[]) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (text: string, images?: string[]): Promise<boolean> =>
|
||||
(await send(text, images)) !== 'rejected',
|
||||
[send]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
@@ -49,24 +49,10 @@ function deferred<T>() {
|
||||
describe('useQuickCommands', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: ReturnType<typeof useQuickCommands> | null = null
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const original = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
state = null
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
async function mount(client: RpcClient, enabled = true): Promise<void> {
|
||||
|
||||
@@ -34,18 +34,6 @@ function createDeferred<T>(): Deferred<T> {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
|
||||
let current: ReturnType<typeof useTerminalLiveInputModePreference> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
@@ -58,14 +46,9 @@ function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePr
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
if (!current || !renderer) {
|
||||
throw new Error('terminal live input mode preference hook did not render')
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useThrottledLatestValue } from './use-throttled-latest-value'
|
||||
|
||||
describe('useThrottledLatestValue', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let latest: string | undefined
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
function Harness({ value }: { value: string | undefined }): null {
|
||||
latest = useThrottledLatestValue(value, 50)
|
||||
@@ -25,22 +24,13 @@ describe('useThrottledLatestValue', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
latest = undefined
|
||||
const original = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('emits the first frame immediately', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement, type ReactElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { MobileGitHistoryList } from './MobileGitHistoryList'
|
||||
@@ -44,10 +44,6 @@ const compareResponse = {
|
||||
describe('MobileGitHistoryList', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { PickerModal } from '../components/PickerModal'
|
||||
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
|
||||
import { openMobilePrUrl } from '../components/mobile-pr-url'
|
||||
import { MobileBranchDiffPreviewDrawer } from './MobileBranchDiffPreviewDrawer'
|
||||
import type { MobileSourceControlState } from './use-mobile-source-control-state'
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { isMobileConflictAborting } from './mobile-source-control-conflict-abort
|
||||
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
|
||||
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
|
||||
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
|
||||
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
|
||||
import { openMobilePrUrl } from '../components/mobile-pr-url'
|
||||
|
||||
export type MobileSourceControlPanelProps = {
|
||||
hostId: string
|
||||
|
||||
@@ -2,13 +2,6 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create'
|
||||
|
||||
// Base-branch selection for the create-PR composer, mirroring the desktop
|
||||
// useCreatePullRequestDialogFields flow: a default ref from repo.baseRefDefault and
|
||||
// a debounced search via repo.searchRefs (both allowlisted for mobile). Result
|
||||
// mapping is pure + unit-tested; the wrappers are thin sendRequest calls.
|
||||
|
||||
// Defensively normalize the repo.searchRefs payload (`{ refs: string[] }`) to a
|
||||
// clean string[] — drops non-string / malformed entries instead of throwing.
|
||||
export function mapBaseRefResults(raw: unknown): string[] {
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
return []
|
||||
@@ -20,20 +13,6 @@ export function mapBaseRefResults(raw: unknown): string[] {
|
||||
return refs.filter((r): r is string => typeof r === 'string' && r.length > 0)
|
||||
}
|
||||
|
||||
export async function fetchDefaultBaseRef(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string
|
||||
): Promise<string | null> {
|
||||
const response = await client.sendRequest('repo.baseRefDefault', {
|
||||
repo: mobileRepoSelectorFromWorktreeId(worktreeId)
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { defaultBaseRef?: string | null }
|
||||
return typeof result?.defaultBaseRef === 'string' ? result.defaultBaseRef : null
|
||||
}
|
||||
|
||||
export async function searchBaseRefs(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc'
|
||||
import type { MobileGitStatusResult } from './mobile-git-status'
|
||||
import { resolveMobilePrPrefill, type MobilePrPrefill } from './mobile-pr-create'
|
||||
|
||||
// Resolves the create-PR prefill from a git status snapshot. Split from the
|
||||
// runners hook to keep that file under the line limit.
|
||||
|
||||
// Reads a fresh git.status after a push so the prefill reflects the just-pushed
|
||||
// branch's upstream/ahead data instead of the pre-push captured status. Best-effort:
|
||||
// returns the captured status on any read failure.
|
||||
// Refresh after a push when possible so readiness reflects the new upstream state.
|
||||
export async function readFreshGitStatus(
|
||||
worktreeId: string,
|
||||
fallback: MobileGitStatusResult | null,
|
||||
@@ -22,23 +15,6 @@ export async function readFreshGitStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildOpenPrPrefill(
|
||||
client: Pick<RpcClient, 'sendRequest'> | null,
|
||||
worktreeId: string,
|
||||
status: MobileGitStatusResult | null,
|
||||
branchLabel: string
|
||||
): Promise<MobilePrPrefill> {
|
||||
if (!client) {
|
||||
return { provider: 'github', base: 'main', title: branchLabel, body: '' }
|
||||
}
|
||||
const gitReadiness = getMobilePrEligibilityReadiness(status)
|
||||
return resolveMobilePrPrefill(client, worktreeId, {
|
||||
branch: status?.branch,
|
||||
title: branchLabel,
|
||||
...gitReadiness
|
||||
})
|
||||
}
|
||||
|
||||
export function getMobilePrEligibilityReadiness(status: MobileGitStatusResult | null): {
|
||||
hasUncommittedChanges?: boolean
|
||||
hasUpstream?: boolean
|
||||
|
||||
@@ -2,7 +2,6 @@ import { hostedReviewCopy } from './hosted-review-copy'
|
||||
import {
|
||||
buildMobileHostedReviewCreateParams,
|
||||
createMobileHostedReview,
|
||||
fetchMobileHostedReviewEligibility,
|
||||
mobileRepoSelectorFromWorktreeId,
|
||||
resolveMobileHostedReviewPrefill,
|
||||
shouldPushBeforeMobileHostedReviewCreate,
|
||||
@@ -20,7 +19,6 @@ export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome
|
||||
export {
|
||||
buildMobileHostedReviewCreateParams as buildMobilePrCreateParams,
|
||||
createMobileHostedReview as createMobilePr,
|
||||
fetchMobileHostedReviewEligibility as fetchMobilePrEligibility,
|
||||
mobileRepoSelectorFromWorktreeId,
|
||||
resolveMobileHostedReviewPrefill as resolveMobilePrPrefill,
|
||||
shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
|
||||
import {
|
||||
buildMobileHostedReviewEligibilityLoadKey,
|
||||
@@ -206,10 +206,6 @@ describe('eligibility request ordering', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let renderedState: MobileCreatePrEligibilityState = { kind: 'idle' }
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -41,18 +41,6 @@ vi.mock('lucide-react-native', () => ({
|
||||
RefreshCw: 'RefreshCw'
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
// Why: mounting TerminalWebView arms the web-ready watchdog; tests must
|
||||
// unmount so the timer can't survive into later tests and fire in teardown.
|
||||
let activeRenderer: ReactTestRenderer | null = null
|
||||
@@ -62,14 +50,9 @@ function createTerminalWebViewRenderer(
|
||||
props: Record<string, unknown> = {}
|
||||
) {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError, ...props }))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError, ...props }))
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('TerminalWebView did not render')
|
||||
}
|
||||
|
||||
@@ -25,18 +25,6 @@ function createDeferredBoolean(): DeferredBoolean {
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
type AccessoryInputCommitHarnessOptions = {
|
||||
readonly heldText?: string
|
||||
readonly sentText?: string
|
||||
@@ -105,14 +93,9 @@ function createAccessoryInputCommitHarness({
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
if (!commit || !renderer) {
|
||||
throw new Error('terminal live accessory input hook did not render')
|
||||
}
|
||||
|
||||
@@ -20,18 +20,6 @@ type TerminalLiveInputCommitHarnessOptions = {
|
||||
readonly sendResult?: boolean
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function createTerminalLiveInputCommitHarness({
|
||||
sendResult = true
|
||||
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
|
||||
@@ -77,14 +65,9 @@ function createTerminalLiveInputCommitHarness({
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
if (!handlers || !renderer) {
|
||||
throw new Error('terminal live input hook did not render')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement, type RefObject } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
TerminalLiveInputFocusTarget,
|
||||
TerminalLiveInputFocusTimerRef
|
||||
@@ -20,17 +20,6 @@ type HarnessProps = {
|
||||
|
||||
type FocusHandlers = ReturnType<typeof useTerminalLiveInputFocus>
|
||||
|
||||
function suppressReactTestRendererWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & {
|
||||
readonly blur: ReturnType<typeof vi.fn>
|
||||
readonly focus: ReturnType<typeof vi.fn>
|
||||
@@ -67,14 +56,9 @@ function createHarness(initialProps: HarnessProps): {
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreWarning = suppressReactTestRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, initialProps))
|
||||
})
|
||||
} finally {
|
||||
restoreWarning()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, initialProps))
|
||||
})
|
||||
if (!handlers || !renderer) {
|
||||
throw new Error('terminal live input focus harness did not render')
|
||||
}
|
||||
@@ -113,10 +97,6 @@ function connectedProps(
|
||||
}
|
||||
|
||||
describe('terminal live input focus hook', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -71,18 +71,6 @@ type Harness = {
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderHarness(hostId: string): Promise<Harness> {
|
||||
let hook: ReturnType<typeof useHostClient> | null = null
|
||||
let closeHost: ((hostId: string) => void) | null = null
|
||||
@@ -94,14 +82,9 @@ async function renderHarness(hostId: string): Promise<Harness> {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
if (!hook || !closeHost || !renderer) {
|
||||
throw new Error('harness did not render')
|
||||
}
|
||||
@@ -124,7 +107,6 @@ async function renderHarness(hostId: string): Promise<Harness> {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
connectMock.mockReset()
|
||||
loadHostsMock.mockReset()
|
||||
})
|
||||
@@ -149,7 +131,6 @@ describe('useHostClient', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -169,7 +150,6 @@ describe('useHostClient', () => {
|
||||
expect(selectedState).toBe('disconnected')
|
||||
expect(connectMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -188,7 +168,6 @@ describe('useHostClient', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -211,7 +190,6 @@ describe('useHostClient', () => {
|
||||
})
|
||||
expect(stateByRenderTick.get(2)).toBe('connecting')
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -263,7 +241,6 @@ describe('useHostClient', () => {
|
||||
states.push(useHostClient(HOST.id).state)
|
||||
return null
|
||||
}
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -277,7 +254,6 @@ describe('useHostClient', () => {
|
||||
expect(states.at(-1)).toBe('connecting')
|
||||
expect(states).not.toContain('disconnected')
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -296,7 +272,6 @@ describe('useHostClient', () => {
|
||||
states.push(useHostClient(HOST.id).state)
|
||||
return null
|
||||
}
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -311,7 +286,6 @@ describe('useHostClient', () => {
|
||||
expect(states.at(-1)).toBe('connecting')
|
||||
expect(states).not.toContain('disconnected')
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -333,14 +307,9 @@ describe('useHostClient', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
expect(loadHostsMock).toHaveBeenCalledOnce()
|
||||
if (!closeHost || !resolveHosts || !renderer) {
|
||||
throw new Error('pending-open harness did not initialize')
|
||||
@@ -370,14 +339,9 @@ describe('useHostClient', () => {
|
||||
useHostClient(HOST.id)
|
||||
return null
|
||||
}
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
expect(loadHostsMock).toHaveBeenCalledOnce()
|
||||
act(() => renderer?.unmount())
|
||||
await act(async () => {
|
||||
@@ -401,7 +365,6 @@ describe('useAllHostClients', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -410,7 +373,6 @@ describe('useAllHostClients', () => {
|
||||
expect(connectMock).toHaveBeenCalledOnce()
|
||||
expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function))
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -433,7 +395,6 @@ describe('useAllHostClients', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -446,7 +407,6 @@ describe('useAllHostClients', () => {
|
||||
'host-997'
|
||||
])
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -477,7 +437,6 @@ describe('useAllHostClients', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
@@ -504,7 +463,6 @@ describe('useAllHostClients', () => {
|
||||
expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce()
|
||||
expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -520,7 +478,6 @@ describe('useAllHostClients', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -528,7 +485,6 @@ describe('useAllHostClients', () => {
|
||||
})
|
||||
expect(connectMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -545,7 +501,6 @@ describe('useAllHostClients', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
@@ -559,7 +514,6 @@ describe('useAllHostClients', () => {
|
||||
})
|
||||
expect(connectMock).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,17 +4,6 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('useHostStatusGates', () => {
|
||||
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
|
||||
let resolveOldStatus: ((response: unknown) => void) | null = null
|
||||
@@ -43,7 +32,6 @@ describe('useHostStatusGates', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient }))
|
||||
@@ -80,7 +68,6 @@ describe('useHostStatusGates', () => {
|
||||
expect(oldSendRequest).toHaveBeenCalledOnce()
|
||||
expect(newSendRequest).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
}
|
||||
})
|
||||
@@ -102,7 +89,6 @@ describe('useHostStatusGates', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe, { hostId: 'host-1' }))
|
||||
@@ -115,7 +101,6 @@ describe('useHostStatusGates', () => {
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
}
|
||||
})
|
||||
@@ -141,7 +126,6 @@ describe('useHostStatusGates', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe, { connState: 'connected' }))
|
||||
@@ -181,7 +165,6 @@ describe('useHostStatusGates', () => {
|
||||
statusPending: false
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
}
|
||||
})
|
||||
@@ -204,7 +187,6 @@ describe('useHostStatusGates', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe, { client: firstClient }))
|
||||
@@ -217,7 +199,6 @@ describe('useHostStatusGates', () => {
|
||||
})
|
||||
expect(gates).toMatchObject({ hostCapabilities: [], statusPending: true })
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -151,29 +151,13 @@ function TestApp({
|
||||
)
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderScreen(screen: Screen, detailHostId?: string): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(TestApp, { screen, detailHostId }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(TestApp, { screen, detailHostId }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('settings lifecycle harness did not render')
|
||||
}
|
||||
@@ -203,7 +187,6 @@ function activeHostIds(): string[] {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
context = null
|
||||
routeFocus.effect = null
|
||||
connectMock.mockReset()
|
||||
@@ -235,16 +218,11 @@ describe('settings host client lifecycle', () => {
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NavigationStack, { settingsVisible: false }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NavigationStack, { settingsVisible: false }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('navigation stack harness did not render')
|
||||
}
|
||||
@@ -354,20 +332,15 @@ describe('settings host client lifecycle', () => {
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(HostListApp, {
|
||||
settingsHostIds: [retainedHostId, sharedHostId, removedHostId]
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(HostListApp, {
|
||||
settingsHostIds: [retainedHostId, sharedHostId, removedHostId]
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('host-list lifecycle harness did not render')
|
||||
}
|
||||
@@ -451,14 +424,9 @@ describe('settings host client lifecycle', () => {
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(RetryApp, { settingsVisible: true }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(RetryApp, { settingsVisible: true }))
|
||||
})
|
||||
if (!renderer || !resolveInitial || !resolveRetry) {
|
||||
throw new Error('retry lifecycle harness did not initialize')
|
||||
}
|
||||
@@ -517,14 +485,9 @@ describe('settings host client lifecycle', () => {
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(PendingApp, { visible: true }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(PendingApp, { visible: true }))
|
||||
})
|
||||
if (!renderer || !resolveFirst || !resolveSecond) {
|
||||
throw new Error('pending settings harness did not initialize')
|
||||
}
|
||||
@@ -580,17 +543,12 @@ describe('settings host client lifecycle', () => {
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer || !context) {
|
||||
throw new Error('replacement lifecycle harness did not initialize')
|
||||
}
|
||||
@@ -665,14 +623,9 @@ describe('settings host client lifecycle', () => {
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(ManualApp, { selected: false }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(ManualApp, { selected: false }))
|
||||
})
|
||||
if (!renderer || !context) {
|
||||
throw new Error('manual connection harness did not initialize')
|
||||
}
|
||||
@@ -735,16 +688,11 @@ describe('settings host client lifecycle', () => {
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(FocusStack))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(FocusStack))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer || !routeFocus.effect) {
|
||||
throw new Error('settings focus harness did not initialize')
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export default defineConfig({
|
||||
oxc: vitestOxcConfig,
|
||||
test: {
|
||||
environment: 'node',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
onConsoleLog: (log) => !log.includes('react-test-renderer is deprecated'),
|
||||
// .tsx too: component tests exist (react-test-renderer + mocked react-native) and were
|
||||
// silently never collected, so render-level regressions shipped untested.
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
@@ -1,179 +0,0 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import type { AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion'
|
||||
import type { ValidateAiVaultSessionDeleteTargetArgs } from './session-delete-target'
|
||||
|
||||
const { trashItemMock } = vi.hoisted(() => ({ trashItemMock: vi.fn() }))
|
||||
|
||||
vi.mock('electron', () => ({ shell: { trashItem: trashItemMock } }))
|
||||
vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: vi.fn().mockResolvedValue(false) }))
|
||||
|
||||
import { deleteAiVaultSessionFile } from './session-delete'
|
||||
import { resolveAiVaultSessionLiveness } from './session-liveness'
|
||||
|
||||
type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown'
|
||||
|
||||
type DeleteWithLiveness = (
|
||||
args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId: string },
|
||||
deps: { getSessionLiveness: () => Promise<AiVaultSessionLiveness> }
|
||||
) => Promise<AiVaultDeleteSessionResult>
|
||||
|
||||
const deleteWithLiveness = deleteAiVaultSessionFile as unknown as DeleteWithLiveness
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
async function createTranscript(): Promise<{ filePath: string; root: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-live-delete-repro-'))
|
||||
fixtureRoots.push(root)
|
||||
const filePath = join(root, 'session-live.json')
|
||||
await writeFile(filePath, '{"sessionId":"session-live"}\n')
|
||||
return { filePath, root }
|
||||
}
|
||||
|
||||
async function attemptDelete(
|
||||
getSessionLiveness: () => Promise<AiVaultSessionLiveness>
|
||||
): Promise<{ filePath: string; result: AiVaultDeleteSessionResult }> {
|
||||
const { filePath, root } = await createTranscript()
|
||||
const result = await deleteWithLiveness(
|
||||
{
|
||||
agent: 'gemini',
|
||||
sessionId: 'session-live',
|
||||
filePath,
|
||||
executionHostId: 'local',
|
||||
rootOptions: { geminiSessionsDir: root }
|
||||
},
|
||||
{ getSessionLiveness }
|
||||
)
|
||||
return { filePath, result }
|
||||
}
|
||||
|
||||
describe('live AI Vault session delete safety invariant', () => {
|
||||
afterEach(async () => {
|
||||
trashItemMock.mockReset()
|
||||
await Promise.all(
|
||||
fixtureRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
it('survives when the session becomes live after confirmation but before main authorization', async () => {
|
||||
trashItemMock.mockImplementation((path: string) => rm(path))
|
||||
const rendererPaneSnapshot = new Set<string>()
|
||||
expect(rendererPaneSnapshot.has('session-live')).toBe(false)
|
||||
|
||||
let authoritativeLiveness: AiVaultSessionLiveness = 'not-live'
|
||||
const confirmationOpened = true
|
||||
authoritativeLiveness = 'live'
|
||||
expect(confirmationOpened).toBe(true)
|
||||
|
||||
const { filePath, result } = await attemptDelete(async () => authoritativeLiveness)
|
||||
|
||||
expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' })
|
||||
expect.soft(existsSync(filePath), 'live transcript must survive on disk').toBe(true)
|
||||
})
|
||||
|
||||
it('survives when a paired owner is absent from the renderer pane snapshot', async () => {
|
||||
trashItemMock.mockImplementation((path: string) => rm(path))
|
||||
const rendererPaneSnapshot = new Set<string>()
|
||||
const authoritativeOwner = {
|
||||
runtimeId: 'host-runtime',
|
||||
connectionId: 'paired-client',
|
||||
generation: 'generation-1',
|
||||
sessionId: 'session-live',
|
||||
liveness: 'live' as const
|
||||
}
|
||||
expect(rendererPaneSnapshot.has(authoritativeOwner.sessionId)).toBe(false)
|
||||
|
||||
const { filePath, result } = await attemptDelete(async () => authoritativeOwner.liveness)
|
||||
|
||||
expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' })
|
||||
expect.soft(existsSync(filePath), 'externally owned transcript must survive on disk').toBe(true)
|
||||
})
|
||||
|
||||
it('survives an exact live local hook identity absent from managed PTY inventory', async () => {
|
||||
trashItemMock.mockImplementation((path: string) => rm(path))
|
||||
const inspectForegroundProcess = vi.fn()
|
||||
const externalLiveStatus: AgentStatusIpcPayload = {
|
||||
paneKey: 'tab:external-live',
|
||||
terminalHandle: 'term_external-live',
|
||||
connectionId: null,
|
||||
receivedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
state: 'working',
|
||||
prompt: 'test',
|
||||
agentType: 'gemini',
|
||||
providerSession: { key: 'session_id', id: 'session-live' }
|
||||
}
|
||||
|
||||
const { filePath, result } = await attemptDelete(() =>
|
||||
resolveAiVaultSessionLiveness(
|
||||
{ agent: 'gemini', sessionId: 'session-live' },
|
||||
{
|
||||
listProcesses: async () => [],
|
||||
getStatusSnapshot: () => [externalLiveStatus],
|
||||
inspectForegroundProcess,
|
||||
getStatusPtyId: () => 'external-live',
|
||||
getAgentHint: () => null
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect.soft(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
agent: 'gemini',
|
||||
reason: 'session-liveness-unknown'
|
||||
})
|
||||
expect.soft(existsSync(filePath), 'external live transcript must survive on disk').toBe(true)
|
||||
expect(inspectForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives a dismissed live row retained as liveness-only identity', async () => {
|
||||
trashItemMock.mockImplementation((path: string) => rm(path))
|
||||
const retainedIdentity: AgentStatusIpcPayload = {
|
||||
paneKey: 'tab:external-live',
|
||||
connectionId: null,
|
||||
receivedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
state: 'working',
|
||||
prompt: 'test',
|
||||
agentType: 'gemini',
|
||||
providerSession: { key: 'session_id', id: 'session-live' },
|
||||
providerSessionOnly: true
|
||||
}
|
||||
|
||||
const { filePath, result } = await attemptDelete(() =>
|
||||
resolveAiVaultSessionLiveness(
|
||||
{ agent: 'gemini', sessionId: 'session-live' },
|
||||
{
|
||||
listProcesses: async () => [],
|
||||
getStatusSnapshot: () => [retainedIdentity],
|
||||
inspectForegroundProcess: vi.fn(),
|
||||
getStatusPtyId: () => null,
|
||||
getAgentHint: () => null
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect.soft(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
agent: 'gemini',
|
||||
reason: 'session-liveness-unknown'
|
||||
})
|
||||
expect.soft(existsSync(filePath), 'dismissed live transcript must survive on disk').toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when authoritative liveness is unavailable', async () => {
|
||||
trashItemMock.mockImplementation((path: string) => rm(path))
|
||||
|
||||
const { filePath, result } = await attemptDelete(async () => 'unknown')
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
agent: 'gemini',
|
||||
reason: 'session-liveness-unknown'
|
||||
})
|
||||
expect(existsSync(filePath), 'unknown-liveness transcript must survive on disk').toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -33,21 +33,13 @@ vi.mock('../wsl-unc-delete', () => ({
|
||||
WslDeleteValidationError: WslDeleteValidationErrorMock
|
||||
}))
|
||||
|
||||
import { deleteAiVaultSessionFile as deleteAiVaultSessionFileWithLiveness } from './session-delete'
|
||||
import { deleteAiVaultSessionFile } from './session-delete'
|
||||
|
||||
const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home')
|
||||
const GEMINI_ROOT = join(HOME, '.gemini', 'tmp')
|
||||
const CLAUDE_ROOT = join(HOME, '.claude', 'projects')
|
||||
const ROVO_ROOT = join(HOME, '.rovodev', 'sessions')
|
||||
|
||||
function deleteAiVaultSessionFile(
|
||||
args: Parameters<typeof deleteAiVaultSessionFileWithLiveness>[0]
|
||||
) {
|
||||
return deleteAiVaultSessionFileWithLiveness(args, {
|
||||
getSessionLiveness: async () => 'not-live'
|
||||
})
|
||||
}
|
||||
|
||||
function enoent(): NodeJS.ErrnoException {
|
||||
const error = new Error('not found') as NodeJS.ErrnoException
|
||||
error.code = 'ENOENT'
|
||||
|
||||
@@ -2,10 +2,8 @@ import { lstat, realpath } from 'node:fs/promises'
|
||||
import { shell } from 'electron'
|
||||
import type {
|
||||
AiVaultDeleteSessionResult,
|
||||
AiVaultSessionDeleteRemoval,
|
||||
AiVaultSessionLiveness
|
||||
AiVaultSessionDeleteRemoval
|
||||
} from '../../shared/ai-vault-session-deletion'
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
validateAiVaultSessionDeleteTarget,
|
||||
@@ -19,14 +17,7 @@ import { isENOENT } from '../ipc/filesystem-auth'
|
||||
// Never throws: IPC payloads are untyped at runtime, so a bad input and an fs
|
||||
// error both resolve to a discriminated result the handler can render.
|
||||
export async function deleteAiVaultSessionFile(
|
||||
args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string },
|
||||
deps: {
|
||||
getSessionLiveness: (target: {
|
||||
agent: AiVaultAgent
|
||||
sessionId: string | undefined
|
||||
filePath: string
|
||||
}) => Promise<AiVaultSessionLiveness>
|
||||
}
|
||||
args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string }
|
||||
): Promise<AiVaultDeleteSessionResult> {
|
||||
const validation = validateAiVaultSessionDeleteTarget(args)
|
||||
if (!validation.allowed) {
|
||||
@@ -34,24 +25,6 @@ export async function deleteAiVaultSessionFile(
|
||||
}
|
||||
const { agent, removals } = validation
|
||||
|
||||
let liveness: AiVaultSessionLiveness = 'unknown'
|
||||
try {
|
||||
liveness = await deps.getSessionLiveness({
|
||||
agent,
|
||||
sessionId: args.sessionId,
|
||||
filePath: validation.resolvedPath
|
||||
})
|
||||
} catch {
|
||||
// Inspection failure is unknown, never permission to delete.
|
||||
}
|
||||
if (liveness !== 'not-live') {
|
||||
return {
|
||||
outcome: 'rejected',
|
||||
agent,
|
||||
reason: liveness === 'live' ? 'session-live' : 'session-liveness-unknown'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const removal of removals) {
|
||||
const rejection = await removeOne(removal)
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import type { PtyProcessInfo } from '../providers/pty-process-info'
|
||||
import {
|
||||
readAiVaultSessionIdentity,
|
||||
resolveAiVaultSessionLiveness,
|
||||
type AiVaultSessionLivenessDependencies
|
||||
} from './session-liveness'
|
||||
|
||||
function processInfo(id: string, terminalHandle = `term_${id}`): PtyProcessInfo {
|
||||
return { id, terminalHandle, cwd: '/workspace', title: 'terminal' }
|
||||
}
|
||||
|
||||
function status(args: {
|
||||
sessionId: string
|
||||
ptyId: string
|
||||
connectionId?: string | null
|
||||
}): AgentStatusIpcPayload {
|
||||
return {
|
||||
paneKey: `tab:${args.ptyId}`,
|
||||
terminalHandle: `term_${args.ptyId}`,
|
||||
connectionId: args.connectionId ?? null,
|
||||
receivedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
state: 'working',
|
||||
prompt: 'test',
|
||||
agentType: 'gemini',
|
||||
providerSession: { key: 'session_id', id: args.sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
overrides: Partial<AiVaultSessionLivenessDependencies> = {}
|
||||
): AiVaultSessionLivenessDependencies {
|
||||
return {
|
||||
listProcesses: async () => [],
|
||||
getStatusSnapshot: () => [],
|
||||
inspectForegroundProcess: async () => ({ available: true, process: 'zsh' }),
|
||||
getStatusPtyId: (row) => row.terminalHandle?.slice('term_'.length) ?? null,
|
||||
getAgentHint: () => null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveAiVaultSessionLiveness', () => {
|
||||
it('binds renderer identity to the session parsed from the validated transcript', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-delete-identity-'))
|
||||
const filePath = join(root, 'session.json')
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
sessionId: 'authoritative-session',
|
||||
startTime: '2026-08-07T00:00:00.000Z',
|
||||
lastUpdated: '2026-08-07T00:01:00.000Z',
|
||||
messages: [{ type: 'user', content: 'test' }]
|
||||
})
|
||||
)
|
||||
try {
|
||||
await expect(
|
||||
readAiVaultSessionIdentity({
|
||||
agent: 'gemini',
|
||||
sessionId: 'spoofed-session',
|
||||
filePath
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'unknown' })
|
||||
await expect(
|
||||
readAiVaultSessionIdentity({
|
||||
agent: 'gemini',
|
||||
sessionId: 'authoritative-session',
|
||||
filePath
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'found', sessionId: 'authoritative-session' })
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('finds a paired-owned live session without consulting a renderer pane snapshot', async () => {
|
||||
const deps = dependencies({
|
||||
listProcesses: async () => [processInfo('paired-pty')],
|
||||
getStatusSnapshot: () => [status({ sessionId: 'session-live', ptyId: 'paired-pty' })],
|
||||
inspectForegroundProcess: async () => ({ available: true, process: 'gemini' })
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps)
|
||||
).resolves.toBe('live')
|
||||
})
|
||||
|
||||
it('reads hook identity after a controlled process-inventory barrier', async () => {
|
||||
let releaseInventory: (processes: PtyProcessInfo[]) => void = () => {}
|
||||
const inventory = new Promise<PtyProcessInfo[]>((resolve) => {
|
||||
releaseInventory = resolve
|
||||
})
|
||||
let statuses = [status({ sessionId: 'session-other', ptyId: 'paired-pty' })]
|
||||
const deps = dependencies({
|
||||
listProcesses: () => inventory,
|
||||
getStatusSnapshot: () => statuses,
|
||||
inspectForegroundProcess: async () => ({ available: true, process: 'gemini' })
|
||||
})
|
||||
|
||||
const liveness = resolveAiVaultSessionLiveness(
|
||||
{ agent: 'gemini', sessionId: 'session-live' },
|
||||
deps
|
||||
)
|
||||
statuses = [status({ sessionId: 'session-live', ptyId: 'paired-pty' })]
|
||||
releaseInventory([processInfo('paired-pty')])
|
||||
|
||||
await expect(liveness).resolves.toBe('live')
|
||||
})
|
||||
|
||||
it('reads hook identity after a controlled foreground-inspection barrier', async () => {
|
||||
let markInspectionStarted: () => void = () => {}
|
||||
const inspectionStarted = new Promise<void>((resolve) => {
|
||||
markInspectionStarted = resolve
|
||||
})
|
||||
let releaseInspection: (inspection: {
|
||||
available: boolean
|
||||
process: string | null
|
||||
}) => void = () => {}
|
||||
const inspection = new Promise<{ available: boolean; process: string | null }>((resolve) => {
|
||||
releaseInspection = resolve
|
||||
})
|
||||
let statuses = [status({ sessionId: 'session-other', ptyId: 'managed-pty' })]
|
||||
const deps = dependencies({
|
||||
listProcesses: async () => [processInfo('managed-pty')],
|
||||
getStatusSnapshot: () => statuses,
|
||||
inspectForegroundProcess: () => {
|
||||
markInspectionStarted()
|
||||
return inspection
|
||||
}
|
||||
})
|
||||
|
||||
const liveness = resolveAiVaultSessionLiveness(
|
||||
{ agent: 'gemini', sessionId: 'session-live' },
|
||||
deps
|
||||
)
|
||||
await inspectionStarted
|
||||
statuses = [status({ sessionId: 'session-live', ptyId: 'managed-pty' })]
|
||||
releaseInspection({ available: true, process: 'gemini' })
|
||||
|
||||
await expect(liveness).resolves.toBe('live')
|
||||
})
|
||||
|
||||
it('treats WSL hook ownership as local authority', async () => {
|
||||
const deps = dependencies({
|
||||
listProcesses: async () => [processInfo('wsl-pty')],
|
||||
getStatusSnapshot: () => [
|
||||
status({ sessionId: 'session-live', ptyId: 'wsl-pty', connectionId: 'wsl:Ubuntu' })
|
||||
],
|
||||
inspectForegroundProcess: async () => ({ available: true, process: 'gemini' })
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps)
|
||||
).resolves.toBe('live')
|
||||
})
|
||||
|
||||
it('does not use SSH identity as local liveness evidence', async () => {
|
||||
const deps = dependencies({
|
||||
listProcesses: async () => [processInfo('local-pty')],
|
||||
getStatusSnapshot: () => [
|
||||
status({ sessionId: 'session-live', ptyId: 'local-pty', connectionId: 'ssh:dev-box' })
|
||||
],
|
||||
inspectForegroundProcess: async () => ({ available: true, process: 'gemini' })
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps)
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('returns not-live when every local Gemini process owns another known session', async () => {
|
||||
const deps = dependencies({
|
||||
listProcesses: async () => [processInfo('other-pty')],
|
||||
getStatusSnapshot: () => [status({ sessionId: 'session-other', ptyId: 'other-pty' })],
|
||||
inspectForegroundProcess: async () => ({ available: true, process: 'gemini' })
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps)
|
||||
).resolves.toBe('not-live')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing session identity', dependencies(), undefined],
|
||||
[
|
||||
'unavailable process inventory',
|
||||
dependencies({
|
||||
listProcesses: async () => {
|
||||
throw new Error('daemon offline')
|
||||
}
|
||||
}),
|
||||
'session-live'
|
||||
],
|
||||
[
|
||||
'unattributed live agent process',
|
||||
dependencies({
|
||||
listProcesses: async () => [processInfo('unknown-pty')],
|
||||
inspectForegroundProcess: async () => ({ available: true, process: 'gemini' })
|
||||
}),
|
||||
'session-live'
|
||||
]
|
||||
])('preserves unknown for %s', async (_name, deps, sessionId) => {
|
||||
await expect(resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId }, deps)).resolves.toBe(
|
||||
'unknown'
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds foreground inspection concurrency', async () => {
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const inspectForegroundProcess = vi.fn(async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve))
|
||||
active -= 1
|
||||
return { available: true, process: 'zsh' }
|
||||
})
|
||||
const deps = dependencies({
|
||||
listProcesses: async () =>
|
||||
Array.from({ length: 20 }, (_, index) => processInfo(`shell-${index}`)),
|
||||
inspectForegroundProcess
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps)
|
||||
).resolves.toBe('not-live')
|
||||
expect(inspectForegroundProcess).toHaveBeenCalledTimes(20)
|
||||
expect(maxActive).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('fails closed before inspecting an oversized inventory', async () => {
|
||||
const inspectForegroundProcess = vi.fn()
|
||||
const deps = dependencies({
|
||||
listProcesses: async () =>
|
||||
Array.from({ length: 513 }, (_, index) => processInfo(`shell-${index}`)),
|
||||
inspectForegroundProcess
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps)
|
||||
).resolves.toBe('unknown')
|
||||
expect(inspectForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start foreground inspections after the shared deadline', async () => {
|
||||
const inspectForegroundProcess = vi.fn()
|
||||
const deps = dependencies({
|
||||
deadlineMs: 0,
|
||||
listProcesses: async () => [processInfo('late-pty')],
|
||||
inspectForegroundProcess
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps)
|
||||
).resolves.toBe('unknown')
|
||||
expect(inspectForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion'
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type { PtyProcessInfo } from '../providers/pty-process-info'
|
||||
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { parseAgentSessionFileCached } from './session-scanner-parse-cache'
|
||||
|
||||
const MAX_LIVENESS_PROCESSES = 512
|
||||
const PROCESS_INSPECTION_CONCURRENCY = 8
|
||||
|
||||
type ProcessInspection = {
|
||||
available: boolean
|
||||
process: string | null
|
||||
}
|
||||
|
||||
type AgentProcessInspection = 'target-agent' | 'unknown' | 'other'
|
||||
|
||||
export type AiVaultSessionLivenessDependencies = {
|
||||
deadlineMs?: number
|
||||
listProcesses: () => Promise<PtyProcessInfo[]>
|
||||
getStatusSnapshot: () => AgentStatusIpcPayload[]
|
||||
inspectForegroundProcess: (ptyId: string) => Promise<ProcessInspection>
|
||||
getStatusPtyId: (status: AgentStatusIpcPayload) => string | null
|
||||
getAgentHint: (process: PtyProcessInfo) => string | null
|
||||
}
|
||||
|
||||
export type AiVaultSessionIdentityRead =
|
||||
| { outcome: 'found'; sessionId: string }
|
||||
| { outcome: 'missing' }
|
||||
| { outcome: 'unknown' }
|
||||
|
||||
/** Binds the requested identity to the validated transcript on disk. */
|
||||
export async function readAiVaultSessionIdentity(target: {
|
||||
agent: AiVaultAgent
|
||||
sessionId: string | undefined
|
||||
filePath: string
|
||||
}): Promise<AiVaultSessionIdentityRead> {
|
||||
try {
|
||||
const fileStats = await lstat(target.filePath)
|
||||
if (!fileStats.isFile()) {
|
||||
return { outcome: 'unknown' }
|
||||
}
|
||||
const session = await parseAgentSessionFileCached(
|
||||
{
|
||||
agent: target.agent,
|
||||
file: {
|
||||
path: target.filePath,
|
||||
mtimeMs: fileStats.mtimeMs,
|
||||
modifiedAt: fileStats.mtime.toISOString(),
|
||||
sizeBytes: fileStats.size
|
||||
},
|
||||
codexHome: null
|
||||
},
|
||||
process.platform
|
||||
)
|
||||
return session?.sessionId && session.sessionId === target.sessionId
|
||||
? { outcome: 'found', sessionId: session.sessionId }
|
||||
: { outcome: 'unknown' }
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
? { outcome: 'missing' }
|
||||
: { outcome: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalStatus(status: AgentStatusIpcPayload): boolean {
|
||||
return status.connectionId === null || isWslHookRelayConnectionId(status.connectionId)
|
||||
}
|
||||
|
||||
function isValidSessionId(sessionId: string | undefined): sessionId is string {
|
||||
return (
|
||||
typeof sessionId === 'string' &&
|
||||
sessionId.length > 0 &&
|
||||
sessionId.length <= 512 &&
|
||||
sessionId.trim() === sessionId
|
||||
)
|
||||
}
|
||||
|
||||
async function inspectInBatches<T>(
|
||||
values: readonly T[],
|
||||
inspect: (value: T) => Promise<AgentProcessInspection>,
|
||||
deadlineMs?: number
|
||||
): Promise<AgentProcessInspection[]> {
|
||||
const results: AgentProcessInspection[] = []
|
||||
let nextIndex = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < values.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
|
||||
results[index] = 'unknown'
|
||||
continue
|
||||
}
|
||||
results[index] = await inspect(values[index]!)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(PROCESS_INSPECTION_CONCURRENCY, values.length) }, worker)
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
/** Resolves owning-runtime liveness without treating unavailable evidence as absence. */
|
||||
export async function resolveAiVaultSessionLiveness(
|
||||
target: { agent: AiVaultAgent; sessionId: string | undefined },
|
||||
deps: AiVaultSessionLivenessDependencies
|
||||
): Promise<AiVaultSessionLiveness> {
|
||||
if (!isValidSessionId(target.sessionId)) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
let processes: PtyProcessInfo[]
|
||||
try {
|
||||
processes = await deps.listProcesses()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
if (processes.length > MAX_LIVENESS_PROCESSES) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const processInspections = await inspectInBatches(
|
||||
processes,
|
||||
async (process) => {
|
||||
let inspection: ProcessInspection
|
||||
try {
|
||||
inspection = await deps.inspectForegroundProcess(process.id)
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
if (!inspection.available) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const recognizedAgent = recognizeAgentProcess(inspection.process)?.agent ?? null
|
||||
const hintedAgent = deps.getAgentHint(process)
|
||||
const ownsTargetAgent =
|
||||
recognizedAgent === target.agent ||
|
||||
(recognizedAgent === null &&
|
||||
(inspection.process === null || !isShellProcess(inspection.process)) &&
|
||||
hintedAgent === target.agent)
|
||||
if (!ownsTargetAgent) {
|
||||
return 'other'
|
||||
}
|
||||
return 'target-agent'
|
||||
},
|
||||
deps.deadlineMs
|
||||
)
|
||||
|
||||
let localStatuses: AgentStatusIpcPayload[]
|
||||
try {
|
||||
localStatuses = deps.getStatusSnapshot().filter(isLocalStatus)
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
const processIds = new Set(processes.map((process) => process.id))
|
||||
const hasUnmatchedTargetStatus = localStatuses.some(
|
||||
(status) =>
|
||||
status.agentType === target.agent &&
|
||||
status.providerSession?.id === target.sessionId &&
|
||||
!processIds.has(deps.getStatusPtyId(status) ?? '')
|
||||
)
|
||||
if (hasUnmatchedTargetStatus) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const results = processInspections.map((inspection, index) => {
|
||||
if (inspection !== 'target-agent') {
|
||||
return inspection
|
||||
}
|
||||
const identities = localStatuses.filter(
|
||||
(status) =>
|
||||
status.agentType === target.agent && deps.getStatusPtyId(status) === processes[index]!.id
|
||||
)
|
||||
if (identities.some((status) => status.providerSession?.id === target.sessionId)) {
|
||||
return 'live'
|
||||
}
|
||||
return identities.some((status) => status.providerSession) ? 'other' : 'unknown'
|
||||
})
|
||||
if (results.includes('live')) {
|
||||
return 'live'
|
||||
}
|
||||
return results.includes('unknown') ? 'unknown' : 'not-live'
|
||||
}
|
||||
+35
-1
@@ -159,6 +159,7 @@ import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy
|
||||
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
|
||||
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
|
||||
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
|
||||
import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration'
|
||||
import {
|
||||
acquireSingleInstanceLock,
|
||||
logSingleInstanceLockBypass,
|
||||
@@ -2124,6 +2125,21 @@ void app.whenReady().then(async () => {
|
||||
|
||||
const activeOrcaProfile = ensureActiveOrcaProfile()
|
||||
store = new Store({ dataFile: activeOrcaProfile.dataFile })
|
||||
const windowsShellPathHydration = createWindowsShellPathHydration()
|
||||
if (process.platform === 'win32') {
|
||||
const settings = store.getSettings()
|
||||
if (app.isPackaged) {
|
||||
void windowsShellPathHydration.hydrate(
|
||||
settings.terminalWindowsShell,
|
||||
settings.terminalWindowsPowerShellImplementation
|
||||
)
|
||||
} else {
|
||||
windowsShellPathHydration.configure(
|
||||
settings.terminalWindowsShell,
|
||||
settings.terminalWindowsPowerShellImplementation
|
||||
)
|
||||
}
|
||||
}
|
||||
wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null)
|
||||
logStartupMilestone('store-loaded')
|
||||
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
|
||||
@@ -2133,6 +2149,22 @@ void app.whenReady().then(async () => {
|
||||
// Why: synchronize fallback WSL distro updates to runner.
|
||||
setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null)
|
||||
}
|
||||
if (
|
||||
('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) &&
|
||||
process.platform === 'win32'
|
||||
) {
|
||||
if (app.isPackaged) {
|
||||
void windowsShellPathHydration.hydrate(
|
||||
settings.terminalWindowsShell,
|
||||
settings.terminalWindowsPowerShellImplementation
|
||||
)
|
||||
} else {
|
||||
windowsShellPathHydration.configure(
|
||||
settings.terminalWindowsShell,
|
||||
settings.terminalWindowsPowerShellImplementation
|
||||
)
|
||||
}
|
||||
}
|
||||
if ('showMenuBarIcon' in updates) {
|
||||
// Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live.
|
||||
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
|
||||
@@ -2742,7 +2774,7 @@ void app.whenReady().then(async () => {
|
||||
if (isAgentStatusHooksEnabled(store.getSettings())) {
|
||||
const managedHookStore = store
|
||||
void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), {
|
||||
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
|
||||
shouldHydrateShellPath: app.isPackaged,
|
||||
onInstallError: recordManagedHookInstallFailure,
|
||||
shouldContinue: (agent) => {
|
||||
const settings = managedHookStore.getSettings()
|
||||
@@ -2916,6 +2948,8 @@ void app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: Git hooks inherit process.env, so Source Control must not open before profile PATH settles.
|
||||
await windowsShellPathHydration.whenReady()
|
||||
startTerminalRuntimeStartupServices()
|
||||
app.on('activate', handleMacAppActivation)
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import { invalidateAiVaultBackgroundCache } from '../ai-vault/session-scanner-ba
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultDeleteSessionArgs,
|
||||
AiVaultDeleteSessionResult,
|
||||
AiVaultSessionLiveness
|
||||
AiVaultDeleteSessionResult
|
||||
} from '../../shared/ai-vault-session-deletion'
|
||||
|
||||
// Which cache backs the multi-host list is ai-vault.ts's concern, so its
|
||||
@@ -18,11 +17,6 @@ import type {
|
||||
type AiVaultDeleteDeps = {
|
||||
invalidateMultiHostListCache: () => void
|
||||
invalidateBackgroundCache?: (paths: string[]) => Promise<void>
|
||||
getSessionLiveness: (target: {
|
||||
agent: AiVaultAgent
|
||||
sessionId: string | undefined
|
||||
filePath: string
|
||||
}) => Promise<AiVaultSessionLiveness>
|
||||
}
|
||||
|
||||
// Binds the delete orchestration to the caller's cache-invalidation seam.
|
||||
@@ -42,16 +36,13 @@ export async function deleteAiVaultSession(
|
||||
// The validator tolerates a malformed agent/filePath but destructures `args`,
|
||||
// so an absent payload is defaulted here to keep the never-throws boundary.
|
||||
const wslHomeDirs = await getAiVaultWslHomeDirs()
|
||||
const result = await deleteAiVaultSessionFile(
|
||||
{
|
||||
agent: args?.agent as AiVaultAgent,
|
||||
sessionId: args?.sessionId,
|
||||
filePath: args?.filePath ?? '',
|
||||
executionHostId: args?.executionHostId,
|
||||
wslHomeDirs
|
||||
},
|
||||
deps
|
||||
)
|
||||
const result = await deleteAiVaultSessionFile({
|
||||
agent: args?.agent as AiVaultAgent,
|
||||
sessionId: args?.sessionId,
|
||||
filePath: args?.filePath ?? '',
|
||||
executionHostId: args?.executionHostId,
|
||||
wslHomeDirs
|
||||
})
|
||||
|
||||
if (result.outcome === 'deleted') {
|
||||
// Three caches could otherwise resurrect it: the desktop per-host leg
|
||||
|
||||
@@ -93,6 +93,7 @@ vi.mock('./ssh', () => ({
|
||||
|
||||
const { OMP_SESSIONS_DIR } = await import('../ai-vault/session-scanner-roots')
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
const { deleteAiVaultSession: deleteAiVaultSessionWithDeps } = await import('./ai-vault-delete')
|
||||
|
||||
const provider = {} as IFilesystemProvider
|
||||
|
||||
@@ -789,9 +790,14 @@ describe('deleteAiVaultSession', () => {
|
||||
}
|
||||
|
||||
it('invalidates every AI Vault cache after a real delete', async () => {
|
||||
const invalidateMultiHostListCache = vi.fn()
|
||||
const invalidateBackgroundCache = vi.fn().mockResolvedValue(undefined)
|
||||
mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' })
|
||||
|
||||
const result = await _internals.deleteAiVaultSession(args)
|
||||
const result = await deleteAiVaultSessionWithDeps(args, {
|
||||
invalidateMultiHostListCache,
|
||||
invalidateBackgroundCache
|
||||
})
|
||||
|
||||
expect(result).toEqual({ outcome: 'deleted' })
|
||||
expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith(
|
||||
@@ -800,11 +806,12 @@ describe('deleteAiVaultSession', () => {
|
||||
sessionId: args.sessionId,
|
||||
filePath: args.filePath,
|
||||
executionHostId: 'local'
|
||||
}),
|
||||
expect.objectContaining({ getSessionLiveness: expect.any(Function) })
|
||||
})
|
||||
)
|
||||
expect(invalidateMultiHostListCache).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath)
|
||||
expect(invalidateBackgroundCache).toHaveBeenCalledWith([args.filePath])
|
||||
})
|
||||
|
||||
it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => {
|
||||
@@ -850,8 +857,7 @@ describe('deleteAiVaultSession', () => {
|
||||
reason: 'invalid-path'
|
||||
})
|
||||
expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '' }),
|
||||
expect.objectContaining({ getSessionLiveness: expect.any(Function) })
|
||||
expect.objectContaining({ filePath: '' })
|
||||
)
|
||||
expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -71,7 +71,6 @@ type AiVaultHandlerOptions = AiVaultSessionSources &
|
||||
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
|
||||
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
|
||||
resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver
|
||||
getSessionLiveness?: Parameters<typeof deleteAiVaultSession>[1]['getSessionLiveness']
|
||||
}
|
||||
|
||||
let scanCoordinator = new AiVaultScanCoordinator()
|
||||
@@ -80,10 +79,7 @@ const listCancellations = createSenderScopedRequestCancellations()
|
||||
// Shared by the IPC registration and the test internals: a delete must drop
|
||||
// the multi-host leg cache, which this module owns the only caller of.
|
||||
const aiVaultDeleteDeps = {
|
||||
invalidateMultiHostListCache: invalidateAiVaultHostLegCache,
|
||||
getSessionLiveness: (
|
||||
target: Parameters<NonNullable<AiVaultHandlerOptions['getSessionLiveness']>>[0]
|
||||
) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const)
|
||||
invalidateMultiHostListCache: invalidateAiVaultHostLegCache
|
||||
}
|
||||
|
||||
const resolveAiVaultSessionTitles = (
|
||||
|
||||
@@ -227,8 +227,7 @@ export function registerCoreHandlers(
|
||||
resolveRuntimeAiVaultSessionTitles: async (environmentId, args) =>
|
||||
resolveRuntimeAiVaultSessionTitles(app.getPath('userData'), environmentId, args),
|
||||
prepareRuntimeSessionResume: async (environmentId, args) =>
|
||||
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args),
|
||||
getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target)
|
||||
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args)
|
||||
})
|
||||
registerNativeChatHandlers()
|
||||
registerClipboardHandlers(store)
|
||||
|
||||
@@ -172,7 +172,7 @@ export function registerSettingsHandlers(
|
||||
if (hookSettingChanged) {
|
||||
try {
|
||||
await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, {
|
||||
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
|
||||
shouldHydrateShellPath: app.isPackaged,
|
||||
onInstallError: recordManagedHookInstallFailure,
|
||||
shouldContinue: (agent) => {
|
||||
const settings = store.getSettings()
|
||||
|
||||
@@ -5,10 +5,12 @@ import { credentialDecryptionMessage } from '../../shared/integration-credential
|
||||
const getClients = vi.fn()
|
||||
const clearToken = vi.fn()
|
||||
const isAuthError = vi.fn()
|
||||
const acquire = vi.fn().mockResolvedValue(undefined)
|
||||
const release = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: vi.fn().mockResolvedValue(undefined),
|
||||
release: vi.fn(),
|
||||
acquire,
|
||||
release,
|
||||
getClients: (...args: unknown[]) => getClients(...args),
|
||||
isAuthError: (...args: unknown[]) => isAuthError(...args),
|
||||
clearToken: (...args: unknown[]) => clearToken(...args)
|
||||
@@ -283,6 +285,30 @@ describe('Linear teams', () => {
|
||||
expect(members).toHaveBeenCalledWith({ first: 100 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['getTeamStates', 'getTeamStatesOrThrow'],
|
||||
['getTeamLabels', 'getTeamLabelsOrThrow'],
|
||||
['getTeamMembers', 'getTeamMembersOrThrow']
|
||||
] as const)('preserves the %s error policy', async (fallbackReader, throwingReader) => {
|
||||
const error = new Error('request failed')
|
||||
const entry = makeTeamLookupEntry('workspace-1', 'Workspace', null)
|
||||
vi.mocked(entry.client.team).mockRejectedValue(error)
|
||||
getClients.mockReturnValue([entry])
|
||||
const readers = await import('./teams')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await expect(readers[fallbackReader]('team-1', 'workspace-1')).resolves.toEqual([])
|
||||
await expect(readers[throwingReader]('team-1', 'workspace-1')).rejects.toBe(error)
|
||||
expect(warn).toHaveBeenCalledWith(`[linear] ${fallbackReader} failed:`, error)
|
||||
expect(release).toHaveBeenCalledTimes(2)
|
||||
|
||||
isAuthError.mockReturnValue(true)
|
||||
await expect(readers[fallbackReader]('team-1', 'workspace-1')).rejects.toBe(error)
|
||||
expect(clearToken).toHaveBeenCalledWith('workspace-1')
|
||||
expect(release).toHaveBeenCalledTimes(3)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('surfaces Linear credential decrypt errors on active team reads', async () => {
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
getClients.mockImplementation(() => {
|
||||
|
||||
+23
-95
@@ -1,3 +1,4 @@
|
||||
import type { Team } from '@linear/sdk'
|
||||
import type {
|
||||
LinearTeam,
|
||||
LinearWorkflowState,
|
||||
@@ -108,10 +109,12 @@ export async function listTeamsForAgent(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamStates(
|
||||
async function readTeamResource<T>(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
workspaceId: string | null | undefined,
|
||||
fetchAll: (team: Team) => Promise<T[]>,
|
||||
fallbackWarning?: string
|
||||
): Promise<T[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
@@ -119,137 +122,62 @@ export async function getTeamStates(
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamStates(team)
|
||||
return await fetchAll(await entry.client.team(teamId))
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
console.warn('[linear] getTeamStates failed:', error)
|
||||
if (!fallbackWarning) {
|
||||
throw error
|
||||
}
|
||||
console.warn(`[linear] ${fallbackWarning} failed:`, error)
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamStates(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamStates, 'getTeamStates')
|
||||
}
|
||||
|
||||
export async function getTeamStatesOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamStates(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamStates)
|
||||
}
|
||||
|
||||
export async function getTeamLabels(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearLabel[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamLabels(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
console.warn('[linear] getTeamLabels failed:', error)
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, 'getTeamLabels')
|
||||
}
|
||||
|
||||
export async function getTeamLabelsOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearLabel[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamLabels(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels)
|
||||
}
|
||||
|
||||
export async function getTeamMembers(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearMember[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamMembers(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
console.warn('[linear] getTeamMembers failed:', error)
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, 'getTeamMembers')
|
||||
}
|
||||
|
||||
export async function getTeamMembersOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearMember[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamMembers(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers)
|
||||
}
|
||||
|
||||
export async function getViewerForWorkspaceOrThrow(
|
||||
|
||||
@@ -259,6 +259,26 @@ describe('registerAppMenu', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['darwin', 'linux', 'win32'] as const)(
|
||||
'preserves terminal undo and redo chords on %s',
|
||||
(platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
registerAppMenu(buildMenuOptions())
|
||||
|
||||
const editSubmenu = getSubmenu(getTemplate(), 'Edit')
|
||||
const expectedRegistration = platform === 'darwin' ? undefined : false
|
||||
const undoItem = editSubmenu.find((item) => item.role === 'undo')
|
||||
const redoItem = editSubmenu.find((item) => item.role === 'redo')
|
||||
|
||||
expect(undoItem?.accelerator).toBeUndefined()
|
||||
expect(redoItem?.accelerator).toBeUndefined()
|
||||
expect(undoItem && 'registerAccelerator' in undoItem).toBe(platform !== 'darwin')
|
||||
expect(redoItem && 'registerAccelerator' in redoItem).toBe(platform !== 'darwin')
|
||||
expect(undoItem?.registerAccelerator).toBe(expectedRegistration)
|
||||
expect(redoItem?.registerAccelerator).toBe(expectedRegistration)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps selection actions native in a focused guest webview', () => {
|
||||
const send = vi.fn()
|
||||
const guestContents = { copy: vi.fn(), selectAll: vi.fn() }
|
||||
|
||||
@@ -167,11 +167,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
||||
]
|
||||
}
|
||||
|
||||
// Why: keep native menu hints while letting non-macOS Ctrl+Z/Ctrl+Y reach the focused terminal or DOM control.
|
||||
const undoRedoOptions: Electron.MenuItemConstructorOptions = isMac
|
||||
? {}
|
||||
: { registerAccelerator: false }
|
||||
const editMenu: Electron.MenuItemConstructorOptions = {
|
||||
label: translateMain('menu.edit', 'Edit'),
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ role: 'undo', ...undoRedoOptions },
|
||||
{ role: 'redo', ...undoRedoOptions },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
createAppMenuSelectionItem({
|
||||
|
||||
@@ -1815,7 +1815,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.objectContaining({ disabledTuiAgents: ['claude'] }),
|
||||
expect.objectContaining({
|
||||
shouldContinue: expect.any(Function),
|
||||
shouldHydrateShellPath: process.platform !== 'win32'
|
||||
shouldHydrateShellPath: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -490,16 +490,11 @@ import {
|
||||
listAiVaultSessions
|
||||
} from '../ai-vault/cached-session-list'
|
||||
import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver'
|
||||
import {
|
||||
readAiVaultSessionIdentity,
|
||||
resolveAiVaultSessionLiveness
|
||||
} from '../ai-vault/session-liveness'
|
||||
import type { AiVaultAgent, AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
@@ -3612,7 +3607,7 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
|
||||
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
|
||||
shouldHydrateShellPath: app.isPackaged,
|
||||
onInstallError: recordManagedHookInstallFailure,
|
||||
shouldContinue: (agent) => {
|
||||
const current = this.store?.getSettings()
|
||||
@@ -4990,74 +4985,6 @@ export class OrcaRuntimeService {
|
||||
return resolveLocalAiVaultSessionTitles(requests, signal)
|
||||
}
|
||||
|
||||
async getAiVaultSessionLiveness(target: {
|
||||
agent: AiVaultAgent
|
||||
sessionId: string | undefined
|
||||
filePath: string
|
||||
}): Promise<AiVaultSessionLiveness> {
|
||||
const provider = this.getLocalProviderFn?.()
|
||||
if (!provider || !this.getAgentProviderSessionSnapshotFn) {
|
||||
return 'unknown'
|
||||
}
|
||||
const identity = await readAiVaultSessionIdentity(target)
|
||||
if (identity.outcome !== 'found') {
|
||||
return 'unknown'
|
||||
}
|
||||
const deadlineMs = Date.now() + 3_000
|
||||
return await resolveAiVaultSessionLiveness(
|
||||
{
|
||||
agent: target.agent,
|
||||
sessionId: identity.sessionId
|
||||
},
|
||||
{
|
||||
deadlineMs,
|
||||
listProcesses: async () => {
|
||||
const result = await withTimeoutResult(
|
||||
provider.listProcesses({ deadlineMs }),
|
||||
Math.max(1, deadlineMs - Date.now())
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
return result.value
|
||||
},
|
||||
getStatusSnapshot: this.getAgentProviderSessionSnapshotFn,
|
||||
inspectForegroundProcess: async (ptyId) => {
|
||||
const result = await withTimeoutResult(
|
||||
provider.getForegroundProcess(ptyId),
|
||||
Math.max(1, deadlineMs - Date.now())
|
||||
)
|
||||
return result.ok
|
||||
? { available: true, process: result.value }
|
||||
: { available: false, process: null }
|
||||
},
|
||||
getStatusPtyId: (status) => {
|
||||
if (status.terminalHandle) {
|
||||
const live = this.getLivePtyForHandle(status.terminalHandle)
|
||||
if (live) {
|
||||
return live.pty.ptyId
|
||||
}
|
||||
for (const [ptyId, handle] of this.handleByPtyId) {
|
||||
if (handle === status.terminalHandle) {
|
||||
return ptyId
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.getPtyRecordForPaneKey(status.paneKey)?.ptyId ?? null
|
||||
},
|
||||
getAgentHint: (process) => {
|
||||
const pty = this.ptysById.get(process.id)
|
||||
const runtimeHint = pty?.foregroundAgent ?? pty?.launchAgent
|
||||
if (runtimeHint) {
|
||||
return runtimeHint
|
||||
}
|
||||
const ownerAgents = new Set(process.agentSessionOwners?.map((owner) => owner.claim.agent))
|
||||
return ownerAgents.size === 1 ? ([...ownerAgents][0] ?? null) : null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
prepareAiVaultSessionResume(
|
||||
args: AiVaultPrepareSessionResumeArgs
|
||||
): Promise<AiVaultPrepareSessionResumeResult> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user