built-bin.e2e.ts 32 KB

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