diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml new file mode 100644 index 0000000000..466071f552 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 7027a8bde51f81bfa7774743f84639cbd4b667d8 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md new file mode 100644 index 0000000000..958abc4aee --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -0,0 +1,37 @@ +# Agent Note: Adopt execa for hand-rolled test subprocess plumbing + +Status: implemented + +English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) + +## Problem + +Roughly ten e2e/smoke files re-derived the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. + +Two related test-infra hand-rolls compounded the case: + +- `packages/support/llm-mock-server/src/cli.ts` hand-tokenized 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). +- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carried two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies dead. +- The snapshot harness hand-rolled three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. + +## Decision + +- `execa` is a root devDependency and a runtime dependency of `@deepseek-ai/dsh-loader-smoke` (the one `src/` consumer). The listed spawn-collect-timeout sites run through `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut, failed }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. `runLoaderSmoke` passes `input: ''` for its stdin-close contract, and sites whose assertions pin exact stream bytes pass `stripFinalNewline: false`. +- The genuinely custom parts stay custom on top of an execa-owned subprocess: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. `smoke-real.e2e.ts` keeps raw `spawn` for its three long-lived interactive servers — ready-line watching across both streams plus a staged SIGTERM→await→SIGKILL teardown are the whole site, so execa would delete nothing there; its share of this note is the dead `.env` parser. +- `llm-mock-server`'s CLI tokenizes via `parseArgs` (strict, no positionals); numeric coercion, bounds, and cross-option constraints stay manual, and the pinned error-message tests carry `parseArgs`'s own tokenizer texts. +- Both `loadRootEnv` copies are deleted outright: the owning vitest configs (`vitest.web.config.ts` unconditionally, `vitest.snapshot.config.ts` in record mode) load the repo-root `.env` before those files run. +- The four poll loops ride `vi.waitFor` with explicit `{ interval, timeout }` and descriptive errors thrown from the callback; `waitForPersistedTurnStart` captures its malformed-record validation error out of the retry loop so it fails the run immediately instead of being retried until the deadline. + +## Alternatives considered + +- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. +- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn cross-platform timeout, termination, and result-normalization behavior that execa already carries. +- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. + +## Consequences + +- The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch. +- Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this. +- Direct-child timeout termination and exit/signal result normalization are owned by execa across platforms instead of per-site hand-rolls; process-tree termination remains outside these helpers, as the `loader-smoke` README states. Each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. +- execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only). +- The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`. diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md new file mode 100644 index 0000000000..7027a8bde5 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 采用 execa 替换手写的测试子进程管道代码 + +Status: implemented + +[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 + +## 问题 + +大约十个 e2e/冒烟测试文件各自手工重写过同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。 + +另有两处相关的测试基础设施手写代码进一步强化了替换的理由: + +- `packages/support/llm-mock-server/src/cli.ts` 曾手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 +- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 曾携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝实为死代码。 +- 快照 harness 曾手写三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 + +## 决定 + +- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费者)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。 +- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;它在本 note 中的份额是那份死的 `.env` 解析器。 +- `llm-mock-server` 的 CLI 经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,被固定的错误消息测试改为携带 `parseArgs` 自己的切分器文本。 +- 两份 `loadRootEnv` 拷贝被整体删除:拥有它们的 vitest 配置(`vitest.web.config.ts` 无条件、`vitest.snapshot.config.ts` 在 record 模式下)在这些文件运行之前就加载了仓库根部的 `.env`。 +- 那四个轮询循环改乘 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。 + +## 曾考虑的替代方案 + +- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 +- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的跨平台超时、终止与结果规范化行为。 +- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 + +## 后果 + +- 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。 +- 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。 +- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 +- execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。 +- mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。 diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml deleted file mode 100644 index 90cad79b89..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-26-execa-for-test-subprocess-plumbing.md: 99a86258fe4d59db6a0e144dbcee94c095f70f8f -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 525e09f07ce3e5dc61f1cadab5c11ea0790cccee diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md deleted file mode 100644 index 99a86258fe..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Adopt execa for hand-rolled test subprocess plumbing - -Status: proposed - -English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) - -## Problem - -Roughly ten e2e/smoke files re-derive the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. Net deletable: ~100–150 lines of test infrastructure. - -Two related test-infra hand-rolls compound the case: - -- `packages/support/llm-mock-server/src/cli.ts` hand-tokenizes 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). -- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carry two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies arguably dead. -- The snapshot harness hand-rolls three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. - -## Proposal - -- Add `execa` as a root devDependency and rewrite the spawn-collect-timeout sites onto `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. Keep the genuinely custom parts custom: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. -- Swap `llm-mock-server`'s CLI tokenizer for `parseArgs` (numeric coercion, bounds, and cross-option constraints stay manual; pinned error-message texts update with the tests). -- Delete both `loadRootEnv` copies in favor of `process.loadEnvFile` in a try/catch, or remove them outright if the vitest-config loading already covers them. -- Replace the four poll loops with `vi.waitFor`/`expect.poll`, passing explicit `{ interval, timeout }` and throwing descriptive errors from the callback. - -## Alternatives considered - -- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. -- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. -- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. - -## Acceptance criteria - -- The listed sites spawn through execa (or the chosen equivalent); the hand-rolled collect/timeout blocks and the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke` are gone. -- `llm-mock-server` CLI parses via `parseArgs`; its cli spec passes with updated message expectations. -- No hand-rolled `.env` parser remains under `apps/web/tests`. -- The affected e2e and snapshot suites pass on both POSIX and Windows CI lanes. - -## Risks - -- `loader-smoke` is a `src/` file under the per-file-100% coverage gate; the swap actually simplifies its coverage story (removes un-inducible branches) but the new call shape needs coverage. -- Each rewritten e2e must be re-run on both platforms; subtle differences in kill escalation or stdin-close semantics (`input: ''` for loader-smoke's stdin-close contract) are the risk to verify per site. -- execa is a new root devDependency (currently absent from the lockfile entirely); it is one of the most-depended-on packages on npm and actively maintained, so health is not a concern, but the exe/runtime closure is unaffected either way (tests only). diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md deleted file mode 100644 index 525e09f07c..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 采用 execa 替换手写的测试子进程管道代码 - -Status: proposed - -[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 - -## 问题 - -大约十个 e2e/冒烟测试文件各自手工重写同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。净可删除量:约 100–150 行测试基础设施代码。 - -另有两处相关的测试基础设施手写代码进一步强化了替换的理由: - -- `packages/support/llm-mock-server/src/cli.ts` 手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 -- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝几乎可以视为死代码。 -- 快照 harness 手写了三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 - -## 提案 - -- 将 `execa` 添加为根 devDependency,把上述 spawn、收集、超时的代码位置改写到 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 上:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。真正定制的部分继续保持定制:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。 -- 把 `llm-mock-server` 的 CLI 切分器换成 `parseArgs`(数值转换、边界检查与跨选项约束仍手工实现;被固定的错误消息文本随测试一并更新)。 -- 删除两份 `loadRootEnv` 拷贝,改用包在 try/catch 中的 `process.loadEnvFile`;如果 vitest 配置的加载已经覆盖了它们,则直接整体移除。 -- 用 `vi.waitFor`/`expect.poll` 替换那四个轮询循环,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误。 - -## 曾考虑的替代方案 - -- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 -- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 -- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 - -## 验收标准 - -- 所列位置全部通过 execa(或最终选定的等价包)spawn 子进程;手写的收集/超时代码块,连同 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支,全部移除。 -- `llm-mock-server` 的 CLI 经由 `parseArgs` 解析;其 cli 测试文件在更新消息期望后通过。 -- `apps/web/tests` 下不再存在手写的 `.env` 解析器。 -- 受影响的 e2e 与快照测试套件在 POSIX 与 Windows 两条 CI 车道上均通过。 - -## 风险 - -- `loader-smoke` 是逐文件 100% 覆盖率门禁下的 `src/` 文件;这次替换实际上简化了它的覆盖率问题(移除了无法人为诱发的分支),但新的调用形态需要补齐覆盖。 -- 每个改写后的 e2e 都必须在两个平台上重新运行;终止信号升级或 stdin 关闭语义上的细微差异(loader-smoke 的 stdin 关闭契约对应 `input: ''`)是需要逐处核验的风险。 -- execa 是新增的根 devDependency(当前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,健康度不是顾虑;至于 exe/运行时闭包,无论选哪个包都不受影响(仅测试使用)。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 03358d280d..082ac040e9 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 437130cc0e4456f40002f0467abaa4075d004181 -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 0e937793a71b03db02847e57ad2d4ef3e5a5a2af +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 9cdb061bf21a7b9ce4747b9022c65d60e9644e0d +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 9648af149c0c517eba372baa12877240f9289aac diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index 437130cc0e..9cdb061bf2 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line. - **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing). - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does. -- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa proposal](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) +- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill. - **node-pty everywhere for the TUI test driver**: [Windows-TUI note](../../implemented/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it is already the Windows leg. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 0e937793a7..9648af149c 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。 - **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。 -- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa 提案](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) +- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。 - **在 TUI 测试驱动器上到处使用 node-pty**:[Windows TUI 决策](../../implemented/feature/2026-07-20-windows-tui-support.md)已明确否决在每个宿主上都用 node-pty;它已经是 Windows 那一条腿。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 9fd1d55ab2..1ea7d9f0db 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -22,25 +22,18 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio; resolve with output + exit code. */ -function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (c: string) => { stdout += c }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete. - child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) - child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.end() +/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ +async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin], { + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) { + throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 89269d743f..d57b934068 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -17,7 +17,7 @@ // the open llm seam post-boot with installLlmReplay on the settled root ctx // (the plugin-row path discards the ReplayHandle; the direct install keeps // assertConsumed for the teardown fixture-consumption check). -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -69,16 +69,6 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') // contextWindow keeps that pressure path provably inert for small fixtures. const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] -/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */ -function loadRootEnv(): void { - const envPath = join(REPO_ROOT, '.env') - if (!existsSync(envPath)) return - for (const line of readFileSync(envPath, 'utf8').split('\n')) { - const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim()) - if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2] - } -} - /** A booted web scaffold: real composition, mode-selected model backend, temp world. */ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ @@ -142,7 +132,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveReady, reject) => { let out = '' diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cb2ab9687e..0d4e4d8f2e 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) @@ -69,7 +69,9 @@ describe('jsonrpc-agent keyless smoke', () => { await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) const address = modelServer.address() if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') - const child = spawn(process.execPath, [ + // The line-predicate protocol driving below is the genuinely custom part; + // execa owns spawn, the deadline, and exit settlement around it. + const child = execa(process.execPath, [ '--import', 'tsx', binScript, @@ -77,27 +79,26 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_CWD: root, DSH_SESSION_ROOT: join(root, '.sessions'), ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), }, - stdio: ['pipe', 'pipe', 'pipe'], + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, }) const lines: string[] = [] let stdoutBuffer = '' let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdoutBuffer += chunk + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') const parts = stdoutBuffer.split('\n') stdoutBuffer = parts.pop() ?? '' lines.push(...parts) }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) try { child.stdin.write(`${JSON.stringify({ @@ -144,16 +145,8 @@ describe('jsonrpc-agent keyless smoke', () => { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) - if (child.exitCode === null) { - await new Promise((resolve, reject) => { - child.once('exit', (code) => { - if (code === 0) resolve() - else reject(new Error(`runtime exited ${code}; stderr=${stderr}`)) - }) - }) - } else { - expect(child.exitCode, stderr).toBe(0) - } + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.sessions') const files = await readdir(sessionsRoot, { recursive: true }) const log = files.find(file => file.endsWith('.jsonl.zstd')) @@ -162,14 +155,16 @@ describe('jsonrpc-agent keyless smoke', () => { expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) } finally { - if (child.exitCode === null) child.kill('SIGKILL') + // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. + child.kill('SIGKILL') + await child await new Promise(resolve => modelServer.close(() => { resolve() })) await rm(root, { recursive: true, force: true }) } }, 40_000) it('rejects an invalid max-token success env value', async () => { - const child = spawn(process.execPath, [ + const { exitCode, stdout, stderr } = await execa(process.execPath, [ '--import', 'tsx', binScript, @@ -177,26 +172,17 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const exitCode = await new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', resolve) + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc') - }, 10_000) + }, 30_000) }) diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 700c67f660..ea088a20f9 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` @@ -94,35 +94,32 @@ async function runPosixPtySmoke( options: TuiPtySmokeOptions, timeoutMs: number, ): Promise { - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - POSIX_PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs + 5_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve(stdout) - else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) + // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline + // only backstops a wedged python3 process itself. + const result = await execa('python3', [ + '-c', + POSIX_PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { + stdin: 'ignore', + timeout: timeoutMs + 5_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return result.stdout } async function runWindowsPtySmoke( diff --git a/package.json b/package.json index 90194a57d3..3aa3d6998a 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", + "execa": "^10.0.0", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "js-yaml": "^4.2.0", diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 4c6098a2ec..5a09dd69f2 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) process.exit(0) ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 02e82ac5ac..8b6533e106 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -17,6 +17,7 @@ import { import { Readable, Writable } from 'node:stream' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -209,25 +210,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n }, 30_000) }) -/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ -function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const proc = spawn(process.execPath, [acpBin, '--config', configArg], { - cwd, - env: { - ...process.env, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stderr = '' - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000) - proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - proc.stdin.end() +/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */ +async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { + const result = await execa(process.execPath, [acpBin, '--config', configArg], { + cwd, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`) + return { code: result.exitCode ?? -1, stderr: result.stderr } } diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 5c3a6ad62e..f87563d27a 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { dirname, join } from 'node:path' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -114,36 +114,34 @@ interface BinResult { readonly stderr: string } -function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { - return new Promise((resolveResult, reject) => { - const child = spawn(process.execPath, [cliBin, ...args], { - cwd, - env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' +async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { + const subprocess = execa(process.execPath, [cliBin, ...args], { + cwd, + env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, + }) + // Genuinely custom mid-stream logic: the signal cases deliver `interrupt` + // once the first streamed chunk proves the turn is in flight. + if (interrupt !== undefined) { + let streamed = '' let interrupted = false - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdout += chunk - if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) { + subprocess.stdout.on('data', (chunk: Buffer) => { + streamed += chunk.toString('utf8') + if (!interrupted && streamed.includes('assistant/chunk')) { interrupted = true - child.kill(interrupt) + subprocess.kill(interrupt) } }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code, signal) => { - clearTimeout(timer) - resolveResult({ code: code ?? -1, signal, stdout, stderr }) - }) - }) + } + const result = await subprocess + if (result.timedOut) { + throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr } } let consumer: string | undefined diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index a2da86d87c..ef44655b19 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -1,9 +1,9 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** @@ -57,12 +57,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) await ctx.fiber.dispose() ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index ce1b52a248..25c7d5797d 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -1,10 +1,10 @@ -import { spawn } from 'node:child_process' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import SessionStore, { SessionId, TOOL_OUTCOME_UNKNOWN, type SessionEvent, @@ -19,22 +19,21 @@ const roots: string[] = [] const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 async function waitForMarker(path: string, expected: string): Promise { - const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS - for (;;) { - try { - const content = await readFile(path, 'utf8') - if (content === expected) return content - if (!expected.startsWith(content)) { - throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) - } - } catch (error: unknown) { + // vi.waitFor retries every callback throw, so terminal states RESOLVE out + // of the retry loop (complete marker, or content that can no longer become + // the expected marker) and only the still-in-progress states throw-to-retry. + const content = await vi.waitFor(async () => { + const current = await readFile(path, 'utf8').catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - if (Date.now() >= deadline) { - throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`) - } - await new Promise(resolve => setTimeout(resolve, 10)) + throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error }) + }) + if (current === expected || !expected.startsWith(current)) return current + throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`) + }, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS }) + if (content !== expected) { + throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) } + return content } async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> { @@ -44,26 +43,24 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker // Keep the open-before-write window deterministic: readiness is marker content, not path existence. await writeFile(marker, '') const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect' - const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { + // The SIGKILL-at-failpoint choreography stays custom: the child must die + // mid-write, so no timeout or graceful termination may reach it first. + const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], + env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdin: 'ignore', + stdout: 'ignore', + reject: false, }) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) try { const markerText = await waitForMarker(marker, expectedMarker) - const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { - child.once('close', (code, signal) => { resolve({ code, signal }) }) - }) child.kill('SIGKILL') - const exit = await closed - expect(exit).toEqual({ code: null, signal: 'SIGKILL' }) + const exit = await child + expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' }) return { root, markerText } } catch (error: unknown) { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') - throw new Error(`crash child failed: ${stderr}`, { cause: error }) + child.kill('SIGKILL') + throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error }) } } diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index baabe0d898..8d82f4b7d4 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: d35872e5bb06be88dc5999bfa1800083b2fbbf3c -README.zh.md: a706b6db5408538c578cb2a1cfc3aa99804a930d +README.md: d22e6e2d95a1ed930a7f4876daf4b06e2f761f7a +README.zh.md: 514b7ebfe02cb34ed559633a0fd82cf6194fa4b3 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index d35872e5bb..d22e6e2d95 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -57,7 +57,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index a706b6db54..514b7ebfe0 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -57,7 +57,7 @@ defineAcpSnapshotSuite({ 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index cd84f42513..ff98e620d3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' -import { setTimeout as delay } from 'node:timers/promises' +import { vi } from 'vitest' import { ClientSideConnection, PROTOCOL_VERSION, @@ -457,17 +457,25 @@ async function waitForPersistedTurnStart( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn?: number, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + let invalidRecord: { error: unknown } | undefined + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - const openTurn = log === undefined ? undefined : latestOpenTurn(log.content) - if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return - if (Date.now() >= deadline) { + let openTurn: number | undefined + try { + openTurn = log === undefined ? undefined : latestOpenTurn(log.content) + } catch (error) { + // A malformed persisted record is a scenario bug, not a not-yet state: + // vi.waitFor retries every callback throw, so capture the validation + // failure, resolve the wait, and rethrow immediately below. + invalidRecord = { error } + return + } + if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) { const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}` throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) + if (invalidRecord !== undefined) throw invalidRecord.error } /** @@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd( sessionId: string, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - if (log !== undefined && latestTurnIsClosed(log.content)) return - if (Date.now() >= deadline) { + if (log === undefined || !latestTurnIsClosed(log.content)) { throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Wait for a cwd-relative marker proving an external action reached readiness. */ @@ -499,13 +504,11 @@ async function waitForWorkspaceFile( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { const target = join(cwd, path) - const deadline = Date.now() + timeoutMs - while (!existsSync(target)) { - if (Date.now() >= deadline) { + await vi.waitFor(() => { + if (!existsSync(target)) { throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Return whether the last complete raw-JSONL turn boundary closes its turn. */ diff --git a/packages/support/llm-mock-server/src/cli.ts b/packages/support/llm-mock-server/src/cli.ts index 786a74c0f4..1787f318ca 100644 --- a/packages/support/llm-mock-server/src/cli.ts +++ b/packages/support/llm-mock-server/src/cli.ts @@ -3,6 +3,7 @@ * @module @deepseek-ai/dsh-llm-mock-server/cli */ +import { parseArgs } from 'node:util' import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts' import type { ConcreteMockLlmBehavior, @@ -63,14 +64,6 @@ Other: --help ` -function optionValue(argv: readonly string[], index: number, option: string): string { - const value = argv[index + 1] - if (value === undefined || value.startsWith('--')) { - throw new Error(`dsh-llm-mock-server: ${option} requires a value`) - } - return value -} - function numberValue(option: string, value: string): number { const parsed = Number(value) if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`) @@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights { return weights } +/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */ +const CLI_OPTIONS = { + 'sequence': { type: 'string' }, + 'host': { type: 'string' }, + 'port': { type: 'string' }, + 'api-key': { type: 'string' }, + 'listen-delay-ms': { type: 'string' }, + 'repeat-last': { type: 'boolean' }, + 'seed': { type: 'string' }, + 'random-weights': { type: 'string' }, + 'success-text': { type: 'string' }, + 'partial-text': { type: 'string' }, + 'reasoning-text': { type: 'string' }, + 'chunk-size': { type: 'string' }, + 'chunk-delay-ms': { type: 'string' }, + 'disconnect-delay-ms': { type: 'string' }, + 'retry-after-ms': { type: 'string' }, + 'request-id': { type: 'string' }, + 'tool-name': { type: 'string' }, + 'tool-arguments': { type: 'string' }, +} as const + /** * Parse standalone server arguments without starting a process or listener. + * Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric + * coercion, bounds, and cross-option constraints remain manual below it. * @param argv - arguments after the executable name. * @returns help or validated run configuration. */ export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult { if (argv.includes('--help')) return { kind: 'help' } - let sequenceRaw: string | undefined - let host: string | undefined - let port = 8_000 - let apiKey: string | undefined - let listenDelayMs: number | undefined - let repeatLast = false - let randomSeed: number | undefined - let randomWeights: MockLlmRandomWeights | undefined - let successText: string | undefined - let partialText: string | undefined - let reasoningText: string | undefined - let chunkSize: number | undefined - let chunkDelayMs: number | undefined - let disconnectDelayMs: number | undefined - let retryAfterMs: number | undefined - let requestId: string | undefined - let toolName: string | undefined - let toolArguments: string | undefined + const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false }) - for (let index = 0; index < argv.length; index += 1) { - const option = argv[index] as string - if (option === '--repeat-last') { - repeatLast = true - continue - } - const value = optionValue(argv, index, option) - index += 1 - switch (option) { - case '--sequence': sequenceRaw = value; break - case '--host': host = value; break - case '--port': port = numberValue(option, value); break - case '--api-key': apiKey = value; break - case '--listen-delay-ms': - listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS) - break - case '--seed': randomSeed = numberValue(option, value); break - case '--random-weights': randomWeights = parseRandomWeights(value); break - case '--success-text': successText = value; break - case '--partial-text': partialText = value; break - case '--reasoning-text': reasoningText = value; break - case '--chunk-size': chunkSize = numberValue(option, value); break - case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break - case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break - case '--retry-after-ms': retryAfterMs = numberValue(option, value); break - case '--request-id': requestId = value; break - case '--tool-name': toolName = value; break - case '--tool-arguments': toolArguments = value; break - default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`) - } - } + const host = values.host + const port = values.port === undefined ? 8_000 : numberValue('--port', values.port) + const apiKey = values['api-key'] + const listenDelayMs = values['listen-delay-ms'] === undefined + ? undefined + : boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS) + const repeatLast = values['repeat-last'] ?? false + const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed) + const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights']) + const successText = values['success-text'] + const partialText = values['partial-text'] + const reasoningText = values['reasoning-text'] + const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size']) + const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms']) + const disconnectDelayMs = values['disconnect-delay-ms'] === undefined + ? undefined + : numberValue('--disconnect-delay-ms', values['disconnect-delay-ms']) + const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms']) + const requestId = values['request-id'] + const toolName = values['tool-name'] + const toolArguments = values['tool-arguments'] - if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + const sequenceRaw = values.sequence const parsedSequence = parseSequence(sequenceRaw) if (parsedSequence.startsUnavailable && port === 0) { throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port') diff --git a/packages/support/llm-mock-server/tests/cli.spec.ts b/packages/support/llm-mock-server/tests/cli.spec.ts index 12c5bd6926..04b221beda 100644 --- a/packages/support/llm-mock-server/tests/cli.spec.ts +++ b/packages/support/llm-mock-server/tests/cli.spec.ts @@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => { it.each([ [[], /--sequence is required/], - [['--wat'], /requires a value/], - [['--wat', 'x'], /unknown option/], + // Tokenizer-level failures carry node:util parseArgs's own messages. + [['--wat'], /Unknown option '--wat'/], + [['--wat', 'x'], /Unknown option '--wat'/], + [['--port'], /Option '--port ' argument missing/], + [['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/], [['--port', 'NaN', '--sequence', 'success'], /finite number/], [['--sequence', 'success,'], /non-empty/], [['--sequence', 'success,connection_refused'], /only as the first/], @@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => { [['--sequence', 'unknown'], /unknown behavior/], [['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/], [['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/], - [['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/], + // `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe. + [['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/], [['--sequence', 'success', '--seed', '1'], /require random/], diff --git a/packages/support/loader-smoke/README.i18n.yaml b/packages/support/loader-smoke/README.i18n.yaml index a4e0016620..4794ac72cb 100644 --- a/packages/support/loader-smoke/README.i18n.yaml +++ b/packages/support/loader-smoke/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 8e53550608037a3c9a272db825933b7224ab24db -README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637 +README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685 +README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 8e53550608..73610ce50e 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. -- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it. +- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget. - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup. diff --git a/packages/support/loader-smoke/README.zh.md b/packages/support/loader-smoke/README.zh.md index 5310429ab5..17f8481220 100644 --- a/packages/support/loader-smoke/README.zh.md +++ b/packages/support/loader-smoke/README.zh.md @@ -19,5 +19,5 @@ ## 已知限制与待完成工作 - **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。 -- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。 +- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。 - **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。 diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 570ee2c5ca..ebdd62373a 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "execa": "^10.0.0", "tsx": "^4.22.4" }, "peerDependencies": { diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 61ad3b9d16..e573684a4e 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -11,10 +11,10 @@ * @module @deepseek-ai/dsh-loader-smoke */ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 @@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise((resolve, reject) => { - const child = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - let deferredFailure: Error | undefined - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`) - child.kill('SIGKILL') - }, processTimeoutMs) - - child.once('exit', (code) => { - clearTimeout(timer) - if (deferredFailure !== undefined) { - reject(deferredFailure) - } else if (code === 0) { - resolve({ stdout, stderr }) - } else { - reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - } - }) - - // process.execPath and a just-created pipe make these OS-error paths - // impractical to induce without replacing the boundary under test. - /* v8 ignore start */ - child.once('error', (error) => { - clearTimeout(timer) - reject(new Error(`${options.label} failed to start: ${error.message}`)) - }) - child.stdin.once('error', (error) => { - deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`) - child.kill('SIGKILL') - }) - /* v8 ignore stop */ - - child.stdin.end() + // `input: ''` writes nothing and closes stdin — the fixture-visible + // stdin-close contract. `reject: false` folds spawn errors, the SIGKILL + // deadline, and nonzero exits into independent result fields, so the + // diagnostics below embed both streams on every failure. + const result = await execa(launch.command, launch.args, { + cwd, + env: launch.env, + input: '', + timeout: processTimeoutMs, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } await options.inspect?.(cwd) - return result + return { stdout: result.stdout, stderr: result.stderr } } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12edd2735c..5d66d5e9bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: eslint-plugin-sonarjs: specifier: ^4.1.0 version: 4.1.0(eslint@10.5.0(jiti@2.7.0)) + execa: + specifier: ^10.0.0 + version: 10.0.0 fast-check: specifier: ^4.8.0 version: 4.8.0 @@ -4008,6 +4011,9 @@ importers: packages/support/loader-smoke: dependencies: + execa: + specifier: ^10.0.0 + version: 10.0.0 tsx: specifier: ^4.22.4 version: 4.22.4 @@ -6925,6 +6931,9 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} @@ -6977,6 +6986,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -8122,6 +8135,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@10.0.0: + resolution: {integrity: sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==} + engines: {node: '>=22'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -8187,6 +8204,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -8270,6 +8291,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -8369,6 +8394,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -8457,6 +8486,14 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -9101,6 +9138,10 @@ packages: non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -9176,6 +9217,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -9201,6 +9246,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -9261,6 +9310,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -9566,6 +9619,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -9764,6 +9821,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -10041,6 +10102,11 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -10104,6 +10170,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -11605,6 +11675,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@2.5.0': dependencies: '@shikijs/engine-javascript': 2.5.0 @@ -11683,6 +11755,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -13029,6 +13103,22 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + execa@10.0.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + path-key: 4.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} express-rate-limit@8.5.2(express@5.2.1): @@ -13118,6 +13208,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -13214,6 +13308,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -13351,6 +13450,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -13410,6 +13511,10 @@ snapshots: is-promise@4.0.0: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + is-what@5.5.0: {} isarray@1.0.0: {} @@ -14231,6 +14336,11 @@ snapshots: non-layered-tidy-tree-layout@2.0.2: optional: true + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -14349,6 +14459,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -14365,6 +14477,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -14413,6 +14527,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} property-information@7.2.0: {} @@ -14849,6 +14967,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -15009,6 +15129,8 @@ snapshots: undici@7.28.0: {} + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -15328,6 +15450,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which-command@0.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -15369,6 +15493,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3