built-bin.e2e.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  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 { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  6. import { execa } from 'execa'
  7. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  8. /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
  9. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  10. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  11. const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
  12. async function runBuiltBin(
  13. args: readonly string[] = [],
  14. env: Readonly<Record<string, string | undefined>> = {},
  15. cwd?: string,
  16. ): Promise<{ stdout: string; code: number; stderr: string }> {
  17. const childEnv = Object.fromEntries(
  18. Object.entries({ ...process.env, ...env })
  19. .filter((entry): entry is [string, string] => entry[1] !== undefined),
  20. )
  21. const result = await execa(process.execPath, [dshBin, ...args], {
  22. input: '',
  23. timeout: 25_000,
  24. killSignal: 'SIGKILL',
  25. reject: false,
  26. env: childEnv,
  27. extendEnv: false,
  28. ...cwd === undefined ? {} : { cwd },
  29. })
  30. if (result.timedOut) {
  31. throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  32. }
  33. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  34. }
  35. async function waitForFile(file: string): Promise<void> {
  36. const deadline = Date.now() + 20_000
  37. while (!existsSync(file)) {
  38. if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
  39. await new Promise(resolve => setTimeout(resolve, 20))
  40. }
  41. }
  42. interface ProfileLifecycleFixture {
  43. home: string
  44. ready: string
  45. settled: string
  46. disposed: string
  47. interrupt: string
  48. }
  49. /**
  50. * A minimal custom profile: one lifecycle-marker plugin bundle listed in
  51. * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
  52. * booting the entire product tree.
  53. */
  54. function createProfileLifecycleFixture(): ProfileLifecycleFixture {
  55. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
  56. const ready = join(home, 'ready')
  57. const settled = join(home, 'settled')
  58. const disposed = join(home, 'disposed')
  59. const interrupt = join(home, 'interrupt')
  60. const bundleDir = join(home, 'lifecycle-bundle')
  61. mkdirSync(bundleDir, { recursive: true })
  62. writeFileSync(join(bundleDir, 'plugin.mjs'), [
  63. "import { existsSync, writeFileSync } from 'node:fs'",
  64. "import { join } from 'node:path'",
  65. "export const name = 'profile-lifecycle-fixture'",
  66. 'export function apply(ctx, config = {}) {',
  67. ' let active = true',
  68. ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
  69. ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
  70. ' let interrupted = false',
  71. ' const heartbeat = setInterval(() => {',
  72. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  73. ' interrupted = true',
  74. " process.emit('SIGTERM')",
  75. ' }, 20)',
  76. ' // Echo the mounted generation so the hot-reload e2e can assert both an',
  77. ' // applied override and its removal reverting to this bundle default.',
  78. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  79. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  80. ' void ctx.loader.await().then(() => {',
  81. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  82. ' })',
  83. ' ctx.effect(() => () => {',
  84. ' active = false',
  85. ' clearInterval(heartbeat)',
  86. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  87. ' })',
  88. '}',
  89. '',
  90. ].join('\n'))
  91. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  92. '- insert:',
  93. ' - id: profile-lifecycle-fixture',
  94. ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
  95. '',
  96. ].join('\n'))
  97. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  98. name: 'dsh-lifecycle-bundle',
  99. version: '0.0.0',
  100. type: 'module',
  101. dsh: { bundle: { patch: './cordis.patch.yml' } },
  102. }, undefined, 2))
  103. const profileDir = join(home, 'profiles', 'lifecycle')
  104. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  105. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  106. name: 'dsh-profile-lifecycle',
  107. private: true,
  108. dependencies: {},
  109. dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
  110. }, undefined, 2))
  111. // Hand-place the "installed" bundle where profile resolution finds it.
  112. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  113. const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
  114. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  115. try {
  116. rmSync(linkTarget, { recursive: true, force: true })
  117. } catch { /* fresh dir */ }
  118. // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
  119. mkdirSync(linkTarget, { recursive: true })
  120. for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
  121. writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
  122. }
  123. return { home, ready, settled, disposed, interrupt }
  124. }
  125. function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
  126. return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
  127. cwd: fixture.home,
  128. input: '',
  129. reject: false,
  130. env: {
  131. DSH_HOME: fixture.home,
  132. RAW_READY_FILE: fixture.ready,
  133. RAW_SETTLED_FILE: fixture.settled,
  134. RAW_DISPOSED_FILE: fixture.disposed,
  135. RAW_INTERRUPT_FILE: fixture.interrupt,
  136. },
  137. })
  138. }
  139. function requestProfileShutdown(
  140. child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
  141. fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
  142. ): void {
  143. if (process.platform === 'win32') {
  144. writeFileSync(fixture.interrupt, 'interrupt')
  145. return
  146. }
  147. child.kill('SIGTERM')
  148. }
  149. function createEnvironmentProbeProfile(home: string, project: string): void {
  150. const pluginFile = join(project, 'environment-probe.mjs')
  151. writeFileSync(pluginFile, [
  152. "export const name = 'environment-probe'",
  153. "export const inject = ['llm']",
  154. 'export function apply(ctx) {',
  155. ' void ctx.loader.await().then(async () => {',
  156. " let text = ''",
  157. ' for await (const chunk of ctx.llm.stream({',
  158. " provider: 'deepseek-official',",
  159. " model: 'deepseek-v4-flash',",
  160. ' messages: [],',
  161. ' maxTokens: 32,',
  162. ' })) {',
  163. " if (chunk.type === 'text-delta') text += chunk.text",
  164. ' }',
  165. ' process.stdout.write(`${text}\\n`)',
  166. " if (process.platform === 'win32') process.emit('SIGTERM')",
  167. " else process.kill(process.pid, 'SIGTERM')",
  168. ' })',
  169. '}',
  170. '',
  171. ].join('\n'))
  172. const profileDir = join(home, 'profiles', 'environment-probe')
  173. mkdirSync(profileDir, { recursive: true })
  174. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  175. name: 'dsh-profile-environment-probe',
  176. private: true,
  177. dependencies: {},
  178. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  179. }, undefined, 2))
  180. writeFileSync(join(profileDir, 'cordis.patch.yml'), [
  181. '- insert:',
  182. ' - id: environment-probe',
  183. ` name: ${pathToFileURL(pluginFile).href}`,
  184. '',
  185. ].join('\n'))
  186. }
  187. interface StartupFixture {
  188. home: string
  189. ready: string
  190. echo: string
  191. interrupt: string
  192. /** An always-running row's echo, used to observe that a user patch reload landed. */
  193. witness: string
  194. }
  195. /**
  196. * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
  197. * a row that reads its app-owned service through a `!!js` config expression.
  198. * Both plugin modules resolve
  199. * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
  200. * fallback, exactly as an installed out-of-tree bundle does.
  201. */
  202. function createStartupFixture(): StartupFixture {
  203. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
  204. const profileDir = join(home, 'profiles', 'startup')
  205. // Written straight into the installed location: a row module resolves its
  206. // own imports from where it is installed, and only inside the profile does
  207. // Node's parent walk reach the installation fallback these plugins need.
  208. const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
  209. mkdirSync(bundleDir, { recursive: true })
  210. writeFileSync(join(bundleDir, 'startup.mjs'), [
  211. "import { Command } from 'commander'",
  212. "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
  213. "export const name = 'fixture-startup'",
  214. "export const inject = ['cmdlineArgs']",
  215. 'export function apply(ctx) {',
  216. " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
  217. ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))',
  218. ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)',
  219. '}',
  220. '',
  221. ].join('\n'))
  222. writeFileSync(join(bundleDir, 'waiting.mjs'), [
  223. "import { existsSync, writeFileSync } from 'node:fs'",
  224. "import { join } from 'node:path'",
  225. "export const name = 'startup-fixture'",
  226. 'export function apply(ctx, config = {}) {',
  227. ' let interrupted = false',
  228. ' const heartbeat = setInterval(() => {',
  229. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  230. ' interrupted = true',
  231. " process.emit('SIGTERM')",
  232. ' }, 20)',
  233. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  234. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  235. ' ctx.effect(() => () => { clearInterval(heartbeat) })',
  236. '}',
  237. '',
  238. ].join('\n'))
  239. writeFileSync(join(bundleDir, 'witness.mjs'), [
  240. "import { writeFileSync } from 'node:fs'",
  241. "import { join } from 'node:path'",
  242. "export const name = 'reload-witness'",
  243. 'export function apply(ctx, config = {}) {',
  244. " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
  245. '}',
  246. '',
  247. ].join('\n'))
  248. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  249. '- insert:',
  250. ' - id: startup-fixture',
  251. ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
  252. ' inject: [fixtureStartup]',
  253. ' config:',
  254. // Lazy interpolation runs only after the provider's service is injected.
  255. " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
  256. ' - id: fixture-startup',
  257. ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
  258. ' - id: reload-witness',
  259. ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
  260. '',
  261. ].join('\n'))
  262. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  263. name: 'dsh-startup-bundle',
  264. version: '0.0.0',
  265. type: 'module',
  266. dsh: { bundle: { patch: './cordis.patch.yml' } },
  267. }, undefined, 2))
  268. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  269. name: 'dsh-profile-startup',
  270. private: true,
  271. dependencies: {},
  272. dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
  273. }, undefined, 2))
  274. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  275. return {
  276. home,
  277. ready: join(home, 'ready'),
  278. echo: join(home, 'config-echo'),
  279. interrupt: join(home, 'interrupt'),
  280. witness: join(home, 'witness'),
  281. }
  282. }
  283. function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
  284. return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
  285. cwd: fixture.home,
  286. input: '',
  287. reject: false,
  288. timeout: 25_000,
  289. killSignal: 'SIGKILL',
  290. env: {
  291. DSH_HOME: fixture.home,
  292. RAW_READY_FILE: fixture.ready,
  293. RAW_INTERRUPT_FILE: fixture.interrupt,
  294. },
  295. })
  296. }
  297. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  298. it('requires --profile and rejects removed commands', async () => {
  299. const bare = await runBuiltBin()
  300. expect(bare.code).toBe(1)
  301. expect(bare.stdout).toBe('')
  302. expect(bare.stderr).toContain('--profile <name> is required')
  303. const help = await runBuiltBin(['--help'])
  304. expect(help.code).toBe(0)
  305. expect(help.stdout).toContain('dsh --profile web')
  306. expect(help.stdout).toContain('dsh plugin --profile')
  307. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  308. for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
  309. const result = await runBuiltBin(removed)
  310. expect(result.code).toBe(1)
  311. }
  312. }, 30_000)
  313. it('routes help and usage errors without activating startup-dependent rows', async () => {
  314. const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
  315. try {
  316. const web = await runBuiltBin(['--profile', 'web', '--help'], {
  317. DSH_HOME: home,
  318. DSH_TELEMETRY_DISABLED: '1',
  319. })
  320. expect(web.code).toBe(0)
  321. expect(web.stderr).toBe('')
  322. expect(web.stdout).toContain('Usage: dsh --profile web')
  323. expect(web.stdout).toContain('--port <port>')
  324. expect(web.stdout).not.toContain('dsh web: http://')
  325. const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
  326. DSH_HOME: home,
  327. DSH_TELEMETRY_DISABLED: '1',
  328. })
  329. expect(headlessHelp.code).toBe(0)
  330. expect(headlessHelp.stderr).toBe('')
  331. expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
  332. const missingTask = await runBuiltBin(['--profile', 'headless'], {
  333. DSH_HOME: home,
  334. DSH_TELEMETRY_DISABLED: '1',
  335. })
  336. expect(missingTask.code).toBe(1)
  337. expect(missingTask.stderr).toContain('a task is required')
  338. } finally {
  339. rmSync(home, { recursive: true, force: true })
  340. }
  341. }, 30_000)
  342. it('runs the headless profile through its app-owned task positional', async () => {
  343. const apiKey = 'built-dsh-headless-key'
  344. const server = await startMockLlmServer({
  345. sequence: ['success'],
  346. apiKey,
  347. successText: 'published headless profile reached the mock',
  348. })
  349. const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
  350. try {
  351. const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
  352. DSH_HOME: home,
  353. DSH_TELEMETRY_DISABLED: '1',
  354. DEEPSEEK_API_KEY: apiKey,
  355. DEEPSEEK_BASE_URL: server.baseURL,
  356. })
  357. expect(result.code, result.stderr).toBe(0)
  358. expect(result.stdout).toBe('published headless profile reached the mock')
  359. expect(result.stderr).toBe('')
  360. expect(server.requests.length).toBeGreaterThan(0)
  361. expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
  362. expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
  363. } finally {
  364. await server.close()
  365. rmSync(home, { recursive: true, force: true })
  366. }
  367. }, 30_000)
  368. it('does not load a project environment for --version', async () => {
  369. const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
  370. writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
  371. try {
  372. const result = await runBuiltBin(['--version'], {}, project)
  373. expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
  374. } finally {
  375. rmSync(project, { recursive: true, force: true })
  376. }
  377. })
  378. it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
  379. const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
  380. try {
  381. const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
  382. expect(result.code).toBe(1)
  383. expect(result.stderr).toContain('profile "nope" does not exist')
  384. expect(result.stderr).toContain('dsh plugin --profile nope add')
  385. } finally {
  386. rmSync(home, { recursive: true, force: true })
  387. }
  388. }, 30_000)
  389. it('uses the Harness-home environment and managed credential through the published entry', async () => {
  390. const apiKey = 'built-home-layer-key'
  391. const server = await startMockLlmServer({
  392. sequence: ['success'],
  393. apiKey,
  394. successText: 'home environment reached the mock',
  395. })
  396. const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
  397. const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
  398. writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
  399. writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
  400. createEnvironmentProbeProfile(home, project)
  401. try {
  402. const result = await runBuiltBin(
  403. ['--profile', 'environment-probe'],
  404. {
  405. DSH_HOME: home,
  406. DSH_TELEMETRY_DISABLED: '1',
  407. DEEPSEEK_API_KEY: undefined,
  408. DEEPSEEK_BASE_URL: undefined,
  409. },
  410. project,
  411. )
  412. expect(
  413. result.code,
  414. `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
  415. ).toBe(0)
  416. expect(result.stdout).toBe('home environment reached the mock')
  417. expect(result.stdout).not.toContain(apiKey)
  418. expect(result.stderr).not.toContain(apiKey)
  419. expect(server.requests).toHaveLength(1)
  420. expect(server.requests[0]?.path).toBe('/chat/completions')
  421. expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
  422. expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
  423. } finally {
  424. await server.close()
  425. rmSync(home, { recursive: true, force: true })
  426. rmSync(project, { recursive: true, force: true })
  427. }
  428. }, 30_000)
  429. it('reports a patch-overlay boot failure without hanging', async () => {
  430. // The HMR main watcher's initial scan once refreshed the include
  431. // mid-initial-apply, deadlocking the failing apply's rollback against the
  432. // refresh drain: dsh exited 13 with no diagnostic instead of settling
  433. // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
  434. const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
  435. try {
  436. const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
  437. DSH_HOME: home,
  438. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  439. DSH_TELEMETRY_DISABLED: '1',
  440. })
  441. expect(result.code).toBe(1)
  442. expect(result.stdout).toBe('')
  443. expect(result.stderr).toContain('llm-pi-ai')
  444. } finally {
  445. rmSync(home, { recursive: true, force: true })
  446. }
  447. }, 30_000)
  448. it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
  449. const fixture = createProfileLifecycleFixture()
  450. const child = startProfileLifecycle(fixture, ['--unclaimed'])
  451. try {
  452. await waitForFile(fixture.ready)
  453. requestProfileShutdown(child, fixture)
  454. const result = await child
  455. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  456. expect(result.signal).toBeUndefined()
  457. expect(existsSync(fixture.disposed)).toBe(true)
  458. } finally {
  459. child.kill('SIGKILL')
  460. rmSync(fixture.home, { recursive: true, force: true })
  461. }
  462. }, 30_000)
  463. it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
  464. const fixture = createProfileLifecycleFixture()
  465. const child = startProfileLifecycle(fixture)
  466. const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
  467. const configFile = join(fixture.home, 'config-echo')
  468. try {
  469. await waitForFile(fixture.settled)
  470. // The live profile layer: even without an hmr row in the composition,
  471. // the launcher mounts a config-only watcher, so an edited
  472. // cordis.patch.yml lands in the running tree (the reload disposes the
  473. // patched row's old fiber — observable as the disposed marker — and
  474. // mounts the new config, which echoes its generation and re-writes the
  475. // ready marker).
  476. rmSync(fixture.ready)
  477. writeFileSync(profilePatch, [
  478. '- id: profile-lifecycle-fixture',
  479. ' config:',
  480. ' generation: 2',
  481. '',
  482. ].join('\n'))
  483. await waitForFile(fixture.ready)
  484. expect(readFileSync(configFile, 'utf8')).toBe('2')
  485. // Removal reverts: the bundle's inserted row must return to its own
  486. // default config, not keep the removed override — the insert-aliasing
  487. // regression (a shared patch object mutated in place by a former
  488. // generation would make this impossible).
  489. rmSync(fixture.ready)
  490. writeFileSync(profilePatch, '[]\n')
  491. await waitForFile(fixture.ready)
  492. expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
  493. // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
  494. // and outranks the per-profile layer.
  495. rmSync(fixture.ready)
  496. writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
  497. '- id: profile-lifecycle-fixture',
  498. ' config:',
  499. ' generation: home',
  500. '',
  501. ].join('\n'))
  502. await waitForFile(fixture.ready)
  503. expect(readFileSync(configFile, 'utf8')).toBe('home')
  504. requestProfileShutdown(child, fixture)
  505. const result = await child
  506. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  507. expect(result.signal).toBeUndefined()
  508. expect(existsSync(fixture.disposed)).toBe(true)
  509. } finally {
  510. child.kill('SIGKILL')
  511. rmSync(fixture.home, { recursive: true, force: true })
  512. }
  513. }, 30_000)
  514. it('hands the app arguments to the profile, which applies them before its rows start', async () => {
  515. const fixture = createStartupFixture()
  516. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  517. try {
  518. await waitForFile(fixture.ready)
  519. // The consumer started once, already carrying the flag value: the
  520. // launcher never saw --generation, and the app provider resolved it first.
  521. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  522. requestProfileShutdown(child, fixture)
  523. expect((await child).exitCode).toBe(0)
  524. } finally {
  525. child.kill('SIGKILL')
  526. rmSync(fixture.home, { recursive: true, force: true })
  527. }
  528. }, 30_000)
  529. it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
  530. const fixture = createStartupFixture()
  531. const child = startStartupProfile(fixture, [])
  532. try {
  533. await waitForFile(fixture.ready)
  534. expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
  535. requestProfileShutdown(child, fixture)
  536. expect((await child).exitCode).toBe(0)
  537. } finally {
  538. child.kill('SIGKILL')
  539. rmSync(fixture.home, { recursive: true, force: true })
  540. }
  541. }, 30_000)
  542. it('keeps the app arguments across a user patch reload', async () => {
  543. // A live edit recomposes every row while the provider service remains
  544. // active, so each config expression reads the same invocation value (a
  545. // served port does not move back to its composed fallback).
  546. const fixture = createStartupFixture()
  547. const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
  548. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  549. try {
  550. // Both rows: the waiting one carries the flag value, and the witness is
  551. // what a reload will re-mount. They start independently, so neither
  552. // marker implies the other.
  553. await waitForFile(fixture.ready)
  554. await waitForFile(fixture.witness)
  555. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  556. // An edit to an unrelated row: the witness re-mounts, which is how this
  557. // test knows the whole tree was recomposed.
  558. rmSync(fixture.witness)
  559. writeFileSync(profilePatch, [
  560. '- id: reload-witness',
  561. ' config:',
  562. ' generation: reloaded',
  563. '',
  564. ].join('\n'))
  565. await waitForFile(fixture.witness)
  566. expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
  567. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  568. requestProfileShutdown(child, fixture)
  569. expect((await child).exitCode).toBe(0)
  570. } finally {
  571. child.kill('SIGKILL')
  572. rmSync(fixture.home, { recursive: true, force: true })
  573. }
  574. }, 30_000)
  575. it("prints the app's own help, starts none of its rows, and exits", async () => {
  576. const fixture = createStartupFixture()
  577. try {
  578. const result = await startStartupProfile(fixture, ['--help'])
  579. expect(result.exitCode).toBe(0)
  580. expect(result.stdout).toContain('Usage: fixture')
  581. expect(result.stdout).toContain('--generation')
  582. expect(existsSync(fixture.ready)).toBe(false)
  583. } finally {
  584. rmSync(fixture.home, { recursive: true, force: true })
  585. }
  586. }, 30_000)
  587. it('anchors a relative add spec to the invoking directory, not the profile', async () => {
  588. // `dsh plugin --profile x add .` from a plugin checkout must install THAT
  589. // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
  590. // would self-link the profile.
  591. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
  592. const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
  593. try {
  594. writeFileSync(join(checkout, 'package.json'), JSON.stringify({
  595. name: 'anchored-bundle',
  596. version: '1.0.0',
  597. dsh: { bundle: { patch: './cordis.patch.yml' } },
  598. }))
  599. writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
  600. const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
  601. cwd: checkout,
  602. input: '',
  603. timeout: 60_000,
  604. killSignal: 'SIGKILL',
  605. reject: false,
  606. env: { DSH_HOME: home },
  607. })
  608. expect(result.exitCode).toBe(0)
  609. const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
  610. dependencies: Record<string, string>
  611. dsh: { profile: { bundles: string[] } }
  612. }
  613. expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
  614. expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
  615. } finally {
  616. rmSync(home, { recursive: true, force: true })
  617. rmSync(checkout, { recursive: true, force: true })
  618. }
  619. }, 90_000)
  620. it('activates a dependency that gained dsh.bundle in a later update', async () => {
  621. // Reconcile runs against the INSTALLED state on every successful pnpm
  622. // run, so `update` (not only `add`) activates a package whose newer
  623. // version declares dsh.bundle. Simulated without a registry: hand-place
  624. // the installed package, flip its manifest, and run a benign pnpm verb.
  625. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
  626. try {
  627. const profileDir = join(home, 'profiles', 'up')
  628. const installed = join(profileDir, 'node_modules', 'late-bundle')
  629. mkdirSync(installed, { recursive: true })
  630. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  631. name: 'dsh-profile-up',
  632. private: true,
  633. dependencies: { 'late-bundle': 'file:./late-bundle' },
  634. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  635. }))
  636. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  637. // v1: no dsh manifest — a plain dependency.
  638. writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
  639. const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  640. expect(first.code).toBe(0)
  641. let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  642. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
  643. // v2: the installed package now declares dsh.bundle (an update landed).
  644. writeFileSync(join(installed, 'package.json'), JSON.stringify({
  645. name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
  646. }))
  647. writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
  648. const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  649. expect(second.code).toBe(0)
  650. manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  651. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
  652. } finally {
  653. rmSync(home, { recursive: true, force: true })
  654. }
  655. }, 30_000)
  656. describe('config dump', () => {
  657. let home: string
  658. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  659. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  660. it('prints the web profile bundle layers without a user layer', async () => {
  661. const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  662. expect(code).toBe(0)
  663. expect(stderr).toBe('')
  664. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  665. expect(stdout).toContain('agents: []')
  666. expect(stdout).toContain('# == @deepseek-ai/dsh-base')
  667. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  668. }, 30_000)
  669. it('prints the headless profile without Host or browser layers', async () => {
  670. const { stdout, code, stderr } = await runBuiltBin(
  671. ['--profile', 'headless', '--dump-default-config'],
  672. { DSH_HOME: home },
  673. )
  674. expect(code).toBe(0)
  675. expect(stderr).toBe('')
  676. expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
  677. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
  678. expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
  679. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
  680. }, 30_000)
  681. it('composes the profile user layer and a --patch overlay in order', async () => {
  682. // Auto-init the web profile first, then write its user layer.
  683. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  684. expect(init.code).toBe(0)
  685. const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
  686. writeFileSync(profilePatch, [
  687. '- id: agent-loop',
  688. ' config:',
  689. ' agents:',
  690. ' - id: personal',
  691. ' provider: personal-provider',
  692. ' model: personal-model',
  693. '- id: absent-row',
  694. ' config:',
  695. ' x: 1',
  696. '',
  697. ].join('\n'))
  698. const overlay = join(home, 'overlay.cordis.yml')
  699. writeFileSync(overlay, [
  700. '- id: agent-loop',
  701. ' config:',
  702. ' agents:',
  703. ' - id: configured',
  704. ' provider: configured-provider',
  705. ' model: configured-model',
  706. '',
  707. ].join('\n'))
  708. const { stdout, code, stderr } = await runBuiltBin(
  709. ['--profile', 'web', '--patch', overlay, '--dump-config'],
  710. { DSH_HOME: home },
  711. )
  712. expect(code).toBe(0)
  713. expect(stdout).toContain('provider: configured-provider')
  714. expect(stdout).not.toContain('personal-provider')
  715. // Both layers patched the row; the comment lists them in application order.
  716. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
  717. expect(stderr).toContain('patch: entry "absent-row" not found')
  718. }, 30_000)
  719. })
  720. })