built-bin.e2e.ts 4.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. import { existsSync, mkdtempSync, rmSync } 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. * examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that
  20. * boots no agent tree, so the built bin is the whole product path.
  21. * Skips before the bin is built.
  22. */
  23. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  24. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  25. /**
  26. * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
  27. * + exit code. `env` isolates the Harness home for surfaces that read it.
  28. */
  29. async function runBuiltBin(
  30. args: readonly string[] = [],
  31. env: Record<string, string> = {},
  32. ): Promise<{ stdout: string; code: number; stderr: string }> {
  33. const result = await execa(process.execPath, [dshBin, ...args], {
  34. input: '',
  35. timeout: 25_000,
  36. killSignal: 'SIGKILL',
  37. reject: false,
  38. env,
  39. })
  40. if (result.timedOut) {
  41. throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  42. }
  43. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  44. }
  45. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  46. it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
  47. const { stdout, code, stderr } = await runBuiltBin()
  48. expect(code).not.toBe(0)
  49. expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
  50. expect(stderr).toContain('dsh -p')
  51. // The refusal happens before any plugin mounts: stdout stays silent.
  52. expect(stdout).toBe('')
  53. }, 30_000)
  54. describe('dsh list-sessions', () => {
  55. let home: string
  56. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) })
  57. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  58. it('reports an empty listing as success, not an error', async () => {
  59. const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home })
  60. expect(code).toBe(0)
  61. expect(stdout.trim()).toBe('no dsh sessions running')
  62. expect(stderr).toBe('')
  63. }, 30_000)
  64. it('emits an empty JSON array for machines', async () => {
  65. const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home })
  66. expect(code).toBe(0)
  67. expect(JSON.parse(stdout)).toEqual([])
  68. }, 30_000)
  69. it('runs without a TTY, unlike the TUI surface', async () => {
  70. // The listing is read-only and boots no agent tree, so piped stdio — the
  71. // launch the TUI refuses — is a supported way to run it.
  72. const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home })
  73. expect(code).toBe(0)
  74. expect(stderr).not.toContain('interactive TTYs')
  75. }, 30_000)
  76. it('rejects a leaked default-surface flag instead of listing', async () => {
  77. const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home })
  78. expect(code).not.toBe(0)
  79. expect(stderr).toContain('list-sessions takes none of')
  80. }, 30_000)
  81. })
  82. })