From 0901140b3fa6cd6206a67c29f55091ed1962b49f Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 13:01:45 +0800 Subject: [PATCH] test(cli): cover the dsh built-bin non-TTY refusal Removing the dsh-tui-demo bin dropped the only test of the TUI's piped-launch refusal. Add apps/cli/tests/built-bin.e2e.ts (apps/*/tests added to the e2e vitest include) running the built lib/bin.js under plain Node with piped stdio, and point the refusal message at `dsh -p "task"` for automation. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 2 +- ...07-24-dsh-commander-argument-adapter.zh.md | 2 +- apps/cli/src/tui.ts | 4 +- apps/cli/tests/built-bin.e2e.ts | 54 +++++++++++++++++++ vitest.e2e.config.ts | 2 +- 6 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 apps/cli/tests/built-bin.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 1780a5f57e..3a85dedb0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-24-dsh-commander-argument-adapter.md: 60c47ef40cb0db833f7a2a526437b6a8ce812433 -2026-07-24-dsh-commander-argument-adapter.zh.md: 41a98499036c16330263d5072aa0fa454b892a24 +2026-07-24-dsh-commander-argument-adapter.md: c038f4facdc62039b8a31be5d660648fd81aebd2 +2026-07-24-dsh-commander-argument-adapter.zh.md: 285917af7c4b0da769ae7595bb1a6e5966adafd9 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index 60c47ef40c..c038f4facd 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -22,7 +22,7 @@ Merging the concurrent safe-session-resume feature onto this parser retired the ## One terminal front door: `dsh` -The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the `built-bin.e2e.ts` (its TUI piped-launch refusal is covered by `dsh`'s own TTY guard in the tui-agent PTY smoke), and the tsdown `bin` entry all leave with it. `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. +The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. ## Package topology diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index 41a9849903..285917af7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -22,7 +22,7 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适 ## 唯一的终端入口:`dsh` -`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh [config]` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并将配置作为位置参数;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、`built-bin.e2e.ts`(其中拒绝通过管道启动 TUI 的行为已由 tui-agent PTY 冒烟测试中 `dsh` 自身的 TTY 守卫覆盖),以及 tsdown 的 `bin` 入口。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 +`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh [config]` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并将配置作为位置参数;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、演示包的 `built-bin.e2e.ts`,以及 tsdown 的 `bin` 入口。`dsh` 自身的 TTY 守卫会在标准输入输出接入管道时,于启动应用前拒绝运行,并提示自动化场景改用 `dsh -p`;为此新增的 `apps/cli/tests/built-bin.e2e.ts` 将标准输入输出接入管道,直接使用 Node 运行构建后的 `lib/bin.js`(`apps/*/tests` 已加入 e2e Vitest 的测试文件匹配范围)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 ## 包拓扑 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 819cb22e42..6ddad89302 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -53,7 +53,9 @@ export async function runTui(config: string | undefined, resumeSessionId: string // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`) + process.stderr.write( + `${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`, + ) process.exit(1) } installFailLoud(NAME) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..6a77e8919d --- /dev/null +++ b/apps/cli/tests/built-bin.e2e.ts @@ -0,0 +1,54 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under + * plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot. + * `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a + * piped launch must exit nonzero with a stderr pointer at the one-shot `-p` + * mode. The guard fires inside `runTui` BEFORE the Loader resolves the config + * tree — a compose-time throw inside the tree is logged per-entry, not + * rethrown, so without this guard a piped launch would settle into an idle + * UI-less process. The bin resolves its workspace deps through the repo's + * node_modules, so no external consumer is assembled; missing-config fail-loud + * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's + * built-bin suite, and interactive TTY behavior is PTY-covered by + * examples/tui-agent. Skips before the bin is built. + */ + +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) + child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) + child.on('error', (err) => { clearTimeout(timer); reject(err) }) + child.stdin.end() + }) +} + +describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { + it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => { + const { stdout, code, stderr } = await runBuiltBin() + expect(code).not.toBe(0) + expect(stderr).toContain('requires stdin and stdout to be interactive TTYs') + expect(stderr).toContain('dsh -p') + // The refusal happens before any plugin mounts: stdout stays silent. + expect(stdout).toBe('') + }, 30_000) +}) diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 2e2221b6e8..e8ca907439 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { setupFiles: ['./scripts/test-invariants.ts'], - include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], + include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate.