built-bin.e2e.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { spawn } from 'node:child_process'
  2. import { cp, mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
  3. import { existsSync } from 'node:fs'
  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. * BUILT-ARTIFACT smoke for the published `dsh-stdio-agent` bin. The other smokes
  10. * boot `src/bin.ts` under tsx — but the package's `bin` field points at
  11. * `lib/bin.js`, run under plain `node` by a real consumer. tsx masks two failure
  12. * modes the built bin had: (1) `boot()` returned before the loader tree settled,
  13. * so the process exited 0 with no output and load errors surfaced as unhandled
  14. * rejections AFTER boot; (2) config-path resolution could fall back to the cwd.
  15. * This test runs the REAL `lib/bin.js` under `node` (NOT tsx) and asserts the
  16. * banner + echo round-trip, so a regression in the published entry fails here.
  17. *
  18. * It build-gates: if `lib/bin.js` is absent (suite run without `pnpm run build`)
  19. * the test SKIPS with a note. CI runs it after the build step. Setup mirrors a
  20. * real install: a temp dir whose `node_modules/@deepseek-ai/*` (and the vendored
  21. * `cordis`/`@cordisjs/*`) are symlinked to the built packages, a `cordis.yml`
  22. * that loads the app + the example's mock backend, and `node --expose-internals`
  23. * (the cordis Loader resolves bare plugin specifiers via its internal module
  24. * loader, active only under that flag — the same flag `demo:echo` passes).
  25. */
  26. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  27. const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
  28. // Workspace packages the stdio app's tree needs, by repo-relative path. Each is
  29. // symlinked into the temp consumer's node_modules under its package name, so
  30. // plain `node` resolves the bare `@deepseek-ai/dsh-*` specifiers in cordis.yml
  31. // to the built `lib/` (package.json `main`), exactly as an installed dep would.
  32. const dshPackages = [
  33. 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
  34. 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
  35. 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
  36. 'session-persistence/session-persistence',
  37. 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
  38. ]
  39. const vendorPackages = [
  40. 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
  41. 'schemastery', 'cosmokit',
  42. ]
  43. async function pkgName(absDir: string): Promise<string> {
  44. const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
  45. return json.name
  46. }
  47. /**
  48. * Build a temp consumer dir: `node_modules` with the workspace + vendor packages
  49. * symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml`
  50. * that wires them onto the stdio app. Returns the dir (caller removes it).
  51. *
  52. * `disabledBrokenEntry` appends an entry that points at a non-existent plugin but
  53. * is marked `disabled: true`. The Loader leaves a disabled entry fiber-less by
  54. * design, so it exercises that the fail-loud entry-load guard does NOT mistake a
  55. * valid disabled entry for a failed import.
  56. */
  57. async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise<string> {
  58. const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
  59. const nm = join(dir, 'node_modules')
  60. for (const rel of dshPackages) {
  61. const abs = join(repoRoot, 'packages', rel)
  62. const name = await pkgName(abs)
  63. const target = join(nm, name)
  64. await mkdir(dirname(target), { recursive: true })
  65. await symlink(abs, target)
  66. }
  67. for (const v of vendorPackages) {
  68. const abs = join(repoRoot, 'vendor', v)
  69. const name = await pkgName(abs)
  70. const target = join(nm, name)
  71. await mkdir(dirname(target), { recursive: true })
  72. await symlink(abs, target)
  73. }
  74. // The example's mock model + echo tool are example-local TS plugins (Node
  75. // 22.19+ — the engines floor — strips types natively, so plain `node` loads
  76. // them); they import the workspace packages the symlinked node_modules now
  77. // provides.
  78. await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true })
  79. await writeFile(join(dir, 'cordis.yml'), [
  80. '- id: mock-llm',
  81. ' name: \'./src/mock-llm.ts\'',
  82. '- id: echo-tool',
  83. ' name: \'./src/echo-tool.ts\'',
  84. '- id: bash',
  85. ' name: \'@deepseek-ai/dsh-bash-local\'',
  86. '- id: stdio-agent',
  87. ' name: \'@deepseek-ai/dsh-stdio-agent\'',
  88. ' config:',
  89. ' model: mock-echo',
  90. ' persona: \'demo\'',
  91. ` welcome: '${welcome}'`,
  92. ...disabledBrokenEntry
  93. ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
  94. : [],
  95. '',
  96. ].join('\n'))
  97. return dir
  98. }
  99. /** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */
  100. function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> {
  101. return new Promise((resolve, reject) => {
  102. // --expose-internals: the cordis Loader resolves bare plugin specifiers via
  103. // its internal module loader (active only under this flag); demo:echo passes
  104. // it too. NO tsx — this is the published `node lib/bin.js` path.
  105. const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
  106. cwd,
  107. // Mock model: never calls the network, so no key needed.
  108. env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
  109. stdio: ['pipe', 'pipe', 'pipe'],
  110. })
  111. let stdout = ''
  112. let stderr = ''
  113. child.stdout.setEncoding('utf8')
  114. child.stdout.on('data', (c: string) => { stdout += c })
  115. child.stderr.setEncoding('utf8')
  116. child.stderr.on('data', (c: string) => { stderr += c })
  117. const timer = setTimeout(() => {
  118. child.kill('SIGKILL')
  119. reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
  120. }, 25_000)
  121. child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
  122. child.on('error', (err) => { clearTimeout(timer); reject(err) })
  123. child.stdin.write(`${line}\n`)
  124. child.stdin.end()
  125. })
  126. }
  127. let consumer: string | undefined
  128. afterEach(async () => {
  129. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
  130. consumer = undefined
  131. })
  132. describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.js, no tsx)', () => {
  133. it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => {
  134. consumer = await makeConsumer('BUILT-BIN-OK ready.')
  135. const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
  136. expect(stderr).not.toContain('UNHANDLED')
  137. expect(stderr).not.toContain('without inject')
  138. // The banner proves boot() awaited the tree (the settle-race regression would
  139. // exit 0 with empty stdout); the round-trip proves the whole app mounted.
  140. expect(stdout).toContain('BUILT-BIN-OK ready.')
  141. expect(stdout).toContain('[tool call] echo')
  142. expect(stdout).toContain('[tool result] ECHO: HI')
  143. expect(code).toBe(0)
  144. }, 30_000)
  145. it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => {
  146. // A `disabled: true` entry settles without a fiber by design; the fail-loud
  147. // entry-load guard must NOT mistake it for a failed import. Even though its
  148. // plugin path does not exist, the app boots and the round-trip works.
  149. consumer = await makeConsumer('DISABLED-OK ready.', true)
  150. const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
  151. expect(stderr).not.toContain('failed to load')
  152. expect(stdout).toContain('DISABLED-OK ready.')
  153. expect(stdout).toContain('[tool result] ECHO: HI')
  154. expect(code).toBe(0)
  155. }, 30_000)
  156. it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
  157. // A consumer who typos the config path must get a clear failure, not silent
  158. // success. This dir does not exist, so the include PLUGIN itself fails to
  159. // import; the cordis Loader logs that and leaves the entry with no fiber (no
  160. // rejection), which `boot()`'s entry-load check turns into a thrown error.
  161. consumer = await makeConsumer('unused')
  162. const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
  163. expect(code).not.toBe(0)
  164. expect(stderr).toContain('failed to load')
  165. }, 30_000)
  166. it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
  167. // The config DIRECTORY exists (the include plugin imports), but the file does
  168. // not — the include's init throws "config file not found", which surfaces as
  169. // an unhandled rejection the fail-loud guard turns into a non-zero exit.
  170. consumer = await makeConsumer('unused')
  171. const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '')
  172. expect(code).not.toBe(0)
  173. expect(stderr).toContain('config file not found')
  174. }, 30_000)
  175. })