mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-20 01:01:53 +00:00
* feat(core): PR-2.5 — post-promote stream redirect + natural-exit registry settle Closes the two limitations PR-2 (#3894) deferred for the Phase D part (b) Ctrl+B promote flow (#3831): 1. **Post-promote stream redirect**: today the `bg_xxx.output` file is frozen at promote time because `ShellExecutionService` detaches its data listener as part of PR-1's ownership-transfer contract. PR-2.5 wires a caller-side `onPostPromoteData` callback so bytes from the still-running child append to the file via an `fs.createWriteStream` opened in `handlePromotedForeground`. 2. **Natural-exit registry settle**: today the registry entry stays `'running'` until `task_stop` / session-end `abortAll` fires its abort listener. PR-2.5 wires `onPostPromoteSettle` so natural child exit transitions the entry to `'completed'` / `'failed'` with the right exitCode / signal / error message. - New exported types: `ShellExecuteOptions`, `ShellPostPromoteHandlers`, `ShellPostPromoteSettleInfo`. - `execute()` options bag now accepts `postPromote?: { onData, onSettle }`. Threaded through to both `executeWithPty` and `childProcessFallback`. - PTY's `performBackgroundPromote` (line ~1159): after disposing the foreground data + exit + error listeners, RE-ATTACH minimal forwarders that call `postPromote.onData` / `postPromote.onSettle` when the caller opted in. Backwards compat: when `postPromote` is unset the PR-2 detach-everything contract is preserved (the re-attach is gated on each callback being defined). - `childProcessFallback`'s `performBackgroundPromote` (line ~706): same pattern — re-attach `stdout.on('data', ...)`, `stderr.on('data', ...)`, `child.once('exit', ...)`, `child.once('error', ...)` when the caller opted in. `error` listener routes through `onSettle` with `error` populated, so spawn-side errors after the foreground errorHandler detached don't crash the daemon via the default unhandled `'error'` event. - Both paths wrap caller callbacks in try/catch so a thrown handler doesn't crash the child's data loop / unhandled-rejection the service. - New `PromoteArtifacts` type — slots shared between the foreground `execute()` postPromote handlers (which fire on the service side as soon as promote happens) and the post-resolve `handlePromotedForeground` finalizer (which runs after `await resultPromise` returns). The two race; the buffer + settle-queue absorb that race so neither chunks nor the eventual exit info are lost. - `executeForeground` wires `postPromote` handlers that route data to either `promoteArtifacts.stream` (if open) or `promoteArtifacts.buffer` (drained when the stream opens), and queue settle info if the wired handler isn't yet installed. - `handlePromotedForeground` opens `fs.createWriteStream(outputPath, { flags: 'w' })`, writes the initial snapshot first, drains the buffer, then registers the entry and wires `onSettleWired` with the full registry decision table: - `error` set → `registry.fail(shellId, error.message, endTime)` - `exitCode === 0` → `registry.complete(shellId, 0, endTime)` - non-zero exitCode → `registry.fail(shellId, "Exited with code N", endTime)` - signal !== null → `registry.fail(shellId, "Terminated by signal N", endTime)` - all-null fallback → `registry.fail(shellId, "Exited with unknown status", endTime)` - Fires queued settle synchronously after wiring so a fast command that exits between promote and finalizer doesn't get lost. - Self-audit catch: closes the output stream on the `registry.register` throw path so the FD doesn't leak past the orphan-child kill. - 3 new in `shellExecutionService.test.ts`: - `post-promote bytes route to postPromote.onData when callback provided` - `postPromote.onSettle fires on natural child exit after promote` - `backwards compat: without postPromote, listeners stay fully detached` - 3 new in `shell.test.ts` under a `foreground → background promote PR-2.5` describe block: - `post-promote bytes APPEND to bg_xxx.output via write stream` - `natural child exit transitions registry entry to "completed"` - `non-zero exit / signal / error → "failed" with descriptive message` - Bulk-replaced 50 prior `{},` (empty 6th-arg shellExecutionConfig) with `expect.objectContaining({}),` + added `expect.objectContaining({ postPromote: expect.any(Object) }),` as the 7th-arg expectation for the foreground execute call. - Updated the existing `registers a bg_xxx entry on result.promoted` test to assert on `fs.createWriteStream` + `stream.write` instead of the now-removed `fs.writeFileSync` snapshot path. 182/182 shell.test.ts pass + 73/73 shellExecutionService.test.ts pass + 111/111 coreToolScheduler.test.ts pass + 60/60 AppContainer.test.tsx pass; tsc + ESLint clean. Self-audit: 3 rounds (positive / reverse / cross-file) found one issue — output stream FD leak on `registry.register` throw — and fixed it before flagging complete. All flagged edge cases (stream errors, child-exits-before-wire-up race, task_stop during natural- exit window, promote-never-happens cleanup, backwards compat without callbacks) have explicit handling and / or test pinning. * fix(core): #4102 review wave — 3 Critical + UTF-8 + tests 3 Critical race/correctness issues + 1 multibyte-corruption suggestion + 3 test coverage gaps addressed: **Critical 1 — child_process late-chunk drop (service)** Settle was fired on 'exit', but stdout/stderr can emit buffered data between 'exit' and 'close'. Late chunks landed in `promoteArtifacts.buffer` after shell.ts had already closed the stream + transitioned the registry → silently dropped → truncated `bg_xxx.output`. Switched to listening on 'close' which guarantees all stdio is fully drained. (code, signal) payload is identical to 'exit', just with proper ordering. **Critical 2 — stream-flush wait before registry transition (shell)** `stream.end()` is asynchronous; pending writes can still be in the libuv queue when it returns. The old code transitioned the registry immediately after `.end()`, so a /tasks consumer could observe a `completed` entry and read the output file BEFORE the trailing bytes were on disk. Fixed: wired settle now `stream.once('finish', ...)` BEFORE calling `registry.complete/fail`. `error` event also short-circuits to the transition so a late ENOSPC doesn't hang the settle path forever. **Critical 3 — stream-open-fail buffer leak (shell)** If `fs.createWriteStream` threw, the catch path set `stream = null` but the foreground `onData` handler would still take the `stream === null` branch and push chunks into `promoteArtifacts.buffer` — unbounded growth under a sustained child whose output file couldn't be opened. Added a `streamFailed: boolean` latch on `PromoteArtifacts`. When set, `onData` drops chunks (with a debug log) instead of buffering. The catch branch sets the latch. **Suggestion — shared TextDecoder corrupts multibyte UTF-8 (service)** child_process post-promote used ONE TextDecoder for both stdout AND stderr. The decoder's continuation-byte state machine assumes one byte source; interleaved multibyte chunks corrupted. Now uses separate decoders + flushes both with `decode()` (no `stream: true`) on settle so trailing bytes surface as their final characters. **Suggestion — llmContent reflects already-settled status (shell)** When the queued-settle drain transitions the registry synchronously (fast-exit race), the model-facing copy was still saying "Status: running. … task_stop({...})". Updated to branch on `postPromoteAlreadySettled` / `postPromoteFinalStatus` — when the process is already gone, the copy says "Status: completed/failed" and replaces the `task_stop` suggestion with "Process has already exited; no `task_stop` needed". **Suggestion — test coverage gaps** Added: (a) `queued-settle race: onSettle BEFORE handlePromotedForeground completes` — custom service impl fires onSettle synchronously before resolving the promote promise, pins the drain path. (b) child_process post-promote tests for stdout/stderr forwarding + 'close'-not-'exit' settle + spawn-error settle. **Self-audit**: Round 1 + reverse audit. Stream.once mock added to fire 'finish' synchronously so existing tests don't hang on the new flush wait. 76/76 shellExecutionService.test.ts (+3) + 183/183 shell.test.ts (+1) pass; tsc + ESLint clean. * fix(core): #4102 review wave-2 — 3 more C1 (shell.ts:2227): the WriteStream `'error'` event handler only logged. `fs.createWriteStream` reports common open failures (ENOENT / EACCES / ENOSPC) asynchronously via that event rather than throwing. Result: `promoteArtifacts.stream` kept pointing at the failed stream; `onSettleWired` attached a `.once('finish')` listener that would never fire → registry stuck on `running` forever. Latch the failure (null the shared `stream` slot, set `streamFailed`); `onSettleWired`'s existing `if (!stream)` branch then transitions the registry immediately. C2 (shellExecutionService.ts:1468): the promote handoff removes the foreground `ptyErrorHandler` and only re-attaches data + exit listeners. A subsequent PTY `error` event had no listener — Node treats an unhandled `error` from an EventEmitter as a fatal exception that takes the whole CLI down. Attach a post-promote forwarder that ignores expected PTY read-exit codes (EIO / EAGAIN, same filter the foreground handler uses) and routes unexpected errors through `postPromote.onSettle` with `error` populated. Single-fire latch shared with `onExit` so settle never fires twice. C3 (shell.ts:2503): `onSettleWired` waits for the stream's asynchronous `'finish'` event before flipping `postPromoteAlreadySettled`, but the model-facing `statusLine` was built immediately after invoking `onSettleWired` on the queued settle. A fast-exited promoted command could therefore land "Status: running" + a `task_stop` instruction in production even though settle was already observed. Split into two flags: `postPromoteSettleObserved` (set synchronously when settle is classified) drives the model copy; the registry transition stays behind the stream flush. Tests: +1 PR-2.5 wave-2 PTY error-routing test; +2 shell.ts tests (stream open async error → registry still transitions; async `'finish'` after queued-settle drain → llmContent says 'completed' before registry transition fires). * fix(core): #4102 review wave-3 — 4 actionable T2 (shell.ts:2456) — Critical buffer-leak race `onSettleWired` previously set `promoteArtifacts.stream = null` BEFORE calling `stream.end()`. Any `postPromote.onData` chunk that landed between that null assignment and the actual flush completing saw `stream === null && streamFailed === false` and pushed into `promoteArtifacts.buffer` — a buffer that has no further drain path (the foreground finalizer has already returned). Result: chunks stranded indefinitely; PTY mode in particular hits this because `onExit` can fire while kernel buffers still hold data. Fix drains the pre-settle buffer to the stream BEFORE nulling AND latches `streamFailed = true` so any subsequent chunk drops via the existing `else if (streamFailed)` arm in `onData` instead of leaking. Updates the `streamFailed` doc to cover both setters (open-fail and settle-done) so the dual semantic is explicit. T3 (shell.ts:2262) — silent chunk-drop in catch path When `fs.createWriteStream` throws synchronously (rare: ENOENT on a vanished tmpdir), chunks already in `promoteArtifacts.buffer` were silently lost with no observability — oncall reading a truncated `bg_xxx.output` had no way to distinguish "stream open failed" from "child produced nothing." Logs the dropped chunk count and empties the buffer. T5 (shell.ts:2443) — opaque all-null fallback The "Exited with unknown status" fallback fired the registry to 'failed' without any context about which fields were null. This branch is meant to be unreachable; hitting it indicates the service emitted a defective settle info object. Includes the field values in both the fail message and a warn log so the oncall engineer can tell this path apart from the other "failed" branches. T6 (shellExecutionService.ts:1452) — leaked PTY post-promote listeners `ptyProcess.onData(...)` returns an `IDisposable` that was being discarded; same for `onExit`. The `'error'` listener function was also not captured (no way to `removeListener` it). EventEmitter holds refs to listener closures, which transitively hold refs to `onPostData` / `onPostSettle` / the caller's `promoteArtifacts`. While bounded by the PTY's lifetime, the closures keep the caller's state pinned for the post-settle delay window. Captures all three handles into `postPromoteDataDisposable` / `postPromoteExitDisposable` / `postPromoteErrorListener`, then releases them via a shared `disposePostPromoteListeners()` call from `firePostSettle` (idempotent — each slot null-checked and nulled after disposal). Tests: +1 service test for IDisposable + error-listener cleanup; +2 shell.ts tests for buffer drain race and catch-path snapshot fallback. Existing tests stay green (262 → 265 in the touched suites; 7819 → 7822 across the core package). * fix(core/test): drop unused 'registry' in wave-3 T2 test (TS6133) CI build failed across all platforms with src/tools/shell.test.ts(4395,15): error TS6133. The variable was a leftover from copying the queued-settle test pattern; the wave-3 T2 test inspects writeStreamMock.write call history directly and never reads the registry, so the assignment is dead code. Drop it. * fix(core): #4102 review wave-4 — 6 actionable T1 (Critical, shellExecutionService.ts:860 child_process onSettle exactly-once) The PTY path used a `firePostSettle` latch but child_process wired `close` and `error` independently to `onPostSettle`. A spawn-side error followed by Node's auto-emitted `'close'` would call the caller's settle TWICE, racing the registry transition. Added the same single-fire latch on the child_process path. T2 (Critical, shell.ts:2264 handoff race reorder) Original order was `write(snapshot) -> drain buffer -> assign stream`. Synchronous today (no race in current code), but assign-after-drain leaves a hazard for any future refactor that adds an `await` inside the drain loop — a chunk arriving in that window would land in `promoteArtifacts.buffer`, then post-assign chunks would write to the stream first, producing out-of-order bytes until the settle drain. Reordered to `write(snapshot) -> assign stream -> drain buffer`, which closes the hazard regardless of future async additions. T3 (Suggestion, shellExecutionService.ts:816 decoder flush gated on onSettle) The trailing-multibyte flush ran inside the `child.once('close', ...)` handler, which was only installed when `onSettle` was set. An `onData`-only caller (no onSettle) lost trailing continuation bytes silently. Hoisted flush into `flushPostPromoteDecoders` called from `firePostSettle`, and made `firePostSettle` available on the `'close'` path independent of onSettle (T6 install). T4 (Suggestion, shell.ts:1700 promoted ANSI passthrough) The regular `executeBackground` path strips ANSI before writing to `bg_xxx.output`; the promoted-foreground onData path appended raw chunks. Reading `bg_xxx.output` after Ctrl+B showed plain text up to the snapshot then raw `\x1b[31m` / cursor-move / clear-screen sequences for the post-promote tail — unreadable. Apply `stripAnsi(rawChunk)` before write/buffer, matching the executeBackground contract. T5 (Suggestion, shellExecutionService.ts:786 UTF-8 hardcoded) The post-promote child_process decoders were hard-coded to `new TextDecoder('utf-8')`, but the foreground decoder runs encoding detection via `getCachedEncodingForBuffer`. On a non-UTF-8 child (e.g. GBK on a Chinese Windows shell), the snapshot decoded correctly but the post-promote tail was mojibake. Capture the foreground decoder's `.encoding` property and reuse it for post-promote (with utf-8 fallback if foreground hadn't seen any bytes yet, and a try/catch around `new TextDecoder` for the rare unsupported-encoding case). T6 (Suggestion, shellExecutionService.ts:1540 `error` listener gated on onSettle) The post-promote `error` listener was attached only when `onSettle` was set. An `onData`-only caller still had the foreground errorHandler detached; a post-promote spawn error would then crash the CLI via Node's unhandled-error default. Hoisted the close + error listeners into `if (postPromote)` so any caller opting into post-promote gets crash protection; if `onSettle` is absent the listeners log + drop instead of routing. T7 (Suggestion, shellExecutionService.ts:791 onSettle-only pipe-block deadlock) Same root cause as T6: when only `onSettle` is set, the foreground `stdout`/`stderr` 'data' listeners are detached and no post-promote listener replaces them. The Readables stay paused, the OS pipe buffer fills (~64KB on Linux), the child blocks on `stdout.write`, 'close' never fires, onSettle never fires. Added `child.stdout?.resume()` and `child.stderr?.resume()` in the no-onData branch so the child can drain its pipes and reach exit. T8 (Suggestion, shell.ts:2614 dead inspectLine ternary) `inspectLine`'s ternary returned the same string on both sides — copy-paste leftover from when the other two adjacent ternaries (statusLine / stopLine) were correctly varied. Collapsed to a single string assignment. Tests: +5 regression tests (4 child_process: T1 double-fire latch, T3 onData-only flush, T6 onData-only error survives, T7 onSettle- only resume; +1 shell.ts: T4 ANSI strip). 265 -> 270 in the touched suites; 7822 -> 7827 across the core package; full suite green. * fix(core/test): use ShellOutputEvent type in wave-4 onData callbacks (TS2345) CI lint failed on the wave-4 (T3 / T6) tests with TS2345: pushing ShellOutputEvent into Array<{type:string;chunk:unknown}> narrows incompatibly. Switch to ShellOutputEvent[] (matches earlier helpers at lines 758/966) and discriminate the union via .type === 'data' when reading .chunk so the narrowed multibyte assertion still type-checks. * fix(core): address PR #4102 review — PTY error guard, flush timeout, diagnostic marker, failed-settle test - Move PTY post-promote error listener from `if (postPromote?.onSettle)` to `if (postPromote)` to match child_process path and prevent unhandled error crashes for onData-only callers - Add 10s flush timeout in onSettleWired so stalled streams don't leave registry entries stuck on 'running' forever - Append diagnostic marker to output file on stream error so truncation is visible without debug logging - Add queued-settle test with exitCode:1 asserting 'Status: failed.' in llmContent * fix(core): address PR #4102 review — align PTY/child_process guards, add flush timeout, diagnostic marker, and tests - Widen PTY post-promote onExit + error listener guard from `if (postPromote?.onSettle)` to `if (postPromote)` to match child_process path — prevents unhandled error crash and listener leak for onData-only callers - Add 10s flush timeout in onSettleWired so stalled streams don't leave registry entries stuck on 'running' indefinitely - Append diagnostic marker to output file on stream error so truncation is visible without debug logging - Remove model name references from code comments - Add tests: PTY onData-only error/exit, flush timeout fallback, appendFileSync diagnostic marker, queued-settle with failed exit code * fix(core): address PR #4102 review round 2 — listener cleanup, rename, constant hoist - Fix expect.objectContaining({}) misused as runtime arg in 2 execute() call sites - Add child_process post-promote stdout/stderr listener cleanup in firePostSettle - Rename streamFailed → streamClosed to reflect its overloaded semantics - Hoist FLUSH_TIMEOUT_MS to module-level PROMOTE_FLUSH_TIMEOUT_MS constant - Fix dangling FLUSH_TIMEOUT_MS reference (was undefined at runtime) - Add Windows note to streams pause/resume comment - Document PTY onData dispose-before-settle as known limitation
2491 lines
90 KiB
TypeScript
2491 lines
90 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
vi,
|
|
describe,
|
|
it,
|
|
expect,
|
|
beforeEach,
|
|
afterEach,
|
|
type Mock,
|
|
} from 'vitest';
|
|
import EventEmitter from 'node:events';
|
|
import type { Readable } from 'node:stream';
|
|
import { type ChildProcess } from 'node:child_process';
|
|
import pkg from '@xterm/headless';
|
|
import type {
|
|
ShellAbortReason,
|
|
ShellExecuteOptions,
|
|
ShellOutputEvent,
|
|
ShellPostPromoteSettleInfo,
|
|
} from './shellExecutionService.js';
|
|
import {
|
|
getShellAbortReasonKind,
|
|
ShellExecutionService,
|
|
} from './shellExecutionService.js';
|
|
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
|
|
|
const { Terminal } = pkg;
|
|
|
|
// Hoisted Mocks
|
|
const mockGetSystemEncoding = vi.hoisted(() =>
|
|
vi.fn().mockReturnValue('utf-8'),
|
|
);
|
|
const mockPtySpawn = vi.hoisted(() => vi.fn());
|
|
const mockCpSpawn = vi.hoisted(() => vi.fn());
|
|
const mockIsBinary = vi.hoisted(() => vi.fn());
|
|
const mockPlatform = vi.hoisted(() => vi.fn());
|
|
const mockGetPty = vi.hoisted(() => vi.fn());
|
|
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
|
|
const mockSerializeTerminalToText = vi.hoisted(() =>
|
|
vi.fn((terminal: pkg.Terminal): string => {
|
|
const buffer = terminal.buffer.active;
|
|
const lines: string[] = [];
|
|
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
const line = buffer.getLine(i);
|
|
const lineContent = line ? line.translateToString(true) : '';
|
|
|
|
if (line?.isWrapped && lines.length > 0) {
|
|
lines[lines.length - 1] += lineContent;
|
|
continue;
|
|
}
|
|
|
|
lines.push(lineContent);
|
|
}
|
|
|
|
return lines.join('\n').trimEnd();
|
|
}),
|
|
);
|
|
const mockGetShellConfiguration = vi.hoisted(() =>
|
|
vi.fn().mockReturnValue({
|
|
executable: 'bash',
|
|
argsPrefix: ['-c'],
|
|
shell: 'bash',
|
|
}),
|
|
);
|
|
|
|
// Top-level Mocks
|
|
vi.mock('@lydell/node-pty', () => ({
|
|
spawn: mockPtySpawn,
|
|
}));
|
|
vi.mock('child_process', () => ({
|
|
spawn: mockCpSpawn,
|
|
}));
|
|
vi.mock('../utils/textUtils.js', () => ({
|
|
isBinary: mockIsBinary,
|
|
}));
|
|
vi.mock('os', () => ({
|
|
default: {
|
|
platform: mockPlatform,
|
|
constants: {
|
|
signals: {
|
|
SIGTERM: 15,
|
|
SIGKILL: 9,
|
|
},
|
|
},
|
|
},
|
|
platform: mockPlatform,
|
|
constants: {
|
|
signals: {
|
|
SIGTERM: 15,
|
|
SIGKILL: 9,
|
|
},
|
|
},
|
|
}));
|
|
vi.mock('../utils/getPty.js', () => ({
|
|
getPty: mockGetPty,
|
|
}));
|
|
vi.mock('../utils/terminalSerializer.js', () => ({
|
|
serializeTerminalToObject: mockSerializeTerminalToObject,
|
|
serializeTerminalToText: mockSerializeTerminalToText,
|
|
}));
|
|
vi.mock('../utils/shell-utils.js', () => ({
|
|
getShellConfiguration: mockGetShellConfiguration,
|
|
}));
|
|
vi.mock('../utils/systemEncoding.js', () => ({
|
|
getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'),
|
|
getSystemEncoding: mockGetSystemEncoding,
|
|
}));
|
|
|
|
const mockProcessKill = vi
|
|
.spyOn(process, 'kill')
|
|
.mockImplementation(() => true);
|
|
|
|
const shellExecutionConfig = {
|
|
terminalWidth: 80,
|
|
terminalHeight: 24,
|
|
pager: 'cat',
|
|
showColor: false,
|
|
disableDynamicLineTrimming: true,
|
|
};
|
|
|
|
const WINDOWS_SYSTEM_PATH = 'C:\\Windows\\System32;C:\\Shared\\Tools';
|
|
const WINDOWS_USER_PATH = 'C:\\Users\\tester\\bin;C:\\Shared\\Tools';
|
|
const EXPECTED_MERGED_WINDOWS_PATH =
|
|
'C:\\Windows\\System32;C:\\Shared\\Tools;C:\\Users\\tester\\bin';
|
|
|
|
let originalProcessEnv: NodeJS.ProcessEnv;
|
|
|
|
const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => {
|
|
const lines = Array.isArray(text) ? text : text.split('\n');
|
|
const expected: AnsiOutput = Array.from(
|
|
{ length: shellExecutionConfig.terminalHeight },
|
|
(_, i) => [
|
|
{
|
|
text: expect.stringMatching((lines[i] || '').trim()),
|
|
bold: false,
|
|
italic: false,
|
|
underline: false,
|
|
dim: false,
|
|
inverse: false,
|
|
fg: '',
|
|
bg: '',
|
|
},
|
|
],
|
|
);
|
|
return expected;
|
|
};
|
|
|
|
const createAnsiToken = (text: string) => ({
|
|
text,
|
|
bold: false,
|
|
italic: false,
|
|
underline: false,
|
|
dim: false,
|
|
inverse: false,
|
|
fg: '',
|
|
bg: '',
|
|
});
|
|
|
|
const setupConflictingPathEnv = () => {
|
|
process.env = {
|
|
...originalProcessEnv,
|
|
PATH: WINDOWS_SYSTEM_PATH,
|
|
Path: WINDOWS_USER_PATH,
|
|
};
|
|
};
|
|
|
|
const expectNormalizedWindowsPathEnv = (env: NodeJS.ProcessEnv) => {
|
|
expect(env['PATH']).toBe(EXPECTED_MERGED_WINDOWS_PATH);
|
|
expect(env['Path']).toBeUndefined();
|
|
};
|
|
|
|
const waitForDataEventCount = async (
|
|
onOutputEventMock: Mock<(event: ShellOutputEvent) => void>,
|
|
expectedCount: number,
|
|
) => {
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
const dataEvents = onOutputEventMock.mock.calls.filter(
|
|
([event]) => event.type === 'data',
|
|
);
|
|
if (dataEvents.length >= expectedCount) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
};
|
|
|
|
describe('ShellExecutionService', () => {
|
|
let mockPtyProcess: EventEmitter & {
|
|
pid: number;
|
|
kill: Mock;
|
|
onData: Mock;
|
|
onExit: Mock;
|
|
write: Mock;
|
|
resize: Mock;
|
|
};
|
|
let mockHeadlessTerminal: {
|
|
resize: Mock;
|
|
scrollLines: Mock;
|
|
buffer: {
|
|
active: {
|
|
viewportY: number;
|
|
};
|
|
};
|
|
};
|
|
let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
originalProcessEnv = process.env;
|
|
|
|
mockIsBinary.mockReturnValue(false);
|
|
mockPlatform.mockReturnValue('linux');
|
|
mockGetPty.mockResolvedValue({
|
|
module: { spawn: mockPtySpawn },
|
|
name: 'mock-pty',
|
|
});
|
|
|
|
onOutputEventMock = vi.fn();
|
|
|
|
mockPtyProcess = new EventEmitter() as EventEmitter & {
|
|
pid: number;
|
|
kill: Mock;
|
|
onData: Mock;
|
|
onExit: Mock;
|
|
write: Mock;
|
|
resize: Mock;
|
|
};
|
|
mockPtyProcess.pid = 12345;
|
|
mockPtyProcess.kill = vi.fn();
|
|
// node-pty's onData/onExit return IDisposable; the production
|
|
// background-promote path calls .dispose() on those handles to detach
|
|
// its listeners cleanly. Mock them to return a disposable stub so the
|
|
// promote path doesn't crash on `undefined.dispose()`.
|
|
mockPtyProcess.onData = vi.fn().mockReturnValue({ dispose: vi.fn() });
|
|
mockPtyProcess.onExit = vi.fn().mockReturnValue({ dispose: vi.fn() });
|
|
mockPtyProcess.write = vi.fn();
|
|
mockPtyProcess.resize = vi.fn();
|
|
|
|
mockHeadlessTerminal = {
|
|
resize: vi.fn(),
|
|
scrollLines: vi.fn(),
|
|
buffer: {
|
|
active: {
|
|
viewportY: 0,
|
|
},
|
|
},
|
|
};
|
|
|
|
mockPtySpawn.mockReturnValue(mockPtyProcess);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalProcessEnv;
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
// Helper function to run a standard execution simulation
|
|
const simulateExecution = async (
|
|
command: string,
|
|
simulation: (
|
|
ptyProcess: typeof mockPtyProcess,
|
|
ac: AbortController,
|
|
) => void,
|
|
config = shellExecutionConfig,
|
|
options: ShellExecuteOptions = {},
|
|
) => {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
command,
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
config,
|
|
options,
|
|
);
|
|
|
|
await new Promise((resolve) => process.nextTick(resolve));
|
|
simulation(mockPtyProcess, abortController);
|
|
const result = await handle.result;
|
|
return { result, handle, abortController };
|
|
};
|
|
|
|
describe('Successful Execution', () => {
|
|
it('should execute a command and capture output', async () => {
|
|
const { result, handle } = await simulateExecution('ls -l', (pty) => {
|
|
pty.onData.mock.calls[0][0]('file1.txt\n');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(mockPtySpawn).toHaveBeenCalledWith(
|
|
'bash',
|
|
['-c', 'ls -l'],
|
|
expect.any(Object),
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.signal).toBeNull();
|
|
expect(result.error).toBeNull();
|
|
expect(result.aborted).toBe(false);
|
|
expect(result.output.trim()).toBe('file1.txt');
|
|
expect(handle.pid).toBe(12345);
|
|
|
|
expect(onOutputEventMock).toHaveBeenCalledWith({
|
|
type: 'data',
|
|
chunk: createExpectedAnsiOutput('file1.txt'),
|
|
});
|
|
});
|
|
|
|
it('should strip ANSI codes from output', async () => {
|
|
const { result } = await simulateExecution('ls --color=auto', (pty) => {
|
|
pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(result.output.trim()).toBe('aredword');
|
|
expect(onOutputEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'data',
|
|
chunk: createExpectedAnsiOutput('aredword'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should correctly decode multi-byte characters split across chunks', async () => {
|
|
const { result } = await simulateExecution('echo "你好"', (pty) => {
|
|
const multiByteChar = '你好';
|
|
pty.onData.mock.calls[0][0](multiByteChar.slice(0, 1));
|
|
pty.onData.mock.calls[0][0](multiByteChar.slice(1));
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
expect(result.output.trim()).toBe('你好');
|
|
});
|
|
|
|
it('should handle commands with no output', async () => {
|
|
await simulateExecution('touch file', (pty) => {
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(onOutputEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
chunk: createExpectedAnsiOutput(''),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should call onPid with the process id', async () => {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'ls -l',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
shellExecutionConfig,
|
|
);
|
|
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
await handle.result;
|
|
expect(handle.pid).toBe(12345);
|
|
});
|
|
|
|
it('should preserve full raw output when terminal writes are backlogged', async () => {
|
|
vi.useFakeTimers();
|
|
const originalWrite = Terminal.prototype.write;
|
|
const delayedWrite = vi
|
|
.spyOn(Terminal.prototype, 'write')
|
|
.mockImplementation(function (
|
|
this: pkg.Terminal,
|
|
data: string | Uint8Array,
|
|
callback?: () => void,
|
|
) {
|
|
setTimeout(() => {
|
|
originalWrite.call(this, data, callback);
|
|
}, 10);
|
|
});
|
|
|
|
try {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'fast-output',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
shellExecutionConfig,
|
|
);
|
|
|
|
const onData = mockPtyProcess.onData.mock.calls[0][0] as (
|
|
data: string,
|
|
) => void;
|
|
for (let i = 1; i <= 500; i++) {
|
|
onData(`Line ${String(i).padStart(4, '0')}\n`);
|
|
}
|
|
|
|
const resultPromise = handle.result;
|
|
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
const result = await resultPromise;
|
|
|
|
const lines = result.output.split('\n');
|
|
expect(lines).toHaveLength(500);
|
|
expect(lines[0]).toBe('Line 0001');
|
|
expect(lines[499]).toBe('Line 0500');
|
|
} finally {
|
|
delayedWrite.mockRestore();
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('should collapse carriage-return progress updates in final output', async () => {
|
|
const { result } = await simulateExecution('progress-output', (pty) => {
|
|
pty.onData.mock.calls[0][0]('Compressing objects: 14% (1/7)\r');
|
|
pty.onData.mock.calls[0][0]('Compressing objects: 28% (2/7)\r');
|
|
pty.onData.mock.calls[0][0]('Compressing objects: 42% (3/7)\r');
|
|
pty.onData.mock.calls[0][0]('Compressing objects: 100% (7/7), done.\n');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(result.output).toBe('Compressing objects: 100% (7/7), done.');
|
|
});
|
|
|
|
it('should not persist narrow terminal soft wraps as transcript newlines', async () => {
|
|
const { result } = await simulateExecution(
|
|
'narrow-output',
|
|
(pty) => {
|
|
pty.onData.mock.calls[0][0]('abcdefghijklmnopqrstuvwxyz\nshort\n');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
},
|
|
{
|
|
...shellExecutionConfig,
|
|
terminalWidth: 8,
|
|
terminalHeight: 4,
|
|
},
|
|
);
|
|
|
|
expect(result.output).toBe('abcdefghijklmnopqrstuvwxyz\nshort');
|
|
});
|
|
});
|
|
|
|
describe('pty interaction', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(ShellExecutionService['activePtys'], 'get').mockReturnValue({
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
ptyProcess: mockPtyProcess as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
headlessTerminal: mockHeadlessTerminal as any,
|
|
});
|
|
});
|
|
|
|
it('should write to the pty and trigger a render', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'interactive-app',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
shellExecutionConfig,
|
|
);
|
|
|
|
ShellExecutionService.writeToPty(handle.pid!, 'input');
|
|
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
|
|
await vi.runAllTimersAsync();
|
|
await handle.result;
|
|
|
|
expect(mockPtyProcess.write).toHaveBeenCalledWith('input');
|
|
expect(onOutputEventMock).toHaveBeenCalled();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('should resize the pty and the headless terminal', async () => {
|
|
await simulateExecution('ls -l', (pty) => {
|
|
pty.onData.mock.calls[0][0]('file1.txt\n');
|
|
ShellExecutionService.resizePty(pty.pid!, 100, 40);
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40);
|
|
expect(mockHeadlessTerminal.resize).toHaveBeenCalledWith(100, 40);
|
|
});
|
|
|
|
it('should ignore expected PTY read EIO errors on process exit', async () => {
|
|
const { result } = await simulateExecution('ls -l', (pty) => {
|
|
const eioError = Object.assign(new Error('read EIO'), { code: 'EIO' });
|
|
pty.emit('error', eioError);
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
});
|
|
|
|
it('should throw unexpected PTY errors from error event', async () => {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'ls -l',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
shellExecutionConfig,
|
|
);
|
|
await new Promise((resolve) => process.nextTick(resolve));
|
|
|
|
const unexpectedError = Object.assign(new Error('unexpected pty error'), {
|
|
code: 'EPIPE',
|
|
});
|
|
expect(() => mockPtyProcess.emit('error', unexpectedError)).toThrow(
|
|
'unexpected pty error',
|
|
);
|
|
|
|
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
await handle.result;
|
|
});
|
|
|
|
it('should ignore ioctl EBADF message-only resize race errors', async () => {
|
|
mockPtyProcess.resize.mockImplementationOnce(() => {
|
|
throw new Error('ioctl(2) failed, EBADF');
|
|
});
|
|
|
|
await simulateExecution('ls -l', (pty) => {
|
|
pty.onData.mock.calls[0][0]('file1.txt\n');
|
|
expect(() =>
|
|
ShellExecutionService.resizePty(pty.pid!, 100, 40),
|
|
).not.toThrow();
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
});
|
|
|
|
it('should ignore exited-pty message-only resize race errors', async () => {
|
|
mockPtyProcess.resize.mockImplementationOnce(() => {
|
|
throw new Error('Cannot resize a pty that has already exited');
|
|
});
|
|
|
|
await simulateExecution('ls -l', (pty) => {
|
|
pty.onData.mock.calls[0][0]('file1.txt\n');
|
|
expect(() =>
|
|
ShellExecutionService.resizePty(pty.pid!, 100, 40),
|
|
).not.toThrow();
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
});
|
|
|
|
it('should scroll the headless terminal', async () => {
|
|
await simulateExecution('ls -l', (pty) => {
|
|
pty.onData.mock.calls[0][0]('file1.txt\n');
|
|
ShellExecutionService.scrollPty(pty.pid!, 10);
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(mockHeadlessTerminal.scrollLines).toHaveBeenCalledWith(10);
|
|
});
|
|
});
|
|
|
|
describe('Failed Execution', () => {
|
|
it('should capture a non-zero exit code', async () => {
|
|
const { result } = await simulateExecution('a-bad-command', (pty) => {
|
|
pty.onData.mock.calls[0][0]('command not found');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 127, signal: null });
|
|
});
|
|
|
|
expect(result.exitCode).toBe(127);
|
|
expect(result.output.trim()).toBe('command not found');
|
|
expect(result.error).toBeNull();
|
|
});
|
|
|
|
it('should capture a termination signal', async () => {
|
|
const { result } = await simulateExecution('long-process', (pty) => {
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 15 });
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.signal).toBe(15);
|
|
});
|
|
|
|
it('should handle a synchronous spawn error', async () => {
|
|
mockGetPty.mockImplementation(() => null);
|
|
|
|
mockCpSpawn.mockImplementation(() => {
|
|
throw new Error('Simulated PTY spawn error');
|
|
});
|
|
|
|
const handle = await ShellExecutionService.execute(
|
|
'any-command',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
new AbortController().signal,
|
|
true,
|
|
{},
|
|
);
|
|
const result = await handle.result;
|
|
|
|
expect(result.error).toBeInstanceOf(Error);
|
|
expect(result.error?.message).toContain('Simulated PTY spawn error');
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.output).toBe('');
|
|
expect(handle.pid).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Aborting Commands', () => {
|
|
it('should abort a running process and set the aborted flag', async () => {
|
|
const { result } = await simulateExecution(
|
|
'sleep 10',
|
|
(pty, abortController) => {
|
|
abortController.abort();
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null });
|
|
},
|
|
);
|
|
|
|
expect(result.aborted).toBe(true);
|
|
// The process kill is mocked, so we just check that the flag is set.
|
|
});
|
|
|
|
it('signal.reason = { kind: "cancel" } still tree-kills (same as default)', async () => {
|
|
const { result } = await simulateExecution(
|
|
'sleep 10',
|
|
(pty, abortController) => {
|
|
abortController.abort({ kind: 'cancel' } satisfies ShellAbortReason);
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null });
|
|
},
|
|
);
|
|
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.promoted).toBeUndefined();
|
|
// The default kill path runs: SIGTERM via process.kill on the
|
|
// process-group pid. Pinning that we DID try to kill — i.e., reason
|
|
// === 'cancel' is NOT mistakenly routed through the background branch.
|
|
expect(mockProcessKill).toHaveBeenCalledWith(
|
|
-mockPtyProcess.pid,
|
|
'SIGTERM',
|
|
);
|
|
});
|
|
|
|
it('signal.reason = { kind: "background" } skips kill and resolves with promoted: true (and aborted: false per design question 7)', async () => {
|
|
// Critical: do NOT fire onExit — the child is still alive after the
|
|
// background-promote abort. The result Promise must resolve via the
|
|
// abort handler's own immediate resolve, not via the exit handler.
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(_pty, abortController) => {
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
);
|
|
|
|
// `aborted: false` (despite signal.aborted = true) is intentional —
|
|
// see #3831 design question 7. The flag answers "emit cancel/timeout
|
|
// copy?" not "did the signal fire?", and a promoted shell is
|
|
// neither cancelled nor timed out.
|
|
expect(result.aborted).toBe(false);
|
|
expect(result.promoted).toBe(true);
|
|
expect(result.exitCode).toBeNull();
|
|
expect(result.signal).toBeNull();
|
|
expect(result.error).toBeNull();
|
|
expect(result.pid).toBe(mockPtyProcess.pid);
|
|
// Verify the kill path did NOT run: neither the PTY's own kill() nor
|
|
// process.kill on the group pid. Caller now owns the child.
|
|
expect(mockPtyProcess.kill).not.toHaveBeenCalled();
|
|
expect(mockProcessKill).not.toHaveBeenCalledWith(
|
|
-mockPtyProcess.pid,
|
|
'SIGTERM',
|
|
);
|
|
expect(mockProcessKill).not.toHaveBeenCalledWith(
|
|
-mockPtyProcess.pid,
|
|
'SIGKILL',
|
|
);
|
|
});
|
|
|
|
it('post-promotion: PTY data is no longer routed to onOutputEvent (handoff boundary)', async () => {
|
|
// Pin the ownership contract: after background-promote, PTY data
|
|
// arriving on the still-running child must NOT surface through the
|
|
// foreground execute()'s onOutputEvent (the caller has its own
|
|
// listeners now). Without dataDisposable.dispose() in the abort
|
|
// handler, the listener-retention bug would let post-promote bytes
|
|
// leak into the foreground consumer.
|
|
//
|
|
// Implementation note: PTY's handleOutput is async (`processingChain`
|
|
// queues microtasks for headlessTerminal.write callbacks), unlike
|
|
// child_process's sync handleOutput. Sync `expect` immediately
|
|
// after emit-then-abort would only see the call count BEFORE chain
|
|
// items run — both pre and post would read 0 and the assertion
|
|
// would tautologically pass without exercising the
|
|
// `listenersDetached` guard. We drive the test using the
|
|
// `simulateExecution` helper, which awaits `handle.result` — by
|
|
// the time the result resolves, the abort handler has run its
|
|
// drain (so all queued chain items have settled) and we can read
|
|
// the final emit count.
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(pty, ac) => {
|
|
// Pre-promote data — fed via the live onData listener so it
|
|
// reaches the foreground onOutputEvent normally.
|
|
pty.onData.mock.calls[0][0]('pre-promote-data\n');
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
// Snapshot the count after promote settled. Pre-promote chain
|
|
// item ran AFTER abort set `listenersDetached = true` (chain
|
|
// items queue at handleOutput time but only execute when their
|
|
// .then microtask runs, which is after the sync abort dispatch
|
|
// that set the flag), so the pre-promote emit was already
|
|
// suppressed by the guard. Asserting `0` here pins both halves
|
|
// of the contract — pre-promote AND post-promote bytes are both
|
|
// suppressed once `listenersDetached` is set. Without the guard,
|
|
// pre-promote's render path would emit a `'data'` event into
|
|
// `onOutputEventMock` and this would be `>= 1`, failing the
|
|
// assertion.
|
|
const eventCountAfterSettle = onOutputEventMock.mock.calls.length;
|
|
expect(eventCountAfterSettle).toBe(0);
|
|
// Drive the data callback again after promote: production-side
|
|
// dataDisposable was disposed, but the mock stub doesn't actually
|
|
// detach the callback (the disposable returned by `vi.fn()` is a
|
|
// no-op). Re-invoking via `mock.calls[0][0]` exercises the
|
|
// production-side `listenersDetached` guard inside the chain
|
|
// callback, which is the real backstop against post-promote
|
|
// bytes leaking to the foreground onOutputEvent.
|
|
mockPtyProcess.onData.mock.calls[0][0]('post-promote-data\n');
|
|
// Wait one macrotask + a microtask flush to let any chain items
|
|
// queued by the post-promote dataCallback fully settle.
|
|
await new Promise((res) => setImmediate(res));
|
|
await new Promise((res) => setImmediate(res));
|
|
expect(onOutputEventMock.mock.calls.length).toBe(eventCountAfterSettle);
|
|
|
|
// The disposable returned by mockPtyProcess.onData was disposed by
|
|
// the abort handler — verify by calling .dispose's mock.
|
|
const dataDisposableStub = mockPtyProcess.onData.mock.results[0]
|
|
.value as { dispose: Mock };
|
|
expect(dataDisposableStub.dispose).toHaveBeenCalled();
|
|
const exitDisposableStub = mockPtyProcess.onExit.mock.results[0]
|
|
.value as { dispose: Mock };
|
|
expect(exitDisposableStub.dispose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('PR-2.5: post-promote bytes route to postPromote.onData when callback provided', async () => {
|
|
// Pin the new opt-in contract: when `postPromote.onData` is set,
|
|
// bytes the still-running PTY emits after promote go to the
|
|
// caller's handler instead of being lost. PR-2 fully detached
|
|
// listeners; PR-2.5 re-attaches a minimal forwarder when the
|
|
// caller opts in.
|
|
const onDataCalls: ShellOutputEvent[] = [];
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(pty, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_pr25_data',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
shellExecutionConfig,
|
|
{
|
|
postPromote: {
|
|
onData: (event) => onDataCalls.push(event),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
// After promote, drive a fresh post-promote chunk through the
|
|
// PTY's onData. The service should have attached a NEW listener
|
|
// (the foreground one is disposed); look at the latest
|
|
// mock.calls entry — index 1 since PR-2.5 adds a second.
|
|
const onDataRegistrations = mockPtyProcess.onData.mock.calls;
|
|
expect(onDataRegistrations.length).toBeGreaterThanOrEqual(2);
|
|
const postPromoteHandler =
|
|
onDataRegistrations[onDataRegistrations.length - 1][0];
|
|
postPromoteHandler('post-promote-byte-stream');
|
|
expect(onDataCalls).toEqual([
|
|
{ type: 'data', chunk: 'post-promote-byte-stream' },
|
|
]);
|
|
});
|
|
|
|
it('PR-2.5: postPromote.onSettle fires on natural child exit after promote', async () => {
|
|
// Pin the natural-exit settle: when the child terminates AFTER
|
|
// promote, the caller's onSettle handler is invoked exactly
|
|
// once with the exit code (or signal / error). PR-2 detached
|
|
// the exit listener entirely; PR-2.5 re-attaches a forwarder
|
|
// when the caller opts in.
|
|
const settleCalls: ShellPostPromoteSettleInfo[] = [];
|
|
const { result } = await simulateExecution(
|
|
'long-running-command',
|
|
(pty, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_pr25_settle',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
shellExecutionConfig,
|
|
{
|
|
postPromote: {
|
|
onSettle: (info) => settleCalls.push(info),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
// After promote, drive the PTY's onExit to simulate natural
|
|
// completion. The service attaches a new exit listener for
|
|
// post-promote settle — find the most-recently-registered.
|
|
const onExitRegistrations = mockPtyProcess.onExit.mock.calls;
|
|
expect(onExitRegistrations.length).toBeGreaterThanOrEqual(2);
|
|
const postPromoteExitHandler =
|
|
onExitRegistrations[onExitRegistrations.length - 1][0];
|
|
postPromoteExitHandler({ exitCode: 0, signal: undefined });
|
|
expect(settleCalls).toHaveLength(1);
|
|
expect(settleCalls[0].exitCode).toBe(0);
|
|
expect(settleCalls[0].signal).toBeNull();
|
|
expect(settleCalls[0].error).toBeUndefined();
|
|
expect(typeof settleCalls[0].endTime).toBe('number');
|
|
});
|
|
|
|
it('PR-2.5 wave-2 (C2): unexpected post-promote PTY error routes to onSettle as failure (does NOT crash the CLI)', async () => {
|
|
// Foreground PTY error handler removed at promote handoff. Before
|
|
// the wave-2 fix the post-promote path attached NO error listener,
|
|
// so an unhandled `error` event would take Node down. Now we
|
|
// attach a forwarder: unexpected errors flow through onSettle
|
|
// with `error` populated; expected PTY read-exit errors
|
|
// (EIO / EAGAIN) are filtered.
|
|
const settleCalls: ShellPostPromoteSettleInfo[] = [];
|
|
const { result } = await simulateExecution(
|
|
'long-running-with-error',
|
|
(pty, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_pr25_pty_err',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
shellExecutionConfig,
|
|
{
|
|
postPromote: {
|
|
onSettle: (info) => settleCalls.push(info),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
|
|
// 1. An expected PTY read-exit error (EIO) is FILTERED — onSettle
|
|
// is NOT invoked yet (the upcoming onExit will carry status).
|
|
mockPtyProcess.emit(
|
|
'error',
|
|
Object.assign(new Error('read EIO'), { code: 'EIO' }),
|
|
);
|
|
expect(settleCalls).toHaveLength(0);
|
|
|
|
// 2. An UNEXPECTED error (EPIPE) routes to onSettle as a failure.
|
|
// Critically: emitting must NOT throw (no unhandled `error`).
|
|
const unexpectedErr = Object.assign(new Error('disk gone'), {
|
|
code: 'EPIPE',
|
|
});
|
|
expect(() => mockPtyProcess.emit('error', unexpectedErr)).not.toThrow();
|
|
expect(settleCalls).toHaveLength(1);
|
|
expect(settleCalls[0].error).toBe(unexpectedErr);
|
|
expect(settleCalls[0].exitCode).toBeNull();
|
|
expect(settleCalls[0].signal).toBeNull();
|
|
expect(typeof settleCalls[0].endTime).toBe('number');
|
|
|
|
// 3. A subsequent onExit MUST NOT fire onSettle again (single-fire
|
|
// latch): callers like the registry's `complete`/`fail`
|
|
// transitions are not idempotent across status types.
|
|
const onExitRegistrations = mockPtyProcess.onExit.mock.calls;
|
|
const postPromoteExitHandler =
|
|
onExitRegistrations[onExitRegistrations.length - 1][0];
|
|
postPromoteExitHandler({ exitCode: 0, signal: undefined });
|
|
expect(settleCalls).toHaveLength(1);
|
|
});
|
|
|
|
it('PR-2.5 wave-3 (T6): post-promote IDisposables and error listener are released on settle (no GC roots dangling)', async () => {
|
|
// Each promoted PTY child can sit dead for milliseconds while
|
|
// the caller's `cancelChild` finalizes. Node's EventEmitter
|
|
// holds refs to listener closures, which in turn hold refs to
|
|
// `onPostData` / `onPostSettle` / the caller's
|
|
// `promoteArtifacts`. Without disposal on settle, those refs
|
|
// dangle until the PTY itself is collected. The fix captures
|
|
// the IDisposables returned by `onData` / `onExit` AND the
|
|
// `'error'` listener function we registered on the EE, then
|
|
// releases them when `firePostSettle` fires (no matter which
|
|
// path triggers settle).
|
|
const removeListenerSpy = vi.spyOn(mockPtyProcess, 'removeListener');
|
|
|
|
const settleCalls: ShellPostPromoteSettleInfo[] = [];
|
|
const { result } = await simulateExecution(
|
|
'long-running-disposable',
|
|
(pty, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_pr25_dispose',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
shellExecutionConfig,
|
|
{
|
|
postPromote: {
|
|
onData: () => {},
|
|
onSettle: (info) => settleCalls.push(info),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
|
|
// The mocked `mockReturnValue({ dispose: vi.fn() })` reuses the
|
|
// SAME disposable object across calls, so foreground +
|
|
// post-promote share the same dispose Mock. The foreground
|
|
// disposable was already disposed at promote handoff; clear
|
|
// the call history so we can assert ONLY on post-settle
|
|
// disposal.
|
|
const sharedDataDisposable = mockPtyProcess.onData.mock.results[0]
|
|
.value as { dispose: Mock };
|
|
const sharedExitDisposable = mockPtyProcess.onExit.mock.results[0]
|
|
.value as { dispose: Mock };
|
|
sharedDataDisposable.dispose.mockClear();
|
|
sharedExitDisposable.dispose.mockClear();
|
|
removeListenerSpy.mockClear();
|
|
|
|
// Drive onExit → firePostSettle runs disposePostPromoteListeners.
|
|
const onExitRegistrations = mockPtyProcess.onExit.mock.calls;
|
|
const postPromoteExitHandler =
|
|
onExitRegistrations[onExitRegistrations.length - 1][0];
|
|
postPromoteExitHandler({ exitCode: 0, signal: undefined });
|
|
|
|
expect(settleCalls).toHaveLength(1);
|
|
// Post-settle: BOTH disposables released, error listener removed.
|
|
expect(sharedDataDisposable.dispose).toHaveBeenCalledTimes(1);
|
|
expect(sharedExitDisposable.dispose).toHaveBeenCalledTimes(1);
|
|
// The post-promote error listener was attached via
|
|
// `ptyProcess.on('error', listener)` and is released via
|
|
// `removeListener('error', listener)`. Verify removeListener
|
|
// was called on the 'error' channel.
|
|
const errorRemoves = removeListenerSpy.mock.calls.filter(
|
|
(args: unknown[]) => args[0] === 'error',
|
|
);
|
|
expect(errorRemoves.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Re-driving onExit must NOT re-fire settle (latched) AND
|
|
// dispose calls must NOT double-count (idempotent disposal —
|
|
// disposePostPromoteListeners nulls the slots after first
|
|
// disposal).
|
|
postPromoteExitHandler({ exitCode: 0, signal: undefined });
|
|
expect(settleCalls).toHaveLength(1);
|
|
expect(sharedDataDisposable.dispose).toHaveBeenCalledTimes(1);
|
|
expect(sharedExitDisposable.dispose).toHaveBeenCalledTimes(1);
|
|
|
|
removeListenerSpy.mockRestore();
|
|
});
|
|
|
|
it('PR-2.5: onData-only PTY caller has post-promote error + exit listeners (no crash, listeners disposed on exit)', async () => {
|
|
const dataChunks: ShellOutputEvent[] = [];
|
|
const { result } = await simulateExecution(
|
|
'tail -f /dev/null',
|
|
(pty, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_pty_ondata_only',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
shellExecutionConfig,
|
|
{
|
|
postPromote: {
|
|
onData: (event) => dataChunks.push(event),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
|
|
// Error listener must be installed even without onSettle —
|
|
// emitting 'error' on an EventEmitter with no listener throws.
|
|
expect(() =>
|
|
mockPtyProcess.emit('error', new Error('post-promote pty err')),
|
|
).not.toThrow();
|
|
|
|
// onExit must also be installed so disposePostPromoteListeners
|
|
// runs on natural exit (cleaning up data + error listeners).
|
|
const onExitRegistrations = mockPtyProcess.onExit.mock.calls;
|
|
expect(onExitRegistrations.length).toBeGreaterThanOrEqual(2);
|
|
const postPromoteExitHandler =
|
|
onExitRegistrations[onExitRegistrations.length - 1][0];
|
|
|
|
// Simulate natural exit — should dispose listeners without crash.
|
|
postPromoteExitHandler({ exitCode: 0 });
|
|
});
|
|
|
|
it('PR-2.5 backwards compat: without postPromote, listeners stay fully detached (no regression on PR-2 contract)', async () => {
|
|
// Pin that omitting `postPromote` preserves the PR-2 detach-
|
|
// everything contract. The pre-existing post-promote test at
|
|
// line ~680 already covers this for the data path; this one
|
|
// adds the symmetric guarantee for the exit path — natural
|
|
// post-promote exit must NOT invoke any callback the caller
|
|
// didn't provide.
|
|
const onDataCalls: ShellOutputEvent[] = [];
|
|
const onSettleCalls: ShellPostPromoteSettleInfo[] = [];
|
|
const { result } = await simulateExecution(
|
|
'no-post-promote-handlers',
|
|
(pty, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_pr25_compat',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
// No options arg → postPromote unset → PR-2 contract.
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
// Drive both PTY events post-promote.
|
|
const onDataRegistrations = mockPtyProcess.onData.mock.calls;
|
|
// PR-2 contract: only ONE onData registration (the foreground
|
|
// one, now disposed). PR-2.5's re-attach is gated on
|
|
// `postPromote.onData` being set, so without it the
|
|
// registration count stays at 1.
|
|
expect(onDataRegistrations.length).toBe(1);
|
|
const onExitRegistrations = mockPtyProcess.onExit.mock.calls;
|
|
expect(onExitRegistrations.length).toBe(1);
|
|
// Caller-provided handlers were never invoked.
|
|
expect(onDataCalls).toHaveLength(0);
|
|
expect(onSettleCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('post-exit race: PTY background-promote refuses if process.kill(pid, 0) reports the pid is gone', async () => {
|
|
// Mirror of the child_process post-exit race test. The PTY may
|
|
// have already exited but our `exitDisposable` (onExit) handler
|
|
// hasn't run yet — node-pty delivers the exit event async after
|
|
// the native SIGCHLD. Promoting in that window would detach our
|
|
// exit listener, miss the real exit status, and report
|
|
// `promoted: true` for a dead PTY. Production guard:
|
|
// process.kill(pid, 0); if it throws ESRCH, fall through.
|
|
mockProcessKill.mockImplementationOnce((pid, signal) => {
|
|
// Only fail the very first liveness probe with signal 0 — let
|
|
// any subsequent kill calls (e.g. cleanup() at process exit)
|
|
// succeed so the test teardown stays clean.
|
|
if (signal === 0) {
|
|
throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' });
|
|
}
|
|
return true;
|
|
});
|
|
const { result } = await simulateExecution(
|
|
'fast-and-cancelled',
|
|
(pty, abortController) => {
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
// Drain the pending onExit (production code falls through;
|
|
// normal exit path resolves with the real exit info).
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: undefined });
|
|
},
|
|
);
|
|
|
|
// Result is the normal exit shape, not the promoted shape.
|
|
expect(result.promoted).toBeUndefined();
|
|
expect(result.exitCode).toBe(0);
|
|
// Our PTY listeners stayed registered — the disposables are
|
|
// disposed by the natural onExit, not the abort handler.
|
|
const dataDisposableStub = mockPtyProcess.onData.mock.results[0]
|
|
.value as { dispose: Mock };
|
|
// dataDisposable is NOT disposed by our abort handler in the
|
|
// race-fallthrough path (the normal onExit handler doesn't
|
|
// dispose it either — it relies on the PTY tearing down its own
|
|
// event source). What matters is that we did NOT pre-dispose it
|
|
// and lose the exit info.
|
|
void dataDisposableStub; // referenced for the future expansion
|
|
});
|
|
|
|
it("post-promotion: ptyProcess error listener is removed via 'removeListener', NOT 'off' (regression guard for @lydell/node-pty)", async () => {
|
|
// node EventEmitter exposes both `off` (Node 10+) and the legacy
|
|
// `removeListener`, but @lydell/node-pty's IPty interface only
|
|
// surfaces `removeListener` — calling `.off(...)` on a real PTY
|
|
// throws TypeError. Pin that the production code path uses
|
|
// `removeListener` so a future refactor swapping to `.off()`
|
|
// doesn't silently regress under the EventEmitter mock (which
|
|
// tolerates both).
|
|
const removeListenerSpy = vi.spyOn(mockPtyProcess, 'removeListener');
|
|
const offSpy = vi.spyOn(mockPtyProcess, 'off');
|
|
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(_pty, abortController) => {
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
);
|
|
|
|
expect(result.promoted).toBe(true);
|
|
// The 'error' handler is removed via legacy API; `.off` must not
|
|
// appear in the production teardown path.
|
|
expect(removeListenerSpy).toHaveBeenCalledWith(
|
|
'error',
|
|
expect.any(Function),
|
|
);
|
|
const offErrorCalls = offSpy.mock.calls.filter(
|
|
([event]) => event === 'error',
|
|
);
|
|
expect(offErrorCalls).toEqual([]);
|
|
});
|
|
|
|
it('post-promotion: PTY exit does NOT re-resolve the result (already resolved with promoted)', async () => {
|
|
// Pin: even if the still-running child later exits naturally and the
|
|
// caller's own exit listener fires, our foreground result Promise
|
|
// must NOT be re-resolved with a different shape (Promise can only
|
|
// resolve once). The exit disposable being disposed prevents our
|
|
// own onExit from firing at all in the first place — but verify the
|
|
// final resolved shape stays `promoted: true` regardless.
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(_pty, abortController) => {
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
);
|
|
|
|
// Resolved as promoted, with no exit info from a post-promote exit.
|
|
expect(result.promoted).toBe(true);
|
|
expect(result.exitCode).toBeNull();
|
|
expect(result.signal).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Binary Output', () => {
|
|
it('should detect binary output and switch to progress events', async () => {
|
|
mockIsBinary.mockReturnValueOnce(true);
|
|
const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
const { result } = await simulateExecution('cat image.png', (pty) => {
|
|
pty.onData.mock.calls[0][0](binaryChunk1);
|
|
pty.onData.mock.calls[0][0](binaryChunk2);
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
expect(result.rawOutput).toEqual(
|
|
Buffer.concat([binaryChunk1, binaryChunk2]),
|
|
);
|
|
expect(onOutputEventMock).toHaveBeenCalledTimes(3);
|
|
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
|
type: 'binary_detected',
|
|
});
|
|
expect(onOutputEventMock.mock.calls[1][0]).toEqual({
|
|
type: 'binary_progress',
|
|
bytesReceived: 4,
|
|
});
|
|
expect(onOutputEventMock.mock.calls[2][0]).toEqual({
|
|
type: 'binary_progress',
|
|
bytesReceived: 8,
|
|
});
|
|
});
|
|
|
|
it('should not emit data events after binary is detected', async () => {
|
|
mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));
|
|
|
|
await simulateExecution('cat mixed_file', (pty) => {
|
|
pty.onData.mock.calls[0][0](Buffer.from([0x00, 0x01, 0x02]));
|
|
pty.onData.mock.calls[0][0](Buffer.from('more text'));
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
});
|
|
|
|
const eventTypes = onOutputEventMock.mock.calls.map(
|
|
(call: [ShellOutputEvent]) => call[0].type,
|
|
);
|
|
expect(eventTypes).toEqual([
|
|
'binary_detected',
|
|
'binary_progress',
|
|
'binary_progress',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Platform-Specific Behavior', () => {
|
|
it('should use cmd.exe on Windows', async () => {
|
|
mockPlatform.mockReturnValue('win32');
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'cmd.exe',
|
|
argsPrefix: ['/d', '/s', '/c'],
|
|
shell: 'cmd',
|
|
});
|
|
await simulateExecution('dir "foo bar"', (pty) =>
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
|
|
);
|
|
|
|
expect(mockPtySpawn).toHaveBeenCalledWith(
|
|
'cmd.exe',
|
|
'/d /s /c dir "foo bar"',
|
|
expect.any(Object),
|
|
);
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'bash',
|
|
argsPrefix: ['-c'],
|
|
shell: 'bash',
|
|
});
|
|
});
|
|
|
|
it('should use PowerShell on Windows with array args and UTF-8 prefix', async () => {
|
|
mockPlatform.mockReturnValue('win32');
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'powershell.exe',
|
|
argsPrefix: ['-NoProfile', '-Command'],
|
|
shell: 'powershell',
|
|
});
|
|
await simulateExecution('Test-Path "C:\\Temp\\"', (pty) =>
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
|
|
);
|
|
|
|
// PowerShell commands on Windows are prefixed with UTF-8 output encoding
|
|
expect(mockPtySpawn).toHaveBeenCalledWith(
|
|
'powershell.exe',
|
|
[
|
|
'-NoProfile',
|
|
'-Command',
|
|
'[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;Test-Path "C:\\Temp\\"',
|
|
],
|
|
expect.any(Object),
|
|
);
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'bash',
|
|
argsPrefix: ['-c'],
|
|
shell: 'bash',
|
|
});
|
|
});
|
|
|
|
it('should normalize PATH-like env keys on Windows for pty execution', async () => {
|
|
mockPlatform.mockReturnValue('win32');
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
|
setupConflictingPathEnv();
|
|
|
|
await simulateExecution('dir', (pty) =>
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
|
|
);
|
|
|
|
const spawnOptions = mockPtySpawn.mock.calls[0][2];
|
|
expectNormalizedWindowsPathEnv(spawnOptions.env);
|
|
});
|
|
|
|
it('should use bash on Linux', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
await simulateExecution('ls "foo bar"', (pty) =>
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
|
|
);
|
|
|
|
expect(mockPtySpawn).toHaveBeenCalledWith(
|
|
'bash',
|
|
['-c', 'ls "foo bar"'],
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('AnsiOutput rendering', () => {
|
|
it('should call onOutputEvent with AnsiOutput when showColor is true', async () => {
|
|
const coloredShellExecutionConfig = {
|
|
...shellExecutionConfig,
|
|
showColor: true,
|
|
defaultFg: '#ffffff',
|
|
defaultBg: '#000000',
|
|
disableDynamicLineTrimming: true,
|
|
};
|
|
const mockAnsiOutput = [
|
|
[{ text: 'hello', fg: '#ffffff', bg: '#000000' }],
|
|
];
|
|
mockSerializeTerminalToObject.mockReturnValue(mockAnsiOutput);
|
|
|
|
await simulateExecution(
|
|
'ls --color=auto',
|
|
(pty) => {
|
|
pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
},
|
|
coloredShellExecutionConfig,
|
|
);
|
|
|
|
expect(mockSerializeTerminalToObject).toHaveBeenCalledWith(
|
|
expect.anything(), // The terminal object
|
|
);
|
|
|
|
expect(onOutputEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'data',
|
|
chunk: mockAnsiOutput,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not re-emit live output when only soft-wrap segmentation changes', async () => {
|
|
const coloredShellExecutionConfig = {
|
|
...shellExecutionConfig,
|
|
showColor: true,
|
|
disableDynamicLineTrimming: true,
|
|
};
|
|
const firstWrappedOutput = [
|
|
[createAnsiToken('abcd')],
|
|
[createAnsiToken('efgh')],
|
|
];
|
|
const rewrappedOutput = [
|
|
[createAnsiToken('ab')],
|
|
[createAnsiToken('cdef')],
|
|
[createAnsiToken('gh')],
|
|
];
|
|
const logicalOutput = [[createAnsiToken('abcdefgh')]];
|
|
let rawRenderCount = 0;
|
|
|
|
mockSerializeTerminalToObject.mockImplementation(
|
|
(
|
|
_terminal,
|
|
_scrollOffset,
|
|
options?: { unwrapWrappedLines?: boolean },
|
|
) => {
|
|
if (options?.unwrapWrappedLines) {
|
|
return logicalOutput;
|
|
}
|
|
|
|
rawRenderCount += 1;
|
|
return rawRenderCount === 1 ? firstWrappedOutput : rewrappedOutput;
|
|
},
|
|
);
|
|
|
|
await simulateExecution(
|
|
'narrow-output',
|
|
(pty) => {
|
|
pty.onData.mock.calls[0][0]('abcdefgh');
|
|
pty.onData.mock.calls[0][0]('\r');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
},
|
|
coloredShellExecutionConfig,
|
|
);
|
|
|
|
const dataEvents = onOutputEventMock.mock.calls.filter(
|
|
([event]) => event.type === 'data',
|
|
);
|
|
expect(dataEvents).toHaveLength(1);
|
|
expect(dataEvents[0][0]).toEqual({
|
|
type: 'data',
|
|
chunk: firstWrappedOutput,
|
|
});
|
|
});
|
|
|
|
it('should call onOutputEvent with AnsiOutput when showColor is false', async () => {
|
|
await simulateExecution(
|
|
'ls --color=auto',
|
|
(pty) => {
|
|
pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword');
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
},
|
|
{
|
|
...shellExecutionConfig,
|
|
showColor: false,
|
|
disableDynamicLineTrimming: true,
|
|
},
|
|
);
|
|
|
|
const expected = createExpectedAnsiOutput('aredword');
|
|
|
|
expect(onOutputEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'data',
|
|
chunk: expected,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not re-emit default plain live output when only soft-wrap segmentation changes', async () => {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'narrow-output',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
{
|
|
...shellExecutionConfig,
|
|
terminalWidth: 4,
|
|
terminalHeight: 4,
|
|
showColor: false,
|
|
disableDynamicLineTrimming: false,
|
|
},
|
|
);
|
|
|
|
await new Promise((resolve) => process.nextTick(resolve));
|
|
mockPtyProcess.onData.mock.calls[0][0]('abcdefgh');
|
|
await waitForDataEventCount(onOutputEventMock, 1);
|
|
|
|
ShellExecutionService.resizePty(handle.pid!, 2, 4);
|
|
mockPtyProcess.onData.mock.calls[0][0]('\r');
|
|
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
await handle.result;
|
|
|
|
const dataEvents = onOutputEventMock.mock.calls.filter(
|
|
([event]) => event.type === 'data',
|
|
);
|
|
expect(dataEvents).toHaveLength(1);
|
|
const firstDataEvent = dataEvents[0][0];
|
|
if (firstDataEvent.type !== 'data') {
|
|
throw new Error('Expected a shell data event.');
|
|
}
|
|
const chunk = firstDataEvent.chunk as AnsiOutput;
|
|
expect(chunk.map((line) => line[0]?.text).filter(Boolean)).toEqual([
|
|
'abcd',
|
|
'efgh',
|
|
]);
|
|
});
|
|
|
|
it('should handle multi-line output correctly when showColor is false', async () => {
|
|
await simulateExecution(
|
|
'ls --color=auto',
|
|
(pty) => {
|
|
pty.onData.mock.calls[0][0](
|
|
'line 1\n\u001b[32mline 2\u001b[0m\nline 3',
|
|
);
|
|
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
},
|
|
{
|
|
...shellExecutionConfig,
|
|
showColor: false,
|
|
disableDynamicLineTrimming: true,
|
|
},
|
|
);
|
|
|
|
const expected = createExpectedAnsiOutput(['line 1', 'line 2', 'line 3']);
|
|
|
|
expect(onOutputEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'data',
|
|
chunk: expected,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ShellExecutionService child_process fallback', () => {
|
|
let mockChildProcess: EventEmitter & Partial<ChildProcess>;
|
|
let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
originalProcessEnv = process.env;
|
|
|
|
mockIsBinary.mockReturnValue(false);
|
|
mockPlatform.mockReturnValue('linux');
|
|
mockGetPty.mockResolvedValue(null);
|
|
|
|
onOutputEventMock = vi.fn();
|
|
|
|
mockChildProcess = new EventEmitter() as EventEmitter &
|
|
Partial<ChildProcess>;
|
|
mockChildProcess.stdout = new EventEmitter() as Readable;
|
|
mockChildProcess.stderr = new EventEmitter() as Readable;
|
|
mockChildProcess.kill = vi.fn();
|
|
|
|
Object.defineProperty(mockChildProcess, 'pid', {
|
|
value: 12345,
|
|
configurable: true,
|
|
});
|
|
// Mirror real Node ChildProcess: `exitCode` / `signalCode` are `null`
|
|
// while the child is alive and become a number / signal name on
|
|
// exit. The background-promote liveness guard reads these to detect
|
|
// an exit that fired between abort dispatch and the abort handler
|
|
// run, and a default of `undefined` would mistakenly look terminal
|
|
// and skip the promote.
|
|
Object.defineProperty(mockChildProcess, 'exitCode', {
|
|
value: null,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(mockChildProcess, 'signalCode', {
|
|
value: null,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
mockCpSpawn.mockReturnValue(mockChildProcess);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalProcessEnv;
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
// Helper function to run a standard execution simulation
|
|
const simulateExecution = async (
|
|
command: string,
|
|
simulation: (cp: typeof mockChildProcess, ac: AbortController) => void,
|
|
options: ShellExecuteOptions = {},
|
|
) => {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
command,
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
shellExecutionConfig,
|
|
options,
|
|
);
|
|
|
|
await new Promise((resolve) => process.nextTick(resolve));
|
|
simulation(mockChildProcess, abortController);
|
|
const result = await handle.result;
|
|
return { result, handle, abortController };
|
|
};
|
|
|
|
describe('Successful Execution', () => {
|
|
it('should execute a command and capture stdout and stderr', async () => {
|
|
const { result, handle } = await simulateExecution('ls -l', (cp) => {
|
|
cp.stdout?.emit('data', Buffer.from('file1.txt\n'));
|
|
cp.stderr?.emit('data', Buffer.from('a warning'));
|
|
cp.emit('exit', 0, null);
|
|
cp.emit('close', 0, null);
|
|
});
|
|
|
|
expect(mockCpSpawn).toHaveBeenCalledWith(
|
|
'bash',
|
|
['-c', 'ls -l'],
|
|
expect.objectContaining({
|
|
detached: true,
|
|
}),
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.signal).toBeNull();
|
|
expect(result.error).toBeNull();
|
|
expect(result.aborted).toBe(false);
|
|
expect(result.output).toBe('file1.txt\na warning');
|
|
expect(handle.pid).toBe(12345);
|
|
|
|
expect(onOutputEventMock).toHaveBeenCalledWith({
|
|
type: 'data',
|
|
chunk: 'file1.txt\na warning',
|
|
});
|
|
});
|
|
|
|
it('should strip ANSI codes from output', async () => {
|
|
const { result } = await simulateExecution('ls --color=auto', (cp) => {
|
|
cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword'));
|
|
cp.emit('exit', 0, null);
|
|
cp.emit('close', 0, null);
|
|
});
|
|
|
|
expect(result.output.trim()).toBe('aredword');
|
|
expect(onOutputEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'data',
|
|
chunk: 'aredword',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should correctly decode multi-byte characters split across chunks', async () => {
|
|
const { result } = await simulateExecution('echo "你好"', (cp) => {
|
|
const multiByteChar = Buffer.from('你好', 'utf-8');
|
|
cp.stdout?.emit('data', multiByteChar.slice(0, 2));
|
|
cp.stdout?.emit('data', multiByteChar.slice(2));
|
|
cp.emit('exit', 0, null);
|
|
cp.emit('close', 0, null);
|
|
});
|
|
expect(result.output.trim()).toBe('你好');
|
|
});
|
|
|
|
it('should handle commands with no output', async () => {
|
|
const { result } = await simulateExecution('touch file', (cp) => {
|
|
cp.emit('exit', 0, null);
|
|
cp.emit('close', 0, null);
|
|
});
|
|
|
|
expect(result.output.trim()).toBe('');
|
|
expect(onOutputEventMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Failed Execution', () => {
|
|
it('should capture a non-zero exit code and format output correctly', async () => {
|
|
const { result } = await simulateExecution('a-bad-command', (cp) => {
|
|
cp.stderr?.emit('data', Buffer.from('command not found'));
|
|
cp.emit('exit', 127, null);
|
|
cp.emit('close', 127, null);
|
|
});
|
|
|
|
expect(result.exitCode).toBe(127);
|
|
expect(result.output.trim()).toBe('command not found');
|
|
expect(result.error).toBeNull();
|
|
});
|
|
|
|
it('should capture a termination signal', async () => {
|
|
const { result } = await simulateExecution('long-process', (cp) => {
|
|
cp.emit('exit', null, 'SIGTERM');
|
|
cp.emit('close', null, 'SIGTERM');
|
|
});
|
|
|
|
expect(result.exitCode).toBeNull();
|
|
expect(result.signal).toBe(15);
|
|
});
|
|
|
|
it('should handle a spawn error', async () => {
|
|
const spawnError = new Error('spawn EACCES');
|
|
const { result } = await simulateExecution('protected-cmd', (cp) => {
|
|
cp.emit('error', spawnError);
|
|
cp.emit('exit', 1, null);
|
|
cp.emit('close', 1, null);
|
|
});
|
|
|
|
expect(result.error).toBe(spawnError);
|
|
expect(result.exitCode).toBe(1);
|
|
});
|
|
|
|
it('handles errors that do not fire the exit event', async () => {
|
|
const error = new Error('spawn abc ENOENT');
|
|
const { result } = await simulateExecution('touch cat.jpg', (cp) => {
|
|
cp.emit('error', error); // No exit event is fired.
|
|
cp.emit('close', 1, null);
|
|
});
|
|
|
|
expect(result.error).toBe(error);
|
|
expect(result.exitCode).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Aborting Commands', () => {
|
|
describe.each([
|
|
{
|
|
platform: 'linux',
|
|
expectedSignal: 'SIGTERM',
|
|
expectedExit: { signal: 'SIGKILL' as const },
|
|
},
|
|
{
|
|
platform: 'win32',
|
|
expectedCommand: 'taskkill',
|
|
expectedExit: { code: 1 },
|
|
},
|
|
])(
|
|
'on $platform',
|
|
({ platform, expectedSignal, expectedCommand, expectedExit }) => {
|
|
it('should abort a running process and set the aborted flag', async () => {
|
|
mockPlatform.mockReturnValue(platform);
|
|
|
|
const { result } = await simulateExecution(
|
|
'sleep 10',
|
|
(cp, abortController) => {
|
|
abortController.abort();
|
|
if (expectedExit.signal) {
|
|
cp.emit('exit', null, expectedExit.signal);
|
|
cp.emit('close', null, expectedExit.signal);
|
|
}
|
|
if (typeof expectedExit.code === 'number') {
|
|
cp.emit('exit', expectedExit.code, null);
|
|
cp.emit('close', expectedExit.code, null);
|
|
}
|
|
},
|
|
);
|
|
|
|
expect(result.aborted).toBe(true);
|
|
|
|
if (platform === 'linux') {
|
|
expect(mockProcessKill).toHaveBeenCalledWith(
|
|
-mockChildProcess.pid!,
|
|
expectedSignal,
|
|
);
|
|
} else {
|
|
expect(mockCpSpawn).toHaveBeenCalledWith(expectedCommand, [
|
|
'/pid',
|
|
String(mockChildProcess.pid),
|
|
'/f',
|
|
'/t',
|
|
]);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
it('signal.reason = { kind: "cancel" } still tree-kills (same as default)', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
const { result } = await simulateExecution(
|
|
'sleep 10',
|
|
(cp, abortController) => {
|
|
abortController.abort({ kind: 'cancel' } satisfies ShellAbortReason);
|
|
cp.emit('exit', null, 'SIGKILL');
|
|
cp.emit('close', null, 'SIGKILL');
|
|
},
|
|
);
|
|
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.promoted).toBeUndefined();
|
|
// Default kill path ran — pin that reason === 'cancel' is NOT
|
|
// mistakenly routed through the background branch.
|
|
expect(mockProcessKill).toHaveBeenCalledWith(
|
|
-mockChildProcess.pid!,
|
|
'SIGTERM',
|
|
);
|
|
});
|
|
|
|
it('signal.reason = { kind: "background" } skips kill and resolves with promoted: true (and aborted: false per design question 7)', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
// Critical: do NOT fire 'exit' — the child is still alive after the
|
|
// background-promote abort. The result Promise must resolve via the
|
|
// abort handler's own immediate resolve.
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(cp, abortController) => {
|
|
// Emit some output first so the snapshot has content.
|
|
cp.stdout?.emit('data', Buffer.from('line1\nline2\n'));
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
},
|
|
);
|
|
|
|
// See PTY equivalent test for the rationale on `aborted: false`.
|
|
expect(result.aborted).toBe(false);
|
|
expect(result.promoted).toBe(true);
|
|
expect(result.exitCode).toBeNull();
|
|
expect(result.signal).toBeNull();
|
|
expect(result.error).toBeNull();
|
|
expect(result.pid).toBe(mockChildProcess.pid);
|
|
// Output captured up to the promote moment is preserved as the
|
|
// snapshot for the caller to seed the BackgroundShellEntry's output
|
|
// file from.
|
|
expect(result.output).toContain('line1');
|
|
expect(result.output).toContain('line2');
|
|
// Verify the kill path did NOT run.
|
|
expect(mockProcessKill).not.toHaveBeenCalledWith(
|
|
-mockChildProcess.pid!,
|
|
'SIGTERM',
|
|
);
|
|
expect(mockProcessKill).not.toHaveBeenCalledWith(
|
|
-mockChildProcess.pid!,
|
|
'SIGKILL',
|
|
);
|
|
expect(mockChildProcess.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('post-promotion: stdout / stderr data is no longer routed to onOutputEvent (handoff boundary)', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
// Pin the ownership contract: after background-promote, stdout/stderr
|
|
// arriving on the still-running child must NOT surface through the
|
|
// foreground execute()'s onOutputEvent. Without off()'ing the
|
|
// stdoutHandler / stderrHandler in the abort handler, post-promote
|
|
// bytes would re-enter handleOutput, which then calls
|
|
// decoder.decode() on a now-finalized decoder (cleanup() called
|
|
// .decode() without stream:true) → TypeError crash, OR routes to
|
|
// onOutputEvent → ownership leak / duplicated emit.
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(cp, abortController) => {
|
|
cp.stdout?.emit('data', Buffer.from('pre-promote\n'));
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
// Capture call count at the moment of promote, then emit more
|
|
// data on the still-live child stream and assert onOutputEvent
|
|
// was NOT called again. (Also verifies no TypeError from
|
|
// decoding through the finalized decoder.)
|
|
const eventCountAtPromote = onOutputEventMock.mock.calls.length;
|
|
cp.stdout?.emit('data', Buffer.from('post-promote-stdout\n'));
|
|
cp.stderr?.emit('data', Buffer.from('post-promote-stderr\n'));
|
|
expect(onOutputEventMock.mock.calls.length).toBe(eventCountAtPromote);
|
|
},
|
|
);
|
|
|
|
expect(result.promoted).toBe(true);
|
|
// Pre-promote data made it into the snapshot; post-promote did not.
|
|
expect(result.output).toContain('pre-promote');
|
|
expect(result.output).not.toContain('post-promote-stdout');
|
|
expect(result.output).not.toContain('post-promote-stderr');
|
|
});
|
|
|
|
it('post-exit race: background-promote refuses if child is already terminal (exitCode/signalCode non-null)', async () => {
|
|
// Race window: the child may have exited (exitCode set) but the
|
|
// 'exit' event hasn't reached our handler yet because Node delivers
|
|
// child_process events on the next microtask. Promoting in that
|
|
// window would detach our exit listener and report `promoted: true`
|
|
// for a process that's already dead — the caller would hold an
|
|
// inert pid expecting to take over. Production code reads
|
|
// exitCode / signalCode before detaching; if either is non-null,
|
|
// it falls through and lets the pending exit handler resolve
|
|
// normally with the real exit info.
|
|
mockPlatform.mockReturnValue('linux');
|
|
const { result } = await simulateExecution(
|
|
'fast-and-cancelled',
|
|
(cp, abortController) => {
|
|
// Simulate the race: pretend the child has already exited
|
|
// (exitCode set on the ChildProcess) but the 'exit' event
|
|
// emit is queued behind the abort dispatch.
|
|
Object.defineProperty(cp, 'exitCode', {
|
|
value: 0,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
// Now drain the pending exit + close events; the normal
|
|
// exit path should resolve the result.
|
|
cp.emit('exit', 0, null);
|
|
cp.emit('close', 0, null);
|
|
},
|
|
);
|
|
|
|
// Result is the normal exit shape, not the promoted shape.
|
|
expect(result.promoted).toBeUndefined();
|
|
expect(result.aborted).toBe(true); // abortSignal.aborted is still true
|
|
expect(result.exitCode).toBe(0);
|
|
});
|
|
|
|
it('post-promotion: child exit does NOT re-resolve the result with a non-promoted shape', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
// Pin: even if the still-running child later exits naturally and the
|
|
// caller's own exit listener fires, our foreground result Promise
|
|
// must NOT be re-resolved (Promise can only resolve once). The
|
|
// detached exit handler prevents our own handler from firing.
|
|
const { result } = await simulateExecution(
|
|
'tail -f /tmp/never.log',
|
|
(cp, abortController) => {
|
|
abortController.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_test123',
|
|
} satisfies ShellAbortReason);
|
|
// Simulate the still-running child exiting later; this should
|
|
// NOT route through our handleExit because the exit listener
|
|
// was off()'d in the background-promote branch.
|
|
cp.emit('exit', 42, null);
|
|
cp.emit('close', 42, null);
|
|
},
|
|
);
|
|
|
|
expect(result.promoted).toBe(true);
|
|
expect(result.exitCode).toBeNull();
|
|
expect(result.signal).toBeNull();
|
|
});
|
|
|
|
it('PR-2.5 child_process: post-promote stdout/stderr forward to postPromote.onData with SEPARATE decoders', async () => {
|
|
// Pin: post-promote bytes from the still-running child route to
|
|
// the caller's onData handler. Separate decoders for stdout vs
|
|
// stderr — a single shared decoder would corrupt interleaved
|
|
// multibyte UTF-8 (the continuation-byte state machine assumes
|
|
// one byte source).
|
|
mockPlatform.mockReturnValue('linux');
|
|
const events: Array<{ type: string; chunk?: string | unknown }> = [];
|
|
const { result } = await simulateExecution(
|
|
'tail -f',
|
|
(cp, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_cp_data',
|
|
} satisfies ShellAbortReason);
|
|
// Drive post-promote chunks — should now flow to onData.
|
|
cp.stdout?.emit('data', Buffer.from('post-promote-stdout\n'));
|
|
cp.stderr?.emit('data', Buffer.from('post-promote-stderr\n'));
|
|
},
|
|
{
|
|
postPromote: {
|
|
onData: (event) => events.push(event),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
// Both streams forwarded.
|
|
const dataChunks = events
|
|
.filter((e) => e.type === 'data')
|
|
.map((e) => e.chunk);
|
|
expect(dataChunks).toContain('post-promote-stdout\n');
|
|
expect(dataChunks).toContain('post-promote-stderr\n');
|
|
});
|
|
|
|
it('PR-2.5 child_process: onSettle fires on `close` (NOT `exit`) so late chunks land before the registry transitions', async () => {
|
|
// Pin the `close`-not-`exit` contract: child can emit buffered
|
|
// data AFTER 'exit' but BEFORE 'close'. If onSettle fired on
|
|
// 'exit' the caller would close the output stream + transition
|
|
// the registry while late chunks were still in flight — they'd
|
|
// hit a closed stream and be dropped, producing truncated logs.
|
|
mockPlatform.mockReturnValue('linux');
|
|
const events: Array<{ type: string; chunk?: string | unknown }> = [];
|
|
const settles: ShellPostPromoteSettleInfo[] = [];
|
|
const { result } = await simulateExecution(
|
|
'cmd',
|
|
(cp, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_cp_close',
|
|
} satisfies ShellAbortReason);
|
|
// Order matters: emit 'exit' first (this would have settled
|
|
// PR-1 of PR-2.5 too early), then a final stdout chunk, then
|
|
// 'close'. With the new contract, onSettle only fires on
|
|
// 'close' so the late chunk is captured.
|
|
cp.emit('exit', 0, null);
|
|
cp.stdout?.emit('data', Buffer.from('late-chunk\n'));
|
|
cp.emit('close', 0, null);
|
|
},
|
|
{
|
|
postPromote: {
|
|
onData: (event) => events.push(event),
|
|
onSettle: (info) => settles.push(info),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
// Late chunk made it through.
|
|
const dataChunks = events
|
|
.filter((e) => e.type === 'data')
|
|
.map((e) => e.chunk);
|
|
expect(dataChunks).toContain('late-chunk\n');
|
|
// onSettle fired exactly once with exitCode 0.
|
|
expect(settles).toHaveLength(1);
|
|
expect(settles[0].exitCode).toBe(0);
|
|
expect(settles[0].signal).toBeNull();
|
|
});
|
|
|
|
it('PR-2.5 child_process: post-promote spawn error routes to onSettle with error populated', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
const settles: ShellPostPromoteSettleInfo[] = [];
|
|
const { result } = await simulateExecution(
|
|
'cmd',
|
|
(cp, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_cp_err',
|
|
} satisfies ShellAbortReason);
|
|
cp.emit('error', new Error('post-promote spawn boom'));
|
|
},
|
|
{
|
|
postPromote: {
|
|
onSettle: (info) => settles.push(info),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
expect(settles).toHaveLength(1);
|
|
expect(settles[0].error?.message).toBe('post-promote spawn boom');
|
|
expect(settles[0].exitCode).toBeNull();
|
|
expect(settles[0].signal).toBeNull();
|
|
});
|
|
|
|
it('PR-2.5 wave-4 (T1): post-promote `error` followed by `close` fires onSettle EXACTLY ONCE', async () => {
|
|
// Regression for the double-fire bug: pre-fix, `child.once('close', ...)`
|
|
// and `child.once('error', ...)` were independent and each invoked
|
|
// `onPostSettle` directly. A spawn-side error followed by the
|
|
// child-process automatic 'close' event would call the caller's
|
|
// settle twice, violating the exactly-once contract and racing
|
|
// the caller's `transitionRegistry`. Fix wraps both branches in
|
|
// a `firePostSettle` latch (mirroring the PTY path).
|
|
mockPlatform.mockReturnValue('linux');
|
|
const settles: ShellPostPromoteSettleInfo[] = [];
|
|
const { result } = await simulateExecution(
|
|
'cmd',
|
|
(cp, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_cp_double',
|
|
} satisfies ShellAbortReason);
|
|
// First: error fires.
|
|
cp.emit('error', new Error('error first'));
|
|
// Then: close (Node child_process always emits 'close' even
|
|
// after an error). Pre-fix this would call onSettle a second
|
|
// time.
|
|
cp.emit('close', 1, null);
|
|
},
|
|
{
|
|
postPromote: {
|
|
onSettle: (info) => settles.push(info),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
expect(settles).toHaveLength(1);
|
|
expect(settles[0].error?.message).toBe('error first');
|
|
});
|
|
|
|
it('PR-2.5 wave-4 (T3): onData-only caller still gets decoder flush on close (no trailing multibyte loss)', async () => {
|
|
// T3 regression: the close handler used to be installed only
|
|
// when `onSettle` was set, so an `onData`-only caller never got
|
|
// the trailing-multibyte flush — a UTF-8 character split across
|
|
// chunks could vanish. Fix installs close whenever ANY
|
|
// postPromote handler is set, and the flush helper runs whenever
|
|
// onData is set independent of onSettle.
|
|
mockPlatform.mockReturnValue('linux');
|
|
const dataChunks: ShellOutputEvent[] = [];
|
|
const { result } = await simulateExecution(
|
|
'cmd',
|
|
(cp, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_cp_t3',
|
|
} satisfies ShellAbortReason);
|
|
// Push the FIRST byte of a 3-byte UTF-8 char (€ = 0xE2 0x82 0xAC).
|
|
// Without flush, the trailing two bytes would be stuck in the
|
|
// decoder's continuation state and lost.
|
|
cp.stdout?.emit('data', Buffer.from([0xe2]));
|
|
cp.stdout?.emit('data', Buffer.from([0x82, 0xac]));
|
|
// Trigger close so the flush runs; no onSettle to gate on.
|
|
cp.emit('close', 0, null);
|
|
},
|
|
{
|
|
postPromote: {
|
|
onData: (event) => dataChunks.push(event),
|
|
// NO onSettle — close handler must still fire flush.
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
// The € character should appear once the second chunk completes
|
|
// the multibyte sequence; flush at close ensures any remainder
|
|
// is surfaced.
|
|
const joined = dataChunks
|
|
.map((d) =>
|
|
d.type === 'data' && typeof d.chunk === 'string' ? d.chunk : '',
|
|
)
|
|
.join('');
|
|
expect(joined).toContain('€');
|
|
});
|
|
|
|
it('PR-2.5 wave-4 (T6): onData-only caller has post-promote `error` listener (does not crash CLI)', async () => {
|
|
// T6 regression: `child.once('error', ...)` install was gated
|
|
// on `onSettle`, so an `onData`-only caller had the foreground
|
|
// errorHandler detached at promote with no replacement — a
|
|
// post-promote spawn error would surface as Node's default
|
|
// unhandled-error crash. Fix attaches an error listener
|
|
// whenever ANY postPromote handler is set.
|
|
mockPlatform.mockReturnValue('linux');
|
|
const dataChunks: ShellOutputEvent[] = [];
|
|
const { result } = await simulateExecution(
|
|
'cmd',
|
|
(cp, ac) => {
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_cp_t6',
|
|
} satisfies ShellAbortReason);
|
|
// Emitting 'error' on an EventEmitter with no listener throws
|
|
// synchronously. With the fix, our listener is attached so
|
|
// the emit does not throw.
|
|
expect(() =>
|
|
cp.emit('error', new Error('post-promote err')),
|
|
).not.toThrow();
|
|
// child_process auto-emits 'close' after 'error'.
|
|
cp.emit('close', null, null);
|
|
},
|
|
{
|
|
postPromote: {
|
|
onData: (event) => dataChunks.push(event),
|
|
// NO onSettle — but error must still be handled (no crash).
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
});
|
|
|
|
it('PR-2.5 wave-4 (T7): onSettle-only caller has stdout/stderr resumed (child does not block on full pipes)', async () => {
|
|
// T7 regression: when `onSettle` is set but `onData` is NOT, the
|
|
// post-promote path used to leave stdout/stderr without any data
|
|
// listener. The Readables stay paused; the OS pipe buffer fills
|
|
// (~64KB on Linux); the child blocks on stdout.write; 'close'
|
|
// never fires; onSettle never fires. Fix calls .resume() on
|
|
// both streams in the no-onData branch so the child can drain.
|
|
mockPlatform.mockReturnValue('linux');
|
|
const settles: ShellPostPromoteSettleInfo[] = [];
|
|
const stdoutResumeSpy = vi.fn();
|
|
const stderrResumeSpy = vi.fn();
|
|
const { result } = await simulateExecution(
|
|
'cmd',
|
|
(cp, ac) => {
|
|
// Patch resume() so we can verify the wire was driven.
|
|
if (cp.stdout) cp.stdout.resume = stdoutResumeSpy;
|
|
if (cp.stderr) cp.stderr.resume = stderrResumeSpy;
|
|
ac.abort({
|
|
kind: 'background',
|
|
shellId: 'bg_cp_t7',
|
|
} satisfies ShellAbortReason);
|
|
cp.emit('close', 0, null);
|
|
},
|
|
{
|
|
postPromote: {
|
|
// NO onData — but stdout/stderr must still be resumed.
|
|
onSettle: (info) => settles.push(info),
|
|
},
|
|
},
|
|
);
|
|
expect(result.promoted).toBe(true);
|
|
expect(stdoutResumeSpy).toHaveBeenCalled();
|
|
expect(stderrResumeSpy).toHaveBeenCalled();
|
|
expect(settles).toHaveLength(1);
|
|
});
|
|
|
|
it('should gracefully attempt SIGKILL on linux if SIGTERM fails', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
vi.useFakeTimers();
|
|
|
|
// Don't await the result inside the simulation block for this specific test.
|
|
// We need to control the timeline manually.
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'unresponsive_process',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true,
|
|
{},
|
|
);
|
|
|
|
abortController.abort();
|
|
|
|
// Check the first kill signal
|
|
expect(mockProcessKill).toHaveBeenCalledWith(
|
|
-mockChildProcess.pid!,
|
|
'SIGTERM',
|
|
);
|
|
|
|
// Now, advance time past the timeout
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
|
|
// Check the second kill signal
|
|
expect(mockProcessKill).toHaveBeenCalledWith(
|
|
-mockChildProcess.pid!,
|
|
'SIGKILL',
|
|
);
|
|
|
|
// Finally, simulate the process exiting and await the result
|
|
mockChildProcess.emit('exit', null, 'SIGKILL');
|
|
mockChildProcess.emit('close', null, 'SIGKILL');
|
|
const result = await handle.result;
|
|
|
|
vi.useRealTimers();
|
|
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.signal).toBe(9);
|
|
});
|
|
});
|
|
|
|
describe('Binary Output', () => {
|
|
it('should detect binary output and switch to progress events', async () => {
|
|
mockIsBinary.mockReturnValueOnce(true);
|
|
const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
const { result } = await simulateExecution('cat image.png', (cp) => {
|
|
cp.stdout?.emit('data', binaryChunk1);
|
|
cp.stdout?.emit('data', binaryChunk2);
|
|
cp.emit('exit', 0, null);
|
|
});
|
|
|
|
expect(result.rawOutput).toEqual(
|
|
Buffer.concat([binaryChunk1, binaryChunk2]),
|
|
);
|
|
expect(onOutputEventMock).toHaveBeenCalledTimes(1);
|
|
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
|
type: 'binary_detected',
|
|
});
|
|
});
|
|
|
|
it('should not emit data events after binary is detected', async () => {
|
|
mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));
|
|
|
|
await simulateExecution('cat mixed_file', (cp) => {
|
|
cp.stdout?.emit('data', Buffer.from('some text'));
|
|
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
|
|
cp.stdout?.emit('data', Buffer.from('more text'));
|
|
cp.emit('exit', 0, null);
|
|
});
|
|
|
|
const eventTypes = onOutputEventMock.mock.calls.map(
|
|
(call: [ShellOutputEvent]) => call[0].type,
|
|
);
|
|
expect(eventTypes).toEqual(['binary_detected']);
|
|
});
|
|
});
|
|
|
|
describe('Platform-Specific Behavior', () => {
|
|
it('should use cmd.exe with windowsVerbatimArguments on Windows', async () => {
|
|
mockPlatform.mockReturnValue('win32');
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'cmd.exe',
|
|
argsPrefix: ['/d', '/s', '/c'],
|
|
shell: 'cmd',
|
|
});
|
|
await simulateExecution('dir "foo bar"', (cp) =>
|
|
cp.emit('exit', 0, null),
|
|
);
|
|
|
|
expect(mockCpSpawn).toHaveBeenCalledWith(
|
|
'cmd.exe',
|
|
['/d', '/s', '/c', 'dir "foo bar"'],
|
|
expect.objectContaining({
|
|
detached: false,
|
|
windowsHide: true,
|
|
windowsVerbatimArguments: true,
|
|
}),
|
|
);
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'bash',
|
|
argsPrefix: ['-c'],
|
|
shell: 'bash',
|
|
});
|
|
});
|
|
|
|
it('should use PowerShell with UTF-8 prefix without windowsVerbatimArguments on Windows', async () => {
|
|
mockPlatform.mockReturnValue('win32');
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'powershell.exe',
|
|
argsPrefix: ['-NoProfile', '-Command'],
|
|
shell: 'powershell',
|
|
});
|
|
await simulateExecution('Test-Path "C:\\Temp\\"', (cp) =>
|
|
cp.emit('exit', 0, null),
|
|
);
|
|
|
|
// PowerShell commands on Windows are prefixed with UTF-8 output encoding
|
|
expect(mockCpSpawn).toHaveBeenCalledWith(
|
|
'powershell.exe',
|
|
[
|
|
'-NoProfile',
|
|
'-Command',
|
|
'[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;Test-Path "C:\\Temp\\"',
|
|
],
|
|
expect.objectContaining({
|
|
detached: false,
|
|
windowsHide: true,
|
|
windowsVerbatimArguments: false,
|
|
}),
|
|
);
|
|
mockGetShellConfiguration.mockReturnValue({
|
|
executable: 'bash',
|
|
argsPrefix: ['-c'],
|
|
shell: 'bash',
|
|
});
|
|
});
|
|
|
|
it('should normalize PATH-like env keys on Windows for child_process fallback', async () => {
|
|
mockPlatform.mockReturnValue('win32');
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
|
setupConflictingPathEnv();
|
|
|
|
await simulateExecution('dir', (cp) => cp.emit('exit', 0, null));
|
|
|
|
const spawnOptions = mockCpSpawn.mock.calls[0][2];
|
|
expectNormalizedWindowsPathEnv(spawnOptions.env);
|
|
});
|
|
|
|
it('should use bash and detached process group on Linux', async () => {
|
|
mockPlatform.mockReturnValue('linux');
|
|
await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null));
|
|
|
|
expect(mockCpSpawn).toHaveBeenCalledWith(
|
|
'bash',
|
|
['-c', 'ls "foo bar"'],
|
|
expect.objectContaining({
|
|
detached: true,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ShellExecutionService execution method selection', () => {
|
|
let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;
|
|
let mockPtyProcess: EventEmitter & {
|
|
pid: number;
|
|
kill: Mock;
|
|
onData: Mock;
|
|
onExit: Mock;
|
|
write: Mock;
|
|
resize: Mock;
|
|
};
|
|
let mockChildProcess: EventEmitter & Partial<ChildProcess>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
onOutputEventMock = vi.fn();
|
|
|
|
// Mock for pty
|
|
mockPtyProcess = new EventEmitter() as EventEmitter & {
|
|
pid: number;
|
|
kill: Mock;
|
|
onData: Mock;
|
|
onExit: Mock;
|
|
write: Mock;
|
|
resize: Mock;
|
|
};
|
|
mockPtyProcess.pid = 12345;
|
|
mockPtyProcess.kill = vi.fn();
|
|
// node-pty's onData/onExit return IDisposable; the production
|
|
// background-promote path calls .dispose() on those handles to detach
|
|
// its listeners cleanly. Mock them to return a disposable stub so the
|
|
// promote path doesn't crash on `undefined.dispose()`.
|
|
mockPtyProcess.onData = vi.fn().mockReturnValue({ dispose: vi.fn() });
|
|
mockPtyProcess.onExit = vi.fn().mockReturnValue({ dispose: vi.fn() });
|
|
mockPtyProcess.write = vi.fn();
|
|
mockPtyProcess.resize = vi.fn();
|
|
|
|
mockPtySpawn.mockReturnValue(mockPtyProcess);
|
|
mockGetPty.mockResolvedValue({
|
|
module: { spawn: mockPtySpawn },
|
|
name: 'mock-pty',
|
|
});
|
|
|
|
// Mock for child_process
|
|
mockChildProcess = new EventEmitter() as EventEmitter &
|
|
Partial<ChildProcess>;
|
|
mockChildProcess.stdout = new EventEmitter() as Readable;
|
|
mockChildProcess.stderr = new EventEmitter() as Readable;
|
|
mockChildProcess.kill = vi.fn();
|
|
Object.defineProperty(mockChildProcess, 'pid', {
|
|
value: 54321,
|
|
configurable: true,
|
|
});
|
|
// Mirror real Node ChildProcess: `exitCode` / `signalCode` are
|
|
// `null` while alive. Kept in sync with the `child_process
|
|
// fallback` describe block's mock setup so any future promote-
|
|
// related test that lands here doesn't trip the production
|
|
// `child.exitCode !== null` race guard with a stale `undefined`.
|
|
Object.defineProperty(mockChildProcess, 'exitCode', {
|
|
value: null,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(mockChildProcess, 'signalCode', {
|
|
value: null,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
mockCpSpawn.mockReturnValue(mockChildProcess);
|
|
});
|
|
|
|
it('should use node-pty when shouldUseNodePty is true and pty is available', async () => {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'test command',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true, // shouldUseNodePty
|
|
shellExecutionConfig,
|
|
);
|
|
|
|
// Simulate exit to allow promise to resolve
|
|
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
|
const result = await handle.result;
|
|
|
|
expect(mockGetPty).toHaveBeenCalled();
|
|
expect(mockPtySpawn).toHaveBeenCalled();
|
|
expect(mockCpSpawn).not.toHaveBeenCalled();
|
|
expect(result.executionMethod).toBe('mock-pty');
|
|
});
|
|
|
|
it('should use child_process when shouldUseNodePty is false', async () => {
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'test command',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
false, // shouldUseNodePty
|
|
{},
|
|
);
|
|
|
|
// Simulate exit to allow promise to resolve
|
|
mockChildProcess.emit('exit', 0, null);
|
|
const result = await handle.result;
|
|
|
|
expect(mockGetPty).not.toHaveBeenCalled();
|
|
expect(mockPtySpawn).not.toHaveBeenCalled();
|
|
expect(mockCpSpawn).toHaveBeenCalled();
|
|
expect(result.executionMethod).toBe('child_process');
|
|
});
|
|
|
|
it('should fall back to child_process if pty is not available even if shouldUseNodePty is true', async () => {
|
|
mockGetPty.mockResolvedValue(null);
|
|
|
|
const abortController = new AbortController();
|
|
const handle = await ShellExecutionService.execute(
|
|
'test command',
|
|
'/test/dir',
|
|
onOutputEventMock,
|
|
abortController.signal,
|
|
true, // shouldUseNodePty
|
|
shellExecutionConfig,
|
|
);
|
|
|
|
// Simulate exit to allow promise to resolve
|
|
mockChildProcess.emit('exit', 0, null);
|
|
const result = await handle.result;
|
|
|
|
expect(mockGetPty).toHaveBeenCalled();
|
|
expect(mockPtySpawn).not.toHaveBeenCalled();
|
|
expect(mockCpSpawn).toHaveBeenCalled();
|
|
expect(result.executionMethod).toBe('child_process');
|
|
});
|
|
});
|
|
|
|
describe('getShellAbortReasonKind (defensive abort-reason read)', () => {
|
|
it("returns 'cancel' for null reason (e.g. plain abortController.abort())", () => {
|
|
expect(getShellAbortReasonKind(null)).toBe('cancel');
|
|
expect(getShellAbortReasonKind(undefined)).toBe('cancel');
|
|
});
|
|
|
|
it("returns 'cancel' for non-object reasons (string / number / DOMException)", () => {
|
|
expect(getShellAbortReasonKind('background')).toBe('cancel');
|
|
expect(getShellAbortReasonKind(42)).toBe('cancel');
|
|
expect(getShellAbortReasonKind(true)).toBe('cancel');
|
|
// DOMException-like object — not the real DOMException constructor in
|
|
// the test runtime, but the principle is the same: a non-discriminated
|
|
// object reason without an own `kind` falls back to cancel.
|
|
expect(getShellAbortReasonKind(new Error('aborted'))).toBe('cancel');
|
|
});
|
|
|
|
it("returns 'cancel' for an empty object (no own kind)", () => {
|
|
expect(getShellAbortReasonKind({})).toBe('cancel');
|
|
});
|
|
|
|
it("returns 'cancel' when 'kind' lives only on the prototype (pollution defense)", () => {
|
|
const polluted: Record<string, unknown> = Object.create({
|
|
kind: 'background',
|
|
});
|
|
// hasOwnProperty('kind') is false → helper rejects the prototype-only kind
|
|
expect(getShellAbortReasonKind(polluted)).toBe('cancel');
|
|
});
|
|
|
|
it("returns 'cancel' for an unknown kind value (typo / future-untyped variant)", () => {
|
|
expect(getShellAbortReasonKind({ kind: 'suspend' })).toBe('cancel');
|
|
expect(getShellAbortReasonKind({ kind: 'BACKGROUND' })).toBe('cancel');
|
|
expect(getShellAbortReasonKind({ kind: 42 })).toBe('cancel');
|
|
});
|
|
|
|
it("returns 'cancel' when reading 'kind' throws (accessor / Proxy trap)", () => {
|
|
const throwingReason = Object.defineProperty({}, 'kind', {
|
|
enumerable: true,
|
|
configurable: true,
|
|
get() {
|
|
throw new Error('accessor blew up');
|
|
},
|
|
});
|
|
expect(getShellAbortReasonKind(throwingReason)).toBe('cancel');
|
|
|
|
const proxyReason = new Proxy(
|
|
{},
|
|
{
|
|
get(_target, prop) {
|
|
if (prop === 'kind') throw new Error('proxy trap blew up');
|
|
return undefined;
|
|
},
|
|
getOwnPropertyDescriptor(_target, prop) {
|
|
if (prop === 'kind') {
|
|
return { configurable: true, enumerable: true, value: 'unused' };
|
|
}
|
|
return undefined;
|
|
},
|
|
},
|
|
);
|
|
expect(getShellAbortReasonKind(proxyReason)).toBe('cancel');
|
|
});
|
|
|
|
it("returns 'cancel' when the `getOwnPropertyDescriptor` Proxy trap throws", () => {
|
|
// `Object.prototype.hasOwnProperty.call(reason, 'kind')` triggers
|
|
// the `[[GetOwnProperty]]` Proxy trap. A Proxy whose
|
|
// `getOwnPropertyDescriptor` handler throws (separate from the
|
|
// `get` trap covered by the test above) used to propagate past
|
|
// the helper because `hasOwnProperty.call` was outside the try.
|
|
// Now the helper wraps both the descriptor probe and the property
|
|
// read, so this also falls back to 'cancel'. (No `get` handler on
|
|
// the proxy: `hasOwnProperty.call` throws before the helper ever
|
|
// tries to read `kind`.)
|
|
const throwingDescriptorProxy = new Proxy(
|
|
{},
|
|
{
|
|
getOwnPropertyDescriptor() {
|
|
throw new Error('getOwnPropertyDescriptor blew up');
|
|
},
|
|
},
|
|
);
|
|
expect(getShellAbortReasonKind(throwingDescriptorProxy)).toBe('cancel');
|
|
});
|
|
|
|
it("returns 'background' for the canonical happy-path reason", () => {
|
|
expect(getShellAbortReasonKind({ kind: 'background' })).toBe('background');
|
|
expect(
|
|
getShellAbortReasonKind({ kind: 'background', shellId: 'bg_x' }),
|
|
).toBe('background');
|
|
});
|
|
|
|
it("returns 'cancel' for the canonical cancel reason", () => {
|
|
expect(getShellAbortReasonKind({ kind: 'cancel' })).toBe('cancel');
|
|
});
|
|
});
|