mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
test: remove mock-only editor, IPC status and startup suites
This commit is contained in:
@@ -23,7 +23,6 @@
|
||||
1 test-no-in-test-import-actual:src/main/workspaces/removal/local-worktree-removal-recovery.test.ts
|
||||
1 test-no-in-test-import-actual:src/renderer/src/components/terminal-pane/pty-connection/notifications/pty-connection-hook-idle-arbitration.test.ts
|
||||
3 test-no-in-test-import-actual:src/renderer/src/components/terminal-pane/snapshots/remote-snapshot-alt-screen-host-chain.test.ts
|
||||
1 test-no-in-test-import-actual:src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts
|
||||
1 test-no-polling-loop:src/main/browser/cookies/browser-cookie-import-concurrency.test.ts
|
||||
1 test-no-polling-loop:src/main/codex/hooks/reconciliation/codex-hook-toggle-off-wins.test.ts
|
||||
1 test-no-polling-loop:src/main/codex/structured/launch/codex-structured-real-cli-command-env.test.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
-398
@@ -1,398 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connectPanePty } from '../connect-pane-pty'
|
||||
import { makePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { flushAsyncTicks } from '../test-fixtures/pty-connection-test-async'
|
||||
import {
|
||||
createManager,
|
||||
createMockTransport,
|
||||
createPane,
|
||||
leafIdForPane,
|
||||
type MockTransport
|
||||
} from '../test-fixtures/pty-connection-test-pane-fixtures'
|
||||
import {
|
||||
buildPaneConnectionDeps,
|
||||
type PtyConnectionAgentTitleMocks,
|
||||
bindPaneAgentTitleMocks
|
||||
} from '../test-fixtures/pty-connection-test-deps'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from '../test-fixtures/pty-connection-test-environment'
|
||||
import { createInitialStoreState } from '../test-fixtures/pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from '../test-fixtures/pty-connection-test-store-state'
|
||||
import type * as TerminalWorktreeRouteModule from '@/lib/workspace/ownership/terminal-worktree-route'
|
||||
const agentTitleMocks = await vi.importMock<PtyConnectionAgentTitleMocks>(
|
||||
'@/lib/agent-status/aggregation/worktree-agent-status'
|
||||
)
|
||||
|
||||
const {
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
waitForAgentDraftInputReady,
|
||||
sendAgentDraftPasteContent,
|
||||
onPasteTimeout,
|
||||
pasteTimeoutNotice
|
||||
} = vi.hoisted(() => ({
|
||||
waitForAgentDraftInputReady: vi.fn(),
|
||||
sendAgentDraftPasteContent: vi.fn(async () => true),
|
||||
onPasteTimeout: vi.fn(),
|
||||
pasteTimeoutNotice: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
|
||||
|
||||
vi.mock('../../../../../../shared/layout-owner-switch', () => ({
|
||||
get ORCA_LAYOUT_OWNER_IS_MAIN() {
|
||||
return layoutOwner.isMain
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph/graph-sync-scheduler', () => ({ scheduleRuntimeGraphSync }))
|
||||
|
||||
vi.mock('@/lib/agent-session/messaging/paste/agent-draft-readiness', () => ({
|
||||
waitForAgentDraftInputReady
|
||||
}))
|
||||
|
||||
const { requestTerminalPaneRecovery } = vi.hoisted(() => ({
|
||||
requestTerminalPaneRecovery: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('../../render-health/recovery/terminal-pane-recovery', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
requestTerminalPaneRecovery
|
||||
}))
|
||||
|
||||
const pairedRoute = vi.hoisted(() => {
|
||||
const route: { runtimeEnvironmentId: string | null } = { runtimeEnvironmentId: null }
|
||||
return route
|
||||
})
|
||||
|
||||
vi.mock('@/lib/workspace/ownership/terminal-worktree-route', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TerminalWorktreeRouteModule>()
|
||||
return {
|
||||
...actual,
|
||||
resolveTerminalWorktreeRoute: (
|
||||
...args: Parameters<typeof actual.resolveTerminalWorktreeRoute>
|
||||
) =>
|
||||
pairedRoute.runtimeEnvironmentId
|
||||
? { runtimeEnvironmentId: pairedRoute.runtimeEnvironmentId }
|
||||
: actual.resolveTerminalWorktreeRoute(...args)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../pty-transport/remote-runtime/remote-runtime-pty-transport', () => ({
|
||||
createRemoteRuntimePtyTransport: vi.fn(
|
||||
(_environmentId: string, options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-session/messaging/paste/agent-draft-paste-content', () => ({
|
||||
sendAgentDraftPasteContent
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-session/launch/prompt/launch-agent-paste-timeout-notice', () => ({
|
||||
createPasteReadinessTimeoutNotice: (args: unknown) => {
|
||||
pasteTimeoutNotice(args)
|
||||
return { onTimeout: onPasteTimeout, wasNotified: () => false }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store/index', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status/aggregation/worktree-agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } =
|
||||
await import('../test-fixtures/pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('../../titles/cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: toastInfo } }))
|
||||
|
||||
vi.mock('@/lib/agent-session/accounts/codex/codex-stale-pane-sweep', () => ({
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../pty-transport/ipc/pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
type ConnectedAgentPane = {
|
||||
paneKey: string
|
||||
transport: MockTransport
|
||||
type: (data: string) => void
|
||||
bind: (ptyId: string) => void
|
||||
unbind: () => void
|
||||
resolveConnect: (result: unknown) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function connectAgentPane(overrides: Record<string, unknown> = {}): ConnectedAgentPane {
|
||||
const transport = createMockTransport()
|
||||
let ptyId: string | null = null
|
||||
transport.getPtyId.mockImplementation(() => ptyId)
|
||||
transport.sendInput.mockImplementation(() => ptyId !== null)
|
||||
let resolveConnect: (result: unknown) => void = () => {}
|
||||
transport.connect.mockImplementation(() => new Promise((resolve) => (resolveConnect = resolve)))
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const userInputListeners: (() => void)[] = []
|
||||
Object.assign(pane.terminal, {
|
||||
_core: {
|
||||
coreService: {
|
||||
onUserInput: (listener: () => void) => {
|
||||
userInputListeners.push(listener)
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const connectArgs = [
|
||||
pane,
|
||||
createManager(1),
|
||||
buildPaneConnectionDeps(() => mockStoreState, {
|
||||
startup: { command: 'claude', launchAgent: 'claude' },
|
||||
...overrides
|
||||
})
|
||||
]
|
||||
bindPaneAgentTitleMocks(connectArgs[2], agentTitleMocks)
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures implement the members connectPanePty touches.
|
||||
const binding = connectPanePty(...(connectArgs as unknown as Parameters<typeof connectPanePty>))
|
||||
return {
|
||||
paneKey: makePaneKey('tab-1', leafIdForPane(1)),
|
||||
transport,
|
||||
type: (data) => {
|
||||
for (const listener of userInputListeners) {
|
||||
listener()
|
||||
}
|
||||
for (const [listener] of pane.terminal.onData.mock.calls) {
|
||||
listener(data)
|
||||
}
|
||||
},
|
||||
bind: (id) => {
|
||||
ptyId = id
|
||||
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn
|
||||
if (typeof onPtySpawn === 'function') {
|
||||
onPtySpawn(id)
|
||||
}
|
||||
},
|
||||
unbind: () => {
|
||||
ptyId = null
|
||||
},
|
||||
resolveConnect: (result) => {
|
||||
if (result && typeof result === 'object' && 'id' in result && typeof result.id === 'string') {
|
||||
ptyId = result.id
|
||||
}
|
||||
resolveConnect(result)
|
||||
},
|
||||
dispose: () => binding.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent pane keys typed before its PTY binds', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
await installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
pairedRoute.runtimeEnvironmentId = null
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
it('pastes them as an unsubmitted draft once the agent is ready', async () => {
|
||||
waitForAgentDraftInputReady.mockResolvedValue(true)
|
||||
const pane = connectAgentPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
pane.type('make the tests pass')
|
||||
pane.type('\r')
|
||||
expect(pane.transport.sendInput).not.toHaveBeenCalled()
|
||||
|
||||
pane.bind('pty-1')
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(waitForAgentDraftInputReady).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.any(Number),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(sendAgentDraftPasteContent).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
'make the tests pass',
|
||||
'driving',
|
||||
expect.any(Function)
|
||||
)
|
||||
pane.dispose()
|
||||
})
|
||||
|
||||
it('drops them with a notice when the agent never reports ready', async () => {
|
||||
waitForAgentDraftInputReady.mockResolvedValue(false)
|
||||
const pane = connectAgentPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
pane.type('hello')
|
||||
pane.bind('pty-1')
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(sendAgentDraftPasteContent).not.toHaveBeenCalled()
|
||||
expect(onPasteTimeout).toHaveBeenCalledOnce()
|
||||
// The typed text exists nowhere else, so the notice must carry it back to the user.
|
||||
expect(pasteTimeoutNotice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ droppedText: 'hello' })
|
||||
)
|
||||
pane.dispose()
|
||||
})
|
||||
|
||||
it('sends keys typed after the bind live and drops the draft rather than pasting over them', async () => {
|
||||
let ready!: (value: boolean) => void
|
||||
waitForAgentDraftInputReady.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => (ready = resolve))
|
||||
)
|
||||
const pane = connectAgentPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
pane.type('fix it')
|
||||
pane.bind('pty-1')
|
||||
pane.type('y')
|
||||
expect(pane.transport.sendInput).toHaveBeenCalledWith('y', 'driving')
|
||||
|
||||
ready(true)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(sendAgentDraftPasteContent).not.toHaveBeenCalled()
|
||||
expect(onPasteTimeout).toHaveBeenCalledOnce()
|
||||
pane.dispose()
|
||||
})
|
||||
|
||||
it('drops the held draft when the pane closes before the bind', async () => {
|
||||
const pane = connectAgentPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
pane.type('lost on close')
|
||||
pane.dispose()
|
||||
pane.bind('pty-1')
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(waitForAgentDraftInputReady).not.toHaveBeenCalled()
|
||||
expect(sendAgentDraftPasteContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards the draft when the first bind is a reattach result, then sends keys live', async () => {
|
||||
const pane = connectAgentPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
waitForAgentDraftInputReady.mockResolvedValue(true)
|
||||
pane.type('held')
|
||||
pane.resolveConnect({ id: 'pty-1', isReattach: true })
|
||||
await flushAsyncTicks(40)
|
||||
// A later fresh spawn (the cold-restore resume) must not receive the old draft either.
|
||||
pane.unbind()
|
||||
pane.bind('pty-2')
|
||||
await flushAsyncTicks(20)
|
||||
pane.type('z')
|
||||
|
||||
expect(waitForAgentDraftInputReady).not.toHaveBeenCalled()
|
||||
expect(sendAgentDraftPasteContent).not.toHaveBeenCalled()
|
||||
expect(pane.transport.sendInput).toHaveBeenCalledWith('z', 'driving')
|
||||
pane.dispose()
|
||||
})
|
||||
|
||||
// Why: the paired-server transport holds and delivers type-ahead itself, Ctrl+C included.
|
||||
it('leaves a paired-server agent pane resuming a live entry on its own transport', async () => {
|
||||
pairedRoute.runtimeEnvironmentId = 'env-1'
|
||||
const paneKey = makePaneKey('tab-1', leafIdForPane(1))
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'claude',
|
||||
paneKey,
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 1_000,
|
||||
stateHistory: [],
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
}
|
||||
const pane = connectAgentPane({ startup: undefined })
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
pane.type('before reattach')
|
||||
pane.type('\x03')
|
||||
|
||||
expect(pane.transport.sendInput).toHaveBeenCalledWith('before reattach', 'driving')
|
||||
// Ctrl+C takes the acknowledged-write path.
|
||||
expect(pane.transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'driving')
|
||||
pane.bind('pty-1')
|
||||
await flushAsyncTicks(20)
|
||||
expect(waitForAgentDraftInputReady).not.toHaveBeenCalled()
|
||||
expect(sendAgentDraftPasteContent).not.toHaveBeenCalled()
|
||||
pane.dispose()
|
||||
})
|
||||
|
||||
// Why: a first connect hung past its grace must still let typing arm recovery.
|
||||
it('still requests recovery for keys held while the first connect hangs past its grace', async () => {
|
||||
const pane = connectAgentPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
pane.type('a')
|
||||
expect(requestTerminalPaneRecovery).not.toHaveBeenCalled()
|
||||
|
||||
const now = Date.now()
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 61_000)
|
||||
try {
|
||||
pane.type('b')
|
||||
expect(requestTerminalPaneRecovery).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
clock.mockRestore()
|
||||
}
|
||||
pane.dispose()
|
||||
})
|
||||
})
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalPaneResumeOutcome } from '@/store/terminals/terminal-state'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../../../shared/workspace-layout/terminal-tab-types'
|
||||
import type { ConnectPanePtySession } from '../connect-pane-pty-session'
|
||||
import type { showMainPaneResumeOutcome as ShowOutcome } from '../spawn/main-pane-resume-banner'
|
||||
|
||||
type Outcomes = Record<string, TerminalPaneResumeOutcome>
|
||||
type StoreState = {
|
||||
terminalPaneResumeOutcomesByPaneKey: Outcomes
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
}
|
||||
|
||||
const store = vi.hoisted(() => {
|
||||
let state: StoreState = { terminalPaneResumeOutcomesByPaneKey: {}, terminalLayoutsByTabId: {} }
|
||||
const listeners = new Set<(next: StoreState) => void>()
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener: (next: StoreState) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
publish: (patch: Partial<StoreState>) => {
|
||||
state = { ...state, ...patch }
|
||||
for (const listener of listeners) {
|
||||
listener(state)
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
state = { terminalPaneResumeOutcomesByPaneKey: {}, terminalLayoutsByTabId: {} }
|
||||
listeners.clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store/index', () => ({ useAppStore: store }))
|
||||
vi.mock('../spawn/main-starts-pane', () => ({ mainStartsPane: () => true }))
|
||||
|
||||
const PANE_KEY = 'tab-1:leaf-1'
|
||||
|
||||
function attachedView() {
|
||||
const view = {
|
||||
cacheKey: PANE_KEY,
|
||||
disposed: false,
|
||||
deps: { tabId: 'tab-1' },
|
||||
pane: { leafId: 'leaf-1' },
|
||||
waitTeardowns: new Array<() => void>(),
|
||||
showSessionRestoredBanner: vi.fn()
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the banner reads only these session fields; mainStartsPane is mocked.
|
||||
return { view, session: view as unknown as ConnectPanePtySession }
|
||||
}
|
||||
|
||||
/** A fresh module, as a renderer reload loads it; session storage survives the reload. */
|
||||
async function loadBannerModule(): Promise<typeof ShowOutcome> {
|
||||
vi.resetModules()
|
||||
const { showMainPaneResumeOutcome, acceptMainPaneResumeStart } =
|
||||
await import('../spawn/main-pane-resume-banner')
|
||||
const { admitTerminalNoticeLifetime } =
|
||||
await import('../../provider-notices/terminal-notice-lifetime')
|
||||
return (session, ptyId) => {
|
||||
admitTerminalNoticeLifetime(ptyId)
|
||||
acceptMainPaneResumeStart(session, ptyId)
|
||||
showMainPaneResumeOutcome(session, ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
describe("the window's banner for main's start of a pane", () => {
|
||||
let storage: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
store.reset()
|
||||
storage = new Map()
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('shows once per start: a later start under the same terminal id shows again', async () => {
|
||||
const showMainPaneResumeOutcome = await loadBannerModule()
|
||||
const publish = (ptyGeneration: number, outcome: TerminalPaneResumeOutcome['outcome']) =>
|
||||
store.publish({
|
||||
terminalPaneResumeOutcomesByPaneKey: {
|
||||
[PANE_KEY]: { ptyId: 'pty-1', ptyGeneration, outcome }
|
||||
}
|
||||
})
|
||||
publish(1, 'restored')
|
||||
const first = attachedView()
|
||||
showMainPaneResumeOutcome(first.session, 'pty-1')
|
||||
// A respawn kept the daemon id; its bind raised the generation, so this is a new start.
|
||||
publish(2, 'resume-unavailable')
|
||||
const respawned = attachedView()
|
||||
showMainPaneResumeOutcome(respawned.session, 'pty-1')
|
||||
expect(respawned.view.showSessionRestoredBanner).toHaveBeenCalledExactlyOnceWith(
|
||||
'resume-unavailable'
|
||||
)
|
||||
const again = attachedView()
|
||||
showMainPaneResumeOutcome(again.session, 'pty-1')
|
||||
expect(again.view.showSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows nothing again after a renderer reload reattaches the same start', async () => {
|
||||
store.publish({
|
||||
terminalPaneResumeOutcomesByPaneKey: {
|
||||
[PANE_KEY]: { ptyId: 'pty-1', ptyGeneration: 1, outcome: 'resume-unavailable' }
|
||||
}
|
||||
})
|
||||
const before = attachedView()
|
||||
;(await loadBannerModule())(before.session, 'pty-1')
|
||||
expect(before.view.showSessionRestoredBanner).toHaveBeenCalledOnce()
|
||||
|
||||
const reloaded = attachedView()
|
||||
;(await loadBannerModule())(reloaded.session, 'pty-1')
|
||||
expect(reloaded.view.showSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains a shown start across a publication gap', async () => {
|
||||
const attach = await loadBannerModule()
|
||||
const publish = (ptyId: string, ptyGeneration: number) =>
|
||||
store.publish({
|
||||
terminalPaneResumeOutcomesByPaneKey: {
|
||||
[PANE_KEY]: { ptyId, ptyGeneration, outcome: 'restored' }
|
||||
}
|
||||
})
|
||||
publish('pty-a', 3)
|
||||
const first = attachedView()
|
||||
attach(first.session, 'pty-a')
|
||||
expect(first.view.showSessionRestoredBanner).toHaveBeenCalledExactlyOnceWith('restored')
|
||||
store.publish({ terminalPaneResumeOutcomesByPaneKey: {} })
|
||||
publish('pty-b', 4)
|
||||
attach(attachedView().session, 'pty-b')
|
||||
publish('pty-a', 3)
|
||||
const remounted = attachedView()
|
||||
attach(remounted.session, 'pty-a')
|
||||
expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
// A late older fact cannot replace the accepted start or reset suppression.
|
||||
publish('pty-a', 2)
|
||||
attach(remounted.session, 'pty-a')
|
||||
publish('pty-a', 3)
|
||||
attach(remounted.session, 'pty-a')
|
||||
expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains a shown start across a publication gap without session storage', async () => {
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
getItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
}
|
||||
})
|
||||
const attach = await loadBannerModule()
|
||||
const publish = (ptyId: string, ptyGeneration: number) =>
|
||||
store.publish({
|
||||
terminalPaneResumeOutcomesByPaneKey: {
|
||||
[PANE_KEY]: { ptyId, ptyGeneration, outcome: 'restored' }
|
||||
}
|
||||
})
|
||||
publish('pty-a', 3)
|
||||
const first = attachedView()
|
||||
attach(first.session, 'pty-a')
|
||||
expect(first.view.showSessionRestoredBanner).toHaveBeenCalledExactlyOnceWith('restored')
|
||||
store.publish({ terminalPaneResumeOutcomesByPaneKey: {} })
|
||||
publish('pty-b', 4)
|
||||
attach(attachedView().session, 'pty-b')
|
||||
publish('pty-a', 3)
|
||||
const remounted = attachedView()
|
||||
attach(remounted.session, 'pty-a')
|
||||
expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
// A late older fact cannot replace the accepted start or reset suppression.
|
||||
publish('pty-a', 2)
|
||||
attach(remounted.session, 'pty-a')
|
||||
publish('pty-a', 3)
|
||||
attach(remounted.session, 'pty-a')
|
||||
expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows nothing for an outcome naming another terminal', async () => {
|
||||
const showMainPaneResumeOutcome = await loadBannerModule()
|
||||
store.publish({
|
||||
terminalPaneResumeOutcomesByPaneKey: {
|
||||
[PANE_KEY]: { ptyId: 'pty-old', ptyGeneration: 1, outcome: 'restored' }
|
||||
}
|
||||
})
|
||||
const { view, session } = attachedView()
|
||||
showMainPaneResumeOutcome(session, 'pty-1')
|
||||
store.publish({
|
||||
terminalPaneResumeOutcomesByPaneKey: {
|
||||
[PANE_KEY]: { ptyId: 'pty-old', ptyGeneration: 2, outcome: 'resume-unavailable' }
|
||||
}
|
||||
})
|
||||
expect(view.showSessionRestoredBanner).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { vi } from 'vitest'
|
||||
import { buildStoreState } from './ipc-events-agent-status-store-test-fixtures'
|
||||
import type { StoreLike } from './ipc-events-agent-status-store-test-fixtures'
|
||||
import { buildWindowApi } from './ipc-events-agent-status-window-test-fixtures'
|
||||
|
||||
export function buildSshAuthorityReconciliationHarness(args: {
|
||||
partialAuthority: { providerEpoch?: string; connectionGeneration?: number }
|
||||
latestAuthority: { providerEpoch: string; connectionGeneration: number }
|
||||
}): {
|
||||
emitPartialState: () => void
|
||||
getState: ReturnType<typeof vi.fn>
|
||||
setSshConnectionState: ReturnType<typeof vi.fn>
|
||||
storeState: StoreLike
|
||||
storedState: () => Record<string, unknown> | undefined
|
||||
} {
|
||||
const targetId = 'target-reconciliation'
|
||||
const baseState = {
|
||||
targetId,
|
||||
status: 'connected' as const,
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
const partialState = { ...baseState, ...args.partialAuthority }
|
||||
const latestState = { ...baseState, ...args.latestAuthority }
|
||||
const sshConnectionStates = new Map<string, Record<string, unknown>>()
|
||||
let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined
|
||||
const getState = vi.fn(() => Promise.resolve(latestState))
|
||||
const setSshConnectionState = vi.fn((nextTargetId: string, state: Record<string, unknown>) => {
|
||||
sshConnectionStates.set(nextTargetId, state)
|
||||
})
|
||||
const storeState = buildStoreState({
|
||||
sshTargetLabels: new Map([[targetId, 'Reconciliation Target']]),
|
||||
sshConnectionStates,
|
||||
setSshConnectionState,
|
||||
setSshTargetsMetadata: vi.fn(),
|
||||
setRemovedSshTargetLabels: vi.fn(),
|
||||
clearRemoteDetectedAgents: vi.fn(),
|
||||
clearRemovedSshTargetState: vi.fn()
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: () => () => {},
|
||||
ssh: {
|
||||
getState,
|
||||
onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => {
|
||||
sshStateListener = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
emitPartialState: () => {
|
||||
if (!sshStateListener) {
|
||||
throw new Error('Expected SSH state listener')
|
||||
}
|
||||
sshStateListener({ targetId, state: partialState })
|
||||
},
|
||||
getState,
|
||||
setSshConnectionState,
|
||||
storeState,
|
||||
storedState: () => sshConnectionStates.get(targetId)
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as EditorAutosaveModule from '@/components/editor/autosave/editor-autosave'
|
||||
import type { FsChangedPayload } from '../../../shared/file-watch/filesystem-change-event'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/components/editor/autosave/editor-autosave', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof EditorAutosaveModule>()
|
||||
return {
|
||||
...actual,
|
||||
notifyEditorExternalFileChange: vi.fn(),
|
||||
getOpenFilesForExternalFileChange: vi.fn(() => [])
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
buildEditorExternalWatchEventHandler as createExternalWatchEventHandler,
|
||||
EXTERNAL_MUTATION_DEBOUNCE_MS
|
||||
} from './editor-external-watch-event-reconciliation'
|
||||
import { verifyLatchedMoveDestinations } from './useEditorExternalWatch'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor/types/open-file'
|
||||
import { createTestStore } from '@/store/testing/app-store-test-fixture'
|
||||
import { getOpenFilesForExternalFileChange } from '@/components/editor/autosave/editor-autosave'
|
||||
import { __clearSelfWriteRegistryForTests } from '@/components/editor/external-changes/editor-self-write-registry'
|
||||
import {
|
||||
__clearEditorPathMovesForTests,
|
||||
beginEditorPathMove,
|
||||
settleEditorPathMove
|
||||
} from '@/components/editor/editor-path-move-inflight'
|
||||
import { getDiskBaselineSignature } from '../../../shared/file-write/disk-baseline-signature'
|
||||
|
||||
const findTarget = (worktreePath: string, runtimeEnvironmentId: string | null = null) =>
|
||||
worktreePath === '/repo'
|
||||
? { worktreeId: 'wt-1', worktreePath: '/repo', connectionId: undefined, runtimeEnvironmentId }
|
||||
: undefined
|
||||
|
||||
function payload(events: FsChangedPayload['events']): FsChangedPayload {
|
||||
return { worktreePath: '/repo', events }
|
||||
}
|
||||
|
||||
describe('self-move source suppression (in-flight)', () => {
|
||||
const setExternalMutation = vi.fn()
|
||||
const fileNotes = {
|
||||
id: 'file-notes',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit' as const,
|
||||
isDirty: false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [fileNotes],
|
||||
setExternalMutation
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
__clearSelfWriteRegistryForTests()
|
||||
__clearEditorPathMovesForTests()
|
||||
})
|
||||
|
||||
function beginMove(): void {
|
||||
beginEditorPathMove({
|
||||
operationId: 'op-1',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
sourcePaths: ['/repo/notes.md']
|
||||
})
|
||||
}
|
||||
|
||||
it('does not tombstone the source of an in-flight move (tab still at old path)', () => {
|
||||
beginMove()
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'deleted')
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'renamed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not tombstone a tab opened UNDER a moving directory mid-rename', () => {
|
||||
// Root move provenance must cover files opened during the move, even absent from its original tab list.
|
||||
const lateTab = {
|
||||
id: 'file-late',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo/src/late.md',
|
||||
relativePath: 'src/late.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit' as const,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [lateTab],
|
||||
setExternalMutation
|
||||
})
|
||||
beginEditorPathMove({
|
||||
operationId: 'op-dir',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
sourcePaths: ['/repo/src']
|
||||
})
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(
|
||||
payload([
|
||||
{ kind: 'delete', absolutePath: '/repo/src/late.md' },
|
||||
{ kind: 'create', absolutePath: '/repo/dst/late.md' }
|
||||
])
|
||||
)
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-late', 'renamed')
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-late', 'deleted')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('tombstones a genuine delete once the move has settled', () => {
|
||||
beginMove()
|
||||
settleEditorPathMove('op-1')
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'deleted')
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('self-move echo verification (content identity)', () => {
|
||||
const setExternalMutation = vi.fn()
|
||||
const clearSelfMoveEcho = vi.fn()
|
||||
|
||||
const BASELINE_CONTENT = 'the file on disk\n'
|
||||
const baselineSignature = getDiskBaselineSignature(BASELINE_CONTENT)
|
||||
const TARGET_PATH = '/repo/subdir/notes.md'
|
||||
|
||||
function movedDirtyTab(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: 'file-notes',
|
||||
worktreeId: 'wt-1',
|
||||
filePath: TARGET_PATH,
|
||||
relativePath: 'subdir/notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit' as const,
|
||||
isDirty: true,
|
||||
lastKnownDiskSignature: baselineSignature,
|
||||
pendingSelfMoveEcho: { operationId: 'op-1', targetPath: TARGET_PATH },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mockState(file: OpenFile): void {
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [file],
|
||||
setExternalMutation,
|
||||
clearSelfMoveEcho
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file])
|
||||
}
|
||||
|
||||
function payloadFor(): FsChangedPayload {
|
||||
return { worktreePath: '/repo', events: [{ kind: 'update', absolutePath: TARGET_PATH }] }
|
||||
}
|
||||
|
||||
function stubDiskRead(result: unknown): void {
|
||||
vi.stubGlobal('window', { api: { fs: { readFile: vi.fn().mockResolvedValue(result) } } })
|
||||
}
|
||||
|
||||
function stubDiskReadError(error: Error): void {
|
||||
vi.stubGlobal('window', { api: { fs: { readFile: vi.fn().mockRejectedValue(error) } } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
__clearSelfWriteRegistryForTests()
|
||||
__clearEditorPathMovesForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('suppresses when disk matches the baseline', async () => {
|
||||
mockState(movedDirtyTab())
|
||||
stubDiskRead({ isBinary: false, content: BASELINE_CONTENT })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payloadFor())
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'changed')
|
||||
expect(clearSelfMoveEcho).toHaveBeenCalledWith('file-notes')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks changed when a genuine external write differs from the baseline', async () => {
|
||||
mockState(movedDirtyTab())
|
||||
stubDiskRead({ isBinary: false, content: 'an agent rewrote this file\n' })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payloadFor())
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fails closed (marks changed) when the tab has no disk baseline', async () => {
|
||||
mockState(movedDirtyTab({ lastKnownDiskSignature: undefined }))
|
||||
stubDiskRead({ isBinary: false, content: BASELINE_CONTENT })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payloadFor())
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fails closed when the destination read errors', async () => {
|
||||
mockState(movedDirtyTab())
|
||||
stubDiskReadError(new Error('EACCES'))
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payloadFor())
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fails closed when the destination is binary', async () => {
|
||||
mockState(movedDirtyTab())
|
||||
stubDiskRead({ isBinary: true, content: '' })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payloadFor())
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not verify an event with no move provenance (normal changed-on-disk path)', async () => {
|
||||
mockState(movedDirtyTab({ pendingSelfMoveEcho: undefined }))
|
||||
stubDiskRead({ isBinary: false, content: BASELINE_CONTENT })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payloadFor())
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyLatchedMoveDestinations (proactive, cross-worktree path)', () => {
|
||||
const clearSelfMoveEcho = vi.fn()
|
||||
const setExternalMutation = vi.fn()
|
||||
|
||||
const CONTENT = 'the echoed file on disk\n'
|
||||
const baseline = getDiskBaselineSignature(CONTENT)
|
||||
// Floating tabs use their own root-relative paths, never the initiating workspace root.
|
||||
const FLOATING_FILE_PATH = '/elsewhere/notes/readme.md'
|
||||
const floatingTab = {
|
||||
id: 'file-x',
|
||||
worktreeId: 'floating',
|
||||
worktreePath: '/elsewhere',
|
||||
filePath: FLOATING_FILE_PATH,
|
||||
relativePath: '../notes/readme.md',
|
||||
runtimeEnvironmentId: null,
|
||||
language: 'markdown',
|
||||
mode: 'edit' as const,
|
||||
isDirty: true,
|
||||
lastKnownDiskSignature: baseline,
|
||||
pendingSelfMoveEcho: { operationId: 'op-1', targetPath: FLOATING_FILE_PATH }
|
||||
}
|
||||
let readFile: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
readFile = vi.fn().mockResolvedValue({ isBinary: false, content: CONTENT })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [floatingTab],
|
||||
clearSelfMoveEcho,
|
||||
setExternalMutation
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reads the tab own filePath, not the initiating worktree joined with its relativePath', async () => {
|
||||
verifyLatchedMoveDestinations('/initiating-worktree', undefined, ['file-x'])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith(expect.objectContaining({ filePath: FLOATING_FILE_PATH }))
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-x', 'changed')
|
||||
// Proactive verification must retain provenance for later watcher echoes.
|
||||
expect(clearSelfMoveEcho).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,203 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
|
||||
type TestWatchTarget = {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
connectionId: string | undefined
|
||||
runtimeEnvironmentId: string | null
|
||||
allowLocalWindowsWslAliases?: true
|
||||
}
|
||||
|
||||
const subscriptionState = vi.hoisted(() => ({
|
||||
snapshot: { targets: [] as TestWatchTarget[], targetsKey: '' },
|
||||
subscribeRuntimeFileChanges: vi.fn(),
|
||||
disposeEventHandler: vi.fn(),
|
||||
handleFsChanged: vi.fn(),
|
||||
contacts: new Map<string, () => void>(),
|
||||
unsubscribeContact: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: unknown) => unknown) => selector({ openFiles: [] }),
|
||||
{
|
||||
getState: () => ({ openFiles: [] })
|
||||
}
|
||||
)
|
||||
}))
|
||||
vi.mock('@/runtime/hosts/connections/runtime-host-contact-regained', () => ({
|
||||
subscribeRuntimeHostContactRegained: (id: string, listener: () => void) => {
|
||||
subscriptionState.contacts.set(id, listener)
|
||||
return subscriptionState.unsubscribeContact
|
||||
}
|
||||
}))
|
||||
vi.mock('@/runtime/files/watching/runtime-file-watch-client', () => ({
|
||||
subscribeRuntimeFileChanges: subscriptionState.subscribeRuntimeFileChanges
|
||||
}))
|
||||
vi.mock('./editor-external-watch-targets', () => ({
|
||||
selectEditorExternalWatchTargets: () => ({
|
||||
unwatchedTargetKeys: new Set<string>(),
|
||||
unwatchedTargetsKey: '',
|
||||
...subscriptionState.snapshot
|
||||
}),
|
||||
getEditorExternalWatchTargetKey: (target: TestWatchTarget) =>
|
||||
[
|
||||
target.worktreeId,
|
||||
target.worktreePath,
|
||||
target.connectionId ?? 'local',
|
||||
target.runtimeEnvironmentId ?? 'client',
|
||||
target.allowLocalWindowsWslAliases ? 'wsl-aliases' : 'literal'
|
||||
].join('::')
|
||||
}))
|
||||
vi.mock('./editor-external-watch-event-reconciliation', () => ({
|
||||
buildEditorExternalWatchEventHandler: vi.fn(() => ({
|
||||
handleFsChanged: subscriptionState.handleFsChanged,
|
||||
dispose: subscriptionState.disposeEventHandler
|
||||
})),
|
||||
collectOverflowEditorExternalReloadTargets: vi.fn()
|
||||
}))
|
||||
vi.mock('./editor-external-watch-disk-verification', () => ({
|
||||
verifyLatchedEditorMoveDestinations: vi.fn()
|
||||
}))
|
||||
|
||||
import { useEditorExternalWatch } from './useEditorExternalWatch'
|
||||
|
||||
function WatchProbe(): null {
|
||||
useEditorExternalWatch()
|
||||
return null
|
||||
}
|
||||
|
||||
function runtimeTarget(): TestWatchTarget {
|
||||
return {
|
||||
worktreeId: 'wt-runtime',
|
||||
worktreePath: '/runtime/repo',
|
||||
connectionId: 'nested-ssh',
|
||||
runtimeEnvironmentId: 'runtime-1'
|
||||
}
|
||||
}
|
||||
|
||||
function deferredRuntimeSubscription(): {
|
||||
promise: Promise<() => void>
|
||||
resolve: (unsubscribe: () => void) => void
|
||||
} {
|
||||
let resolve!: (unsubscribe: () => void) => void
|
||||
const promise = new Promise<() => void>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useEditorExternalWatch subscriptions', () => {
|
||||
let previousApi: unknown
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let watchWorktree: ReturnType<typeof vi.fn>
|
||||
let unwatchWorktree: ReturnType<typeof vi.fn>
|
||||
let unsubscribeFsEvents: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
subscriptionState.contacts.clear()
|
||||
subscriptionState.subscribeRuntimeFileChanges.mockReset()
|
||||
subscriptionState.snapshot = { targets: [], targetsKey: '' }
|
||||
watchWorktree = vi.fn().mockResolvedValue(undefined)
|
||||
unwatchWorktree = vi.fn().mockResolvedValue(undefined)
|
||||
unsubscribeFsEvents = vi.fn()
|
||||
previousApi = (window as unknown as { api?: unknown }).api
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
fs: {
|
||||
watchWorktree,
|
||||
unwatchWorktree,
|
||||
onFsChanged: vi.fn(() => unsubscribeFsEvents)
|
||||
}
|
||||
}
|
||||
container = document.body.appendChild(document.createElement('div'))
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
;(window as unknown as { api?: unknown }).api = previousApi
|
||||
})
|
||||
|
||||
it('cannot let an old runtime subscribe resolution replace a re-added watch', async () => {
|
||||
const stalePending = deferredRuntimeSubscription()
|
||||
const currentPending = deferredRuntimeSubscription()
|
||||
const unsubscribeStale = vi.fn()
|
||||
const unsubscribeCurrent = vi.fn()
|
||||
subscriptionState.subscribeRuntimeFileChanges
|
||||
.mockReturnValueOnce(stalePending.promise)
|
||||
.mockReturnValueOnce(currentPending.promise)
|
||||
subscriptionState.snapshot = {
|
||||
targets: [runtimeTarget()],
|
||||
targetsKey: 'runtime-watch-1'
|
||||
}
|
||||
await act(async () => root.render(createElement(WatchProbe)))
|
||||
|
||||
subscriptionState.snapshot = { targets: [], targetsKey: 'no-runtime-watch' }
|
||||
await act(async () => root.render(createElement(WatchProbe)))
|
||||
subscriptionState.snapshot = {
|
||||
targets: [runtimeTarget()],
|
||||
targetsKey: 'runtime-watch-2'
|
||||
}
|
||||
await act(async () => root.render(createElement(WatchProbe)))
|
||||
|
||||
currentPending.resolve(unsubscribeCurrent)
|
||||
await act(async () => currentPending.promise)
|
||||
stalePending.resolve(unsubscribeStale)
|
||||
await act(async () => stalePending.promise)
|
||||
expect(unsubscribeStale).toHaveBeenCalledTimes(1)
|
||||
expect(unsubscribeCurrent).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => root.unmount())
|
||||
expect(unsubscribeCurrent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries a failed initial watch when the same host answers again', async () => {
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
subscriptionState.subscribeRuntimeFileChanges
|
||||
.mockRejectedValueOnce(new Error('connection lost'))
|
||||
.mockResolvedValueOnce(vi.fn())
|
||||
subscriptionState.snapshot = { targets: [runtimeTarget()], targetsKey: 'recovering-watch' }
|
||||
try {
|
||||
await act(async () => root.render(createElement(WatchProbe)))
|
||||
await act(async () => subscriptionState.contacts.get('runtime-1')?.())
|
||||
expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2)
|
||||
expect(subscriptionState.handleFsChanged).toHaveBeenCalledWith(
|
||||
{
|
||||
worktreePath: '/runtime/repo',
|
||||
events: [{ kind: 'overflow', absolutePath: '/runtime/repo' }],
|
||||
connectionId: 'nested-ssh',
|
||||
resync: true
|
||||
},
|
||||
'runtime-1',
|
||||
runtimeTarget()
|
||||
)
|
||||
} finally {
|
||||
warning.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces a lost watch and stops contact recovery when the editor unmounts', async () => {
|
||||
const firstStop = vi.fn()
|
||||
const secondStop = vi.fn()
|
||||
subscriptionState.subscribeRuntimeFileChanges
|
||||
.mockResolvedValueOnce(firstStop)
|
||||
.mockResolvedValueOnce(secondStop)
|
||||
subscriptionState.snapshot = { targets: [runtimeTarget()], targetsKey: 'live-watch' }
|
||||
await act(async () => root.render(createElement(WatchProbe)))
|
||||
await act(async () => subscriptionState.contacts.get('runtime-1')?.())
|
||||
expect(firstStop).toHaveBeenCalledOnce()
|
||||
expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2)
|
||||
await act(async () => root.unmount())
|
||||
expect(secondStop).toHaveBeenCalledOnce()
|
||||
expect(subscriptionState.unsubscribeContact).toHaveBeenCalledOnce()
|
||||
subscriptionState.contacts.get('runtime-1')?.()
|
||||
expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,590 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import type * as EditorAutosaveModule from '@/components/editor/autosave/editor-autosave'
|
||||
import type { FsChangedPayload } from '../../../shared/file-watch/filesystem-change-event'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: vi.fn()
|
||||
}
|
||||
}))
|
||||
// Stub autosave’s window-event exports so the handler runs in headless Node.
|
||||
vi.mock('@/components/editor/autosave/editor-autosave', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof EditorAutosaveModule>()
|
||||
return {
|
||||
...actual,
|
||||
notifyEditorExternalFileChange: vi.fn(),
|
||||
getOpenFilesForExternalFileChange: vi.fn(() => [])
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
buildEditorExternalWatchEventHandler as createExternalWatchEventHandler,
|
||||
EXTERNAL_MUTATION_DEBOUNCE_MS
|
||||
} from './editor-external-watch-event-reconciliation'
|
||||
import { useAppStore } from '@/store'
|
||||
import { createTestStore } from '@/store/testing/app-store-test-fixture'
|
||||
import {
|
||||
getOpenFilesForExternalFileChange,
|
||||
notifyEditorExternalFileChange
|
||||
} from '@/components/editor/autosave/editor-autosave'
|
||||
import {
|
||||
__clearSelfWriteRegistryForTests,
|
||||
recordSelfWrite
|
||||
} from '@/components/editor/external-changes/editor-self-write-registry'
|
||||
import { __clearEditorPathMovesForTests } from '@/components/editor/editor-path-move-inflight'
|
||||
|
||||
describe('createExternalWatchEventHandler tombstone coalescing', () => {
|
||||
const setExternalMutation = vi.fn()
|
||||
const findTarget = (
|
||||
worktreePath: string,
|
||||
runtimeEnvironmentId: string | null = null
|
||||
):
|
||||
| {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
connectionId: string | undefined
|
||||
runtimeEnvironmentId: string | null
|
||||
}
|
||||
| undefined =>
|
||||
worktreePath === '/repo'
|
||||
? {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined,
|
||||
runtimeEnvironmentId
|
||||
}
|
||||
: undefined
|
||||
|
||||
const fileNotes = {
|
||||
id: 'file-notes',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit' as const,
|
||||
isDirty: false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [fileNotes],
|
||||
setExternalMutation
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
__clearSelfWriteRegistryForTests()
|
||||
__clearEditorPathMovesForTests()
|
||||
})
|
||||
|
||||
function payload(events: FsChangedPayload['events']): FsChangedPayload {
|
||||
return { worktreePath: '/repo', events }
|
||||
}
|
||||
|
||||
it('does not flash deleted when a create at the same path arrives in the next payload', () => {
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }]))
|
||||
|
||||
vi.advanceTimersByTime(10)
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'deleted')
|
||||
|
||||
handleFsChanged(payload([{ kind: 'create', absolutePath: '/repo/notes.md' }]))
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'deleted')
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('still sets deleted after the debounce window for a naked delete', () => {
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }]))
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'deleted')
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('resolves batched delete+create in a single payload synchronously as renamed', () => {
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
handleFsChanged(
|
||||
payload([
|
||||
{ kind: 'delete', absolutePath: '/repo/notes.md' },
|
||||
{ kind: 'create', absolutePath: '/repo/subdir/notes.md' }
|
||||
])
|
||||
)
|
||||
|
||||
// Same-payload delete/create already proves recreation; reconcile without deferral.
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'renamed')
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('reloads Windows editor tabs when watcher event casing differs', () => {
|
||||
const file = {
|
||||
id: 'file-win',
|
||||
worktreeId: 'wt-win',
|
||||
worktreePath: 'C:\\Repo',
|
||||
filePath: 'C:\\Repo\\notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit' as const,
|
||||
isDirty: false,
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [file],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({
|
||||
worktreeId: 'wt-win',
|
||||
worktreePath: 'C:\\Repo',
|
||||
connectionId: undefined,
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}))
|
||||
|
||||
handleFsChanged({
|
||||
worktreePath: 'c:\\repo',
|
||||
events: [{ kind: 'update', absolutePath: 'c:\\repo\\notes.md', isDirectory: false }]
|
||||
})
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-win',
|
||||
worktreePath: 'C:\\Repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('reloads UNC editor tabs without collapsing the share root', () => {
|
||||
const file = {
|
||||
id: 'file-unc',
|
||||
worktreeId: 'wt-unc',
|
||||
worktreePath: '//Server/Share/Repo',
|
||||
filePath: '//Server/Share/Repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit' as const,
|
||||
isDirty: false,
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [file],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({
|
||||
worktreeId: 'wt-unc',
|
||||
worktreePath: '//Server/Share/Repo',
|
||||
connectionId: undefined,
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}))
|
||||
|
||||
handleFsChanged({
|
||||
worktreePath: '//server/share/repo',
|
||||
events: [{ kind: 'update', absolutePath: '//server/share/repo/notes.md', isDirectory: false }]
|
||||
})
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-unc',
|
||||
worktreePath: '//Server/Share/Repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not drop external edits that arrive inside the self-write TTL', async () => {
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [fileNotes],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([fileNotes] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'agent edit', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'orca save')
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('still suppresses the watcher echo from Orca self-writes', async () => {
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [fileNotes],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([fileNotes] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'orca save', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'orca save')
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not let a local self-write stamp suppress a runtime owner update', async () => {
|
||||
const runtimeFile = {
|
||||
...fileNotes,
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [runtimeFile],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([runtimeFile] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'local save', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'local save', null)
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]), 'env-1')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('routes local and runtime watch events to the matching editor owner', () => {
|
||||
const localFile = fileNotes
|
||||
const runtimeFile = {
|
||||
...fileNotes,
|
||||
id: 'runtime-notes',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [localFile, runtimeFile],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockImplementation((_files, notification) =>
|
||||
notification.runtimeEnvironmentId === 'env-1'
|
||||
? ([runtimeFile] as never)
|
||||
: ([localFile] as never)
|
||||
)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]), null)
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]), 'env-1')
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('reloads a combined Changes tab even though no single-file tab matches', () => {
|
||||
const combinedDiffTab = {
|
||||
id: 'wt-1::all-diffs::uncommitted',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo',
|
||||
relativePath: 'Changes',
|
||||
language: 'markdown',
|
||||
mode: 'diff' as const,
|
||||
diffSource: 'combined-uncommitted' as const,
|
||||
isDirty: false
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [combinedDiffTab],
|
||||
setExternalMutation
|
||||
})
|
||||
// Why: combined tabs match no single path, so the per-path lookup stays empty.
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([])
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/src/foo.ts' }]))
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'src/foo.ts',
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('reloads a combined Changes tab when the matching single-file tab is dirty', () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
const combinedDiffTab = {
|
||||
id: 'wt-1::all-diffs::uncommitted',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo',
|
||||
relativePath: 'Changes',
|
||||
language: 'markdown',
|
||||
mode: 'diff' as const,
|
||||
diffSource: 'combined-uncommitted' as const,
|
||||
isDirty: false
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [dirtyFile, combinedDiffTab],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks a dirty edit tab changed-on-disk instead of reloading it', () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [dirtyFile],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
expect(notifyEditorExternalFileChange).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('still reloads a clean sibling diff tab when the edit tab for the path is dirty', () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
const cleanDiffTab = {
|
||||
id: 'diff-notes',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'diff' as const,
|
||||
diffSource: 'unstaged' as const,
|
||||
isDirty: false
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [dirtyFile, cleanDiffTab],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile, cleanDiffTab] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not mark a dirty tab for the echo of Orca’s own save', async () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [dirtyFile],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'orca save', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'orca save')
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks a dirty tab when a genuine external write lands inside the self-write TTL', async () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [dirtyFile],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'agent content', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'orca save')
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks a lone dirty unstaged-diff tab changed-on-disk', () => {
|
||||
const dirtyDiffTab = {
|
||||
id: 'diff-notes',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'diff' as const,
|
||||
diffSource: 'unstaged' as const,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [dirtyDiffTab],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyDiffTab] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('diff-notes', 'changed')
|
||||
expect(notifyEditorExternalFileChange).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not let an update event clear a changed-on-disk mark while the tab stays dirty', () => {
|
||||
const dirtyChangedFile = {
|
||||
...fileNotes,
|
||||
isDirty: true,
|
||||
externalMutation: 'changed' as const
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [dirtyChangedFile],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyChangedFile] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', null)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not reload a branch-compare combined diff for working-tree changes', () => {
|
||||
const branchDiffTab = {
|
||||
id: 'wt-1::all-diffs::branch',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo',
|
||||
relativePath: 'Branch Changes',
|
||||
language: 'markdown',
|
||||
mode: 'diff' as const,
|
||||
diffSource: 'combined-branch' as const,
|
||||
isDirty: false
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [branchDiffTab],
|
||||
setExternalMutation
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([])
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/src/foo.ts' }]))
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(notifyEditorExternalFileChange).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('tombstones only the matching owner for same-path delete events', () => {
|
||||
const localFile = fileNotes
|
||||
const runtimeFile = {
|
||||
...fileNotes,
|
||||
id: 'runtime-notes',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [localFile, runtimeFile],
|
||||
setExternalMutation
|
||||
})
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }]), 'env-1')
|
||||
vi.advanceTimersByTime(EXTERNAL_MUTATION_DEBOUNCE_MS)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('runtime-notes', 'deleted')
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'deleted')
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,480 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildStoreState,
|
||||
expectWorktreeRouting,
|
||||
FUTURE_LEAF_ID,
|
||||
ORPHAN_LEAF_ID,
|
||||
TAB_1_LEAF_ID,
|
||||
STALE_PANE_KEY,
|
||||
ORPHAN_PANE_KEY,
|
||||
TAB_1_PANE_KEY,
|
||||
type AgentStatusSetData,
|
||||
type StoreLike,
|
||||
type StoreSubscribeListener
|
||||
} from './ipc-events-agent-status-store-test-fixtures'
|
||||
import {
|
||||
buildWindowApi,
|
||||
stubReactSyncEffect,
|
||||
stubAuxiliaryModules
|
||||
} from './ipc-events-agent-status-window-test-fixtures'
|
||||
|
||||
describe('useIpcEvents agent status snapshot integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('pulls the snapshot once workspace session is ready even before settings load', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const getSnapshot = vi.fn(() => Promise.resolve([]))
|
||||
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
tabsByWorktree: {},
|
||||
workspaceSessionReady: true,
|
||||
settings: null
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn((listener: StoreSubscribeListener) => {
|
||||
subscribeListenerRef.current = listener
|
||||
return () => {
|
||||
subscribeListenerRef.current = null
|
||||
}
|
||||
}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
getSnapshot,
|
||||
onSet: () => () => {}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
expect(getSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently discards snapshot entries whose tabs are still unknown', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const getSnapshot = vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
paneKey: ORPHAN_PANE_KEY,
|
||||
state: 'done' as const,
|
||||
prompt: 'p',
|
||||
agentType: 'claude',
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-other', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Other' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-orphan': {
|
||||
root: { type: 'leaf', leafId: ORPHAN_LEAF_ID },
|
||||
activeLeafId: ORPHAN_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
getSnapshot,
|
||||
onSet: () => () => {}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently discards stale worktree-attributed snapshots for unknown panes', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const getSnapshot = vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
paneKey: ORPHAN_PANE_KEY,
|
||||
state: 'done' as const,
|
||||
prompt: 'old copilot turn',
|
||||
agentType: 'copilot',
|
||||
worktreeId: 'wt-1',
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Copilot' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
getSnapshot,
|
||||
onSet: () => () => {}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies worktree-attributed child snapshots when runtime identity is present', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const getSnapshot = vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
paneKey: ORPHAN_PANE_KEY,
|
||||
state: 'working' as const,
|
||||
prompt: 'child task',
|
||||
agentType: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
terminalHandle: 'term-child',
|
||||
orchestration: {
|
||||
taskId: 'task-child',
|
||||
dispatchId: 'dispatch-child',
|
||||
parentTerminalHandle: 'term-parent'
|
||||
},
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
getSnapshot,
|
||||
onSet: () => () => {}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
ORPHAN_PANE_KEY,
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'child task',
|
||||
agentType: 'codex',
|
||||
orchestration: expect.objectContaining({ taskId: 'task-child' })
|
||||
}),
|
||||
undefined,
|
||||
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 },
|
||||
expect.objectContaining({ worktreeId: 'wt-1', terminalHandle: 'term-child' }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('silently discards valid paneKeys whose leaf is not in the current layout', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const getSnapshot = vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
paneKey: STALE_PANE_KEY,
|
||||
state: 'done' as const,
|
||||
prompt: 'p',
|
||||
agentType: 'claude',
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
getSnapshot,
|
||||
onSet: () => () => {}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards events whose connectionId matches the live repo connection', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
repos: [{ id: 'repo-1', connectionId: 'conn-1' }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: TAB_1_LEAF_ID },
|
||||
activeLeafId: TAB_1_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => storeState }
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
onSetListenerRef.current?.({
|
||||
paneKey: TAB_1_PANE_KEY,
|
||||
connectionId: 'conn-1',
|
||||
state: 'working',
|
||||
receivedAt: 1_700_000_000_100,
|
||||
stateStartedAt: 1_699_999_999_100
|
||||
})
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
TAB_1_PANE_KEY,
|
||||
expect.objectContaining({ state: 'working' }),
|
||||
'Terminal 1',
|
||||
{ updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 },
|
||||
expectWorktreeRouting('wt-1'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('drops events whose connectionId no longer matches the live local repo', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: TAB_1_LEAF_ID },
|
||||
activeLeafId: TAB_1_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => storeState }
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
onSetListenerRef.current?.({
|
||||
paneKey: TAB_1_PANE_KEY,
|
||||
connectionId: 'conn-stale',
|
||||
state: 'working',
|
||||
receivedAt: 1_700_000_000_100,
|
||||
stateStartedAt: 1_699_999_999_100
|
||||
})
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts events without a stamped connectionId for preload compatibility', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
repos: [{ id: 'repo-1', connectionId: 'conn-1' }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: TAB_1_LEAF_ID },
|
||||
activeLeafId: TAB_1_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => storeState }
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
onSetListenerRef.current?.({
|
||||
paneKey: TAB_1_PANE_KEY,
|
||||
state: 'working',
|
||||
receivedAt: 1_700_000_000_100,
|
||||
stateStartedAt: 1_699_999_999_100
|
||||
})
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,444 +0,0 @@
|
||||
import type * as ReactModule from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status/contracts/agent-status-ipc-payload'
|
||||
import {
|
||||
buildStoreState,
|
||||
expectWorktreeRouting,
|
||||
FUTURE_LEAF_ID,
|
||||
FUTURE_PANE_KEY,
|
||||
type AgentStatusSetData,
|
||||
type StoreLike
|
||||
} from './ipc-events-agent-status-store-test-fixtures'
|
||||
import {
|
||||
buildWindowApi,
|
||||
stubReactSyncEffect,
|
||||
stubAuxiliaryModules
|
||||
} from './ipc-events-agent-status-window-test-fixtures'
|
||||
|
||||
describe('useIpcEvents agent status snapshot integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not send an out-of-order hook event to completion lifecycle tracking', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const updateTabTitle = vi.fn()
|
||||
const observeAgentHookCompletionForNotification = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
updateTabTitle,
|
||||
workspaceSessionReady: true,
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Claude' }]
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[FUTURE_PANE_KEY]: {
|
||||
state: 'working',
|
||||
prompt: 'newer turn',
|
||||
agentType: 'claude',
|
||||
updatedAt: 1_700_000_000_500,
|
||||
stateStartedAt: 1_700_000_000_400
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
vi.doMock('./agent-hook-completion-notifications', () => ({
|
||||
observeAgentHookCompletionForNotification,
|
||||
resetAgentHookCompletionNotificationCoordinators: vi.fn(),
|
||||
syncAgentHookCompletionNotificationSettings: vi.fn()
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
tabId: 'tab-future',
|
||||
worktreeId: 'wt-1',
|
||||
state: 'done',
|
||||
prompt: 'older turn',
|
||||
agentType: 'claude',
|
||||
receivedAt: 1_700_000_000_300,
|
||||
stateStartedAt: 1_700_000_000_200
|
||||
})
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
expect(updateTabTitle).not.toHaveBeenCalled()
|
||||
expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps missing-tab runtime attribution for tabs that were never explicitly closed', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
recentlyClosedAgentStatusTabIds: {},
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: false } },
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'runtime child',
|
||||
agentType: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
receivedAt: 1_700_000_000_200,
|
||||
stateStartedAt: 1_700_000_000_000,
|
||||
orchestration: {
|
||||
parentPaneKey: 'parent-tab:11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
FUTURE_PANE_KEY,
|
||||
expect.objectContaining({ state: 'working', prompt: 'runtime child', agentType: 'codex' }),
|
||||
undefined,
|
||||
{ updatedAt: 1_700_000_000_200, stateStartedAt: 1_700_000_000_000 },
|
||||
expectWorktreeRouting('wt-1'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('clears a worktree-attributed live row when main reports pane teardown', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const removeAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const onClearListenerRef: {
|
||||
current: ((data: AgentStatusClearIpcPayload) => void) | null
|
||||
} = {
|
||||
current: null
|
||||
}
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
removeAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: false } },
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
terminalLayoutsByTabId: {},
|
||||
agentStatusByPaneKey: {
|
||||
[FUTURE_PANE_KEY]: {
|
||||
state: 'working',
|
||||
prompt: 'hidden worker',
|
||||
agentType: 'codex',
|
||||
updatedAt: 1_700_000_000_200,
|
||||
stateStartedAt: 1_700_000_000_000,
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
},
|
||||
onClear: (cb) => {
|
||||
onClearListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
if (typeof onClearListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onClear listener to be registered')
|
||||
}
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'hidden worker',
|
||||
agentType: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
receivedAt: 1_700_000_000_200,
|
||||
stateStartedAt: 1_700_000_000_000,
|
||||
orchestration: {
|
||||
parentPaneKey: 'parent-tab:11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
FUTURE_PANE_KEY,
|
||||
expect.objectContaining({ state: 'working', prompt: 'hidden worker', agentType: 'codex' }),
|
||||
undefined,
|
||||
{ updatedAt: 1_700_000_000_200, stateStartedAt: 1_700_000_000_000 },
|
||||
expectWorktreeRouting('wt-1'),
|
||||
undefined
|
||||
)
|
||||
|
||||
onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY })
|
||||
|
||||
expect(removeAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY)
|
||||
})
|
||||
|
||||
it('blocks cleared snapshots across remount and accepts newer reconnect replay', async () => {
|
||||
let resolveOldSnapshot!: (entries: AgentStatusSetData[]) => void
|
||||
let resolveCurrentSnapshot!: (entries: AgentStatusSetData[]) => void
|
||||
const oldSnapshot = new Promise<AgentStatusSetData[]>((resolve) => {
|
||||
resolveOldSnapshot = resolve
|
||||
})
|
||||
const currentSnapshot = new Promise<AgentStatusSetData[]>((resolve) => {
|
||||
resolveCurrentSnapshot = resolve
|
||||
})
|
||||
const effectCleanups: (() => void)[] = []
|
||||
const setAgentStatus = vi.fn()
|
||||
const clearTransientAgentStatuses = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const onClearListenerRef: {
|
||||
current: ((data: AgentStatusClearIpcPayload) => void) | null
|
||||
} = { current: null }
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
clearTransientAgentStatuses,
|
||||
workspaceSessionReady: true,
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: false } },
|
||||
repos: [{ id: 'repo-1', connectionId: 'ssh-a' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Remote agent' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': { root: { type: 'leaf', leafId: FUTURE_LEAF_ID } }
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
const cleanup = effect()
|
||||
effectCleanups.push(typeof cleanup === 'function' ? cleanup : () => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (callback) => {
|
||||
onSetListenerRef.current = callback
|
||||
return () => {}
|
||||
},
|
||||
onClear: (callback) => {
|
||||
onClearListenerRef.current = callback
|
||||
return () => {}
|
||||
},
|
||||
getSnapshot: vi.fn().mockReturnValueOnce(oldSnapshot).mockReturnValueOnce(currentSnapshot)
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
effectCleanups[0]?.()
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
if (!onSetListenerRef.current || !onClearListenerRef.current) {
|
||||
throw new Error('Expected agent status listeners to be registered')
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
onClearListenerRef.current?.(null as unknown as AgentStatusClearIpcPayload)
|
||||
).not.toThrow()
|
||||
expect(clearTransientAgentStatuses).not.toHaveBeenCalled()
|
||||
|
||||
onClearListenerRef.current({
|
||||
transient: true,
|
||||
connectionId: 'ssh-a',
|
||||
clearedAt: 100
|
||||
})
|
||||
const staleEntry: AgentStatusSetData = {
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'stale snapshot',
|
||||
agentType: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-a',
|
||||
receivedAt: 100,
|
||||
stateStartedAt: 90
|
||||
}
|
||||
resolveOldSnapshot([staleEntry])
|
||||
resolveCurrentSnapshot([staleEntry])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(clearTransientAgentStatuses).toHaveBeenCalledWith('ssh-a', 100)
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'replayed',
|
||||
agentType: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-a',
|
||||
receivedAt: 101,
|
||||
stateStartedAt: 101
|
||||
})
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledOnce()
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
FUTURE_PANE_KEY,
|
||||
expect.objectContaining({ prompt: 'replayed' }),
|
||||
'Remote agent',
|
||||
{ updatedAt: 101, stateStartedAt: 101 },
|
||||
expect.objectContaining({ worktreeId: 'wt-1', connectionId: 'ssh-a' }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a completed worktree-attributed row when main reports pane teardown', async () => {
|
||||
const removeAgentStatus = vi.fn()
|
||||
const onClearListenerRef: {
|
||||
current: ((data: AgentStatusClearIpcPayload) => void) | null
|
||||
} = {
|
||||
current: null
|
||||
}
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
removeAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
agentStatusByPaneKey: {
|
||||
[FUTURE_PANE_KEY]: {
|
||||
state: 'done',
|
||||
prompt: 'hidden worker',
|
||||
agentType: 'codex',
|
||||
updatedAt: 1_700_000_000_200,
|
||||
stateStartedAt: 1_700_000_000_000,
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: () => () => {},
|
||||
onClear: (cb) => {
|
||||
onClearListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onClearListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onClear listener to be registered')
|
||||
}
|
||||
|
||||
onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY })
|
||||
|
||||
expect(removeAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,254 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusUpdate } from '../store/slices/agent-status/agent-status-contract'
|
||||
import {
|
||||
buildStoreState,
|
||||
FUTURE_LEAF_ID,
|
||||
FUTURE_PANE_KEY,
|
||||
type AgentStatusSetData,
|
||||
type StoreLike,
|
||||
type StoreSubscribeListener
|
||||
} from './ipc-events-agent-status-store-test-fixtures'
|
||||
import {
|
||||
buildWindowApi,
|
||||
stubReactSyncEffect,
|
||||
stubAuxiliaryModules
|
||||
} from './ipc-events-agent-status-window-test-fixtures'
|
||||
|
||||
describe('useIpcEvents agent status snapshot integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves queued set-clear order for working removal and done retention', async () => {
|
||||
vi.useFakeTimers()
|
||||
let storeState: StoreLike
|
||||
const applyStatusUpdate = (
|
||||
paneKey: string,
|
||||
payload: { state: string },
|
||||
timing: { updatedAt?: number } | undefined
|
||||
): void => {
|
||||
storeState.agentStatusByPaneKey = {
|
||||
...(storeState.agentStatusByPaneKey as Record<string, unknown>),
|
||||
[paneKey]: { ...payload, updatedAt: timing?.updatedAt }
|
||||
}
|
||||
}
|
||||
const setAgentStatus = vi.fn(
|
||||
(
|
||||
paneKey: string,
|
||||
payload: { state: string },
|
||||
_title: unknown,
|
||||
timing: { updatedAt?: number } | undefined
|
||||
) => {
|
||||
applyStatusUpdate(paneKey, payload, timing)
|
||||
}
|
||||
)
|
||||
const setAgentStatuses = vi.fn((updates: readonly AgentStatusUpdate[]) => {
|
||||
for (const update of updates) {
|
||||
applyStatusUpdate(update.paneKey, update.payload, update.timing)
|
||||
}
|
||||
return updates.map(() => true)
|
||||
})
|
||||
const removeAgentStatus = vi.fn((paneKey: string) => {
|
||||
const next = { ...(storeState.agentStatusByPaneKey as Record<string, unknown>) }
|
||||
delete next[paneKey]
|
||||
storeState.agentStatusByPaneKey = next
|
||||
})
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const onClearListenerRef: { current: ((data: { paneKey: string }) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
storeState = buildStoreState({
|
||||
setAgentStatus,
|
||||
setAgentStatuses,
|
||||
removeAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
},
|
||||
onClear: (cb) => {
|
||||
onClearListenerRef.current = cb as (data: { paneKey: string }) => void
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
if (
|
||||
typeof onSetListenerRef.current !== 'function' ||
|
||||
typeof onClearListenerRef.current !== 'function'
|
||||
) {
|
||||
throw new Error('Expected agentStatus listeners to be registered')
|
||||
}
|
||||
const emit = (
|
||||
receivedAt: number,
|
||||
prompt: string,
|
||||
state: AgentStatusSetData['state'] = 'working'
|
||||
): void => {
|
||||
onSetListenerRef.current!({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state,
|
||||
prompt,
|
||||
agentType: 'claude',
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt
|
||||
})
|
||||
}
|
||||
|
||||
emit(1_700_000_000_000, 'leading')
|
||||
emit(1_700_000_000_001, 'queued')
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY })
|
||||
expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY)
|
||||
expect(storeState.agentStatusByPaneKey).toEqual({})
|
||||
|
||||
vi.advanceTimersByTime(40)
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatuses).toHaveBeenCalledTimes(1)
|
||||
expect(storeState.agentStatusByPaneKey).toEqual({})
|
||||
|
||||
emit(1_700_000_000_002, 'task')
|
||||
emit(1_700_000_000_003, 'task', 'done')
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(2)
|
||||
|
||||
onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY })
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(2)
|
||||
expect(setAgentStatuses).toHaveBeenCalledTimes(2)
|
||||
expect(setAgentStatuses.mock.calls[1][0][0]).toEqual(
|
||||
expect.objectContaining({ payload: expect.objectContaining({ state: 'done' }) })
|
||||
)
|
||||
expect(removeAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(storeState.agentStatusByPaneKey).toEqual({
|
||||
[FUTURE_PANE_KEY]: expect.objectContaining({ state: 'done' })
|
||||
})
|
||||
vi.advanceTimersByTime(40)
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(2)
|
||||
expect(setAgentStatuses).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not recurse when flushing a pending status re-enters via the store subscriber', async () => {
|
||||
// Model synchronous Zustand notifications: pending flush once reentered itself with the same queued event until stack overflow (crash 9fc89529).
|
||||
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
let setAgentStatusCalls = 0
|
||||
const notify = (previousState: StoreLike = storeState): void => {
|
||||
const listener = subscribeListenerRef.current
|
||||
if (listener) {
|
||||
listener(storeState, previousState)
|
||||
}
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus: (paneKey: string, entry: unknown) => {
|
||||
setAgentStatusCalls += 1
|
||||
storeState.agentStatusByPaneKey = {
|
||||
...(storeState.agentStatusByPaneKey as Record<string, unknown>),
|
||||
[paneKey]: entry
|
||||
}
|
||||
notify()
|
||||
},
|
||||
workspaceSessionReady: true,
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: false } },
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn((listener: StoreSubscribeListener) => {
|
||||
subscribeListenerRef.current = listener
|
||||
return () => {
|
||||
subscribeListenerRef.current = null
|
||||
}
|
||||
}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
if (typeof subscribeListenerRef.current !== 'function') {
|
||||
throw new Error('Expected useAppStore.subscribe listener to be registered')
|
||||
}
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'p',
|
||||
agentType: 'claude',
|
||||
receivedAt: 1_700_000_000_100,
|
||||
stateStartedAt: 1_699_999_999_100
|
||||
})
|
||||
expect(setAgentStatusCalls).toBe(0)
|
||||
|
||||
// Hydration retries synchronously; the re-entry guard must apply this event once without recursion.
|
||||
const beforeHydration = { ...storeState }
|
||||
storeState.tabsByWorktree = {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
|
||||
}
|
||||
storeState.terminalLayoutsByTabId = {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => notify(beforeHydration)).not.toThrow()
|
||||
expect(setAgentStatusCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,364 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildStoreState,
|
||||
expectWorktreeRouting,
|
||||
FUTURE_LEAF_ID,
|
||||
FUTURE_PANE_KEY,
|
||||
type AgentStatusSetData,
|
||||
type StoreLike,
|
||||
type StoreSubscribeListener
|
||||
} from './ipc-events-agent-status-store-test-fixtures'
|
||||
import {
|
||||
buildWindowApi,
|
||||
stubReactSyncEffect,
|
||||
stubAuxiliaryModules
|
||||
} from './ipc-events-agent-status-window-test-fixtures'
|
||||
|
||||
describe('useIpcEvents agent status snapshot integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('queues replayed startup snapshots until the pane hydrates', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
|
||||
let resolveSnapshot!: (entries: AgentStatusSetData[]) => void
|
||||
const getSnapshot = vi.fn(
|
||||
() =>
|
||||
new Promise<AgentStatusSetData[]>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn((listener: StoreSubscribeListener) => {
|
||||
subscribeListenerRef.current = listener
|
||||
return () => {
|
||||
subscribeListenerRef.current = null
|
||||
}
|
||||
}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
getSnapshot,
|
||||
onSet: () => () => {}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
resolveSnapshot([
|
||||
{
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
tabId: 'tab-future',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-1',
|
||||
state: 'working',
|
||||
prompt: 'PreToolUse: Bash',
|
||||
agentType: 'codex',
|
||||
toolName: 'Bash',
|
||||
toolInput: 'pnpm test',
|
||||
terminalHandle: 'term-future',
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000,
|
||||
// A reload starts with no state history; the host's turn start must arrive with the row.
|
||||
turnStartedAt: 1_699_999_990_000
|
||||
}
|
||||
])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
|
||||
const beforeHydration = { ...storeState }
|
||||
Object.assign(storeState, {
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
subscribeListenerRef.current?.(storeState, beforeHydration)
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
FUTURE_PANE_KEY,
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'PreToolUse: Bash',
|
||||
agentType: 'codex',
|
||||
toolName: 'Bash',
|
||||
toolInput: 'pnpm test'
|
||||
}),
|
||||
'SSH Tab',
|
||||
{
|
||||
updatedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000,
|
||||
turnStartedAt: 1_699_999_990_000
|
||||
},
|
||||
expectWorktreeRouting('wt-1'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses notifications for replayed done snapshots after pane hydration', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const observeAgentHookCompletionForNotification = vi.fn()
|
||||
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
|
||||
let resolveSnapshot!: (entries: AgentStatusSetData[]) => void
|
||||
const getSnapshot = vi.fn(
|
||||
() =>
|
||||
new Promise<AgentStatusSetData[]>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } },
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn((listener: StoreSubscribeListener) => {
|
||||
subscribeListenerRef.current = listener
|
||||
return () => {
|
||||
subscribeListenerRef.current = null
|
||||
}
|
||||
}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
vi.doMock('./agent-hook-completion-notifications', () => ({
|
||||
observeAgentHookCompletionForNotification,
|
||||
resetAgentHookCompletionNotificationCoordinators: vi.fn(),
|
||||
syncAgentHookCompletionNotificationSettings: vi.fn(),
|
||||
syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn()
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
getSnapshot,
|
||||
onSet: () => () => {}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
resolveSnapshot([
|
||||
{
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
tabId: 'tab-future',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-1',
|
||||
state: 'done',
|
||||
prompt: 'remote completion',
|
||||
agentType: 'codex',
|
||||
lastAssistantMessage: 'queued completion',
|
||||
terminalHandle: 'term-future',
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
}
|
||||
])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
|
||||
const beforeHydration = { ...storeState }
|
||||
Object.assign(storeState, {
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
subscribeListenerRef.current?.(storeState, beforeHydration)
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
FUTURE_PANE_KEY,
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'remote completion',
|
||||
agentType: 'codex',
|
||||
lastAssistantMessage: 'queued completion'
|
||||
}),
|
||||
'SSH Tab',
|
||||
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 },
|
||||
expectWorktreeRouting('wt-1'),
|
||||
undefined
|
||||
)
|
||||
expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts WSL-relayed status events for a local repo (wsl:* is transport provenance, not ownership)', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'WSL Tab' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'wsl p',
|
||||
agentType: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'wsl:Ubuntu',
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
})
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
FUTURE_PANE_KEY,
|
||||
expect.objectContaining({ state: 'working', prompt: 'wsl p', agentType: 'claude' }),
|
||||
'WSL Tab',
|
||||
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 },
|
||||
expectWorktreeRouting('wt-1'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('still rejects WSL-relayed status events against an SSH-owned repo', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
repos: [{ id: 'repo-1', connectionId: 'ssh-1' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'wsl p',
|
||||
agentType: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'wsl:Ubuntu',
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
})
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,352 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildStoreState } from './ipc-events-agent-status-store-test-fixtures'
|
||||
import type { StoreLike } from './ipc-events-agent-status-store-test-fixtures'
|
||||
import {
|
||||
buildWindowApi,
|
||||
stubReactSyncEffect,
|
||||
stubAuxiliaryModules
|
||||
} from './ipc-events-agent-status-window-test-fixtures'
|
||||
import { buildSshAuthorityReconciliationHarness } from './ipc-events-ssh-authority-test-fixtures'
|
||||
|
||||
function stubDirectSshModules(args: { storeState: StoreLike }): void {
|
||||
stubReactSyncEffect()
|
||||
stubAuxiliaryModules()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => args.storeState
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Pull main’s durable status snapshot through the real hook only after tabs are ready, recovering startup pushes received before local state.
|
||||
describe('useIpcEvents agent status snapshot integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ partialAuthority: { providerEpoch: 'epoch-current' } },
|
||||
{ partialAuthority: { connectionGeneration: 7 } }
|
||||
])('fills a same-watermark partial SSH authority', async ({ partialAuthority }) => {
|
||||
const harness = buildSshAuthorityReconciliationHarness({
|
||||
partialAuthority,
|
||||
latestAuthority: {
|
||||
providerEpoch: 'epoch-current',
|
||||
connectionGeneration: 7
|
||||
}
|
||||
})
|
||||
stubDirectSshModules({ storeState: harness.storeState })
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
harness.emitPartialState()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.setSshConnectionState).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(harness.getState).toHaveBeenCalledOnce()
|
||||
expect(harness.storedState()).toEqual({
|
||||
targetId: 'target-reconciliation',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
providerEpoch: 'epoch-current',
|
||||
connectionGeneration: 7
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ partialAuthority: { providerEpoch: 'epoch-conflict' } },
|
||||
{ partialAuthority: { connectionGeneration: 6 } }
|
||||
])(
|
||||
'rejects a reconciliation reply that conflicts with present authority',
|
||||
async ({ partialAuthority }) => {
|
||||
const harness = buildSshAuthorityReconciliationHarness({
|
||||
partialAuthority,
|
||||
latestAuthority: {
|
||||
providerEpoch: 'epoch-current',
|
||||
connectionGeneration: 7
|
||||
}
|
||||
})
|
||||
stubDirectSshModules({ storeState: harness.storeState })
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
harness.emitPartialState()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.getState).toHaveBeenCalledOnce()
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(harness.setSshConnectionState).toHaveBeenCalledOnce()
|
||||
expect(harness.storedState()).toEqual(
|
||||
expect.objectContaining({
|
||||
targetId: 'target-reconciliation',
|
||||
...partialAuthority
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not let initial SSH port snapshots overwrite newer push events', async () => {
|
||||
const targetId = 'target-ports'
|
||||
const secondTargetId = 'target-ports-second'
|
||||
const rejectingTargetId = 'target-ports-rejecting'
|
||||
const partialTargetId = 'target-ports-partial'
|
||||
const connectedState = {
|
||||
targetId,
|
||||
status: 'connected' as const,
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
providerEpoch: 'epoch-ports',
|
||||
connectionGeneration: 3
|
||||
}
|
||||
const secondConnectedState = {
|
||||
...connectedState,
|
||||
targetId: secondTargetId,
|
||||
providerEpoch: 'epoch-ports-second'
|
||||
}
|
||||
const rejectingConnectedState = {
|
||||
...connectedState,
|
||||
targetId: rejectingTargetId,
|
||||
providerEpoch: 'epoch-ports-rejecting'
|
||||
}
|
||||
const partialConnectedState = {
|
||||
...connectedState,
|
||||
targetId: partialTargetId,
|
||||
providerEpoch: null,
|
||||
connectionGeneration: undefined
|
||||
}
|
||||
const reconciledPartialState = {
|
||||
...connectedState,
|
||||
targetId: partialTargetId,
|
||||
providerEpoch: 'epoch-ports-partial',
|
||||
connectionGeneration: 4
|
||||
}
|
||||
const liveForward = {
|
||||
id: 'forward-live',
|
||||
targetId,
|
||||
localPort: 17860,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 7860,
|
||||
status: 'active' as const
|
||||
}
|
||||
const secondForward = {
|
||||
...liveForward,
|
||||
id: 'forward-second',
|
||||
targetId: secondTargetId,
|
||||
localPort: 17861,
|
||||
remotePort: 7861
|
||||
}
|
||||
const rejectingTargetForward = {
|
||||
...liveForward,
|
||||
id: 'forward-rejecting-target',
|
||||
targetId: rejectingTargetId,
|
||||
localPort: 17862,
|
||||
remotePort: 7862
|
||||
}
|
||||
const partialTargetForward = {
|
||||
...liveForward,
|
||||
id: 'forward-partial-target',
|
||||
targetId: partialTargetId,
|
||||
localPort: 17863,
|
||||
remotePort: 7863
|
||||
}
|
||||
const detectedPort = {
|
||||
port: 7860,
|
||||
pid: 42,
|
||||
processName: 'python',
|
||||
command: 'python -m http.server 7860'
|
||||
}
|
||||
const secondDetectedPort = {
|
||||
...detectedPort,
|
||||
port: 7861,
|
||||
pid: 43,
|
||||
command: 'python -m http.server 7861'
|
||||
}
|
||||
let resolveForwards: (value: []) => void = () => {}
|
||||
let resolveDetected: (value: (typeof detectedPort)[]) => void = () => {}
|
||||
let resolveSecondForwards: (value: (typeof secondForward)[]) => void = () => {}
|
||||
let resolveSecondDetected: (value: []) => void = () => {}
|
||||
const forwardsSnapshot = new Promise<[]>((resolve) => {
|
||||
resolveForwards = resolve
|
||||
})
|
||||
const detectedSnapshot = new Promise<(typeof detectedPort)[]>((resolve) => {
|
||||
resolveDetected = resolve
|
||||
})
|
||||
const secondForwardsSnapshot = new Promise<(typeof secondForward)[]>((resolve) => {
|
||||
resolveSecondForwards = resolve
|
||||
})
|
||||
const secondDetectedSnapshot = new Promise<[]>((resolve) => {
|
||||
resolveSecondDetected = resolve
|
||||
})
|
||||
const listPortForwards = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === rejectingTargetId) {
|
||||
return Promise.resolve([rejectingTargetForward])
|
||||
}
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
return Promise.resolve([partialTargetForward])
|
||||
}
|
||||
return requestedTargetId === targetId ? forwardsSnapshot : secondForwardsSnapshot
|
||||
})
|
||||
const listDetectedPorts = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === rejectingTargetId) {
|
||||
return Promise.reject(new Error('detected snapshot unavailable'))
|
||||
}
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
return requestedTargetId === targetId ? detectedSnapshot : secondDetectedSnapshot
|
||||
})
|
||||
let forwardListener:
|
||||
| ((data: { targetId: string; forwards: (typeof liveForward)[] }) => void)
|
||||
| undefined
|
||||
let detectedListener:
|
||||
| ((data: { targetId: string; ports: (typeof detectedPort)[] }) => void)
|
||||
| undefined
|
||||
const setPortForwards = vi.fn()
|
||||
const setDetectedPorts = vi.fn()
|
||||
const sshConnectionStates = new Map<
|
||||
string,
|
||||
| typeof connectedState
|
||||
| typeof secondConnectedState
|
||||
| typeof rejectingConnectedState
|
||||
| typeof partialConnectedState
|
||||
| typeof reconciledPartialState
|
||||
>()
|
||||
const storeState = buildStoreState({
|
||||
sshTargetLabels: new Map([
|
||||
[targetId, 'Ports Target'],
|
||||
[secondTargetId, 'Second Ports Target'],
|
||||
[rejectingTargetId, 'Rejecting Ports Target'],
|
||||
[partialTargetId, 'Partial Ports Target']
|
||||
]),
|
||||
sshConnectionStates,
|
||||
setSshConnectionState: (
|
||||
nextTargetId: string,
|
||||
state:
|
||||
| typeof connectedState
|
||||
| typeof secondConnectedState
|
||||
| typeof rejectingConnectedState
|
||||
| typeof partialConnectedState
|
||||
| typeof reconciledPartialState
|
||||
) => {
|
||||
sshConnectionStates.set(nextTargetId, state)
|
||||
},
|
||||
setPortForwards,
|
||||
setUnrestoredPortForwards: vi.fn(),
|
||||
setDetectedPorts,
|
||||
setSshTargetsMetadata: vi.fn(),
|
||||
setRemovedSshTargetLabels: vi.fn(),
|
||||
fetchRuntimeEnvironmentRepos: vi.fn(async () => []),
|
||||
fetchWorktreeLineage: vi.fn(async () => undefined),
|
||||
clearRemoteDetectedAgents: vi.fn(),
|
||||
clearRemovedSshTargetState: vi.fn()
|
||||
})
|
||||
let partialTargetStateCalls = 0
|
||||
|
||||
stubDirectSshModules({ storeState })
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: () => () => {},
|
||||
ssh: {
|
||||
listTargets: () =>
|
||||
Promise.resolve([
|
||||
{ id: rejectingTargetId, label: 'Rejecting Ports Target' },
|
||||
{ id: partialTargetId, label: 'Partial Ports Target' },
|
||||
{ id: targetId, label: 'Ports Target' },
|
||||
{ id: secondTargetId, label: 'Second Ports Target' }
|
||||
]),
|
||||
listRemovedTargetLabels: () => Promise.resolve({}),
|
||||
getState: ({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
partialTargetStateCalls += 1
|
||||
return Promise.resolve(
|
||||
partialTargetStateCalls === 1 ? partialConnectedState : reconciledPartialState
|
||||
)
|
||||
}
|
||||
return Promise.resolve(
|
||||
requestedTargetId === rejectingTargetId
|
||||
? rejectingConnectedState
|
||||
: requestedTargetId === targetId
|
||||
? connectedState
|
||||
: secondConnectedState
|
||||
)
|
||||
},
|
||||
listPortForwards,
|
||||
listUnrestoredPortForwards: () => Promise.resolve([]),
|
||||
listDetectedPorts,
|
||||
onPortForwardsChanged: (
|
||||
listener: (data: { targetId: string; forwards: (typeof liveForward)[] }) => void
|
||||
) => {
|
||||
forwardListener = listener
|
||||
return () => {}
|
||||
},
|
||||
onDetectedPortsChanged: (
|
||||
listener: (data: { targetId: string; ports: (typeof detectedPort)[] }) => void
|
||||
) => {
|
||||
detectedListener = listener
|
||||
return () => {}
|
||||
},
|
||||
onTargetRemoved: () => () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(forwardListener).toBeTypeOf('function')
|
||||
expect(detectedListener).toBeTypeOf('function')
|
||||
expect(partialTargetStateCalls).toBe(2)
|
||||
expect(sshConnectionStates.get(partialTargetId)).toEqual(reconciledPartialState)
|
||||
expect(listPortForwards).toHaveBeenCalledTimes(4)
|
||||
expect(listDetectedPorts).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === rejectingTargetId
|
||||
)
|
||||
).toEqual([[rejectingTargetId, [rejectingTargetForward]]])
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === partialTargetId
|
||||
)
|
||||
).toEqual([[partialTargetId, [partialTargetForward]]])
|
||||
})
|
||||
forwardListener?.({ targetId, forwards: [liveForward] })
|
||||
resolveForwards([])
|
||||
resolveDetected([detectedPort])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId)
|
||||
).toEqual([[targetId, [liveForward]]])
|
||||
expect(
|
||||
setDetectedPorts.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId)
|
||||
).toEqual([[targetId, [detectedPort]]])
|
||||
expect(listPortForwards).toHaveBeenCalledTimes(4)
|
||||
expect(listDetectedPorts).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
forwardListener?.({ targetId, forwards: [liveForward] })
|
||||
detectedListener?.({ targetId: secondTargetId, ports: [secondDetectedPort] })
|
||||
resolveSecondForwards([secondForward])
|
||||
resolveSecondDetected([])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === secondTargetId
|
||||
)
|
||||
).toEqual([[secondTargetId, [secondForward]]])
|
||||
expect(
|
||||
setDetectedPorts.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === secondTargetId
|
||||
)
|
||||
).toEqual([[secondTargetId, [secondDetectedPort]]])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings'
|
||||
import { applyAgentPermissionMode, AUTO_TUI_AGENT_ARGS } from '../permissions/tui-agent-permissions'
|
||||
import { applyAgentPermissionMode } from '../permissions/tui-agent-permissions'
|
||||
|
||||
describe('agent launch permission projection compatibility', () => {
|
||||
it('changes only agents Auto does not cover', () => {
|
||||
@@ -13,7 +13,6 @@ describe('agent launch permission projection compatibility', () => {
|
||||
{ type: 'permissions', mode: 'manual', exceptAuto: true },
|
||||
'darwin'
|
||||
)
|
||||
expect(updates.agentDefaultArgs).toMatchObject(AUTO_TUI_AGENT_ARGS)
|
||||
expect(updates.agentDefaultArgs?.gemini).toBe('')
|
||||
expect(() =>
|
||||
agentLaunchSettingsMutationUpdates(
|
||||
|
||||
@@ -33,7 +33,6 @@ windowsIt('preserves the startup query and spaced custom args in native Windows
|
||||
})
|
||||
const encodedCommand = plan?.command.match(/-EncodedCommand\s+(\S+)/)?.[1]
|
||||
|
||||
expect(encodedCommand).toBeDefined()
|
||||
execFileSync('powershell.exe', ['-NoProfile', '-EncodedCommand', encodedCommand!], {
|
||||
cwd: tempDir,
|
||||
env: { ...process.env, ...plan?.env }
|
||||
|
||||
@@ -56,13 +56,9 @@ it.each(
|
||||
env: { ...process.env, ...plan.env, ORCA_OMP_FRESH_CONFIG: config, CAPTURE: calls }
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(present ? 17 : 1)
|
||||
expect(result.stderr).toContain(
|
||||
present ? 'agent-stderr' : 'fresh OMP settings are unavailable'
|
||||
)
|
||||
if (present) {
|
||||
expect(result.stderr).toBe('agent-stderr')
|
||||
}
|
||||
expect(result.stderr).not.toContain('command substitutions not allowed')
|
||||
expect(await readFile(calls, 'utf8')).toBe('task with spaces\n')
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -34,7 +34,6 @@ it.each(shells)(
|
||||
platform: 'linux',
|
||||
isRemote: true
|
||||
})
|
||||
expect(plan).not.toBeNull()
|
||||
const define =
|
||||
shell === 'fish'
|
||||
? 'function omp; printf "%s\\n" $argv >> "$CAPTURE"; return 17; end; '
|
||||
|
||||
Reference in New Issue
Block a user