From 4a0847fba2c5ea3ac680dfab9d7b2272438dcc03 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 24 Aug 2026 03:56:52 +0000 Subject: [PATCH 1/6] fix(terminal): finalize commands when terminal closes --- src/integrations/terminal/Terminal.ts | 67 +++++++++- src/integrations/terminal/TerminalProcess.ts | 17 +++ src/integrations/terminal/TerminalRegistry.ts | 13 +- .../__tests__/TerminalRegistry.spec.ts | 122 ++++++++++++++++++ 4 files changed, 208 insertions(+), 11 deletions(-) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 21f98b86c6..fc80dd311f 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -11,6 +11,8 @@ import { mergePromise } from "./mergePromise" export class Terminal extends BaseTerminal { public terminal: vscode.Terminal + private closed = false + private cancelShellIntegrationWait?: () => void public cmdCounter: number = 0 @@ -74,7 +76,23 @@ export class Terminal extends BaseTerminal { * active. (This value is set when onDidCloseTerminal is fired.) */ public override isClosed(): boolean { - return this.terminal.exitStatus !== undefined + return this.closed || this.terminal.exitStatus !== undefined + } + + public handleClose(): void { + if (this.closed) { + return + } + + this.closed = true + this.cancelShellIntegrationWait?.() + this.cancelShellIntegrationWait = undefined + + if (this.process instanceof TerminalProcess) { + this.process.handleTerminalClosed() + } else { + this.shellExecutionComplete({ exitCode: undefined }) + } } public override runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise { @@ -123,6 +141,14 @@ export class Terminal extends BaseTerminal { // customised startup that suppresses the OSC 633;A marker). this.waitForShellIntegration(Terminal.getShellIntegrationTimeout()) .then(() => { + if (this.isClosed()) { + if (this.process === process) { + process.handleTerminalClosed() + } + + return + } + // Clean up temporary directory if shell integration is available, zsh did its job: ShellIntegrationManager.zshCleanupTmpDir(this.id) @@ -130,6 +156,14 @@ export class Terminal extends BaseTerminal { void process.run(command).catch((error) => process.emit("error", error)) }) .catch(() => { + if (this.isClosed()) { + if (this.process === process) { + process.handleTerminalClosed() + } + + return + } + console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`) // Clean up temporary directory if shell integration is not available @@ -153,22 +187,43 @@ export class Terminal extends BaseTerminal { * than polling — important for slow-starting shells (heavy .zshrc, nvm, etc.). */ private waitForShellIntegration(timeoutMs: number): Promise { + if (this.isClosed()) { + return Promise.reject(new Error("Terminal closed before shell integration became available")) + } + if (this.terminal.shellIntegration) { return Promise.resolve() } return new Promise((resolve, reject) => { const ref = { disposable: null as vscode.Disposable | null } - const timer = setTimeout(() => { + let settled = false + let cancel = () => {} + const finish = (callback: () => void) => { + if (settled) { + return + } + + settled = true + clearTimeout(timer) ref.disposable?.dispose() - reject(new Error(`Shell integration did not activate within ${timeoutMs / 1000}s`)) + + if (this.cancelShellIntegrationWait === cancel) { + this.cancelShellIntegrationWait = undefined + } + + callback() + } + const timer = setTimeout(() => { + finish(() => reject(new Error(`Shell integration did not activate within ${timeoutMs / 1000}s`))) }, timeoutMs) + cancel = () => finish(() => reject(new Error("Terminal closed before shell integration became available"))) + this.cancelShellIntegrationWait = cancel + ref.disposable = vscode.window.onDidChangeTerminalShellIntegration((e) => { if (e.terminal === this.terminal) { - clearTimeout(timer) - ref.disposable?.dispose() - resolve() + finish(resolve) } }) }) diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index d1643dec3a..991318697c 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -58,6 +58,23 @@ export class TerminalProcess extends BaseTerminalProcess { return terminal } + public handleTerminalClosed(): void { + const executionStarted = this.ownExecution !== undefined + this.terminal.shellExecutionComplete({ exitCode: undefined }) + + if (executionStarted) { + return + } + + // run() has not installed its completion listener yet, so finish the + // startup-wait path directly instead of leaving runCommand() pending. + this.terminal.activeShellExecution = undefined + this.cleanupScriptFile() + this.stopHotTimer() + this.emit("completed", "") + this.emit("continue") + } + public override async run(command: string) { this.command = command diff --git a/src/integrations/terminal/TerminalRegistry.ts b/src/integrations/terminal/TerminalRegistry.ts index da4b3dd16d..d7385af1b5 100644 --- a/src/integrations/terminal/TerminalRegistry.ts +++ b/src/integrations/terminal/TerminalRegistry.ts @@ -33,13 +33,16 @@ export class TerminalRegistry { // TODO: This initialization code is VSCode specific, and therefore // should probably live elsewhere. - // Register handler for terminal close events to clean up temporary - // directories. + // Treat terminal closure as a completion path because VS Code may not emit + // onDidEndTerminalShellExecution after the terminal is disposed. const closeDisposable = vscode.window.onDidCloseTerminal((vsceTerminal) => { - const terminal = this.getTerminalByVSCETerminal(vsceTerminal) + // Do not use getTerminalByVSCETerminal here: exitStatus is already set when + // this event fires, so that helper removes closed terminals before returning. + const terminal = this.terminals.find((t) => t instanceof Terminal && t.terminal === vsceTerminal) - if (terminal) { - ShellIntegrationManager.zshCleanupTmpDir(terminal.id) + if (terminal instanceof Terminal) { + terminal.handleClose() + this.removeTerminal(terminal.id) } }) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index f60c0d0722..47ea940bec 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -209,6 +209,8 @@ describe("TerminalRegistry", () => { }) describe("onDidEndTerminalShellExecution race condition (#489, #622)", () => { + let closeHandler: (terminal: vscode.Terminal) => void + let shellIntegrationHandler: (event: vscode.TerminalShellIntegrationChangeEvent) => void let startHandler: (e: any) => Promise let endHandler: (e: any) => Promise @@ -221,6 +223,15 @@ describe("TerminalRegistry", () => { ;(vscode.window as any).onDidStartTerminalShellExecution ??= () => ({ dispose: () => {} }) ;(vscode.window as any).onDidEndTerminalShellExecution ??= () => ({ dispose: () => {} }) + vi.spyOn(vscode.window, "onDidCloseTerminal").mockImplementation((handler) => { + closeHandler = handler + return { dispose: vi.fn() } + }) + vi.spyOn(vscode.window, "onDidChangeTerminalShellIntegration").mockImplementation((handler) => { + shellIntegrationHandler = handler + return { dispose: vi.fn() } + }) + vi.spyOn(vscode.window, "onDidStartTerminalShellExecution" as any).mockImplementation((handler: any) => { startHandler = handler return { dispose: vi.fn() } @@ -291,6 +302,117 @@ describe("TerminalRegistry", () => { expect(completeSpy).not.toHaveBeenCalled() }) + it("finalizes an active process when its terminal closes (#1362)", () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const process = new TerminalProcess(terminal) + process.ownExecution = { commandLine: { value: "git status" } } as vscode.TerminalShellExecution + terminal.process = process + terminal.busy = true + terminal.running = true + const completionSpy = vi.fn() + process.on("shell_execution_complete", completionSpy) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: undefined, reason: 3 }, + configurable: true, + }) + + closeHandler(terminal.terminal) + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }) + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + expect(terminal.running).toBe(false) + }) + + it("unblocks a process when its terminal closes while shell integration is initializing (#1362)", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const completedSpy = vi.fn() + const completionSpy = vi.fn() + const noShellIntegrationSpy = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + onNoShellIntegration: noShellIntegrationSpy, + }) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: undefined, reason: 3 }, + configurable: true, + }) + + closeHandler(terminal.terminal) + await result + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledOnce() + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + }) + + it("does not submit a command when shell integration resolves immediately before terminal closure", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const executeCommand = vi.fn(() => { + throw new Error("command should not execute after terminal closure") + }) + const completedSpy = vi.fn() + const completionSpy = vi.fn() + const noShellIntegrationSpy = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + onNoShellIntegration: noShellIntegrationSpy, + }) + Object.defineProperty(terminal.terminal, "shellIntegration", { + value: { executeCommand }, + configurable: true, + }) + + shellIntegrationHandler({ + terminal: terminal.terminal, + shellIntegration: terminal.terminal.shellIntegration!, + }) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: undefined, reason: 3 }, + configurable: true, + }) + closeHandler(terminal.terminal) + await result + + expect(executeCommand).not.toHaveBeenCalled() + expect(completionSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledOnce() + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + }) + + it("does not finalize a process twice when its terminal closes after the end event", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const execution = { commandLine: { value: "git status" } } as vscode.TerminalShellExecution + const process = new TerminalProcess(terminal) + process.ownExecution = execution + terminal.process = process + terminal.busy = true + terminal.running = true + const completionSpy = vi.fn() + process.on("shell_execution_complete", completionSpy) + + await endHandler({ terminal: terminal.terminal, execution, exitCode: 0 }) + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: 0, reason: 2 }, + configurable: true, + }) + closeHandler(terminal.terminal) + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith(expect.objectContaining({ exitCode: 0 })) + }) + it( "ignores a late end event for a superseded execution instead of completing " + "the next command on the same reused terminal", From 22b5fb800821081a1102eedd09af4c304ee89b68 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 00:32:26 +0000 Subject: [PATCH 2/6] test(terminal): model close lifecycle --- docs/architecture/task-lifecycle-model.md | 15 +- package.json | 3 +- scripts/check-terminal-lifecycle.ts | 141 ++++++++++++++++++ .../__tests__/TerminalRegistry.spec.ts | 84 +++++++++++ 4 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 scripts/check-terminal-lifecycle.ts diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index d39c0fd9e2..095c9a21aa 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -6,13 +6,14 @@ Zoo Code checks task lifecycle protocols through one compositional verification pnpm lifecycle:model-check ``` -The command runs five independent bounded submodels in sequence: +The command runs six independent bounded submodels in sequence: 1. the persisted task delegation lifecycle; 2. shared-store concurrency across task-history hosts; 3. the task cleanup protocol; -4. request-stream parser scoping; and -5. completion persistence. +4. request-stream parser scoping; +5. completion persistence; and +6. the terminal command lifecycle. This umbrella command is the single model-check entry point in the `compile` CI job after type checking. Command-level composition does not merge the submodels' state spaces: each checker retains its own bounds, transitions, invariant ownership, reachability requirements, and counterexample format. In particular, parser state is not part of the persisted lifecycle graph. The focused parser checker remains directly runnable with `pnpm parser-scope:model-check` for debugging. @@ -20,6 +21,8 @@ An individual checker fails if it finds an invariant violation, a modeled action Executable cross-model composition should be added only when a correctness claim genuinely spans two or more submodels and there is an explicit, production-grounded boundary mapping between their events or state. That composition must state a bounded joint exploration strategy and own cross-model invariants that cannot be proved within either child model alone. Shared command orchestration or conceptual adjacency is not sufficient reason to multiply independent state spaces. +`pnpm lifecycle:model` runs the same six checks directly; `lifecycle:model-check` is the CI-facing alias. + ## Why an executable TypeScript model The models use small explicit-state explorers rather than adding Quint, TLA+/TLC, or Alloy. This is deliberate: @@ -49,6 +52,12 @@ The model has three fixed task slots, enough to cover competing siblings and a n Production completion also accepts a recovery-compatible `active` parent that still awaits the returning child, then clears the stale pointers. Normal model transitions never create that intermediate state, so it is covered by a focused reducer test rather than admitted as a generally valid reachable state. +## Terminal command lifecycle model + +The same command runs a bounded terminal lifecycle explorer for issue #1362. It models command startup, shell activation, streamed output, normal completion, and terminal closure. Its invariants require completion to remain at-most-once, closure to detach the process, buffered output to be delivered, and an active stream iterator to be released. Named landmarks retain the important interleavings: closure before command submission, closure after output, closure after a normal end event, and duplicate closure. + +This terminal model is intentionally separate from persisted task delegation state because VS Code terminal events are an extension-host adapter protocol rather than `HistoryItem` transitions. Focused `TerminalRegistry` tests bind the abstract properties to production behavior, including omitted `onDidEndTerminalShellExecution` events and an undefined `exitStatus` during the close callback. + ## Shared-store concurrency model The same `pnpm lifecycle:model-check` command also runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions, so its semantics match the store rather than assuming coherent caches or transactional pair writes: diff --git a/package.json b/package.json index 94f2d52e27..305c20613f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", + "lifecycle:model": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && tsx scripts/check-terminal-lifecycle.ts", + "lifecycle:model-check": "pnpm lifecycle:model", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs", "test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only", diff --git a/scripts/check-terminal-lifecycle.ts b/scripts/check-terminal-lifecycle.ts new file mode 100644 index 0000000000..f9a14afacc --- /dev/null +++ b/scripts/check-terminal-lifecycle.ts @@ -0,0 +1,141 @@ +type Phase = "idle" | "waiting" | "running" | "completed" | "closed" +type Action = "run" | "activate" | "output" | "end" | "close" + +interface ModelState { + phase: Phase + processAttached: boolean + commandSubmitted: boolean + completionCount: number + output: string + deliveredOutput: string + iteratorReleased: boolean +} + +interface TraceStep { + action: Action | "initial" + state: ModelState +} + +const actions: Action[] = ["run", "activate", "output", "end", "close"] +const MAX_DEPTH = 7 +const MAX_STATES = 100 + +function initialState(): ModelState { + return { + phase: "idle", + processAttached: false, + commandSubmitted: false, + completionCount: 0, + output: "", + deliveredOutput: "", + iteratorReleased: false, + } +} + +function complete(state: ModelState, phase: "completed" | "closed"): ModelState { + return { + ...state, + phase, + processAttached: false, + completionCount: state.processAttached ? state.completionCount + 1 : state.completionCount, + deliveredOutput: state.output, + iteratorReleased: state.iteratorReleased || state.phase === "running", + } +} + +function transition(state: ModelState, action: Action): ModelState { + switch (action) { + case "run": + return state.phase === "idle" ? { ...state, phase: "waiting", processAttached: true } : state + case "activate": + return state.phase === "waiting" ? { ...state, phase: "running", commandSubmitted: true } : state + case "output": + return state.phase === "running" ? { ...state, output: `${state.output}chunk` } : state + case "end": + return state.phase === "waiting" || state.phase === "running" ? complete(state, "completed") : state + case "close": + return state.phase === "closed" ? state : complete(state, "closed") + } +} + +function violations(state: ModelState): string[] { + const result: string[] = [] + if (state.completionCount > 1) result.push("a command completed more than once") + if (state.phase === "closed" && state.processAttached) result.push("a closed terminal retained its process") + if (state.phase === "closed" && state.commandSubmitted && !state.iteratorReleased) { + result.push("closing a submitted command did not release its stream iterator") + } + if ((state.phase === "completed" || state.phase === "closed") && state.deliveredOutput !== state.output) { + result.push("completion did not deliver all buffered output") + } + return result +} + +function formatCounterexample(message: string, trace: TraceStep[]): string { + return [ + `Terminal lifecycle invariant failed: ${message}`, + `Bounds: depth=${MAX_DEPTH}, states=${MAX_STATES}`, + ...trace.map((step, index) => `${index}. ${step.action}: ${JSON.stringify(step.state)}`), + ].join("\n") +} + +const landmarks = { + "waiting-close-without-submit": (trace: TraceStep[]) => + trace.some((step) => step.action === "run") && + trace.at(-1)?.action === "close" && + trace.at(-1)?.state.commandSubmitted === false && + trace.at(-1)?.state.completionCount === 1, + "running-close-after-output": (trace: TraceStep[]) => + trace.some((step) => step.action === "output") && + trace.at(-1)?.action === "close" && + trace.at(-1)?.state.deliveredOutput === "chunk" && + trace.at(-1)?.state.iteratorReleased === true, + "end-then-close": (trace: TraceStep[]) => + trace.some((step) => step.action === "end") && + trace.at(-1)?.action === "close" && + trace.at(-1)?.state.completionCount === 1, + "duplicate-close": (trace: TraceStep[]) => trace.filter((step) => step.action === "close").length >= 2, +} satisfies Record boolean> + +const start = initialState() +const queue: Array<{ state: ModelState; trace: TraceStep[] }> = [ + { state: start, trace: [{ action: "initial", state: start }] }, +] +const visited = new Set([JSON.stringify(start)]) +const reachedActions = new Set() +const reachedLandmarks = new Set() + +for (let index = 0; index < queue.length; index++) { + const node = queue[index]! + const stateViolations = violations(node.state) + if (stateViolations.length) throw new Error(formatCounterexample(stateViolations.join("; "), node.trace)) + for (const [name, predicate] of Object.entries(landmarks)) { + if (predicate(node.trace)) reachedLandmarks.add(name) + } + if (node.trace.length - 1 === MAX_DEPTH) continue + + for (const action of actions) { + const next = transition(node.state, action) + const trace = [...node.trace, { action, state: next }] + for (const [name, predicate] of Object.entries(landmarks)) { + if (predicate(trace)) reachedLandmarks.add(name) + } + if (next === node.state) continue + reachedActions.add(action) + const key = JSON.stringify(next) + if (visited.has(key)) continue + visited.add(key) + queue.push({ state: next, trace }) + if (visited.size > MAX_STATES) throw new Error(`Terminal lifecycle exceeded its ${MAX_STATES}-state budget`) + } +} + +const missingActions = actions.filter((action) => !reachedActions.has(action)) +if (missingActions.length) throw new Error(`Terminal lifecycle has unreachable actions: ${missingActions.join(", ")}`) +const missingLandmarks = Object.keys(landmarks).filter((name) => !reachedLandmarks.has(name)) +if (missingLandmarks.length) + throw new Error(`Terminal lifecycle has unreachable landmarks: ${missingLandmarks.join(", ")}`) + +console.log( + `Terminal lifecycle model check passed: ${visited.size} reachable states, ${actions.length}/${actions.length} actions reachable, ${Object.keys(landmarks).length}/${Object.keys(landmarks).length} landmarks reached, depth <= ${MAX_DEPTH}`, +) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index 47ea940bec..16e1d44fb2 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -325,6 +325,61 @@ describe("TerminalRegistry", () => { expect(terminal.running).toBe(false) }) + it("delivers buffered output and releases the stream iterator when an active terminal closes", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + let nextCall = 0 + let signalWaitingForNext: () => void = () => {} + const waitingForNext = new Promise((resolve) => { + signalWaitingForNext = resolve + }) + const returnSpy = vi.fn().mockResolvedValue({ done: true, value: undefined }) + const stream: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: vi.fn(() => { + nextCall++ + if (nextCall === 1) { + return Promise.resolve({ done: false, value: "\x1b]633;C\x07hello\n" }) + } + + signalWaitingForNext() + return new Promise>(() => {}) + }), + return: returnSpy, + } + }, + } + const execution = { + commandLine: { value: "printf hello" }, + read: vi.fn().mockReturnValue(stream), + } as unknown as vscode.TerminalShellExecution + const executeCommand = vi.fn().mockReturnValue(execution) + Object.defineProperty(terminal.terminal, "shellIntegration", { + value: { executeCommand }, + configurable: true, + }) + const completedSpy = vi.fn() + const result = terminal.runCommand("printf hello", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: vi.fn(), + }) + + await vi.waitFor(() => expect(executeCommand).toHaveBeenCalledOnce()) + await startHandler({ terminal: terminal.terminal, execution }) + await waitingForNext + closeHandler(terminal.terminal) + await result + + expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("hello\n", expect.any(TerminalProcess)) + expect(returnSpy).toHaveBeenCalledOnce() + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + expect(terminal.running).toBe(false) + }) + it("unblocks a process when its terminal closes while shell integration is initializing (#1362)", async () => { const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal const completedSpy = vi.fn() @@ -348,6 +403,7 @@ describe("TerminalRegistry", () => { expect(completionSpy).toHaveBeenCalledOnce() expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) expect(noShellIntegrationSpy).not.toHaveBeenCalled() expect(terminal.process).toBeUndefined() expect(terminal.busy).toBe(false) @@ -388,9 +444,37 @@ describe("TerminalRegistry", () => { expect(executeCommand).not.toHaveBeenCalled() expect(completionSpy).toHaveBeenCalledOnce() expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) expect(noShellIntegrationSpy).not.toHaveBeenCalled() }) + it("marks closure explicitly and completes only once when exitStatus remains undefined", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const completedSpy = vi.fn() + const completionSpy = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + }) + + expect(terminal.terminal.exitStatus).toBeUndefined() + terminal.handleClose() + terminal.handleClose() + await result + + expect(terminal.isClosed()).toBe(true) + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }, expect.any(TerminalProcess)) + expect(completedSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) + expect(terminal.process).toBeUndefined() + expect(terminal.busy).toBe(false) + expect(terminal.running).toBe(false) + }) + it("does not finalize a process twice when its terminal closes after the end event", async () => { const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal const execution = { commandLine: { value: "git status" } } as vscode.TerminalShellExecution From 8eab2713b2265c5939c24c82231aea03a5e511ab Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:29:04 +0000 Subject: [PATCH 3/6] test(terminal): cover close lifecycle branches --- src/integrations/terminal/Terminal.ts | 13 +- src/integrations/terminal/TerminalProcess.ts | 3 - .../__tests__/TerminalRegistry.spec.ts | 117 ++++++++++++++++++ 3 files changed, 122 insertions(+), 11 deletions(-) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index fc80dd311f..da84170d21 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -122,6 +122,11 @@ export class Terminal extends BaseTerminal { reject(error) }) + if (this.isClosed()) { + process.handleTerminalClosed() + return + } + if (Terminal.isActiveShellCmdExe()) { // Keep this defensive fallback for callers that invoke Terminal.runCommand() // directly instead of routing through executeCommandInTerminal(). @@ -142,10 +147,6 @@ export class Terminal extends BaseTerminal { this.waitForShellIntegration(Terminal.getShellIntegrationTimeout()) .then(() => { if (this.isClosed()) { - if (this.process === process) { - process.handleTerminalClosed() - } - return } @@ -157,10 +158,6 @@ export class Terminal extends BaseTerminal { }) .catch(() => { if (this.isClosed()) { - if (this.process === process) { - process.handleTerminalClosed() - } - return } diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 991318697c..8d310ec5bd 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -68,9 +68,6 @@ export class TerminalProcess extends BaseTerminalProcess { // run() has not installed its completion listener yet, so finish the // startup-wait path directly instead of leaving runCommand() pending. - this.terminal.activeShellExecution = undefined - this.cleanupScriptFile() - this.stopHotTimer() this.emit("completed", "") this.emit("continue") } diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index 16e1d44fb2..0d16ed728b 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -320,11 +320,41 @@ describe("TerminalRegistry", () => { expect(completionSpy).toHaveBeenCalledOnce() expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }) + expect(Object.hasOwn(completionSpy.mock.calls[0][0], "exitCode")).toBe(true) expect(terminal.process).toBeUndefined() expect(terminal.busy).toBe(false) expect(terminal.running).toBe(false) }) + it("removes only the closed registered terminal when no process is attached", () => { + const closed = TerminalRegistry.createTerminal("/closed", "vscode") as Terminal + const open = TerminalRegistry.createTerminal("/open", "vscode") as Terminal + closed.busy = true + closed.running = true + const completionSpy = vi.spyOn(closed, "shellExecutionComplete") + + closeHandler(closed.terminal) + + expect(completionSpy).toHaveBeenCalledOnce() + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }) + expect(Object.hasOwn(completionSpy.mock.calls[0][0], "exitCode")).toBe(true) + expect(closed.isClosed()).toBe(true) + expect(closed.busy).toBe(false) + expect(closed.running).toBe(false) + expect(TerminalRegistry.getAllTerminals()).toEqual([open]) + }) + + it("ignores close events from unregistered terminals", () => { + const registered = TerminalRegistry.createTerminal("/registered", "vscode") as Terminal + const foreign = { name: "foreign" } as vscode.Terminal + const closeSpy = vi.spyOn(registered, "handleClose") + + closeHandler(foreign) + + expect(closeSpy).not.toHaveBeenCalled() + expect(TerminalRegistry.getAllTerminals()).toEqual([registered]) + }) + it("delivers buffered output and releases the stream iterator when an active terminal closes", async () => { const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal let nextCall = 0 @@ -475,6 +505,93 @@ describe("TerminalRegistry", () => { expect(terminal.running).toBe(false) }) + it("does not start a command invoked after the terminal has already closed", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + const executeCommand = vi.fn() + Object.defineProperty(terminal.terminal, "shellIntegration", { + value: { executeCommand }, + configurable: true, + }) + terminal.handleClose() + const completedSpy = vi.fn() + const completionSpy = vi.fn() + + const result = terminal.runCommand("git status", { + onLine: vi.fn(), + onCompleted: completedSpy, + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: completionSpy, + }) + await result + + expect(executeCommand).not.toHaveBeenCalled() + expect(completionSpy).toHaveBeenCalledOnce() + expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) + expect(terminal.busy).toBe(false) + }) + + it("settles a shell-integration wait once and ignores unrelated terminal events", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + let waitHandler: (event: vscode.TerminalShellIntegrationChangeEvent) => void = () => {} + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce((handler) => { + waitHandler = handler + return { dispose: disposeSpy } + }) + const wait = terminal["waitForShellIntegration"](100) + const settledSpy = vi.fn() + void wait.then(settledSpy) + + waitHandler({ terminal: { name: "foreign" } as vscode.Terminal, shellIntegration: {} as never }) + await Promise.resolve() + expect(settledSpy).not.toHaveBeenCalled() + + const event = { terminal: terminal.terminal, shellIntegration: {} as never } + waitHandler(event) + waitHandler(event) + await wait + + expect(settledSpy).toHaveBeenCalledOnce() + expect(disposeSpy).toHaveBeenCalledOnce() + expect(terminal["cancelShellIntegrationWait"]).toBeUndefined() + }) + + it("does not let an older shell-integration wait clear a newer cancellation", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const handlers: Array<(event: vscode.TerminalShellIntegrationChangeEvent) => void> = [] + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementation((handler) => { + handlers.push(handler) + return { dispose: vi.fn() } + }) + const first = terminal["waitForShellIntegration"](100) + const firstCancel = terminal["cancelShellIntegrationWait"] + const second = terminal["waitForShellIntegration"](100) + const secondCancel = terminal["cancelShellIntegrationWait"] + + expect(firstCancel).not.toBe(secondCancel) + handlers[0]({ terminal: terminal.terminal, shellIntegration: {} as never }) + await first + expect(terminal["cancelShellIntegrationWait"]).toBe(secondCancel) + + handlers[1]({ terminal: terminal.terminal, shellIntegration: {} as never }) + await second + expect(terminal["cancelShellIntegrationWait"]).toBeUndefined() + }) + + it("uses the native exit status to recognize closure before the close event is handled", () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + expect(terminal.isClosed()).toBe(false) + + Object.defineProperty(terminal.terminal, "exitStatus", { + value: { code: 0, reason: 2 }, + configurable: true, + }) + + expect(terminal.isClosed()).toBe(true) + }) + it("does not finalize a process twice when its terminal closes after the end event", async () => { const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal const execution = { commandLine: { value: "git status" } } as vscode.TerminalShellExecution From 61b06bb0ba60a09f4ca0caca5e89660eeb5c4332 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:34:40 +0000 Subject: [PATCH 4/6] test(terminal): verify wait cleanup edges --- .../__tests__/TerminalRegistry.spec.ts | 101 ++++++++++++++++-- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index 0d16ed728b..9bc8f4fa5b 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -11,6 +11,13 @@ import { TerminalRegistry } from "../TerminalRegistry" const PAGER = process.platform === "win32" ? "" : "cat" +function settleWithin(promise: PromiseLike): Promise { + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => setTimeout(() => reject(new Error("terminal lifecycle did not settle")), 250)), + ]) +} + vi.mock("execa", () => ({ execa: vi.fn(), })) @@ -332,6 +339,7 @@ describe("TerminalRegistry", () => { closed.busy = true closed.running = true const completionSpy = vi.spyOn(closed, "shellExecutionComplete") + const cleanupSpy = vi.spyOn(ShellIntegrationManager, "zshCleanupTmpDir") closeHandler(closed.terminal) @@ -341,7 +349,8 @@ describe("TerminalRegistry", () => { expect(closed.isClosed()).toBe(true) expect(closed.busy).toBe(false) expect(closed.running).toBe(false) - expect(TerminalRegistry.getAllTerminals()).toEqual([open]) + expect(cleanupSpy).toHaveBeenCalledWith(closed.id) + expect(TerminalRegistry["terminals"]).toEqual([open]) }) it("ignores close events from unregistered terminals", () => { @@ -400,7 +409,7 @@ describe("TerminalRegistry", () => { await startHandler({ terminal: terminal.terminal, execution }) await waitingForNext closeHandler(terminal.terminal) - await result + await settleWithin(result) expect(completedSpy).toHaveBeenCalledOnce() expect(completedSpy).toHaveBeenCalledWith("hello\n", expect.any(TerminalProcess)) @@ -429,7 +438,7 @@ describe("TerminalRegistry", () => { }) closeHandler(terminal.terminal) - await result + await settleWithin(result) expect(completionSpy).toHaveBeenCalledOnce() expect(completedSpy).toHaveBeenCalledOnce() @@ -469,7 +478,7 @@ describe("TerminalRegistry", () => { configurable: true, }) closeHandler(terminal.terminal) - await result + await settleWithin(result) expect(executeCommand).not.toHaveBeenCalled() expect(completionSpy).toHaveBeenCalledOnce() @@ -491,9 +500,10 @@ describe("TerminalRegistry", () => { }) expect(terminal.terminal.exitStatus).toBeUndefined() + const shellCompleteSpy = vi.spyOn(terminal, "shellExecutionComplete") terminal.handleClose() terminal.handleClose() - await result + await settleWithin(result) expect(terminal.isClosed()).toBe(true) expect(completionSpy).toHaveBeenCalledOnce() @@ -503,6 +513,7 @@ describe("TerminalRegistry", () => { expect(terminal.process).toBeUndefined() expect(terminal.busy).toBe(false) expect(terminal.running).toBe(false) + expect(shellCompleteSpy).toHaveBeenCalledOnce() }) it("does not start a command invoked after the terminal has already closed", async () => { @@ -522,7 +533,7 @@ describe("TerminalRegistry", () => { onShellExecutionStarted: vi.fn(), onShellExecutionComplete: completionSpy, }) - await result + await settleWithin(result) expect(executeCommand).not.toHaveBeenCalled() expect(completionSpy).toHaveBeenCalledOnce() @@ -550,7 +561,7 @@ describe("TerminalRegistry", () => { const event = { terminal: terminal.terminal, shellIntegration: {} as never } waitHandler(event) waitHandler(event) - await wait + await settleWithin(wait) expect(settledSpy).toHaveBeenCalledOnce() expect(disposeSpy).toHaveBeenCalledOnce() @@ -572,14 +583,86 @@ describe("TerminalRegistry", () => { expect(firstCancel).not.toBe(secondCancel) handlers[0]({ terminal: terminal.terminal, shellIntegration: {} as never }) - await first + await settleWithin(first) expect(terminal["cancelShellIntegrationWait"]).toBe(secondCancel) handlers[1]({ terminal: terminal.terminal, shellIntegration: {} as never }) - await second + await settleWithin(second) expect(terminal["cancelShellIntegrationWait"]).toBeUndefined() }) + it("rejects a direct shell-integration wait when the terminal is already closed", async () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + terminal.handleClose() + + await expect(terminal["waitForShellIntegration"](100)).rejects.toThrow( + "Terminal closed before shell integration became available", + ) + }) + + it("clears the timeout and disposes the listener when shell integration activates", async () => { + vi.useFakeTimers() + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + let waitHandler: (event: vscode.TerminalShellIntegrationChangeEvent) => void = () => {} + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce((handler) => { + waitHandler = handler + return { dispose: disposeSpy } + }) + const wait = terminal["waitForShellIntegration"](1_000) + + waitHandler({ terminal: terminal.terminal, shellIntegration: {} as never }) + await wait + + expect(disposeSpy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it("reports the configured timeout and releases wait resources", async () => { + vi.useFakeTimers() + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce(() => ({ + dispose: disposeSpy, + })) + const rejectedSpy = vi.fn() + void terminal["waitForShellIntegration"](1_500).catch(rejectedSpy) + + await vi.advanceTimersByTimeAsync(1_500) + + expect(rejectedSpy).toHaveBeenCalledOnce() + expect(rejectedSpy.mock.calls[0][0]).toEqual(new Error("Shell integration did not activate within 1.5s")) + expect(disposeSpy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it("cancels a pending shell-integration wait with the terminal-close reason", async () => { + vi.useFakeTimers() + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal + Object.defineProperty(terminal.terminal, "shellIntegration", { value: undefined, configurable: true }) + const disposeSpy = vi.fn() + vi.mocked(vscode.window.onDidChangeTerminalShellIntegration).mockImplementationOnce(() => ({ + dispose: disposeSpy, + })) + const rejectedSpy = vi.fn() + void terminal["waitForShellIntegration"](1_000).catch(rejectedSpy) + + terminal["cancelShellIntegrationWait"]?.() + await Promise.resolve() + + expect(rejectedSpy).toHaveBeenCalledOnce() + expect(rejectedSpy.mock.calls[0][0]).toEqual( + new Error("Terminal closed before shell integration became available"), + ) + expect(disposeSpy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + it("uses the native exit status to recognize closure before the close event is handled", () => { const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") as Terminal expect(terminal.isClosed()).toBe(false) From d23f47b488ad4a568aeff237a80cac16e2c52f45 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:38:53 +0000 Subject: [PATCH 5/6] test(terminal): keep registry assertion typed --- src/integrations/terminal/__tests__/TerminalRegistry.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index 9bc8f4fa5b..6926fa1768 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -361,7 +361,7 @@ describe("TerminalRegistry", () => { closeHandler(foreign) expect(closeSpy).not.toHaveBeenCalled() - expect(TerminalRegistry.getAllTerminals()).toEqual([registered]) + expect(TerminalRegistry["terminals"]).toEqual([registered]) }) it("delivers buffered output and releases the stream iterator when an active terminal closes", async () => { From c02c87562e739076c965bce210c6df9f1976a43b Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:48:37 +0000 Subject: [PATCH 6/6] test(terminal): assert exact process identity --- src/integrations/terminal/Terminal.ts | 1 + src/integrations/terminal/TerminalProcess.ts | 1 + .../__tests__/TerminalRegistry.spec.ts | 23 ++++++++++++++----- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index da84170d21..de68c4284c 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -79,6 +79,7 @@ export class Terminal extends BaseTerminal { return this.closed || this.terminal.exitStatus !== undefined } + /** Finalizes any attached command when VS Code disposes this terminal. */ public handleClose(): void { if (this.closed) { return diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 8d310ec5bd..c32805b53d 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -58,6 +58,7 @@ export class TerminalProcess extends BaseTerminalProcess { return terminal } + /** Completes this process when its terminal closes without an execution-end event. */ public handleTerminalClosed(): void { const executionStarted = this.ownExecution !== undefined this.terminal.shellExecutionComplete({ exitCode: undefined }) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index 6926fa1768..36a0468c54 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -404,6 +404,8 @@ describe("TerminalRegistry", () => { onShellExecutionStarted: vi.fn(), onShellExecutionComplete: vi.fn(), }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) await vi.waitFor(() => expect(executeCommand).toHaveBeenCalledOnce()) await startHandler({ terminal: terminal.terminal, execution }) @@ -412,7 +414,7 @@ describe("TerminalRegistry", () => { await settleWithin(result) expect(completedSpy).toHaveBeenCalledOnce() - expect(completedSpy).toHaveBeenCalledWith("hello\n", expect.any(TerminalProcess)) + expect(completedSpy).toHaveBeenCalledWith("hello\n", process) expect(returnSpy).toHaveBeenCalledOnce() expect(terminal.process).toBeUndefined() expect(terminal.busy).toBe(false) @@ -432,6 +434,8 @@ describe("TerminalRegistry", () => { onShellExecutionComplete: completionSpy, onNoShellIntegration: noShellIntegrationSpy, }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) Object.defineProperty(terminal.terminal, "exitStatus", { value: { code: undefined, reason: 3 }, configurable: true, @@ -442,7 +446,7 @@ describe("TerminalRegistry", () => { expect(completionSpy).toHaveBeenCalledOnce() expect(completedSpy).toHaveBeenCalledOnce() - expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) + expect(completedSpy).toHaveBeenCalledWith("", process) expect(noShellIntegrationSpy).not.toHaveBeenCalled() expect(terminal.process).toBeUndefined() expect(terminal.busy).toBe(false) @@ -464,6 +468,8 @@ describe("TerminalRegistry", () => { onShellExecutionComplete: completionSpy, onNoShellIntegration: noShellIntegrationSpy, }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) Object.defineProperty(terminal.terminal, "shellIntegration", { value: { executeCommand }, configurable: true, @@ -483,7 +489,7 @@ describe("TerminalRegistry", () => { expect(executeCommand).not.toHaveBeenCalled() expect(completionSpy).toHaveBeenCalledOnce() expect(completedSpy).toHaveBeenCalledOnce() - expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) + expect(completedSpy).toHaveBeenCalledWith("", process) expect(noShellIntegrationSpy).not.toHaveBeenCalled() }) @@ -498,6 +504,8 @@ describe("TerminalRegistry", () => { onShellExecutionStarted: vi.fn(), onShellExecutionComplete: completionSpy, }) + const process = terminal.process + expect(process).toBeInstanceOf(TerminalProcess) expect(terminal.terminal.exitStatus).toBeUndefined() const shellCompleteSpy = vi.spyOn(terminal, "shellExecutionComplete") @@ -507,9 +515,9 @@ describe("TerminalRegistry", () => { expect(terminal.isClosed()).toBe(true) expect(completionSpy).toHaveBeenCalledOnce() - expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }, expect.any(TerminalProcess)) + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }, process) expect(completedSpy).toHaveBeenCalledOnce() - expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) + expect(completedSpy).toHaveBeenCalledWith("", process) expect(terminal.process).toBeUndefined() expect(terminal.busy).toBe(false) expect(terminal.running).toBe(false) @@ -534,10 +542,13 @@ describe("TerminalRegistry", () => { onShellExecutionComplete: completionSpy, }) await settleWithin(result) + const completedProcess = completedSpy.mock.calls[0][1] expect(executeCommand).not.toHaveBeenCalled() expect(completionSpy).toHaveBeenCalledOnce() - expect(completedSpy).toHaveBeenCalledWith("", expect.any(TerminalProcess)) + expect(completionSpy).toHaveBeenCalledWith({ exitCode: undefined }, completedProcess) + expect(completedSpy).toHaveBeenCalledWith("", completedProcess) + expect(completedProcess).toBeInstanceOf(TerminalProcess) expect(terminal.busy).toBe(false) })