built-bin.e2e.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath, pathToFileURL } from 'node:url'
  5. import { execa } from 'execa'
  6. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  7. /** Published-entry acceptance for raw argument errors and boot-free config dumps. */
  8. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  9. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  10. const rawOverlay = fileURLToPath(new URL('./fixtures/raw-overlay.cordis.yml', import.meta.url))
  11. const rawInvalidProvider = fileURLToPath(new URL('./fixtures/raw-invalid-provider.cordis.yml', import.meta.url))
  12. async function runBuiltBin(
  13. args: readonly string[] = [],
  14. env: Record<string, string> = {},
  15. ): Promise<{ stdout: string; code: number; stderr: string }> {
  16. const result = await execa(process.execPath, [dshBin, ...args], {
  17. input: '',
  18. timeout: 25_000,
  19. killSignal: 'SIGKILL',
  20. reject: false,
  21. env,
  22. })
  23. if (result.timedOut) {
  24. throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  25. }
  26. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  27. }
  28. async function waitForFile(file: string): Promise<void> {
  29. const deadline = Date.now() + 20_000
  30. while (!existsSync(file)) {
  31. if (Date.now() >= deadline) throw new Error(`dsh raw lifecycle marker did not appear: ${file}`)
  32. await new Promise(resolve => setTimeout(resolve, 20))
  33. }
  34. }
  35. interface RawLifecycleFixture {
  36. home: string
  37. ready: string
  38. settled: string
  39. disposed: string
  40. overlay: string
  41. }
  42. function createRawLifecycleFixture(): RawLifecycleFixture {
  43. const home = mkdtempSync(join(tmpdir(), 'dsh-raw-lifecycle-'))
  44. const ready = join(home, 'ready')
  45. const settled = join(home, 'settled')
  46. const disposed = join(home, 'disposed')
  47. const plugin = join(home, 'lifecycle.mjs')
  48. const overlay = join(home, 'overlay.cordis.yml')
  49. writeFileSync(plugin, [
  50. "import { writeFileSync } from 'node:fs'",
  51. "export const name = 'raw-lifecycle-fixture'",
  52. "export const inject = ['sessionQuery']",
  53. 'export function apply(ctx) {',
  54. ' let active = true',
  55. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  56. ' void ctx.loader.await().then(() => {',
  57. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  58. ' })',
  59. ' ctx.effect(() => () => {',
  60. ' active = false',
  61. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  62. ' })',
  63. '}',
  64. '',
  65. ].join('\n'))
  66. writeFileSync(overlay, [
  67. '- insert:',
  68. ' - id: raw-lifecycle-fixture',
  69. ` name: ${pathToFileURL(plugin).href}`,
  70. '',
  71. ].join('\n'))
  72. return { home, ready, settled, disposed, overlay }
  73. }
  74. function startRawLifecycle(fixture: RawLifecycleFixture) {
  75. return execa(process.execPath, [dshBin, '--config', fixture.overlay], {
  76. cwd: fixture.home,
  77. input: '',
  78. reject: false,
  79. env: {
  80. DSH_HOME: fixture.home,
  81. DSH_TELEMETRY_DISABLED: '1',
  82. RAW_READY_FILE: fixture.ready,
  83. RAW_SETTLED_FILE: fixture.settled,
  84. RAW_DISPOSED_FILE: fixture.disposed,
  85. },
  86. })
  87. }
  88. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  89. it('requires --config for the raw command and rejects removed commands', async () => {
  90. const bare = await runBuiltBin()
  91. expect(bare.code).toBe(1)
  92. expect(bare.stdout).toBe('')
  93. expect(bare.stderr).toContain('--config <path> is required')
  94. const help = await runBuiltBin(['--help'])
  95. expect(help.code).toBe(0)
  96. expect(help.stdout).toContain('dsh --config ./app.cordis.yml')
  97. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  98. for (const command of ['tui', 'meta', 'upgrade']) {
  99. const removed = await runBuiltBin([command])
  100. expect(removed.code).toBe(1)
  101. expect(removed.stderr).not.toContain('experimental')
  102. }
  103. }, 30_000)
  104. it('reports a raw overlay boot failure without hanging', async () => {
  105. const result = await runBuiltBin(['--config', rawInvalidProvider], {
  106. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  107. DSH_TELEMETRY_DISABLED: '1',
  108. })
  109. expect(result.code).toBe(1)
  110. expect(result.stdout).toBe('')
  111. expect(result.stderr).toContain('llm-pi-ai')
  112. }, 30_000)
  113. it('applies an inserted raw plugin and disposes it on a startup-time signal', async () => {
  114. const fixture = createRawLifecycleFixture()
  115. const child = startRawLifecycle(fixture)
  116. try {
  117. await waitForFile(fixture.ready)
  118. child.kill('SIGTERM')
  119. const result = await child
  120. expect(result.exitCode).toBe(0)
  121. expect(result.signal).toBeUndefined()
  122. expect(existsSync(fixture.disposed)).toBe(true)
  123. } finally {
  124. child.kill('SIGKILL')
  125. rmSync(fixture.home, { recursive: true, force: true })
  126. }
  127. }, 30_000)
  128. it('fully settles a valid raw overlay and disposes it on a signal', async () => {
  129. const fixture = createRawLifecycleFixture()
  130. const child = startRawLifecycle(fixture)
  131. try {
  132. await waitForFile(fixture.settled)
  133. child.kill('SIGTERM')
  134. const result = await child
  135. expect(result.exitCode).toBe(0)
  136. expect(result.signal).toBeUndefined()
  137. expect(existsSync(fixture.disposed)).toBe(true)
  138. } finally {
  139. child.kill('SIGKILL')
  140. rmSync(fixture.home, { recursive: true, force: true })
  141. }
  142. }, 30_000)
  143. describe('config dump', () => {
  144. let home: string
  145. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  146. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  147. it('prints the shipped base without a user layer', async () => {
  148. const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
  149. expect(code).toBe(0)
  150. expect(stderr).toBe('')
  151. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  152. expect(stdout).toContain('agents: []')
  153. expect(stdout).toContain('# == base.cordis.yml')
  154. }, 30_000)
  155. it('composes the required raw overlay directly over the base', async () => {
  156. writeFileSync(join(home, 'config.yaml'), [
  157. '- id: agent-loop',
  158. ' config:',
  159. ' agents:',
  160. ' - id: personal',
  161. ' provider: personal-provider',
  162. ' model: personal-model',
  163. '',
  164. ].join('\n'))
  165. const { stdout, code, stderr } = await runBuiltBin(
  166. ['--config', rawOverlay, '--dump-config'],
  167. { DSH_HOME: home },
  168. )
  169. expect(code).toBe(0)
  170. expect(stdout).toContain('provider: configured-provider')
  171. expect(stdout).not.toContain('personal-provider')
  172. expect(stdout).toContain(`patched by ${rawOverlay}`)
  173. expect(stderr).toContain('patch: entry "absent-row" not found')
  174. }, 30_000)
  175. it('keeps the Web overlay and personal layer on the Web command', async () => {
  176. writeFileSync(join(home, 'config.yaml'), [
  177. '- id: agent-loop',
  178. ' config:',
  179. ' agents:',
  180. ' - id: personal',
  181. ' provider: personal-provider',
  182. ' model: personal-model',
  183. '',
  184. ].join('\n'))
  185. const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
  186. expect(code).toBe(0)
  187. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  188. expect(stdout).toContain('provider: personal-provider')
  189. }, 30_000)
  190. })
  191. })