From 629db0a1f4baceeac7cf92b5ba5fc7970c6538a0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:31:35 -0700 Subject: [PATCH] [CI] Reduce test imports, clock waits, and headless cleanup costs (#29312) * [CI] Keep transport-only tests outside the app store graph * [CI] Advance the updater install clock in failure tests * [CI] Avoid descriptor formatting in replay clip counts * [CI] Advance native-chat test observation clocks * [CI] Restore observation timers after failed test setup * [CI] Clean up final headless daemons during public close * [CI] Keep serializer test oracle out of production smoke triggers * [CI] Keep three unit fixtures out of production smoke triggers * [CI] Bound frozen-clock close callbacks in tests * [CI] Avoid the app-store graph in feedback dialog tests * [CI] Keep hidden popup fixture compositor frames active * test(ci): reuse mobile runtime fixture for pairing listeners * test: restore native chat clocks before rig cleanup --- .github/workflows/computer-e2e.yml | 4 + .../workflows/win-orcad-serve-switch-e2e.yml | 4 + .../scripts/orcad-e2e-change-scope.test.mjs | 43 ++++++ ...er-popup-compatibility-electron-fixture.ts | 2 + src/main/daemon/serialize-grid-roundtrip.ts | 9 +- src/main/ipc/mobile.test.ts | 4 +- ...-session-observation-clock.test-fixture.ts | 30 +++++ ...ed-agent-session-observation-clock.test.ts | 48 +++++++ ...ured-agent-session-queued-messages.test.ts | 97 ++++++------- ...ed-agent-session-queued-pause-lift.test.ts | 11 +- ...ent-session-queued-withdrawn-draft.test.ts | 10 +- ...d-agent-session-stop-event-entries.test.ts | 127 +++++++++++++----- ...tructured-agent-session-stop-event.test.ts | 18 ++- ...red-agent-session-unsent-send-hold.test.ts | 29 ++-- ...me-rpc-mobile-method-allowlist-fixtures.ts | 1 + .../updater.install-failure-cause.test.ts | 14 +- .../sidebar/SidebarFeedbackDialog.test.tsx | 2 + .../sidebar-feedback-draft.test-fixture.ts | 14 ++ ...connect-multi-pane-materialization.test.ts | 15 ++- ...te-runtime-error-surface-dismissal.test.ts | 15 ++- ...ge-toast-flood-and-stuck-reconnect.test.ts | 15 ++- ...mote-runtime-pty-deadline-reattach.test.ts | 15 ++- ...runtime-pty-latched-pane-retention.test.ts | 15 ++- ...-runtime-pty-query-reply-immediate.test.ts | 15 ++- ...e-runtime-pty-snapshot-escape-tail.test.ts | 15 ++- ...e-runtime-pty-snapshot-source-grid.test.ts | 15 ++- ...-transport-create-outcome-recovery.test.ts | 15 ++- ...-pty-transport-host-session-launch.test.ts | 13 ++ ...ime-pty-transport-pairing-rotation.test.ts | 18 ++- ...-pty-transport-reattach-type-ahead.test.ts | 15 ++- ...runtime-pty-transport-spawn-intent.test.ts | 12 ++ ...-pty-transport-subscribe-ack-input.test.ts | 15 ++- ...subscribe-failure-recovery-routing.test.ts | 15 ++- .../remote-snapshot-history-seam.test.ts | 15 ++- .../helpers/headless-paired-runtime-host.ts | 13 +- 35 files changed, 595 insertions(+), 123 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-feedback-draft.test-fixture.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index ed88f584678..054c30be5e1 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -36,6 +36,10 @@ on: - '!src/main/daemon/daemon-init-test-harness.ts' - '!src/main/daemon/daemon-init-fresh-import.ts' - '!src/main/daemon/daemon-init-mock-types.ts' + - '!src/main/daemon/serialize-grid-roundtrip.ts' + - '!src/main/daemon/mock-descendant-sweep.ts' + - '!src/main/daemon/daemon-host-relocation.test-fixture.ts' + - '!src/main/daemon/daemon-endpoint-reachability-test-harness.ts' - 'src/main/providers/local-pty-provider.ts' - 'src/main/providers/windows-shell-args.ts' - 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts' diff --git a/.github/workflows/win-orcad-serve-switch-e2e.yml b/.github/workflows/win-orcad-serve-switch-e2e.yml index 7b8970a95a6..7a23876dc49 100644 --- a/.github/workflows/win-orcad-serve-switch-e2e.yml +++ b/.github/workflows/win-orcad-serve-switch-e2e.yml @@ -12,6 +12,10 @@ on: paths: - 'src/main/daemon/**' - '!src/main/daemon/**/*.test.ts' + - '!src/main/daemon/serialize-grid-roundtrip.ts' + - '!src/main/daemon/mock-descendant-sweep.ts' + - '!src/main/daemon/daemon-host-relocation.test-fixture.ts' + - '!src/main/daemon/daemon-endpoint-reachability-test-harness.ts' # D7 packages production entries; the unit jobs own these test roots. - 'src/main/orcad/**' - '!src/main/orcad/**/*.test.ts' diff --git a/config/scripts/orcad-e2e-change-scope.test.mjs b/config/scripts/orcad-e2e-change-scope.test.mjs index b7b98c81e00..84daa684bdd 100644 --- a/config/scripts/orcad-e2e-change-scope.test.mjs +++ b/config/scripts/orcad-e2e-change-scope.test.mjs @@ -12,6 +12,9 @@ const workflows = { ), wsl: parse( readFileSync(new URL('../../.github/workflows/wsl-orcad-e2e.yml', import.meta.url), 'utf8') + ), + computer: parse( + readFileSync(new URL('../../.github/workflows/computer-e2e.yml', import.meta.url), 'utf8') ) } @@ -97,6 +100,46 @@ for (const [lane, { excluded, retained, product }] of Object.entries(cases)) { }) } +describe.each(['windows', 'computer'])('%s unit-support routing', (lane) => { + const paths = workflows[lane].on.pull_request.paths + const helpers = [ + 'src/main/daemon/serialize-grid-roundtrip.ts', + 'src/main/daemon/mock-descendant-sweep.ts', + 'src/main/daemon/daemon-host-relocation.test-fixture.ts', + 'src/main/daemon/daemon-endpoint-reachability-test-harness.ts' + ] + const product = 'src/main/daemon/daemon-entry.ts' + const renamed = 'src/main/daemon/serialize-grid-runtime.ts' + + it.each(helpers)('leaves known unit support to its suites: %s', (helper) => { + expect(workflowIncludes(paths, [helper])).toBe(false) + expect(workflowIncludes(paths, [helper, 'docs/ci.md'])).toBe(false) + expect(workflowIncludes(paths, [helper, product])).toBe(true) + // Both names of a rename must retain the production workflow. + expect(workflowIncludes(paths, [helper, renamed])).toBe(true) + expect(workflowIncludes(paths, [renamed, helper])).toBe(true) + expect(workflowIncludes(paths, ['src/main/daemon/unknown-test-harness.ts'])).toBe(true) + }) + + it('keeps production selected when multiple unit fixtures change', () => { + expect(workflowIncludes(paths, helpers)).toBe(false) + expect(workflowIncludes(paths, [...helpers, product])).toBe(true) + }) +}) + +it.each([ + 'config/scripts/daemon-boot-smoke.mjs', + 'config/scripts/daemon-endpoint-handover-smoke.mjs', + 'config/scripts/windows-daemon-workspace-close-repro.mjs', + 'config/scripts/computer-use-smoke.test.mjs', + 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts', + 'native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift', + 'src/cli/handlers/computer.ts', + '.github/workflows/computer-e2e.yml' +])('keeps the computer contract owner selected: %s', (file) => { + expect(workflowIncludes(workflows.computer.on.pull_request.paths, [file])).toBe(true) +}) + it('re-includes the real WSL distro root after excluding unit tests', () => { const paths = workflows.wsl.on.pull_request.paths const exclude = paths.indexOf('!src/main/wsl/**/*.test.ts') diff --git a/src/main/browser/browser-popup-compatibility-electron-fixture.ts b/src/main/browser/browser-popup-compatibility-electron-fixture.ts index 1196ff997e6..d81b21c5bfe 100644 --- a/src/main/browser/browser-popup-compatibility-electron-fixture.ts +++ b/src/main/browser/browser-popup-compatibility-electron-fixture.ts @@ -143,6 +143,8 @@ async function createHost(name: string, partition: string, nativeControl = false contextIsolation: true, nodeIntegration: false, sandbox: true, + // Hidden popup probes still need compositor frames for real CDP input. + backgroundThrottling: false, webviewTag: true } }) diff --git a/src/main/daemon/serialize-grid-roundtrip.ts b/src/main/daemon/serialize-grid-roundtrip.ts index 870788a03e8..df8c13b534e 100644 --- a/src/main/daemon/serialize-grid-roundtrip.ts +++ b/src/main/daemon/serialize-grid-roundtrip.ts @@ -18,7 +18,7 @@ import { variantTrailingBackgroundRows } from './serialize-grid-variant-scope' import type { GridDiff } from './serialize-grid-cell-descriptors' -import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors' +import { compareBufferRows } from './serialize-grid-cell-descriptors' export type NamedSerializer = { name: string; create: () => SerializeAddon } @@ -202,7 +202,12 @@ function countClippedWideCells(terminal: Terminal): number { const scratch = buffer.getNullCell() let count = 0 for (let y = 0; y < buffer.length; y++) { - if (cellDescriptor(buffer.getLine(y), terminal.cols - 1, terminal.cols, scratch) === CLIPPED) { + const line = buffer.getLine(y) + if ( + line && + line.length > terminal.cols && + (line.getCell(terminal.cols - 1, scratch)?.getWidth() ?? 0) > 1 + ) { count++ } } diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 44e7f88e8d8..e433c23e068 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -37,7 +37,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { registerMobileHandlers } from './mobile' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' -import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { createMobileRpcSurfaceRuntime } from '../runtime/runtime-rpc-mobile-method-allowlist-fixtures' import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import { WebSocketTransport } from '../runtime/rpc/ws-transport' @@ -856,7 +856,7 @@ describe('runtime pairing bind host', () => { const startServer = async (userDataPath: string): Promise => { const server = new OrcaRuntimeRpcServer({ methods: [], - runtime: new OrcaRuntimeService(), + runtime: createMobileRpcSurfaceRuntime().runtime, userDataPath, enableWebSocket: true, wsPort: 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test-fixture.ts new file mode 100644 index 00000000000..950e3dad1f1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test-fixture.ts @@ -0,0 +1,30 @@ +import { vi } from 'vitest' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export function createStructuredSessionObservationClock( + getHost: () => StructuredAgentSessionHost, + sessionId: string +) { + function restore(): void { + vi.useRealTimers() + } + + return { + start(): void { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + }, + async observe(ms: number): Promise { + await vi.advanceTimersByTimeAsync(ms) + const host = getHost() + await host.flushStreamedEvents(sessionId) + await host.collaboratorsForTests().serialize(sessionId, async () => {}) + await new Promise((resolve) => setImmediate(resolve)) + await host.collaboratorsForTests().serialize(sessionId, async () => {}) + }, + restore, + async restoreBeforeCleanup(cleanup: () => Promise): Promise { + restore() + await cleanup() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test.ts new file mode 100644 index 00000000000..e5e7b823160 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-observation-clock.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { withTimeout } from '../../../shared/promise-timeout-fallback' +import { createStructuredSessionObservationClock } from './structured-agent-session-observation-clock.test-fixture' + +const realSetTimeout = setTimeout +const realClearTimeout = clearTimeout + +afterEach(() => vi.useRealTimers()) + +it('restores the clock before pending cleanup and propagates its real deadline failure', async () => { + const clock = createStructuredSessionObservationClock(() => { + throw new Error('cleanup must not access a session host') + }, 'cleanup-clock') + const phaseFailure = new Error('teardown phase timed out') + let releasePhase: () => void = () => {} + const pendingPhase = new Promise((resolve) => { + releasePhase = resolve + }) + let watchdog: ReturnType | undefined + let observationFired = false + clock.start() + setTimeout(() => { + observationFired = true + }, 1) + + try { + const cleanup = clock.restoreBeforeCleanup(async () => { + const outcome = await withTimeout(pendingPhase, 10, null) + if (outcome === null) { + throw phaseFailure + } + }) + const guardedCleanup = Promise.race([ + cleanup, + new Promise((_, reject) => { + watchdog = realSetTimeout(() => reject(new Error('cleanup deadline stayed frozen')), 1_000) + }) + ]) + expect(vi.isFakeTimers()).toBe(false) + await expect(guardedCleanup).rejects.toBe(phaseFailure) + expect(vi.isFakeTimers()).toBe(false) + expect(observationFired).toBe(false) + } finally { + clock.restore() + realClearTimeout(watchdog) + releasePhase() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index ffe732bfbbd..afd6f4cd9ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -34,29 +34,34 @@ import { } from './structured-agent-session-host-test-data' import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' +import { createStructuredSessionObservationClock } from './structured-agent-session-observation-clock.test-fixture' + let rig: QueuedMessageTestRig +let currentRig: QueuedMessageTestRig | undefined +const observationClock = createStructuredSessionObservationClock(() => rig.host, SESSION) +beforeEach(() => observationClock.start()) let host: QueuedMessageTestRig['host'] let store: QueuedMessageTestRig['store'] let dispatch: QueuedMessageTestRig['dispatch'] beforeEach(async () => { + currentRig = undefined rig = await createQueuedMessageTestRig() + currentRig = rig ;({ host, store, dispatch } = rig) }) -afterEach(() => rig.dispose()) +afterEach(async () => { + await observationClock.restoreBeforeCleanup(async () => { + await currentRig?.dispose() + }) +}) -const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args) const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args) -const stop: QueuedMessageTestRig['stop'] = (...args) => rig.stop(...args) -const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args) -const deleteQueued: QueuedMessageTestRig['deleteQueued'] = (...args) => rig.deleteQueued(...args) const drafts: QueuedMessageTestRig['drafts'] = (...args) => rig.drafts(...args) const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend() const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) => rig.settleAccepted(...args) -const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) => - rig.settleRejected(...args) describe('accept', () => { it('queues a capable send while the session owes work; an ordinary send still dispatches', async () => { @@ -76,7 +81,7 @@ describe('accept', () => { const body = hostTestMessage('queued behind') const clientOperationId = hostTestOperationId() const params = { - envelope: envelope( + envelope: rig.envelope( { body, delivery: 'queue-if-active' }, 'agentSession.send', clientOperationId @@ -106,7 +111,7 @@ describe('accept', () => { } const clientOperationId = hostTestOperationId() const result = await host.send(CALLER, { - envelope: envelope( + envelope: rig.envelope( { body, delivery: 'queue-if-active' }, 'agentSession.send', clientOperationId @@ -184,7 +189,7 @@ describe('drain', () => { const draftId = queued.value.queued.messageId await settleAccepted(working, 'a') await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) - await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') await eventually(async () => expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) @@ -198,7 +203,7 @@ describe('drain', () => { const overtaking = await send('sent past the card', 'queue-if-active').result expect(overtaking).toMatchObject({ ok: true, value: { submission: expect.anything() } }) // And the card still offers Send: a fresh submission id re-delivers it. - const resent = await sendNow(draftId) + const resent = await rig.sendNow(draftId) expect(resent).toMatchObject({ ok: true, value: { submission: expect.anything() } }) if (!resent.ok || !('submission' in resent.value)) { throw new Error('expected the submission arm') @@ -208,7 +213,7 @@ describe('drain', () => { expect(resent.value.submission.queuedMessageId).toBe(draftId) expect(await drafts()).toHaveLength(0) // Refused again: the card returns, matched through its current submission (N4). - await settleRejected(resent.value.submission.clientMessageId, 'refused again') + await rig.settleRejected(resent.value.submission.clientMessageId, 'refused again') await eventually(async () => expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) @@ -230,7 +235,7 @@ describe('drain', () => { throw new Error('bookkeeping failed') }) try { - await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') await eventually(async () => expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) @@ -251,7 +256,7 @@ describe('drain', () => { const firstId = first.value.queued.messageId const secondId = second.value.queued.messageId await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) - await settleRejected(await rig.handoffId(firstId), 'refused') + await rig.settleRejected(await rig.handoffId(firstId), 'refused') await eventually(async () => expect(await drafts()).toMatchObject([ { messageId: firstId, state: 'returned' }, @@ -259,7 +264,7 @@ describe('drain', () => { ]) ) // Deleting the card unblocks the one behind it. - expect(await deleteQueued(firstId)).toMatchObject({ ok: true, value: { deleted: true } }) + expect(await rig.deleteQueued(firstId)).toMatchObject({ ok: true, value: { deleted: true } }) await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined()) }) }) @@ -279,9 +284,9 @@ describe('held drafts', () => { expect(await rig.queuePause()).toBeNull() await host.close(SESSION, 'evict') expect(await rig.queuePause()).toBeNull() - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(draftId)).toBeUndefined() - expect(await sendNow(draftId)).toMatchObject({ + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true, value: { submission: expect.anything() } }) @@ -296,7 +301,7 @@ describe('held drafts', () => { const draftId = queued.value.queued.messageId await rig.restartHostProcess() await settleAccepted(working, 'a') - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toBeNull() // The user's send starting its turn lifts it. @@ -344,7 +349,7 @@ describe('held drafts', () => { QUEUED_MESSAGE_PAUSED_SEND_FAILED ) expect(await rig.handoff(draftId)).toBeUndefined() - expect(await sendNow(draftId)).toMatchObject({ + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true, value: { submission: expect.anything() } }) @@ -363,7 +368,7 @@ describe('Stop and Delete', () => { // The Stop comes from a DIFFERENT client than the one that typed the // drafts: it must never move their text anywhere. const operationId = hostTestOperationId() - const stopped = await stop(operationId, { callerKey: 'client-2' }) + const stopped = await rig.stop(operationId, { callerKey: 'client-2' }) expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(stopped.ok && Object.keys(stopped.value).sort()).toEqual(['cancelled']) expect(await drafts()).toEqual([ @@ -373,7 +378,7 @@ describe('Stop and Delete', () => { // One pause for the whole queue: "Queue paused because you interrupted". expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // A lost acknowledgement replays the settled Stop; still no text, no field. - const replayed = await stop(operationId, { callerKey: 'client-2' }) + const replayed = await rig.stop(operationId, { callerKey: 'client-2' }) expect(replayed).toMatchObject({ ok: true, replayed: true, value: { cancelled: false } }) expect(replayed.ok && Object.keys(replayed.value).sort()).toEqual(['cancelled']) expect(await drafts()).toHaveLength(2) @@ -397,7 +402,7 @@ describe('Stop and Delete', () => { it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => { const { draftId, release } = await consumedButNotHandedOver() - const stopping = stop() + const stopping = rig.stop() release() const stopped = await stopping expect(stopped).toMatchObject({ ok: true }) @@ -418,7 +423,7 @@ describe('Stop and Delete', () => { throw new Error('expected a queued receipt') } const draftId = queued.value.queued.messageId - await stop() + await rig.stop() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await settleAccepted(working, 'a') // Close and reopen it (the history read opens it at rest): the derived pause still holds, so @@ -427,10 +432,10 @@ describe('Stop and Delete', () => { expect(await rig.queuePause()).toBeNull() const reopened = host.collaboratorsForTests().sessions.get(SESSION)!.journal expect(structuredQueuePauses(reopened).map((pause) => pause.reason)).toContain('stopped') - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(draftId)).toBeUndefined() // Send-now overrides the pause — the user acting is a release. - expect(await sendNow(draftId)).toMatchObject({ + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true, value: { submission: expect.anything() } }) @@ -444,10 +449,10 @@ describe('Stop and Delete', () => { throw new Error('expected a queued receipt') } const draftId = queued.value.queued.messageId - await stop() + await rig.stop() await settleAccepted(working, 'a') // Settling the stopped turn is not the user starting one: still paused. - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // The host accepting the send is not yet a turn: the pause lifts when the provider accepts @@ -468,12 +473,12 @@ describe('Stop and Delete', () => { throw new Error('expected a queued receipt') } const draftId = queued.value.queued.messageId - await stop() + await rig.stop() await settleAccepted(working, 'a') // All three reach the host as a send the client send RPC did not make. const mail = send('coordinator mail') expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) // Not shown while the mail awaits the agent, and nothing sends yet. expect(await rig.queuePause()).toBeNull() expect(await rig.handoff(draftId)).toBeUndefined() @@ -507,14 +512,14 @@ describe('Stop and Delete', () => { const next = send('user starts a new turn') await next.result await settleAccepted(next.id, 'b') - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(draftId)).toBeUndefined() const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( QUEUED_MESSAGE_PAUSED_SEND_FAILED ) // The explicit Send is still the release. - expect(await sendNow(draftId)).toMatchObject({ + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true, value: { submission: expect.anything() } }) @@ -531,7 +536,7 @@ describe('Stop and Delete', () => { .mockRejectedValueOnce(new Error('disk full')) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - expect(await stop()).toMatchObject({ + expect(await rig.stop()).toMatchObject({ ok: false, refusal: { details: { reason: 'stopFailed' } } }) @@ -557,7 +562,7 @@ describe('Stop and Delete', () => { } const draftId = queued.value.queued.messageId const operationId = hostTestOperationId() - const deleted = await deleteQueued(draftId, operationId) + const deleted = await rig.deleteQueued(draftId, operationId) expect(deleted).toMatchObject({ ok: true, replayed: false, @@ -566,13 +571,13 @@ describe('Stop and Delete', () => { // The card leaving the published list is the whole answer. expect(deleted.ok && Object.keys(deleted.value).sort()).toEqual(['deleted', 'messageId']) expect(await drafts()).toHaveLength(0) - expect(await deleteQueued(draftId, operationId)).toMatchObject({ + expect(await rig.deleteQueued(draftId, operationId)).toMatchObject({ ok: true, replayed: true, value: { deleted: true, messageId: draftId } }) // A FRESH delete of the already-withdrawn draft reports the disposition. - expect(await deleteQueued(draftId)).toMatchObject({ + expect(await rig.deleteQueued(draftId)).toMatchObject({ ok: true, value: { deleted: false, disposition: 'withdrawn' } }) @@ -583,7 +588,7 @@ describe('/clear', () => { function clear(clientOperationId: string) { const fields = { command: 'clear' as const } return host.conversationCommand(CALLER, { - envelope: envelope(fields, 'agentSession.conversationCommand', clientOperationId), + envelope: rig.envelope(fields, 'agentSession.conversationCommand', clientOperationId), ...fields }) } @@ -597,13 +602,13 @@ describe('/clear', () => { if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { throw new Error('expected queued receipts') } - await stop() + await rig.stop() await settleAccepted(working, 'a') return [first.value.queued.messageId, second.value.queued.messageId] } it('the clear schema refuses the never-shipped withdraw opt-in', () => { - const base = { envelope: envelope({}, 'agentSession.conversationCommand', 'op-schema') } + const base = { envelope: rig.envelope({}, 'agentSession.conversationCommand', 'op-schema') } expect(ConversationCommandParams.safeParse({ ...base, command: 'clear' }).success).toBe(true) expect( ConversationCommandParams.safeParse({ ...base, command: 'clear', withdrawQueued: true }) @@ -626,7 +631,7 @@ describe('/clear', () => { ]) expect(await rig.queuePause(replacementId)).toBeNull() // Paused from before the first carried card lands: the idle replacement auto-sends nothing. - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect( (await host.journalSnapshot(replacementId)).submissions.filter( (entry) => entry.queuedMessageId !== undefined @@ -653,7 +658,7 @@ describe('/clear', () => { const working = await workingSend() await send('pointer', 'queue-if-active', { internal: true, from }).result await send('typed', 'queue-if-active').result - await stop() + await rig.stop() await settleAccepted(working, 'a') const cleared = await clear(hostTestOperationId()) const replacementId = cleared.ok ? SESSION : undefined @@ -678,7 +683,7 @@ describe('/clear', () => { } expect(await rig.queuePause(replacementId)).toBeNull() const resumed = await host.queuedMessagesResume(CALLER, { - envelope: envelope( + envelope: rig.envelope( {}, 'agentSession.queuedMessagesResume', hostTestOperationId(), @@ -717,7 +722,7 @@ describe('/clear', () => { const draftId = queued.value.queued.messageId await settleAccepted(working, 'a') await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) - await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') await eventually(async () => expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) @@ -834,7 +839,7 @@ describe('publication', () => { append.mockRestore() } // Send releases the process-level pause, which later tests' reused ids would otherwise inherit. - expect(await sendNow(draftId)).toMatchObject({ ok: true }) + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) }) it("live frames carry the queue's pause with the list, and Resume's lift", async () => { @@ -849,7 +854,7 @@ describe('publication', () => { event.type !== 'end' && event.queuePause !== undefined ? [event.queuePause] : [] ) await eventually(() => expect(pauses().at(-1)).toBeNull()) - await stop() + await rig.stop() await eventually(() => expect(pauses().at(-1)).toEqual({ reason: 'stopped' })) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(() => expect(pauses().at(-1)).toBeNull()) @@ -865,12 +870,12 @@ describe('publication', () => { const firstId = first.value.queued.messageId await settleAccepted(working, 'a') await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) - await settleRejected(await rig.handoffId(firstId), 'refused') + await rig.settleRejected(await rig.handoffId(firstId), 'refused') await eventually(async () => expect(await drafts()).toMatchObject([{ messageId: firstId, state: 'returned' }, {}]) ) // Idle, nothing in flight and nothing withdrawn: the Stop changes nothing. - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await rig.queuePause()).toBeNull() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 474c11d320e..128ed48723c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -24,7 +24,12 @@ import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test- import { sameQueuePause } from './structured-agent-session-queued-publication' import { structuredQueuePauses } from './structured-agent-session-queued-pause' +import { createStructuredSessionObservationClock } from './structured-agent-session-observation-clock.test-fixture' + let rig: QueuedMessageTestRig +const observationClock = createStructuredSessionObservationClock(() => rig.host, HOST_TEST_SESSION) +beforeEach(() => observationClock.start()) +afterEach(() => observationClock.restore()) /** Integration suites own their rig; pure projection checks stay fixture-free. */ function installRigLifecycleHooks(): void { @@ -32,12 +37,12 @@ function installRigLifecycleHooks(): void { rig = await createQueuedMessageTestRig() }) - afterEach(() => rig.dispose()) + afterEach(() => observationClock.restoreBeforeCleanup(() => rig.dispose())) } /** No drain step may convert the drafts, and the queue reads paused. */ async function expectPaused(...draftIds: string[]): Promise { - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) for (const draftId of draftIds) { expect(await rig.handoff(draftId)).toBeUndefined() } @@ -152,7 +157,7 @@ describe("a Stop's queue pause", () => { await handedOver(sentId) // Only the card the user asked for went: the queue is still paused, though not shown while // that turn is on its way. - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(heldId)).toBeUndefined() expect(derivedPauses()).toEqual(['stopped']) expect(await rig.queuePause()).toBeNull() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index e6c8e19d77a..6e97ea7e03f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -15,13 +15,19 @@ import { type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { createStructuredSessionObservationClock } from './structured-agent-session-observation-clock.test-fixture' + let rig: QueuedMessageTestRig +const observationClock = createStructuredSessionObservationClock(() => rig.host, SESSION) +beforeEach(() => observationClock.start()) beforeEach(async () => { rig = await createQueuedMessageTestRig() }) -afterEach(() => rig.dispose()) +afterEach(async () => { + await observationClock.restoreBeforeCleanup(() => rig.dispose()) +}) async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result @@ -166,7 +172,7 @@ it("a Stop whose withdrawal's settlement was skipped still pauses the card it se const { a } = await stopWithSkippedSettlement() // The drain heals the skipped return; the Stop's row already pauses whatever comes back. await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'stopped' })) - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) // Healed back to waiting, but the Stop's pause holds it: it does not send. expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 9bba98fcf3d..28db59ab917 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -17,6 +17,7 @@ import { import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig +const { setTimeout: realSetTimeout, clearTimeout: realClearTimeout } = globalThis afterEach(() => rig.dispose()) @@ -43,9 +44,10 @@ function stopEvents(): JournalStopEvent[] { } /** The Stop events as the provider's close finds them, or null when no close ran. */ -function stopEventsAtClose(): { events: JournalStopEvent[] | null } { +function stopEventsAtClose(onClose?: () => void): { events: JournalStopEvent[] | null } { const seen: { events: JournalStopEvent[] | null } = { events: null } rig.closeSession.mockImplementationOnce(async () => { + onClose?.() // The event is issued before the kill, never awaited by it: the journal writes it ahead of // anything the kill makes the child write. await new Promise((resolve) => setImmediate(resolve)) @@ -55,6 +57,62 @@ function stopEventsAtClose(): { events: JournalStopEvent[] | null } { return seen } +async function awaitCloseStep(pending: Promise, phase: string): Promise { + let timeout: ReturnType | undefined + // A missing host callback must fail without advancing the observation clock. + const deadline = new Promise((_, reject) => { + timeout = realSetTimeout(() => reject(new Error(`Timed out awaiting ${phase}`)), 2_000) + }) + try { + return await Promise.race([pending, deadline]) + } finally { + realClearTimeout(timeout) + } +} + +async function closePastSlowDrain( + cause: 'user-close' | 'evict', + prepare: () => void | Promise +) { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + const began = Date.now() + const entered = Promise.withResolvers() + const closingProvider = Promise.withResolvers() + const runtime = rig.host.collaboratorsForTests().runtimeState + const flush = runtime.flushEventSink.bind(runtime) + let closing: ReturnType | undefined + const flushing = vi.spyOn(runtime, 'flushEventSink') + try { + await awaitCloseStep(Promise.resolve().then(prepare), 'slow sink preparation') + flushing.mockImplementationOnce((sessionId) => { + const pending = flush(sessionId) + entered.resolve() + return pending + }) + const atClose = stopEventsAtClose(closingProvider.resolve) + const callsBefore = rig.closeSession.mock.calls.length + closing = rig.host.close(HOST_TEST_SESSION, cause) + await awaitCloseStep(entered.promise, 'close entering the sink drain') + await new Promise((resolve) => setImmediate(resolve)) + await vi.advanceTimersByTimeAsync(999) + expect(rig.closeSession).toHaveBeenCalledTimes(callsBefore) + await vi.advanceTimersByTimeAsync(1) + await awaitCloseStep(closingProvider.promise, 'provider close at 1,000ms') + expect(Date.now() - began).toBe(1_000) + return atClose + } finally { + try { + // Finish the original 1,500ms sink delay before teardown drains it. + await vi.advanceTimersByTimeAsync(Math.max(0, 1_500 - (Date.now() - began))) + } finally { + flushing.mockRestore() + vi.useRealTimers() + } + await awaitCloseStep(Promise.resolve(closing), 'host close cleanup') + await awaitCloseStep(rig.host.flushStreamedEvents(HOST_TEST_SESSION), 'event sink cleanup') + } +} + async function runningTurn(turnId = 'turn-1'): Promise { const working = await rig.workingSend() await journal().appendItem( @@ -161,29 +219,37 @@ describe('every Stop entry writes its event, with its reason, before it ends the const open = journal() const append = open.appendItem.bind(open) let held = false - vi.spyOn(open, 'appendItem').mockImplementation(async (...args: Parameters) => { - if (!held && args[1].kind === 'turn' && args[1].state === 'running') { - held = true - await new Promise((resolve) => setTimeout(resolve, 1_500)) - } - return append(...args) + const heldSink = Promise.withResolvers() + const atClose = await closePastSlowDrain('user-close', async () => { + vi.spyOn(open, 'appendItem').mockImplementation( + async (...args: Parameters) => { + if (!held && args[1].kind === 'turn' && args[1].state === 'running') { + held = true + const delay = new Promise((resolve) => setTimeout(resolve, 1_500)) + heldSink.resolve() + await delay + } + return append(...args) + } + ) + rig.host + .collaboratorsForTests() + .runtimeState.eventSinkFor(HOST_TEST_SESSION) + .sink.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, + { + kind: 'turn', + turnId: 'turn-1', + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(sent) + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await heldSink.promise + // The echo's acceptance lands straight in the journal, ahead of the turn row. + await rig.settleAccepted(sent, 'turn-1') }) - rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION).sink.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, - { - kind: 'turn', - turnId: 'turn-1', - state: 'running', - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(sent) - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - // The echo's acceptance lands straight in the journal, ahead of the turn row. - await rig.settleAccepted(sent, 'turn-1') - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'user-close') expect(atClose.events?.map((event) => event.reason)).toEqual(['user-close']) }, 20_000) @@ -241,14 +307,13 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) - ) - .mockImplementation(drained) - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'evict') + const atClose = await closePastSlowDrain('evict', () => { + vi.spyOn(sink, 'drained') + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) + ) + .mockImplementation(drained) + }) expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) // The close hides the row, as every close of a chat does; the Stop still pauses. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 3845d848e26..e6c917ba563 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -2,7 +2,7 @@ // before the interrupt and before anything that ends the child, naming the turn, who asked and // the target it captured; never by a Stop that stopped nothing. -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' @@ -16,9 +16,15 @@ import { type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -let rig: QueuedMessageTestRig +import { createStructuredSessionObservationClock } from './structured-agent-session-observation-clock.test-fixture' -afterEach(() => rig.dispose()) +let rig: QueuedMessageTestRig +const observationClock = createStructuredSessionObservationClock(() => rig.host, HOST_TEST_SESSION) +beforeEach(() => observationClock.start()) + +afterEach(async () => { + await observationClock.restoreBeforeCleanup(() => rig.dispose()) +}) function journal() { const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal @@ -146,7 +152,7 @@ describe("a Stop's event", () => { return true }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(rig.cancelTurn).not.toHaveBeenCalled() @@ -231,7 +237,7 @@ describe("a Stop's event", () => { expect(stopEvents()).toHaveLength(2) await withdraw(await rig.handoffId(steered)) await rig.settleAccepted(working, 'stopped') - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.drafts()).toEqual([{ messageId: steered, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) @@ -298,7 +304,7 @@ describe("a Stop's event", () => { }) await rig.settleAccepted(working, 'working') await eventually(() => expect(injected).toBe(true)) - await new Promise((resolve) => setTimeout(resolve, 250)) + await observationClock.observe(250) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index e3b0257711e..5e28a4fa9ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -53,7 +53,11 @@ const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed /** A kept send: an ordinary waiting card, with no hold of its own. */ const KEPT = { state: 'waiting' } +import { createStructuredSessionObservationClock } from './structured-agent-session-observation-clock.test-fixture' + let rig: QueuedMessageTestRig +const observationClock = createStructuredSessionObservationClock(() => rig.host, SESSION) +beforeEach(() => observationClock.start()) beforeEach(async () => { rig = await createQueuedMessageTestRig({ restartable: true }) @@ -61,7 +65,7 @@ beforeEach(async () => { afterEach(async () => { vi.restoreAllMocks() - await rig.dispose() + await observationClock.restoreBeforeCleanup(() => rig.dispose()) }) /** The exact request a client sends, so a test can send the same one again. */ @@ -209,7 +213,7 @@ describe('the next accepted turn releases a kept card', () => { it('a Resume sends it, and nothing sends it before', async () => { const id = await acceptWhileStarting(sendRequest('resume me')) await quitRestart() - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect(rig.dispatch).not.toHaveBeenCalled() expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(() => expect(dispatchedTexts()).toEqual(['resume me'])) @@ -289,7 +293,7 @@ describe('the next accepted turn releases a kept card', () => { await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) rig.crashRestartHostProcess() - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect(rig.dispatch).not.toHaveBeenCalled() const working = await rig.workingSend() const later = rig.send('queued after the restart', 'queue-if-active') @@ -322,13 +326,13 @@ describe('the next accepted turn releases a kept card', () => { ).toMatchObject({ ok: true }) await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) expect(await rig.sendNow(id)).toMatchObject({ ok: true }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect((await rig.handoff(id))?.handedOverAt).toBeUndefined() await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(journal().queuedMessages.get(id)?.holdReason).toBeNull() - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect(rig.dispatch).not.toHaveBeenCalled() }) }) @@ -418,7 +422,7 @@ describe('the queue at a quit', () => { release() await healing.mock.results[0]?.value // The step's append check runs on the turn after its heal resolves. - await new Promise((resolve) => setTimeout(resolve, 0)) + await vi.advanceTimersByTimeAsync(0) expect(await rig.handoff(queued.id)).toBeUndefined() } else { const quitting = host.flushAllStreamedEvents() @@ -444,7 +448,7 @@ describe('the queue at a quit', () => { const pushed = rig.send('pushed ahead', 'queue-if-active') await pushed.result expect(await rig.sendNow(pushed.id)).toMatchObject({ ok: true }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect((await rig.handoff(pushed.id))?.handedOverAt).toBeUndefined() await quitRestart() @@ -516,7 +520,7 @@ describe('the same send arriving again after the restart', () => { expect(again.ok && 'queued' in again.value).toBe(false) expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(submissionsBefore) expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect(rig.dispatch).not.toHaveBeenCalled() // Retry on such a client sends the words under a new id, and the card follows that turn, as @@ -701,7 +705,7 @@ describe("the reopen's mark", () => { await queued.result await rig.host.close(SESSION, cause) expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect(await rig.handoff(queued.id)).toBeUndefined() expect(dispatchedTexts()).toEqual(['work on this']) void working @@ -724,7 +728,8 @@ describe("the reopen's mark", () => { // An idle eviction is Orca's own act: the person sees the same rows, and nothing new is held. it('an idle-sweep eviction of a stopped chat writes nothing, and its Stop row stays', async () => { - await rig.dispose() + await observationClock.restoreBeforeCleanup(() => rig.dispose()) + observationClock.start() rig = await createQueuedMessageTestRig({ idleSweep: { idleMs: 0, intervalMs: 60 * 60 * 1000 } }) const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') const working = await rig.workingSend() @@ -758,7 +763,7 @@ describe("the reopen's mark", () => { rig.crashRestartHostProcess() await rig.host.restoreReadableSessions([SESSION]) expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect(await rig.handoff(queued.id)).toBeUndefined() expect(rig.dispatch).toHaveBeenCalledTimes(sent) expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) @@ -787,7 +792,7 @@ describe("the reopen's mark", () => { expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(marks).toHaveBeenCalledOnce() - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) expect(rig.dispatch).not.toHaveBeenCalled() const next = rig.send('a new request') diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts index d44d44d2acf..6a9f7eee67e 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts @@ -119,6 +119,7 @@ export function createMobileRpcSurfaceRuntime() { const runtime = { configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'test-runtime', + getStartedAt: () => 1, machineNameReady: async () => undefined, getStatus, pushRuntimeGit, diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts index 22a5db36863..e061ff682b6 100644 --- a/src/main/updater.install-failure-cause.test.ts +++ b/src/main/updater.install-failure-cause.test.ts @@ -163,12 +163,13 @@ async function reachDownloaded(): Promise { const updater = await loadUpdaterModule() updater.setupAutoUpdater(mainWindow as never) + await vi.advanceTimersByTimeAsync(0) await vi.waitFor(() => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) }) autoUpdaterMock.emit('checking-for-update') autoUpdaterMock.emit('update-available', { version: '1.4.163' }) - await new Promise((resolve) => setTimeout(resolve, 0)) + await vi.advanceTimersByTimeAsync(0) autoUpdaterMock.downloadUpdate.mockResolvedValue([]) updater.downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.4.163' }) @@ -202,11 +203,13 @@ describe('quitAndInstall failure carries the updater cause', () => { value: 'linux', configurable: true }) - vi.useRealTimers() + vi.useFakeTimers() spans = [] }) afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true @@ -223,6 +226,7 @@ describe('quitAndInstall failure carries the updater cause', () => { }) quitAndInstall() + await vi.advanceTimersByTimeAsync(100) await vi.waitFor(() => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) @@ -244,6 +248,7 @@ describe('quitAndInstall failure carries the updater cause', () => { }) quitAndInstall() + await vi.advanceTimersByTimeAsync(100) await vi.waitFor(() => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) @@ -263,6 +268,7 @@ describe('quitAndInstall failure carries the updater cause', () => { }) quitAndInstall() + await vi.advanceTimersByTimeAsync(100) await vi.waitFor(() => { expect(installSpan()).toBeDefined() }) @@ -279,6 +285,7 @@ describe('quitAndInstall failure carries the updater cause', () => { }) quitAndInstall() + await vi.advanceTimersByTimeAsync(100) await vi.waitFor(() => { expect(installSpan()).toBeDefined() }) @@ -298,6 +305,7 @@ describe('quitAndInstall failure carries the updater cause', () => { }) quitAndInstall() + await vi.advanceTimersByTimeAsync(100) await vi.waitFor(() => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) @@ -320,6 +328,7 @@ describe('quitAndInstall failure carries the updater cause', () => { }) quitAndInstall() + await vi.advanceTimersByTimeAsync(100) await vi.waitFor(() => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) @@ -340,6 +349,7 @@ describe('quitAndInstall failure carries the updater cause', () => { }) quitAndInstall() + await vi.advanceTimersByTimeAsync(100) await vi.waitFor(() => { expect(getUpdateStatus().state).toBe('error') }) diff --git a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx index 9cd8a9f3b50..1f5e7272c6c 100644 --- a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx @@ -56,6 +56,8 @@ vi.mock('@/lib/feedback-image-attachments', async (importOriginal) => { } }) +vi.mock('@/store', async () => import('./sidebar-feedback-draft.test-fixture')) + import { SidebarFeedbackDialog } from './SidebarFeedbackDialog' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/sidebar/sidebar-feedback-draft.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-feedback-draft.test-fixture.ts new file mode 100644 index 00000000000..e265e7e7e52 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-feedback-draft.test-fixture.ts @@ -0,0 +1,14 @@ +import { useStore } from 'zustand' +import type { AppState } from '@/store/types' +import { createFeedbackDraftSlice } from '@/store/slices/feedback-draft' +import { createUIStore } from '@/store/slices/ui-slice-test-harness' + +const feedbackStore = createUIStore() +feedbackStore.setState( + createFeedbackDraftSlice(feedbackStore.setState, feedbackStore.getState, feedbackStore) +) + +export const useAppStore = Object.assign( + (selector: (state: AppState) => T): T => useStore(feedbackStore, selector), + feedbackStore +) diff --git a/src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts b/src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts index b4269d01ad9..eb86e669196 100644 --- a/src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts +++ b/src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts @@ -11,7 +11,20 @@ * The fault is injected at the multiplex transport seam (onClose), never with * elapsed time; the oracle is the per-pane handle inventory. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts index 201da24a431..0e2a0dd0658 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { stubRuntimeEnvironmentBridge } from '../../runtime/runtime-environment-bridge-fixture' // Why: the transport suppresses a repeat of the message it last surfaced so one outage cannot spam the diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts index 5da02fd98cf..bfc2d66cf6b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts @@ -14,7 +14,20 @@ * name/message/stack), which forces the renderer's message-fragment * classification path. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts index ba29a4eb787..d54f1f1aa01 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts index 66c69a6966b..43f852dfb44 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts index 0f02bb69ad2..e59d73aad72 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' import { stubRuntimeEnvironmentBridge } from '../../runtime/runtime-environment-bridge-fixture' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts index 17d9e6221a5..d99be95b2de 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts index 4290a046371..aa4b17e98c2 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts index 59dc3612ed1..7844e4bb8d5 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { createRemoteRuntimeTransportMocks, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 4ad4e950c81..4cfd751cb17 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -1,4 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts index 578056d7e87..ae1a862527a 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts @@ -7,6 +7,19 @@ import { import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' import type { PtyTransport } from './pty-transport-types' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' @@ -144,11 +157,12 @@ async function createCatalogStore() { vi.doMock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() } })) - const { createTestStore } = await import('@/store/slices/store-test-helpers') + const { createRuntimeStatusTestStore } = + await import('@/store/slices/runtime-status.test-fixture') const { resetDeferredPeerChecksForTests } = await import('@/store/slices/runtime-environment-peer-replacement') resetDeferredPeerChecksForTests() - return createTestStore() + return createRuntimeStatusTestStore() } async function restartServerUnderLivePane(tabId: string): Promise<{ diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts index 301a3b22bf1..3e84c227ca8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamText diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts index bf96cadbc60..9d55c0318df 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts @@ -1,4 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) const RESUME = { key: 'session_id', id: 'provider-session-1' } as const const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-subscribe-ack-input.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-subscribe-ack-input.test.ts index 34b18894a51..6d5e42e7c79 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-subscribe-ack-input.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-subscribe-ack-input.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode } from '../../../../shared/terminal-stream-protocol' import { createRemoteRuntimeTransportMocks, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts index 298128a2343..75de29e5d1f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts @@ -20,7 +20,20 @@ * connected state, then the multiplexed stream is closed so recovery starts * with a live epoch, and only the resubscribe attempt fails. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { TerminalStreamOpcode, decodeTerminalStreamFrame, diff --git a/src/renderer/src/components/terminal-pane/remote-snapshot-history-seam.test.ts b/src/renderer/src/components/terminal-pane/remote-snapshot-history-seam.test.ts index 4c164086f67..fc57b6ef014 100644 --- a/src/renderer/src/components/terminal-pane/remote-snapshot-history-seam.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-snapshot-history-seam.test.ts @@ -1,4 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + import { Terminal } from '@xterm/headless' import { TerminalStreamOpcode, diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index aa0a1094cf0..86f73d793a6 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -187,7 +187,14 @@ export async function launchHeadlessPairedRuntimeHost( }, dispose: async () => { await cleanupHeadlessHostResources([ - ...(serveProcessOpen ? [() => closeElectronAppForE2E(serveProcess)] : []), + ...(serveProcessOpen + ? [ + () => + closeElectronAppForE2E(serveProcess, { + cleanupDaemonsForUserDataDir: userDataDir + }) + ] + : []), () => cleanupE2EDaemons(userDataDir), () => preserveProfileLogs(userDataDir), () => rmSync(userDataDir, PROFILE_REMOVAL), @@ -206,7 +213,9 @@ export async function launchHeadlessPairedRuntimeHost( } catch (error) { try { await cleanupHeadlessHostResources([ - ...(app ? [() => closeElectronAppForE2E(app)] : []), + ...(app + ? [() => closeElectronAppForE2E(app, { cleanupDaemonsForUserDataDir: userDataDir })] + : []), () => cleanupE2EDaemons(userDataDir), () => preserveProfileLogs(userDataDir), () => rmSync(userDataDir, PROFILE_REMOVAL),