built-bin.e2e.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { execa } from 'execa'
  6. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  7. /**
  8. * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
  9. * plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot.
  10. * `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a
  11. * piped launch must exit nonzero with a stderr pointer at the one-shot `-p`
  12. * mode. The guard fires inside `runTui` BEFORE the Loader resolves the config
  13. * tree — a compose-time throw inside the tree is logged per-entry, not
  14. * rethrown, so without this guard a piped launch would settle into an idle
  15. * UI-less process. The bin resolves its workspace deps through the repo's
  16. * node_modules, so no external consumer is assembled; missing-config fail-loud
  17. * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
  18. * built-bin suite, and interactive TTY behavior is PTY-covered by
  19. * apps/cli/tests. Skips before the bin is built.
  20. */
  21. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  22. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  23. /**
  24. * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
  25. * + exit code. `env` isolates the Harness home for surfaces that read it.
  26. */
  27. async function runBuiltBin(
  28. args: readonly string[] = [],
  29. env: Record<string, string> = {},
  30. ): Promise<{ stdout: string; code: number; stderr: string }> {
  31. const result = await execa(process.execPath, [dshBin, ...args], {
  32. input: '',
  33. timeout: 25_000,
  34. killSignal: 'SIGKILL',
  35. reject: false,
  36. env,
  37. })
  38. if (result.timedOut) {
  39. throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  40. }
  41. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  42. }
  43. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  44. it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
  45. const { stdout, code, stderr } = await runBuiltBin()
  46. expect(code).not.toBe(0)
  47. expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
  48. expect(stderr).toContain('dsh -p')
  49. // The refusal happens before any plugin mounts: stdout stays silent.
  50. expect(stdout).toBe('')
  51. }, 30_000)
  52. describe('experimental subcommand gate', () => {
  53. // The gate has two halves: a per-invocation --experimental flag parsed by
  54. // Commander and an env opt-in read by bin.ts as exactly '1'. Passing the
  55. // gate is proven by reaching the NEXT failure — the TUI's piped-stdio
  56. // refusal — instead of the gate diagnostic.
  57. it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => {
  58. for (const command of ['meta', 'upgrade']) {
  59. const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' })
  60. expect(code).toBe(1)
  61. expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
  62. }
  63. }, 30_000)
  64. it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => {
  65. const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' })
  66. expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs')
  67. const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' })
  68. expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs')
  69. // The env opt-in is exact: '0' (or any other value) does not enable.
  70. const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' })
  71. expect(zero.code).toBe(1)
  72. expect(zero.stderr).toContain('meta is experimental')
  73. }, 30_000)
  74. })
  75. describe('dsh --dump-config', () => {
  76. let home: string
  77. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  78. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  79. it('prints the shipped TUI composition without booting or needing a TTY', async () => {
  80. const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
  81. expect(code).toBe(0)
  82. expect(stderr).toBe('')
  83. // Base rows composed with the TUI overlay's surface values, `!!js`
  84. // expressions verbatim (unevaluated), and TUI-only inserted rows present.
  85. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  86. expect(stdout).toContain('model: deepseek-v4-pro')
  87. expect(stdout).toContain('cwd: !!js process.cwd()')
  88. expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
  89. expect(stdout).toContain([
  90. '- id: tool-web',
  91. " name: '@deepseek-ai/dsh-tool-web'",
  92. ' config:',
  93. ' fetch: false',
  94. ' searchTimeoutMs: 60000',
  95. ].join('\n'))
  96. // Provenance comment separators name each section's source file.
  97. expect(stdout).toContain('# == base.cordis.yml')
  98. expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml')
  99. expect(stdout).toContain('# == tui.cordis.yml')
  100. }, 30_000)
  101. it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => {
  102. writeFileSync(join(home, 'config.yaml'), [
  103. '- id: agent-loop',
  104. ' config:',
  105. ' agents:',
  106. ' - id: main',
  107. ' provider: custom-provider',
  108. ' model: custom-model',
  109. '- id: only-on-web',
  110. ' config:',
  111. ' value: 1',
  112. '',
  113. ].join('\n'))
  114. const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
  115. expect(code).toBe(0)
  116. expect(stdout).toContain('provider: custom-provider')
  117. expect(stdout).not.toContain('model: deepseek-v4-pro')
  118. // The personal layer appears in the patched row's provenance and the
  119. // skipped-patch warning carries its label.
  120. expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`)
  121. expect(stderr).toContain('patch: entry "only-on-web" not found')
  122. // The shipped view ignores the personal overlay entirely.
  123. const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
  124. expect(shipped.stdout).not.toContain('custom-provider')
  125. expect(shipped.stdout).toContain('model: deepseek-v4-pro')
  126. }, 30_000)
  127. it('composes the web overlay for `dsh web --dump-config`', async () => {
  128. const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
  129. expect(code).toBe(0)
  130. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  131. expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'")
  132. }, 30_000)
  133. })
  134. })