built-bin.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 argument errors, profile lifecycle, 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 coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url))
  11. const invalidProvider = fileURLToPath(new URL('./fixtures/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 profile lifecycle marker did not appear: ${file}`)
  32. await new Promise(resolve => setTimeout(resolve, 20))
  33. }
  34. }
  35. interface ProfileLifecycleFixture {
  36. home: string
  37. ready: string
  38. settled: string
  39. disposed: string
  40. }
  41. /**
  42. * A minimal custom profile: one lifecycle-marker plugin bundle listed in
  43. * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
  44. * booting the entire product tree.
  45. */
  46. function createProfileLifecycleFixture(): ProfileLifecycleFixture {
  47. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
  48. const ready = join(home, 'ready')
  49. const settled = join(home, 'settled')
  50. const disposed = join(home, 'disposed')
  51. const bundleDir = join(home, 'lifecycle-bundle')
  52. mkdirSync(bundleDir, { recursive: true })
  53. writeFileSync(join(bundleDir, 'plugin.mjs'), [
  54. "import { writeFileSync } from 'node:fs'",
  55. "import { join } from 'node:path'",
  56. "export const name = 'profile-lifecycle-fixture'",
  57. 'export function apply(ctx, config = {}) {',
  58. ' let active = true',
  59. ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
  60. ' const heartbeat = setInterval(() => {}, 1000)',
  61. ' // Echo the mounted generation so the hot-reload e2e can assert both an',
  62. ' // applied override and its removal reverting to this bundle default.',
  63. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  64. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  65. ' void ctx.loader.await().then(() => {',
  66. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  67. ' })',
  68. ' ctx.effect(() => () => {',
  69. ' active = false',
  70. ' clearInterval(heartbeat)',
  71. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  72. ' })',
  73. '}',
  74. '',
  75. ].join('\n'))
  76. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  77. '- insert:',
  78. ' - id: profile-lifecycle-fixture',
  79. ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
  80. '',
  81. ].join('\n'))
  82. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  83. name: 'dsh-lifecycle-bundle',
  84. version: '0.0.0',
  85. type: 'module',
  86. dsh: { bundle: { patch: './cordis.patch.yml' } },
  87. }, undefined, 2))
  88. const profileDir = join(home, 'profiles', 'lifecycle')
  89. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  90. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  91. name: 'dsh-profile-lifecycle',
  92. private: true,
  93. dependencies: {},
  94. dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
  95. }, undefined, 2))
  96. // Hand-place the "installed" bundle where profile resolution finds it.
  97. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  98. const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
  99. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  100. try {
  101. rmSync(linkTarget, { recursive: true, force: true })
  102. } catch { /* fresh dir */ }
  103. // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
  104. mkdirSync(linkTarget, { recursive: true })
  105. for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
  106. writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
  107. }
  108. return { home, ready, settled, disposed }
  109. }
  110. function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
  111. return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
  112. cwd: fixture.home,
  113. input: '',
  114. reject: false,
  115. env: {
  116. DSH_HOME: fixture.home,
  117. RAW_READY_FILE: fixture.ready,
  118. RAW_SETTLED_FILE: fixture.settled,
  119. RAW_DISPOSED_FILE: fixture.disposed,
  120. },
  121. })
  122. }
  123. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  124. it('requires --profile and rejects removed commands', async () => {
  125. const bare = await runBuiltBin()
  126. expect(bare.code).toBe(1)
  127. expect(bare.stdout).toBe('')
  128. expect(bare.stderr).toContain('--profile <name> is required')
  129. const help = await runBuiltBin(['--help'])
  130. expect(help.code).toBe(0)
  131. expect(help.stdout).toContain('dsh --profile web')
  132. expect(help.stdout).toContain('dsh plugin --profile')
  133. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  134. for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) {
  135. const result = await runBuiltBin(removed)
  136. expect(result.code).toBe(1)
  137. }
  138. }, 30_000)
  139. it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
  140. const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
  141. try {
  142. const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
  143. expect(result.code).toBe(1)
  144. expect(result.stderr).toContain('profile "nope" does not exist')
  145. expect(result.stderr).toContain('dsh plugin --profile nope add')
  146. } finally {
  147. rmSync(home, { recursive: true, force: true })
  148. }
  149. }, 30_000)
  150. it('reports a patch-overlay boot failure without hanging', async () => {
  151. // The HMR main watcher's initial scan once refreshed the include
  152. // mid-initial-apply, deadlocking the failing apply's rollback against the
  153. // refresh drain: dsh exited 13 with no diagnostic instead of settling
  154. // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
  155. const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
  156. try {
  157. const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
  158. DSH_HOME: home,
  159. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  160. DSH_TELEMETRY_DISABLED: '1',
  161. })
  162. expect(result.code).toBe(1)
  163. expect(result.stdout).toBe('')
  164. expect(result.stderr).toContain('llm-pi-ai')
  165. } finally {
  166. rmSync(home, { recursive: true, force: true })
  167. }
  168. }, 30_000)
  169. it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
  170. const fixture = createProfileLifecycleFixture()
  171. const child = startProfileLifecycle(fixture)
  172. try {
  173. await waitForFile(fixture.ready)
  174. child.kill('SIGTERM')
  175. const result = await child
  176. expect(result.exitCode).toBe(0)
  177. expect(result.signal).toBeUndefined()
  178. expect(existsSync(fixture.disposed)).toBe(true)
  179. } finally {
  180. child.kill('SIGKILL')
  181. rmSync(fixture.home, { recursive: true, force: true })
  182. }
  183. }, 30_000)
  184. it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
  185. const fixture = createProfileLifecycleFixture()
  186. const child = startProfileLifecycle(fixture)
  187. const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
  188. const configFile = join(fixture.home, 'config-echo')
  189. try {
  190. await waitForFile(fixture.settled)
  191. // The live profile layer: even without an hmr row in the composition,
  192. // the launcher mounts a config-only watcher, so an edited
  193. // cordis.patch.yml lands in the running tree (the reload disposes the
  194. // patched row's old fiber — observable as the disposed marker — and
  195. // mounts the new config, which echoes its generation and re-writes the
  196. // ready marker).
  197. rmSync(fixture.ready)
  198. writeFileSync(profilePatch, [
  199. '- id: profile-lifecycle-fixture',
  200. ' config:',
  201. ' generation: 2',
  202. '',
  203. ].join('\n'))
  204. await waitForFile(fixture.ready)
  205. expect(readFileSync(configFile, 'utf8')).toBe('2')
  206. // Removal reverts: the bundle's inserted row must return to its own
  207. // default config, not keep the removed override — the insert-aliasing
  208. // regression (a shared patch object mutated in place by a former
  209. // generation would make this impossible).
  210. rmSync(fixture.ready)
  211. writeFileSync(profilePatch, '[]\n')
  212. await waitForFile(fixture.ready)
  213. expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
  214. // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
  215. // and outranks the per-profile layer.
  216. rmSync(fixture.ready)
  217. writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
  218. '- id: profile-lifecycle-fixture',
  219. ' config:',
  220. ' generation: home',
  221. '',
  222. ].join('\n'))
  223. await waitForFile(fixture.ready)
  224. expect(readFileSync(configFile, 'utf8')).toBe('home')
  225. child.kill('SIGTERM')
  226. const result = await child
  227. expect(result.exitCode).toBe(0)
  228. expect(result.signal).toBeUndefined()
  229. expect(existsSync(fixture.disposed)).toBe(true)
  230. } finally {
  231. child.kill('SIGKILL')
  232. rmSync(fixture.home, { recursive: true, force: true })
  233. }
  234. }, 30_000)
  235. it('anchors a relative add spec to the invoking directory, not the profile', async () => {
  236. // `dsh plugin --profile x add .` from a plugin checkout must install THAT
  237. // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
  238. // would self-link the profile.
  239. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
  240. const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
  241. try {
  242. writeFileSync(join(checkout, 'package.json'), JSON.stringify({
  243. name: 'anchored-bundle',
  244. version: '1.0.0',
  245. dsh: { bundle: { patch: './cordis.patch.yml' } },
  246. }))
  247. writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
  248. const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
  249. cwd: checkout,
  250. input: '',
  251. timeout: 60_000,
  252. killSignal: 'SIGKILL',
  253. reject: false,
  254. env: { DSH_HOME: home },
  255. })
  256. expect(result.exitCode).toBe(0)
  257. const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
  258. dependencies: Record<string, string>
  259. dsh: { profile: { bundles: string[] } }
  260. }
  261. expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
  262. expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
  263. } finally {
  264. rmSync(home, { recursive: true, force: true })
  265. rmSync(checkout, { recursive: true, force: true })
  266. }
  267. }, 90_000)
  268. it('activates a dependency that gained dsh.bundle in a later update', async () => {
  269. // Reconcile runs against the INSTALLED state on every successful pnpm
  270. // run, so `update` (not only `add`) activates a package whose newer
  271. // version declares dsh.bundle. Simulated without a registry: hand-place
  272. // the installed package, flip its manifest, and run a benign pnpm verb.
  273. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
  274. try {
  275. const profileDir = join(home, 'profiles', 'up')
  276. const installed = join(profileDir, 'node_modules', 'late-bundle')
  277. mkdirSync(installed, { recursive: true })
  278. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  279. name: 'dsh-profile-up',
  280. private: true,
  281. dependencies: { 'late-bundle': 'file:./late-bundle' },
  282. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  283. }))
  284. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  285. // v1: no dsh manifest — a plain dependency.
  286. writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
  287. const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  288. expect(first.code).toBe(0)
  289. let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  290. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
  291. // v2: the installed package now declares dsh.bundle (an update landed).
  292. writeFileSync(join(installed, 'package.json'), JSON.stringify({
  293. name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
  294. }))
  295. writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
  296. const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  297. expect(second.code).toBe(0)
  298. manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  299. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
  300. } finally {
  301. rmSync(home, { recursive: true, force: true })
  302. }
  303. }, 30_000)
  304. describe('config dump', () => {
  305. let home: string
  306. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  307. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  308. it('prints the web profile bundle layers without a user layer', async () => {
  309. const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  310. expect(code).toBe(0)
  311. expect(stderr).toBe('')
  312. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  313. expect(stdout).toContain('agents: []')
  314. expect(stdout).toContain('# == @deepseek-ai/dsh-base')
  315. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  316. }, 30_000)
  317. it('composes the profile user layer and a --patch overlay in order', async () => {
  318. // Auto-init the web profile first, then write its user layer.
  319. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  320. expect(init.code).toBe(0)
  321. const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
  322. writeFileSync(profilePatch, [
  323. '- id: agent-loop',
  324. ' config:',
  325. ' agents:',
  326. ' - id: personal',
  327. ' provider: personal-provider',
  328. ' model: personal-model',
  329. '- id: absent-row',
  330. ' config:',
  331. ' x: 1',
  332. '',
  333. ].join('\n'))
  334. const overlay = join(home, 'overlay.cordis.yml')
  335. writeFileSync(overlay, [
  336. '- id: agent-loop',
  337. ' config:',
  338. ' agents:',
  339. ' - id: configured',
  340. ' provider: configured-provider',
  341. ' model: configured-model',
  342. '',
  343. ].join('\n'))
  344. const { stdout, code, stderr } = await runBuiltBin(
  345. ['--profile', 'web', '--patch', overlay, '--dump-config'],
  346. { DSH_HOME: home },
  347. )
  348. expect(code).toBe(0)
  349. expect(stdout).toContain('provider: configured-provider')
  350. expect(stdout).not.toContain('personal-provider')
  351. // Both layers patched the row; provenance lists them in application order.
  352. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
  353. expect(stderr).toContain('patch: entry "absent-row" not found')
  354. }, 30_000)
  355. it('shows the RL Web patch disabling runtime surface context', async () => {
  356. const { stdout, code, stderr } = await runBuiltBin(
  357. ['web', '--patch', coreWebOverlay, '--dump-config'],
  358. { DSH_HOME: home },
  359. )
  360. expect(code).toBe(0)
  361. expect(stderr).toBe('')
  362. expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'")
  363. expect(stdout).toContain('surfaceContext: false')
  364. }, 30_000)
  365. })
  366. })