import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { afterEach, beforeEach, 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 * apps/cli/tests. 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 (stdin closed at EOF); resolve with output * + exit code. `env` isolates the Harness home for surfaces that read it. */ async function runBuiltBin( args: readonly string[] = [], env: Record = {}, ): Promise<{ stdout: string; code: number; stderr: string }> { const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, env, }) 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)', () => { 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) describe('experimental subcommand gate', () => { // The gate has two halves: a per-invocation --experimental flag parsed by // Commander and an env opt-in read by bin.ts as exactly '1'. Passing the // gate is proven by reaching the NEXT failure — the TUI's piped-stdio // refusal — instead of the gate diagnostic. it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => { for (const command of ['meta', 'upgrade']) { const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' }) expect(code).toBe(1) expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`) } }, 30_000) it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => { const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' }) expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs') const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' }) expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs') // The env opt-in is exact: '0' (or any other value) does not enable. const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' }) expect(zero.code).toBe(1) expect(zero.stderr).toContain('meta is experimental') }, 30_000) }) describe('dsh --dump-config', () => { let home: string beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) }) afterEach(() => { rmSync(home, { recursive: true, force: true }) }) it('prints the shipped TUI composition without booting or needing a TTY', async () => { const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) expect(code).toBe(0) expect(stderr).toBe('') // Base rows composed with the TUI overlay's surface values, `!!js` // expressions verbatim (unevaluated), and TUI-only inserted rows present. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") expect(stdout).toContain('model: deepseek-v4-pro') expect(stdout).toContain('cwd: !!js process.cwd()') expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'") // Provenance comment separators name each section's source file. expect(stdout).toContain('# == base.cordis.yml') expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml') expect(stdout).toContain('# == tui.cordis.yml') }, 30_000) it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => { writeFileSync(join(home, 'config.yaml'), [ '- id: agent-loop', ' config:', ' agents:', ' - id: main', ' provider: custom-provider', ' model: custom-model', '- id: only-on-web', ' config:', ' value: 1', '', ].join('\n')) const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) expect(code).toBe(0) expect(stdout).toContain('provider: custom-provider') expect(stdout).not.toContain('model: deepseek-v4-pro') // The personal layer appears in the patched row's provenance and the // skipped-patch warning carries its label. expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`) expect(stderr).toContain('patch: entry "only-on-web" not found') // The shipped view ignores the personal overlay entirely. const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) expect(shipped.stdout).not.toContain('custom-provider') expect(shipped.stdout).toContain('model: deepseek-v4-pro') }, 30_000) it('composes the web overlay for `dsh web --dump-config`', async () => { const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home }) expect(code).toBe(0) expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'") }, 30_000) }) })