1
0

built-bin.e2e.ts 4.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. import { spawn } from 'node:child_process'
  2. import { existsSync } from 'node:fs'
  3. import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. /**
  9. * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
  10. * The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
  11. * nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
  12. * because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
  13. * links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
  14. * fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
  15. * full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
  16. * skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
  17. * one sanctioned PTY surface).
  18. */
  19. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  20. const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
  21. // Symlink each package the bin imports at module load by package name so plain
  22. // Node resolves its built `main`, matching an installed dependency rather than
  23. // tsconfig paths.
  24. const dshPackages = ['examples/tui-demo', 'ui/app-boot']
  25. const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
  26. async function pkgName(absDir: string): Promise<string> {
  27. const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
  28. return json.name
  29. }
  30. /** Build a temporary external consumer with built workspace/vendor links. */
  31. async function makeConsumer(): Promise<string> {
  32. const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
  33. const nm = join(dir, 'node_modules')
  34. for (const rel of dshPackages) {
  35. const abs = join(repoRoot, 'packages', rel)
  36. const target = join(nm, await pkgName(abs))
  37. await mkdir(dirname(target), { recursive: true })
  38. await symlink(abs, target)
  39. }
  40. for (const v of vendorPackages) {
  41. const abs = join(repoRoot, 'vendor', v)
  42. const target = join(nm, await pkgName(abs))
  43. await mkdir(dirname(target), { recursive: true })
  44. await symlink(abs, target)
  45. }
  46. return dir
  47. }
  48. /** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
  49. function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
  50. return new Promise((resolve, reject) => {
  51. // NO tsx — this is the published `node lib/bin.js` path; the guard fires
  52. // before the Loader resolves the config tree.
  53. const child = spawn(process.execPath, [tuiBin, './cordis.yml'], {
  54. cwd,
  55. env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
  56. stdio: ['pipe', 'pipe', 'pipe'],
  57. })
  58. let stdout = ''
  59. let stderr = ''
  60. child.stdout.setEncoding('utf8')
  61. child.stdout.on('data', (c: string) => { stdout += c })
  62. child.stderr.setEncoding('utf8')
  63. child.stderr.on('data', (c: string) => { stderr += c })
  64. const timer = setTimeout(() => {
  65. child.kill('SIGKILL')
  66. reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
  67. }, 25_000)
  68. child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
  69. child.on('error', (err) => { clearTimeout(timer); reject(err) })
  70. child.stdin.end()
  71. })
  72. }
  73. let consumer: string | undefined
  74. afterEach(async () => {
  75. // Windows can briefly retain released handles after exit; retry removal.
  76. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  77. consumer = undefined
  78. })
  79. describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
  80. it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
  81. consumer = await makeConsumer()
  82. const { stdout, code, stderr } = await runBuiltBin(consumer)
  83. expect(code).not.toBe(0)
  84. expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
  85. expect(stderr).toContain('dsh-cli-demo')
  86. // The refusal happens before any plugin mounts: stdout stays silent.
  87. expect(stdout).toBe('')
  88. }, 30_000)
  89. })