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.
This commit is contained in:
Turtle
2026-07-25 13:01:45 +08:00
parent 870fb1cafa
commit 0901140b3f
6 changed files with 62 additions and 6 deletions
@@ -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
@@ -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
@@ -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、ACPAgent 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、ACPAgent Client Protocol)、JSON-RPC)。
## 包拓扑
+3 -1
View File
@@ -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)
+54
View File
@@ -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)
})
+1 -1
View File
@@ -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.