built-bin.e2e.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
  10. * require the banner plus echo round-trip. This catches built-only early-exit and config-resolution
  11. * failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis
  12. * bare-plugin loading, matching the demo command.
  13. */
  14. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  15. const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js')
  16. // Symlink each required workspace package by package name so plain Node resolves its built `main`,
  17. // matching an installed dependency rather than tsconfig paths.
  18. const dshPackages = [
  19. 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
  20. 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
  21. 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
  22. 'session-persistence/session-persistence',
  23. 'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', 'util/paths',
  24. 'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction',
  25. ]
  26. const vendorPackages = [
  27. 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
  28. 'schemastery', 'cosmokit',
  29. ]
  30. async function pkgName(absDir: string): Promise<string> {
  31. const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
  32. return json.name
  33. }
  34. async function installWorkspacePackageCopy(absDir: string, target: string): Promise<void> {
  35. await mkdir(dirname(target), { recursive: true })
  36. await cp(absDir, target, {
  37. recursive: true,
  38. filter: source => !source.split('/').includes('node_modules'),
  39. })
  40. }
  41. /**
  42. * Build a temporary external consumer with built workspace/vendor links and a mock-backed config.
  43. * The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less
  44. * entries rather than treating them as import failures.
  45. */
  46. async function makeConsumer(
  47. welcome: string,
  48. disabledBrokenEntry = false,
  49. extraDshPackages: string[] = [],
  50. extraEntries: string[] = [],
  51. ): Promise<string> {
  52. const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
  53. const nm = join(dir, 'node_modules')
  54. for (const rel of [...dshPackages, ...extraDshPackages]) {
  55. const abs = join(repoRoot, 'packages', rel)
  56. const name = await pkgName(abs)
  57. const target = join(nm, name)
  58. if (extraDshPackages.includes(rel)) {
  59. await installWorkspacePackageCopy(abs, target)
  60. } else {
  61. await mkdir(dirname(target), { recursive: true })
  62. await symlink(abs, target)
  63. }
  64. }
  65. for (const v of vendorPackages) {
  66. const abs = join(repoRoot, 'vendor', v)
  67. const name = await pkgName(abs)
  68. const target = join(nm, name)
  69. await mkdir(dirname(target), { recursive: true })
  70. await symlink(abs, target)
  71. }
  72. // The example's mock model + echo tool are example-local TS plugins (Node
  73. // 22.19+ — the engines floor — strips types natively, so plain `node` loads
  74. // them); they import the workspace packages the symlinked node_modules now
  75. // provides.
  76. await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true })
  77. await writeFile(join(dir, 'cordis.yml'), [
  78. '- id: mock-llm',
  79. ' name: \'./src/mock-llm.ts\'',
  80. '- id: echo-tool',
  81. ' name: \'./src/echo-tool.ts\'',
  82. '- id: bash',
  83. ' name: \'@deepseek-ai/dsh-bash-local\'',
  84. '- id: stdio-agent',
  85. ' name: \'@deepseek-ai/dsh-stdio-demo\'',
  86. ' config:',
  87. ' provider: mock',
  88. ' model: mock-echo',
  89. ' persona: \'demo\'',
  90. ' workspaceContext: false',
  91. ` welcome: '${welcome}'`,
  92. ...extraEntries,
  93. ...disabledBrokenEntry
  94. ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
  95. : [],
  96. '',
  97. ].join('\n'))
  98. return dir
  99. }
  100. /** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */
  101. function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> {
  102. return new Promise((resolve, reject) => {
  103. // --expose-internals: the cordis Loader resolves bare plugin specifiers via
  104. // its internal module loader (active only under this flag); demo:echo passes
  105. // it too. NO tsx — this is the published `node lib/bin.js` path.
  106. const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
  107. cwd,
  108. // Mock model: never calls the network, so no key needed.
  109. env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
  110. stdio: ['pipe', 'pipe', 'pipe'],
  111. })
  112. let stdout = ''
  113. let stderr = ''
  114. child.stdout.setEncoding('utf8')
  115. child.stdout.on('data', (c: string) => { stdout += c })
  116. child.stderr.setEncoding('utf8')
  117. child.stderr.on('data', (c: string) => { stderr += c })
  118. const timer = setTimeout(() => {
  119. child.kill('SIGKILL')
  120. reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
  121. }, 25_000)
  122. child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
  123. child.on('error', (err) => { clearTimeout(timer); reject(err) })
  124. child.stdin.write(`${line}\n`)
  125. child.stdin.end()
  126. })
  127. }
  128. let consumer: string | undefined
  129. afterEach(async () => {
  130. // Windows can briefly retain released handles after exit; retry removal.
  131. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  132. consumer = undefined
  133. })
  134. describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.js, no tsx)', () => {
  135. it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => {
  136. consumer = await makeConsumer('BUILT-BIN-OK ready.')
  137. const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
  138. expect(stderr).not.toContain('UNHANDLED')
  139. expect(stderr).not.toContain('without inject')
  140. // The banner proves boot() awaited the tree (the settle-race regression would
  141. // exit 0 with empty stdout); the round-trip proves the whole app mounted.
  142. expect(stdout).toContain('BUILT-BIN-OK ready.')
  143. expect(stdout).toContain('[tool call] echo')
  144. expect(stdout).toContain('[tool result] ECHO: HI')
  145. expect(code).toBe(0)
  146. }, 30_000)
  147. it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => {
  148. // A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load
  149. // guard must not mistake it for a failed import. The nonexistent path makes that distinction
  150. // observable while the successful round-trip proves boot continued.
  151. consumer = await makeConsumer('DISABLED-OK ready.', true)
  152. const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
  153. expect(stderr).not.toContain('failed to load')
  154. expect(stdout).toContain('DISABLED-OK ready.')
  155. expect(stdout).toContain('[tool result] ECHO: HI')
  156. expect(code).toBe(0)
  157. }, 30_000)
  158. it('boots when optional spill plugins are loaded from a built consumer install', async () => {
  159. consumer = await makeConsumer(
  160. 'SPILL-OK ready.',
  161. false,
  162. ['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'],
  163. [
  164. '- id: spill-local',
  165. ' name: \'@deepseek-ai/dsh-spill-local\'',
  166. '- id: spill-policy',
  167. ' name: \'@deepseek-ai/dsh-spill-policy\'',
  168. ' config:',
  169. ' maxInlineBytes: 50000',
  170. ],
  171. )
  172. const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '')
  173. expect(stderr).not.toContain('failed to load')
  174. expect(stderr).not.toContain('Cannot find package')
  175. expect(stdout).toContain('SPILL-OK ready.')
  176. expect(code).toBe(0)
  177. }, 30_000)
  178. it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
  179. // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
  180. // directory cannot break its import; the include plugin's own read must fail loud instead.
  181. consumer = await makeConsumer('unused')
  182. const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
  183. expect(code).not.toBe(0)
  184. expect(stderr).toContain('config file not found')
  185. }, 30_000)
  186. it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
  187. // Existing directory plus missing config exercises the include plugin's fail-loud path.
  188. consumer = await makeConsumer('unused')
  189. const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '')
  190. expect(code).not.toBe(0)
  191. expect(stderr).toContain('config file not found')
  192. }, 30_000)
  193. })