Merge origin/main into isolate-ai-vault-process

This commit is contained in:
Brennan Benson
2026-08-09 19:01:44 -07:00
172 changed files with 1746 additions and 3114 deletions
+1 -98
View File
@@ -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",
+4 -21
View File
@@ -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 -5
View File
@@ -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
+6
View File
@@ -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 -11
View File
@@ -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',
+4 -23
View File
@@ -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> {
+1 -3
View File
@@ -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 -12
View File
@@ -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 -15
View File
@@ -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()
})
+9 -55
View File
@@ -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')
}
+2
View File
@@ -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']
+1
View File
@@ -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)
})
})
+1 -9
View File
@@ -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 -29
View File
@@ -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)
-263
View File
@@ -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()
})
})
-187
View File
@@ -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
View File
@@ -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)
+8 -17
View File
@@ -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
+11 -5
View File
@@ -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()
})
+1 -5
View File
@@ -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 = (
+1 -2
View File
@@ -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)
+1 -1
View File
@@ -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()
+28 -2
View File
@@ -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
View File
@@ -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(
+20
View File
@@ -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() }
+6 -2
View File
@@ -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({
+1 -1
View File
@@ -1815,7 +1815,7 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({ disabledTuiAgents: ['claude'] }),
expect.objectContaining({
shouldContinue: expect.any(Function),
shouldHydrateShellPath: process.platform !== 'win32'
shouldHydrateShellPath: true
})
)
})
+2 -75
View File
@@ -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