From 3545e9a2a620589296c6a1d2fe8db8425493fb87 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 28 Sep 2026 21:13:12 +0800 Subject: [PATCH 1/6] fix(ci): provision Electron before Windows smoke tests --- .../tests/diagnose-script.spec.ts | 4 +- scripts/run-gates.spec.ts | 38 +++++++++++++++++++ scripts/run-gates.ts | 10 ++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts index 49a3d55b42..0ba05f869e 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -402,14 +402,14 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(grant.code, `${grant.output}\n${aclLines(target).join('\n')}`).toBe(2) expect(grant.output, grant.output).toContain('GRANT_FAILED') expect(grant.output).toContain('GRANTED=0 REFUSED=1') - expect(aclLines(target).join('\n')).toContain('(DENY)(WO)') + // Rollback must preserve the complete descriptor, not an icacls rights spelling. + expect(sddlOf(target)).toBe(before) expect(reports(grant)).toEqual(containingArray([ containingObject({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), containingObject({ kind: 'verification', operation: 'grant', status: 'failed', details: containingObject({ recovery: containingString('-Restore') }) }), containingObject({ kind: 'verification', operation: 'restore', status: 'verified' }), containingObject({ kind: 'summary', details: containingObject({ automaticRollback: true, granted: 0, restored: 1, rollback: 'verified', rollbackCommands: [], nextAction: 'stop' }) }), ])) - expect(sddlOf(target)).toBe(before) const transcript = reports(grant) .filter(entry => entry.path === target && entry.kind !== 'observation') .map(entry => `${entry.kind} ${entry.operation} ${entry.status} path={{target}}: ${entry.reason.replaceAll(meSid, '{{caller_sid}}')}`) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 4631258c16..fc42c247d0 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -518,6 +518,44 @@ describe('gate graph validation', () => { expect(results.some(result => result.status === 'skipped')).toBe(false) }) + it.each(['ci-windows-complete', 'ci-windows-observational-ready'] as const)( + 'provisions the locked Electron binary before built smoke in %s', (mode) => { + const gates = withPnpmEntrypoint(() => gatesForMode(mode)) + const install = gates.find(gate => gate.id === 'electron-install') + expect(install).toMatchObject({ + command: process.execPath, + args: ['/private/pnpm.cjs', '--filter', '@deepseek-ai/dsh-desktop', 'exec', 'install-electron'], + env: { ELECTRON_GET_USE_PROXY: 'true' }, + }) + expect(gates.find(gate => gate.id === 'built-bin-smoke')?.needs).toContain('electron-install') + expect(gates.filter(gate => gate.id !== 'electron-install').every(gate => gate.env?.ELECTRON_GET_USE_PROXY === undefined)).toBe(true) + }, + ) + + it.each(['ci-windows-complete', 'ci-windows-observational-ready'] as const)( + 'keeps failed Electron provisioning visible without stopping unrelated diagnostics in %s', async (mode) => { + const gates = withPnpmEntrypoint(() => gatesForMode(mode)) + const attempted: string[] = [] + const results = await runGates(gates, 8, async (subject) => { + attempted.push(subject.id) + return resultFor(subject, subject.id === 'electron-install' ? 'failed' : 'passed') + }) + expect(results.filter(result => result.status === 'failed').map(result => result.gate.id)).toEqual(['electron-install']) + expect(attempted).not.toContain('built-bin-smoke') + expect(results.find(result => result.gate.id === 'built-bin-smoke')?.status).toBe('skipped') + expect(results.find(result => result.gate.id === 'doc-graphs')?.status).toBe('passed') + if (mode === 'ci-windows-complete') { + expect(results.filter(result => result.status === 'failed' && !result.gate.allowFailure)).toEqual([]) + } + }, + ) + + it.each(['ci-primary', 'ci-linux-primary', 'ci-artifacts', 'ci-windows-blocking'] as const)( + 'does not provision the observational Electron dependency in %s', (mode) => { + expect(withPnpmEntrypoint(() => gatesForMode(mode)).some(gate => gate.id === 'electron-install')).toBe(false) + }, + ) + it('applies one configured test, polling, and hook timeout to both coverage gates', () => { const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () => withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e1e2de9c21..19ae5725ce 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -606,6 +606,14 @@ function ciWindowsCompleteGates(): Gate[] { function ciWindowsObservationalGates(): Gate[] { const predecessors = [ ...ciStaticGates({ ownsBuild: true }), + // Electron's lazy download must finish before Vitest removes ambient proxies. + { + id: 'electron-install', + label: 'Electron binary', + displayCommand: 'pnpm --filter @deepseek-ai/dsh-desktop exec install-electron', + ...pnpmInvocation(['--filter', '@deepseek-ai/dsh-desktop', 'exec', 'install-electron']), + env: { ELECTRON_GET_USE_PROXY: 'true' }, + }, // Linux owns required lint and snapshots; Windows omits those duplicates. pnpmScript('duplication', 'duplication'), pnpmScript('publint', 'publint', { needs: ['build'] }), @@ -618,7 +626,7 @@ function ciWindowsObservationalGates(): Gate[] { return [ ...predecessors, { - ...builtBinSmokeGate(), + ...builtBinSmokeGate(['build', 'electron-install']), // This smoke starts real application children with bounded startup // deadlines. Let other Windows processes settle before measuring startup. after: predecessors.map(gate => gate.id), From 23706d7ff995e9194e89cb28fc23e33ac8db779e Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 00:04:57 +0800 Subject: [PATCH 2/6] test(ptc): await committed background job publication --- .../tests/profiles/headless/tests/ptc.e2e.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index ba55c91d16..87442c5319 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -226,7 +226,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { expect(taskOutput.job).toMatchObject({ id: jobId, kind: 'bash', status: 'completed' }) }, 15_000) - it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', async () => { + it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', { timeout: 15_000, retry: 0 }, async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-task-cancel-')) ctx = await backgroundPtcModeHarness(workdir) @@ -238,15 +238,26 @@ describe('PTC mode typed values: keyless real-process contracts', () => { expect(preResult.isError).toBe(true) expect(ctx.jobs.list()).toEqual([]) + const jobs = ctx.jobs + // Registration is announced after the job's ownership has committed. + const registered = new Promise((resolve) => { + onTestFinished(jobs.events.subscribe({ owners: 'all' }, (event) => { + if (event.type === 'registered') resolve() + })) + }) const afterPublication = new AbortController() const running = runCode(ctx, ` const started = await tools.bash({ command: 'sleep 10', description: 'Wait for explicit task kill', run_in_background: true }); console.log(started.jobId); await new Promise(() => {}); `, afterPublication.signal) - for (let attempt = 0; attempt < 100 && ctx.jobs.list().length === 0; attempt++) { - await new Promise(resolve => setTimeout(resolve, 10)) - } + await Promise.race([ + registered, + running.then((result) => { + completion(result) + throw new Error('run_code completed before background job registration') + }), + ]) const job = ctx.jobs.list()[0] expect(job).toMatchObject({ id: 'bash-1', status: 'running' }) afterPublication.abort('outer-call-cancelled') @@ -261,7 +272,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { return await tools.job_output({ job_id: ${JSON.stringify(job!.id)}, wait: true, timeout_ms: 5000 }); `)) expect(settled).toMatchObject({ job: { id: job!.id, status: 'killed' } }) - }, 15_000) + }) it('keeps foreground bash coupled to the outer signal', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-foreground-cancel-')) From 374f7df31b873ea5968ccbc3f3c230757e649b97 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 01:07:38 +0800 Subject: [PATCH 3/6] chore(ci): align proxy opt-in and clarify test contracts --- apps/cli/tests/profiles/headless/tests/ptc.e2e.ts | 1 + .../sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts | 2 +- scripts/run-gates.spec.ts | 2 +- scripts/run-gates.ts | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 87442c5319..48e8f9fc9f 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -254,6 +254,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { await Promise.race([ registered, running.then((result) => { + // Promise.race handles a late rejection after registration, too. completion(result) throw new Error('run_code completed before background job registration') }), diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts index 0ba05f869e..7e526139f9 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -402,7 +402,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(grant.code, `${grant.output}\n${aclLines(target).join('\n')}`).toBe(2) expect(grant.output, grant.output).toContain('GRANT_FAILED') expect(grant.output).toContain('GRANTED=0 REFUSED=1') - // Rollback must preserve the complete descriptor, not an icacls rights spelling. + // Rollback must preserve the complete descriptor. expect(sddlOf(target)).toBe(before) expect(reports(grant)).toEqual(containingArray([ containingObject({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index a36e9c2725..21ad22a3cb 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -540,7 +540,7 @@ describe('gate graph validation', () => { expect(install).toMatchObject({ command: process.execPath, args: ['/private/pnpm.cjs', '--filter', '@deepseek-ai/dsh-desktop', 'exec', 'install-electron'], - env: { ELECTRON_GET_USE_PROXY: 'true' }, + env: { ELECTRON_GET_USE_PROXY: '1' }, }) expect(gates.find(gate => gate.id === 'built-bin-smoke')?.needs).toContain('electron-install') expect(gates.filter(gate => gate.id !== 'electron-install').every(gate => gate.env?.ELECTRON_GET_USE_PROXY === undefined)).toBe(true) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index cd7d745338..7392b51171 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -621,7 +621,7 @@ function ciWindowsObservationalGates(): Gate[] { label: 'Electron binary', displayCommand: 'pnpm --filter @deepseek-ai/dsh-desktop exec install-electron', ...pnpmInvocation(['--filter', '@deepseek-ai/dsh-desktop', 'exec', 'install-electron']), - env: { ELECTRON_GET_USE_PROXY: 'true' }, + env: { ELECTRON_GET_USE_PROXY: '1' }, }, // Linux owns required lint and snapshots; Windows omits those duplicates. pnpmScript('duplication', 'duplication'), From 4b7a45d1549869d20d5a14f85804a9f9ac5cb64e Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 03:11:05 +0800 Subject: [PATCH 4/6] fix(pwsh): accept ASCII-padded completion status lines --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tool-pwsh-persistent/README.i18n.yaml | 8 +- packages/shell/tool-pwsh-persistent/README.md | 4 +- .../shell/tool-pwsh-persistent/README.zh.md | 4 +- .../shell/tool-pwsh-persistent/src/index.ts | 3 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 59 ++++++++++++ .../cordis.snapshot.yml | 92 +++++++++++++++++++ .../cordis.yml | 84 +++++++++++++++++ .../pwsh-snapshot-backend.mjs | 72 +++++++++++++++ .../session.v4.jsonl | 27 ++++++ .../snapshot.yml | 10 ++ 12 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 snapshots/session/persistent-pwsh-padded-completion/cordis.snapshot.yml create mode 100644 snapshots/session/persistent-pwsh-padded-completion/cordis.yml create mode 100644 snapshots/session/persistent-pwsh-padded-completion/pwsh-snapshot-backend.mjs create mode 100644 snapshots/session/persistent-pwsh-padded-completion/session.v4.jsonl create mode 100644 snapshots/session/persistent-pwsh-padded-completion/snapshot.yml diff --git a/docs/config-catalog.md b/docs/config-catalog.md index aeb6975be9..cc659f5e8c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3714,7 +3714,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh-persistent` - `inject`: `tools` · `terminals` -- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:456`](../packages/shell/tool-pwsh-persistent/src/index.ts) +- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:457`](../packages/shell/tool-pwsh-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent pwsh tool. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 13218e1684..e89f722b88 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3716,7 +3716,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh-persistent` - `inject`: `tools` · `terminals` -- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:456`](../packages/shell/tool-pwsh-persistent/src/index.ts) +- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:457`](../packages/shell/tool-pwsh-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent pwsh tool. */ diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index bc6ff68375..178cdcd384 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -33,14 +33,14 @@ en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d /deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation/design-philosophy: - en: b8544c7025ef7f9e - zh: 573b0ec492dde975 + en: b9b75c3251f29f02 + zh: 9114aad4726f6d0c /deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation/source-map: en: bb2d17f1385fb11d zh: 8e103837238ccda2 /deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation/command-flow: - en: 52f69b3256d2d2c2 - zh: 34ce45e101760793 + en: 7440916d998ed5bd + zh: 66f70f803c519802 /deepseek-ai-dsh-tool-pwsh-persistent/further-exploration: en: e836ecbf7370bc76 zh: 84fed68318df81ff diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index cd78f224f9..871db34210 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -73,7 +73,7 @@ This section explains the design decisions behind the tool and points at the cod ### Design philosophy - **A deliberate twin of `dsh-tool-bash-persistent`.** The session registry, polling loop, and reset contract mirror the persistent bash tool by design ([pwsh persistent PTY Agent Note](../../../.agents/notes/archived/architecture/2026-08-11-pwsh-persistent-pty.md)). -- **Backend-owned prompt readiness.** The shell's `prompt` function belongs to the backend: it prints a BEL-terminated OSC marker plus the controlled printable prompt, and that exact prompt text settles every command through the backend's fast path. The tool neither installs nor matches a prompt of its own, so only a model redefinition of `prompt` degrades readiness to the silence tier. +- **Backend-owned prompt readiness.** The shell's `prompt` function belongs to the backend: it prints a BEL-terminated OSC marker plus the controlled printable prompt. The backend accepts recognized prompt text through its fast path and otherwise uses its own readiness checks or silence tier. The tool neither installs nor matches a prompt of its own. - **PSReadLine echo stripped by anchoring.** PowerShell renders submitted input back into the stream; the marker-anchored extraction and a wrapper-source strip remove the echo, and a wrapper that wraps across the terminal width may leave a partial echo in partial-output results. - **Reset, never repair.** Any uncertain state — an explicit `exit`, a timeout, a send failure, an abort — closes the shell and starts the next call fresh. @@ -86,7 +86,7 @@ This section explains the design decisions behind the tool and points at the cod ### Command flow -A first command spawns the shell through `ctx.terminals.spawn`, whose pwsh startup installs the controlled `prompt` function and returns only once the shell reached readiness. Each command is wrapped into one physical line — `Write-Output` of the start marker, the body escaped with backtick escapes into a double-quoted string, and `Write-Output` of the end marker plus the exit status — so PSReadLine's echo of a wrapped line cannot fabricate completion. The tool polls the scrollback in 1,000-line pages until the end marker appears or the send settles as `stdin_read`, extracts the span, strips the echoed wrapper, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell. +A first command spawns the shell through `ctx.terminals.spawn`, whose pwsh startup installs the controlled `prompt` function and returns only once the shell reached readiness. Each command is wrapped into one physical line — `Write-Output` of the start marker, the body escaped with backtick escapes into a double-quoted string, and `Write-Output` of the end marker plus the exit status — so PSReadLine's echo of a wrapped line cannot fabricate completion. The parser accepts ASCII-space padding after the status digits but still requires LF or CRLF. Spaces in command output remain unchanged. The tool polls the scrollback in 1,000-line pages until a complete end-marker status line is available or the send settles as `stdin_read`, extracts the span, strips the echoed wrapper, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell. diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index 53b0b4cdd6..caea272185 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -73,7 +73,7 @@ kind: "package-reference" ### 设计理念 - **`dsh-tool-bash-persistent` 的刻意孪生。** 会话注册表、轮询循环与重置约定按设计镜像持久 bash 工具([pwsh 持久 PTY Agent Note](../../../.agents/notes/archived/architecture/2026-08-11-pwsh-persistent-pty.md))。 -- **后端拥有提示符就绪。** shell 的 `prompt` 函数属于后端:它打印 BEL 结尾的 OSC 标记加受控的可打印提示符,该确切提示符文本让每条命令都经由后端快路径结算。工具既不安装也不匹配自己的提示符,因此只有模型重定义 `prompt` 才会把就绪降级到静默层级。 +- **后端拥有提示符就绪。** shell 的 `prompt` 函数属于后端:它打印 BEL 结尾的 OSC 标记加受控的可打印提示符。后端通过快路径接受已识别的提示符文本,否则使用其自身的就绪检查或静默层级。工具既不安装也不匹配自己的提示符。 - **PSReadLine 回显靠锚定剥离。** PowerShell 会把提交的输入渲染回流中;标记锚定提取与包装源码剥离移除回显,而跨终端宽度换行的包装可能在部分输出结果中留下部分回显。 - **重置,而非修复。** 任何不确定状态——显式 `exit`、超时、发送失败、中止——都会关闭 shell 并让下一次调用从全新状态开始。 @@ -86,7 +86,7 @@ kind: "package-reference" ### 命令流程 -首条命令通过 `ctx.terminals.spawn` 生成 shell,其 pwsh 启动流程安装受控的 `prompt` 函数,并只在该 shell 达到就绪后返回。随后每条命令都包装成一行物理文本——`Write-Output` 起始标记、用反引号转义进双引号字符串的命令体、`Write-Output` 结束标记加退出状态——因此 PSReadLine 对换行包装的回显无法伪造完成。工具以 1,000 行一页轮询 scrollback,直到出现结束标记或该 send 以 `stdin_read` 结算,提取区间、剥离回显的包装,并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。 +首条命令通过 `ctx.terminals.spawn` 生成 shell,其 pwsh 启动流程安装受控的 `prompt` 函数,并只在该 shell 达到就绪后返回。随后每条命令都包装成一行物理文本——`Write-Output` 起始标记、用反引号转义进双引号字符串的命令体、`Write-Output` 结束标记加退出状态——因此 PSReadLine 对换行包装的回显无法伪造完成。解析器允许状态码数字后的 ASCII 空格,但仍要求以 LF 或 CRLF 结束。命令输出中的空格保持不变。工具以 1,000 行一页轮询 scrollback,直到出现完整的结束标记状态行或该 send 以 `stdin_read` 结算,提取区间、剥离回显的包装,并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。 diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 93a3e9331f..84c6833a73 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -109,7 +109,8 @@ function commandOutput( ): CapturedOutput | undefined { const text = snapshot.text const end = text.lastIndexOf(marker.end) - const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + // Terminal padding belongs to the status line, not to captured command output. + const status = /^(\d+) *\r?\n/.exec(text.slice(end + marker.end.length))?.[1] if (status === undefined) return undefined const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 701402b7fc..45429d4f4e 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -92,6 +92,8 @@ type StubMode = | 'nonzero' | 'torn-status' | 'finish-torn-status' + | 'status-tail' + | 'continue-status-tail' | 'end-only' | 'spawn-error' | 'send-error' @@ -118,6 +120,7 @@ class StubTerminalSession implements TerminalBackendSession { historyTruncated = false throwOnSend = false largeOutput = 'x'.repeat(100) + statusChunks: string[] = [] constructor(mode: StubMode) { this.mode = mode @@ -190,6 +193,15 @@ class StubTerminalSession implements TerminalBackendSession { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) } + if (this.mode === 'status-tail' || this.mode === 'continue-status-tail') { + const prefix = this.mode === 'status-tail' ? `${start ?? ''}\nkept \ndsh> \n${end ?? ''}` : '' + const fragment = this.statusChunks.shift() + if (fragment === undefined) throw new Error('missing scripted completion fragment') + const output = prefix + fragment + this.mode = this.statusChunks.length > 0 ? 'continue-status-tail' : 'send-error' + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } if (this.mode === 'torn-status') { const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` this.scrollback += output @@ -366,6 +378,53 @@ describe('tool-pwsh-persistent', () => { expect(text(await call(ctx, owner, 'complete prompt collision'))).toBe(session.motd) }) + it.each([ + ['0 \r\n', 0], + ['7 \r\n', 7], + ['0 \n', 0], + ['7 \n', 7], + ] as const)('accepts ASCII-padded completion status %j without changing output', async (tail, exitCode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'status-tail' + session.statusChunks = [tail] + + const result = await call(ctx, owner, 'padded completion') + expect(result.isError).not.toBe(true) + expect(text(result)).toBe('kept \ndsh> ' + (exitCode === 0 ? '' : `\n[exit code: ${exitCode}]`)) + expect(session.sends).toBe(2) + }) + + it.each([0, 1, 2, 3, 4])('waits for the complete padded status newline at split %i', async (split) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'status-tail' + const tail = '7 \r\n' + session.statusChunks = [tail.slice(0, split), tail.slice(split)] + const send = vi.spyOn(session, 'startSend') + + expect(text(await call(ctx, owner, 'split completion'))).toBe('kept \ndsh> \n[exit code: 7]') + expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[1]?.[0]).toMatchObject({ text: '', submit: false }) + }) + + it.each(['0\t\n', '0\u00a0\n', '0\v\n', '0 ', '0 \r', '0junk\n', ' 0\n', '-1\n', '+1\n', "' + $__s)\n"])( + 'does not accept unsupported completion status %j', async (tail) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'status-tail' + session.statusChunks = [tail] + + const result = await call(ctx, owner, 'invalid completion') + expect(result.isError).toBe(true) + expect(text(result)).toContain('stub send failed') + expect(session.sends).toBe(3) + }, + ) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') diff --git a/snapshots/session/persistent-pwsh-padded-completion/cordis.snapshot.yml b/snapshots/session/persistent-pwsh-padded-completion/cordis.snapshot.yml new file mode 100644 index 0000000000..014c8c7aaa --- /dev/null +++ b/snapshots/session/persistent-pwsh-padded-completion/cordis.snapshot.yml @@ -0,0 +1,92 @@ +# Keyless replay for padded persistent PowerShell completion frames. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek-api-key' + disabled: true + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- insert: + - id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- insert: + - id: padded-pwsh-backend + name: './pwsh-snapshot-backend.mjs' + +- id: agent-default-model + name: '@deepseek-ai/dsh-agent-default-model' + config: + provider: deepseek-official + model: deepseek-v4-pro + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: none + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + disabled: true + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + personaPrefix: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + disabled: true + +- id: goal + name: '@deepseek-ai/dsh-goal' + disabled: true + +- id: goal-round-driver + name: '@deepseek-ai/dsh-goal-round-driver' + disabled: true + +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' + disabled: true + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + disabled: true + +- id: skill + name: '@deepseek-ai/dsh-skill' + disabled: true + +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + disabled: true + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + disabled: true + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: true + +- insert: + - id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/snapshots/session/persistent-pwsh-padded-completion/cordis.yml b/snapshots/session/persistent-pwsh-padded-completion/cordis.yml new file mode 100644 index 0000000000..2ab9c9d4b0 --- /dev/null +++ b/snapshots/session/persistent-pwsh-padded-completion/cordis.yml @@ -0,0 +1,84 @@ +# Minimal live counterpart for the persistent-pwsh-padded-completion snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek-api-key' + config: + models: + - id: deepseek-v4-pro + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- insert: + - id: terminal + name: '@deepseek-ai/dsh-terminal' + +- insert: + - id: padded-pwsh-backend + name: './pwsh-snapshot-backend.mjs' + +- id: agent-default-model + name: '@deepseek-ai/dsh-agent-default-model' + config: + provider: deepseek-official + model: deepseek-v4-pro + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: !!js 'process.env.DSH_SNAPSHOT === undefined ? ''zstd'' : ''none''' + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + disabled: true + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + personaPrefix: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + disabled: true + +- id: goal + name: '@deepseek-ai/dsh-goal' + disabled: true + +- id: goal-round-driver + name: '@deepseek-ai/dsh-goal-round-driver' + disabled: true + +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' + disabled: true + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + disabled: true + +- id: skill + name: '@deepseek-ai/dsh-skill' + disabled: true + +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + disabled: true + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + disabled: true + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: true + +- insert: + - id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/snapshots/session/persistent-pwsh-padded-completion/pwsh-snapshot-backend.mjs b/snapshots/session/persistent-pwsh-padded-completion/pwsh-snapshot-backend.mjs new file mode 100644 index 0000000000..7e059029d3 --- /dev/null +++ b/snapshots/session/persistent-pwsh-padded-completion/pwsh-snapshot-backend.mjs @@ -0,0 +1,72 @@ +/** Scripted terminal frames exercise the real persistent PowerShell tool and Session log. */ + +const responses = [ + { token: 'PWSH_OK', code: 0 }, + { token: 'PWSH_FAIL', code: 7 }, +] + +class SnapshotSession { + motd = 'dsh> ' + statusValue = { kind: 'running' } + scrollback = 'dsh> ' + next = 0 + + startSend(request) { + const response = responses[this.next++] + if (!response || !request.submit || !request.text.includes(`[Console]::Out.Write(' ${response.token} ')`)) { + throw new Error('unexpected padded-completion snapshot command') + } + const start = /__DSH_PERSISTENT_PWSH_START_[a-f0-9-]+__/.exec(request.text)?.[0] + const end = /__DSH_PERSISTENT_PWSH_END_[a-f0-9-]+:/.exec(request.text)?.[0] + if (!start || !end) throw new Error('padded-completion snapshot requires the actual command markers') + const viewport = `${request.text}\r\n${start}\r\n ${response.token} ${end}${response.code} \r\n${this.motd}` + this.scrollback += viewport + const result = { viewport, waitReason: 'stdin_read', sessionStatus: this.statusValue, truncated: false } + let consumed = false + return { + done: Promise.resolve(result), + readOutput: () => { + if (consumed) return { delta: '', truncated: false } + consumed = true + return { delta: viewport, truncated: false } + }, + cancel: () => false, + } + } + + read(request) { + const lines = this.scrollback.split('\n') + const offset = request.offset ?? 0 + const count = request.count ?? 500 + const end = lines.length - offset + const start = Math.max(0, end - count) + const text = lines.slice(start, end).join('\n') + return { text, totalLines: lines.length, lineBegin: offset, lineEnd: offset + text.split('\n').length, truncated: false } + } + + signal() { + return Promise.resolve({ delivered: true, targetPgid: 1 }) + } + + status() { + return this.statusValue + } + + close() { + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + return Promise.resolve() + } +} + +/** Scenario-local fixture identity. */ +export const name = 'pwsh-padded-completion-snapshot' +/** Terminal service owns the backend registration and sessions. */ +export const inject = ['terminals'] + +/** Register deterministic completion frames under the real terminal service. @param ctx - Effect-scoped plugin context. */ +export function apply(ctx) { + ctx.terminals.registerBackend({ + type: 'shell', + spawn: () => Promise.resolve(new SnapshotSession()), + }) +} diff --git a/snapshots/session/persistent-pwsh-padded-completion/session.v4.jsonl b/snapshots/session/persistent-pwsh-padded-completion/session.v4.jsonl new file mode 100644 index 0000000000..c3376af2af --- /dev/null +++ b/snapshots/session/persistent-pwsh-padded-completion/session.v4.jsonl @@ -0,0 +1,27 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run both pwsh commands in order: [Console]::Out.Write(' PWSH_OK '), then [Console]::Out.Write(' PWSH_FAIL '); $global:LASTEXITCODE = 7. Reply DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run both pwsh commands in order: [Console]::Out.Write(' PWSH_OK '), then [Console]::Out.Write(' PWSH_FAIL '); $global:LASTEXITCODE = 7. Reply DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"session/title","data":{"title":"Run both pwsh commands in","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pwsh-padded-1","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write(' PWSH_OK ')\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"pwsh-padded-1","name":"pwsh","args":["{\"command\":\"[Console]::Out.Write(' PWSH_OK ')\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pwsh-padded-1","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write(' PWSH_OK ')\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"pwsh-padded-1","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write(' PWSH_OK ')\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"pwsh-padded-1"},"toolCallId":"pwsh-padded-1","content":[{"type":"text","text":" PWSH_OK "}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pwsh-padded-2","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write(' PWSH_FAIL '); $global:LASTEXITCODE = 7\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"pwsh-padded-2","name":"pwsh","args":["{\"command\":\"[Console]::Out.Write(' PWSH_FAIL '); $global:LASTEXITCODE = 7\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pwsh-padded-2","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write(' PWSH_FAIL '); $global:LASTEXITCODE = 7\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"pwsh-padded-2","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write(' PWSH_FAIL '); $global:LASTEXITCODE = 7\"}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"pwsh-padded-2"},"toolCallId":"pwsh-padded-2","content":[{"type":"text","text":" PWSH_FAIL \n[exit code: 7]"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/persistent-pwsh-padded-completion/snapshot.yml b/snapshots/session/persistent-pwsh-padded-completion/snapshot.yml new file mode 100644 index 0000000000..f59c8e521f --- /dev/null +++ b/snapshots/session/persistent-pwsh-padded-completion/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: persistent-pwsh-padded-completion +profile: headless +composition: persistent-pwsh-padded-completion +recording: authored +header: + class: persistent-pwsh-padded-completion + pin: true + systemPromptSource: persistent-pwsh-tool-turn + toolSchemasSource: persistent-pwsh-tool-turn From a1e6ded917b3971546f24dafb260f035e53b082a Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 13:46:15 +0800 Subject: [PATCH 5/6] test(workspace-files): report missing-file watcher phases --- .../api/workspace-files/tests/changes.spec.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts index c0e6a012ae..687951e45c 100644 --- a/packages/api/workspace-files/tests/changes.spec.ts +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -1,8 +1,9 @@ /** Target-scoped Host changes, fresh filesystem metadata, and watcher ownership. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' import type { Mock, MockInstance } from 'vitest' import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { performance } from 'node:perf_hooks' import type { Context } from '@deepseek-ai/cordis' import type { FileSystem, FsObservation, FsTarget } from '@deepseek-ai/dsh-fs' import { FsVersion } from '@deepseek-ai/dsh-fs' @@ -422,23 +423,39 @@ describe('workspaceFiles.changes — backends and access', () => { }) it('observes creation of a missing outside file through the real local watcher', async () => { + const started = performance.now() + // Keep earlier awaits visible if teardown advances a timed-out test. + const phases: Array<{ phase: string; elapsedMs: number }> = [] + const mark = (phase: string): void => { + phases.push({ phase, elapsedMs: Math.round(performance.now() - started) }) + } + onTestFailed(() => { console.error(`workspace missing-file watcher phases: ${JSON.stringify(phases)}`) }) + watch.mockRestore() const path = join(harness.outside, 'created.txt') const staging = join(harness.outside, 'staged.txt') // Publish complete bytes so an early watcher stat cannot observe a partial write. + mark('staging') await writeFile(staging, 'new file contents') const service = harness.endpoint() const stream = open(service, path) + mark('waiting-ready') await ready(stream) const pending = stream.next() + mark('renaming') await rename(staging, path) + mark('reading-stat') const current = await service.stat(harness.scope, path, stream.controller.signal) + mark('waiting-change') await expect(pending).resolves.toEqual({ done: false, value: { kind: 'change', change: { absolutePath: current.absolutePath, version: current.version } }, }) + mark('closing') await stream.close() + mark('checking-end') await expect(stream.next()).resolves.toEqual({ done: true, value: undefined }) + mark('complete') }) it.each(['create', 'modify', 'remove'] as const)( From 2b6d046f327451f60dabfbb6cea60f76796bac36 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 14:31:45 +0800 Subject: [PATCH 6/6] test(ci): arm signal trap before reporting readiness --- scripts/run-gates.spec.ts | 50 +++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index bda1e11346..8f2f636b2e 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi, type MockInstance } from 'vitest' import { cliGateOptions, ciWorkerEnvironment, @@ -28,12 +28,14 @@ afterEach(() => vi.unstubAllEnvs()) /** * Capture output a gate streams through runGate's streamOutput path. + * @param onOutput - optional observer called after each captured chunk. * @returns the accumulated chunks and the stdout spy to restore in finally. */ -function captureStreamedOutput(): { writes: string[]; write: MockInstance } { +function captureStreamedOutput(onOutput?: (chunks: readonly string[]) => void): { writes: string[]; write: MockInstance } { const writes: string[] = [] const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { writes.push(String(chunk)) + onOutput?.(writes) return true }) return { writes, write } @@ -973,29 +975,41 @@ describe('fail-fast scheduling', () => { }) it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => { - const { writes, write } = captureStreamedOutput() + const marker = 'signal-trap-armed' + const armed = Promise.withResolvers() + const controller = new AbortController() + let promise: Promise | undefined + const { write } = captureStreamedOutput((chunks) => { + if (chunks.join('').split('\n').slice(0, -1).includes(marker)) armed.resolve(undefined) + }) + const cleanup = async (): Promise => { + controller.abort() + try { await promise } finally { write.mockRestore() } + } + onTestFinished(cleanup) try { - const controller = new AbortController() - const child = gate('traps-signal', { - args: ['-e', "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"], - streamOutput: true, - }) - const promise = runGate(child, controller.signal) - // Wait for the child to register its SIGTERM trap before aborting, so - // the signal is caught and the child really exits zero. - const deadline = Date.now() + 5000 - while (!writes.join('').includes('ready') && Date.now() < deadline) { - await new Promise(resolve => setTimeout(resolve, 10)) - } + // Readiness belongs after native signal-handler registration. + const script = [ + "process.on('SIGTERM', () => process.exit(0))", + `process.stdout.write(${JSON.stringify(`${marker}\n`)})`, + 'setInterval(() => {}, 1000)', + ].join(';') + const child = gate('traps-signal', { args: ['-e', script], streamOutput: true }) + promise = runGate(child, controller.signal) + await Promise.race([ + armed.promise, + promise.then((result) => { throw new Error(`signal fixture exited before readiness: ${formatGateResultReason(result)}`) }), + ]) controller.abort() const result = await promise // The child trapped the signal and exited zero; the drain must not // report this gate passed, so the raw outcome carries the abort mark. - expect(result.status).toBe('passed') - expect(result.aborted).toBe(true) + expect(result, formatGateResultReason(result)).toMatchObject({ + status: 'passed', exitCode: 0, signalCode: null, aborted: true, + }) } finally { - write.mockRestore() + await cleanup() } })