built-bin.e2e.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  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 coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url))
  12. const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
  13. async function runBuiltBin(
  14. args: readonly string[] = [],
  15. env: Readonly<Record<string, string | undefined>> = {},
  16. cwd?: string,
  17. ): Promise<{ stdout: string; code: number; stderr: string }> {
  18. const childEnv = Object.fromEntries(
  19. Object.entries({ ...process.env, ...env })
  20. .filter((entry): entry is [string, string] => entry[1] !== undefined),
  21. )
  22. const result = await execa(process.execPath, [dshBin, ...args], {
  23. input: '',
  24. timeout: 25_000,
  25. killSignal: 'SIGKILL',
  26. reject: false,
  27. env: childEnv,
  28. extendEnv: false,
  29. ...cwd === undefined ? {} : { cwd },
  30. })
  31. if (result.timedOut) {
  32. throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  33. }
  34. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  35. }
  36. async function waitForFile(file: string): Promise<void> {
  37. const deadline = Date.now() + 20_000
  38. while (!existsSync(file)) {
  39. if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
  40. await new Promise(resolve => setTimeout(resolve, 20))
  41. }
  42. }
  43. interface ProfileLifecycleFixture {
  44. home: string
  45. ready: string
  46. settled: string
  47. disposed: string
  48. interrupt: string
  49. }
  50. /**
  51. * A minimal custom profile: one lifecycle-marker plugin bundle listed in
  52. * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
  53. * booting the entire product tree.
  54. */
  55. function createProfileLifecycleFixture(): ProfileLifecycleFixture {
  56. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
  57. const ready = join(home, 'ready')
  58. const settled = join(home, 'settled')
  59. const disposed = join(home, 'disposed')
  60. const interrupt = join(home, 'interrupt')
  61. const bundleDir = join(home, 'lifecycle-bundle')
  62. mkdirSync(bundleDir, { recursive: true })
  63. writeFileSync(join(bundleDir, 'plugin.mjs'), [
  64. "import { existsSync, writeFileSync } from 'node:fs'",
  65. "import { join } from 'node:path'",
  66. "export const name = 'profile-lifecycle-fixture'",
  67. 'export function apply(ctx, config = {}) {',
  68. ' let active = true',
  69. ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
  70. ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
  71. ' let interrupted = false',
  72. ' const heartbeat = setInterval(() => {',
  73. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  74. ' interrupted = true',
  75. " process.emit('SIGTERM')",
  76. ' }, 20)',
  77. ' // Echo the mounted generation so the hot-reload e2e can assert both an',
  78. ' // applied override and its removal reverting to this bundle default.',
  79. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  80. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  81. ' void ctx.loader.await().then(() => {',
  82. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  83. ' })',
  84. ' ctx.effect(() => () => {',
  85. ' active = false',
  86. ' clearInterval(heartbeat)',
  87. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  88. ' })',
  89. '}',
  90. '',
  91. ].join('\n'))
  92. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  93. '- insert:',
  94. ' - id: profile-lifecycle-fixture',
  95. ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
  96. '',
  97. ].join('\n'))
  98. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  99. name: 'dsh-lifecycle-bundle',
  100. version: '0.0.0',
  101. type: 'module',
  102. dsh: { bundle: { patch: './cordis.patch.yml' } },
  103. }, undefined, 2))
  104. const profileDir = join(home, 'profiles', 'lifecycle')
  105. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  106. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  107. name: 'dsh-profile-lifecycle',
  108. private: true,
  109. dependencies: {},
  110. dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
  111. }, undefined, 2))
  112. // Hand-place the "installed" bundle where profile resolution finds it.
  113. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  114. const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
  115. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  116. try {
  117. rmSync(linkTarget, { recursive: true, force: true })
  118. } catch { /* fresh dir */ }
  119. // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
  120. mkdirSync(linkTarget, { recursive: true })
  121. for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
  122. writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
  123. }
  124. return { home, ready, settled, disposed, interrupt }
  125. }
  126. function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
  127. return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
  128. cwd: fixture.home,
  129. input: '',
  130. reject: false,
  131. env: {
  132. DSH_HOME: fixture.home,
  133. RAW_READY_FILE: fixture.ready,
  134. RAW_SETTLED_FILE: fixture.settled,
  135. RAW_DISPOSED_FILE: fixture.disposed,
  136. RAW_INTERRUPT_FILE: fixture.interrupt,
  137. },
  138. })
  139. }
  140. function requestProfileShutdown(
  141. child: ReturnType<typeof startProfileLifecycle>,
  142. fixture: ProfileLifecycleFixture,
  143. ): void {
  144. if (process.platform === 'win32') {
  145. writeFileSync(fixture.interrupt, 'interrupt')
  146. return
  147. }
  148. child.kill('SIGTERM')
  149. }
  150. function createEnvironmentProbeProfile(home: string, project: string): void {
  151. const pluginFile = join(project, 'environment-probe.mjs')
  152. writeFileSync(pluginFile, [
  153. "export const name = 'environment-probe'",
  154. "export const inject = ['llm']",
  155. 'export function apply(ctx) {',
  156. ' void ctx.loader.await().then(async () => {',
  157. " let text = ''",
  158. ' for await (const chunk of ctx.llm.stream({',
  159. " provider: 'deepseek-official',",
  160. " model: 'deepseek-v4-flash',",
  161. ' messages: [],',
  162. ' maxTokens: 32,',
  163. ' })) {',
  164. " if (chunk.type === 'text-delta') text += chunk.text",
  165. ' }',
  166. ' process.stdout.write(`${text}\\n`)',
  167. " if (process.platform === 'win32') process.emit('SIGTERM')",
  168. " else process.kill(process.pid, 'SIGTERM')",
  169. ' })',
  170. '}',
  171. '',
  172. ].join('\n'))
  173. const profileDir = join(home, 'profiles', 'environment-probe')
  174. mkdirSync(profileDir, { recursive: true })
  175. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  176. name: 'dsh-profile-environment-probe',
  177. private: true,
  178. dependencies: {},
  179. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  180. }, undefined, 2))
  181. writeFileSync(join(profileDir, 'cordis.patch.yml'), [
  182. '- insert:',
  183. ' - id: environment-probe',
  184. ` name: ${pathToFileURL(pluginFile).href}`,
  185. '',
  186. ].join('\n'))
  187. }
  188. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  189. it('requires --profile and rejects removed commands', async () => {
  190. const bare = await runBuiltBin()
  191. expect(bare.code).toBe(1)
  192. expect(bare.stdout).toBe('')
  193. expect(bare.stderr).toContain('--profile <name> is required')
  194. const help = await runBuiltBin(['--help'])
  195. expect(help.code).toBe(0)
  196. expect(help.stdout).toContain('dsh --profile web')
  197. expect(help.stdout).toContain('dsh run "run the tests"')
  198. expect(help.stdout).toContain('dsh plugin --profile')
  199. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  200. for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
  201. const result = await runBuiltBin(removed)
  202. expect(result.code).toBe(1)
  203. }
  204. }, 30_000)
  205. it('prints run help without initializing the selected profile', async () => {
  206. const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-'))
  207. const home = join(parent, 'not-created')
  208. try {
  209. const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home })
  210. expect(result.code).toBe(0)
  211. expect(result.stderr).toBe('')
  212. expect(result.stdout).toContain('Usage: dsh run [options] <task...>')
  213. expect(existsSync(home)).toBe(false)
  214. } finally {
  215. rmSync(parent, { recursive: true, force: true })
  216. }
  217. })
  218. it('runs the default headless profile through the published run command', async () => {
  219. const apiKey = 'built-dsh-run-key'
  220. const server = await startMockLlmServer({
  221. sequence: ['success'],
  222. apiKey,
  223. successText: 'published dsh run reached the mock',
  224. })
  225. const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
  226. try {
  227. const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], {
  228. DSH_HOME: home,
  229. DSH_TELEMETRY_DISABLED: '1',
  230. DEEPSEEK_API_KEY: apiKey,
  231. DEEPSEEK_BASE_URL: server.baseURL,
  232. })
  233. expect(result.code, result.stderr).toBe(0)
  234. expect(result.stdout).toBe('published dsh run reached the mock')
  235. expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+$/u)
  236. expect(server.requests.length).toBeGreaterThan(0)
  237. expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
  238. expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
  239. } finally {
  240. await server.close()
  241. rmSync(home, { recursive: true, force: true })
  242. }
  243. }, 30_000)
  244. it('does not load a project environment for --version', async () => {
  245. const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
  246. writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
  247. try {
  248. const result = await runBuiltBin(['--version'], {}, project)
  249. expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
  250. } finally {
  251. rmSync(project, { recursive: true, force: true })
  252. }
  253. })
  254. it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
  255. const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
  256. try {
  257. const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
  258. expect(result.code).toBe(1)
  259. expect(result.stderr).toContain('profile "nope" does not exist')
  260. expect(result.stderr).toContain('dsh plugin --profile nope add')
  261. } finally {
  262. rmSync(home, { recursive: true, force: true })
  263. }
  264. }, 30_000)
  265. it('uses the Harness-home environment and managed credential through the published entry', async () => {
  266. const apiKey = 'built-home-layer-key'
  267. const server = await startMockLlmServer({
  268. sequence: ['success'],
  269. apiKey,
  270. successText: 'home environment reached the mock',
  271. })
  272. const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
  273. const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
  274. writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
  275. writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
  276. createEnvironmentProbeProfile(home, project)
  277. try {
  278. const result = await runBuiltBin(
  279. ['--profile', 'environment-probe'],
  280. {
  281. DSH_HOME: home,
  282. DSH_TELEMETRY_DISABLED: '1',
  283. DEEPSEEK_API_KEY: undefined,
  284. DEEPSEEK_BASE_URL: undefined,
  285. },
  286. project,
  287. )
  288. expect(
  289. result.code,
  290. `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
  291. ).toBe(0)
  292. expect(result.stdout).toBe('home environment reached the mock')
  293. expect(result.stdout).not.toContain(apiKey)
  294. expect(result.stderr).not.toContain(apiKey)
  295. expect(server.requests).toHaveLength(1)
  296. expect(server.requests[0]?.path).toBe('/chat/completions')
  297. expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
  298. expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
  299. } finally {
  300. await server.close()
  301. rmSync(home, { recursive: true, force: true })
  302. rmSync(project, { recursive: true, force: true })
  303. }
  304. }, 30_000)
  305. it('reports a patch-overlay boot failure without hanging', async () => {
  306. // The HMR main watcher's initial scan once refreshed the include
  307. // mid-initial-apply, deadlocking the failing apply's rollback against the
  308. // refresh drain: dsh exited 13 with no diagnostic instead of settling
  309. // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
  310. const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
  311. try {
  312. const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
  313. DSH_HOME: home,
  314. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  315. DSH_TELEMETRY_DISABLED: '1',
  316. })
  317. expect(result.code).toBe(1)
  318. expect(result.stdout).toBe('')
  319. expect(result.stderr).toContain('llm-pi-ai')
  320. } finally {
  321. rmSync(home, { recursive: true, force: true })
  322. }
  323. }, 30_000)
  324. it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
  325. const fixture = createProfileLifecycleFixture()
  326. const child = startProfileLifecycle(fixture)
  327. try {
  328. await waitForFile(fixture.ready)
  329. requestProfileShutdown(child, fixture)
  330. const result = await child
  331. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  332. expect(result.signal).toBeUndefined()
  333. expect(existsSync(fixture.disposed)).toBe(true)
  334. } finally {
  335. child.kill('SIGKILL')
  336. rmSync(fixture.home, { recursive: true, force: true })
  337. }
  338. }, 30_000)
  339. it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
  340. const fixture = createProfileLifecycleFixture()
  341. const child = startProfileLifecycle(fixture)
  342. const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
  343. const configFile = join(fixture.home, 'config-echo')
  344. try {
  345. await waitForFile(fixture.settled)
  346. // The live profile layer: even without an hmr row in the composition,
  347. // the launcher mounts a config-only watcher, so an edited
  348. // cordis.patch.yml lands in the running tree (the reload disposes the
  349. // patched row's old fiber — observable as the disposed marker — and
  350. // mounts the new config, which echoes its generation and re-writes the
  351. // ready marker).
  352. rmSync(fixture.ready)
  353. writeFileSync(profilePatch, [
  354. '- id: profile-lifecycle-fixture',
  355. ' config:',
  356. ' generation: 2',
  357. '',
  358. ].join('\n'))
  359. await waitForFile(fixture.ready)
  360. expect(readFileSync(configFile, 'utf8')).toBe('2')
  361. // Removal reverts: the bundle's inserted row must return to its own
  362. // default config, not keep the removed override — the insert-aliasing
  363. // regression (a shared patch object mutated in place by a former
  364. // generation would make this impossible).
  365. rmSync(fixture.ready)
  366. writeFileSync(profilePatch, '[]\n')
  367. await waitForFile(fixture.ready)
  368. expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
  369. // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
  370. // and outranks the per-profile layer.
  371. rmSync(fixture.ready)
  372. writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
  373. '- id: profile-lifecycle-fixture',
  374. ' config:',
  375. ' generation: home',
  376. '',
  377. ].join('\n'))
  378. await waitForFile(fixture.ready)
  379. expect(readFileSync(configFile, 'utf8')).toBe('home')
  380. requestProfileShutdown(child, fixture)
  381. const result = await child
  382. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  383. expect(result.signal).toBeUndefined()
  384. expect(existsSync(fixture.disposed)).toBe(true)
  385. } finally {
  386. child.kill('SIGKILL')
  387. rmSync(fixture.home, { recursive: true, force: true })
  388. }
  389. }, 30_000)
  390. it('anchors a relative add spec to the invoking directory, not the profile', async () => {
  391. // `dsh plugin --profile x add .` from a plugin checkout must install THAT
  392. // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
  393. // would self-link the profile.
  394. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
  395. const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
  396. try {
  397. writeFileSync(join(checkout, 'package.json'), JSON.stringify({
  398. name: 'anchored-bundle',
  399. version: '1.0.0',
  400. dsh: { bundle: { patch: './cordis.patch.yml' } },
  401. }))
  402. writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
  403. const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
  404. cwd: checkout,
  405. input: '',
  406. timeout: 60_000,
  407. killSignal: 'SIGKILL',
  408. reject: false,
  409. env: { DSH_HOME: home },
  410. })
  411. expect(result.exitCode).toBe(0)
  412. const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
  413. dependencies: Record<string, string>
  414. dsh: { profile: { bundles: string[] } }
  415. }
  416. expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
  417. expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
  418. } finally {
  419. rmSync(home, { recursive: true, force: true })
  420. rmSync(checkout, { recursive: true, force: true })
  421. }
  422. }, 90_000)
  423. it('activates a dependency that gained dsh.bundle in a later update', async () => {
  424. // Reconcile runs against the INSTALLED state on every successful pnpm
  425. // run, so `update` (not only `add`) activates a package whose newer
  426. // version declares dsh.bundle. Simulated without a registry: hand-place
  427. // the installed package, flip its manifest, and run a benign pnpm verb.
  428. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
  429. try {
  430. const profileDir = join(home, 'profiles', 'up')
  431. const installed = join(profileDir, 'node_modules', 'late-bundle')
  432. mkdirSync(installed, { recursive: true })
  433. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  434. name: 'dsh-profile-up',
  435. private: true,
  436. dependencies: { 'late-bundle': 'file:./late-bundle' },
  437. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  438. }))
  439. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  440. // v1: no dsh manifest — a plain dependency.
  441. writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
  442. const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  443. expect(first.code).toBe(0)
  444. let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  445. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
  446. // v2: the installed package now declares dsh.bundle (an update landed).
  447. writeFileSync(join(installed, 'package.json'), JSON.stringify({
  448. name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
  449. }))
  450. writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
  451. const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  452. expect(second.code).toBe(0)
  453. manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  454. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
  455. } finally {
  456. rmSync(home, { recursive: true, force: true })
  457. }
  458. }, 30_000)
  459. describe('config dump', () => {
  460. let home: string
  461. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  462. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  463. it('prints the web profile bundle layers without a user layer', async () => {
  464. const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  465. expect(code).toBe(0)
  466. expect(stderr).toBe('')
  467. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  468. expect(stdout).toContain('agents: []')
  469. expect(stdout).toContain('# == @deepseek-ai/dsh-base')
  470. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  471. }, 30_000)
  472. it('composes the profile user layer and a --patch overlay in order', async () => {
  473. // Auto-init the web profile first, then write its user layer.
  474. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  475. expect(init.code).toBe(0)
  476. const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
  477. writeFileSync(profilePatch, [
  478. '- id: agent-loop',
  479. ' config:',
  480. ' agents:',
  481. ' - id: personal',
  482. ' provider: personal-provider',
  483. ' model: personal-model',
  484. '- id: absent-row',
  485. ' config:',
  486. ' x: 1',
  487. '',
  488. ].join('\n'))
  489. const overlay = join(home, 'overlay.cordis.yml')
  490. writeFileSync(overlay, [
  491. '- id: agent-loop',
  492. ' config:',
  493. ' agents:',
  494. ' - id: configured',
  495. ' provider: configured-provider',
  496. ' model: configured-model',
  497. '',
  498. ].join('\n'))
  499. const { stdout, code, stderr } = await runBuiltBin(
  500. ['--profile', 'web', '--patch', overlay, '--dump-config'],
  501. { DSH_HOME: home },
  502. )
  503. expect(code).toBe(0)
  504. expect(stdout).toContain('provider: configured-provider')
  505. expect(stdout).not.toContain('personal-provider')
  506. // Both layers patched the row; provenance lists them in application order.
  507. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
  508. expect(stderr).toContain('patch: entry "absent-row" not found')
  509. }, 30_000)
  510. it('shows the RL Web patch disabling runtime surface context', async () => {
  511. const { stdout, code, stderr } = await runBuiltBin(
  512. ['web', '--patch', coreWebOverlay, '--dump-config'],
  513. { DSH_HOME: home },
  514. )
  515. expect(code).toBe(0)
  516. expect(stderr).toBe('')
  517. expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'")
  518. expect(stdout).toContain('surfaceContext: false')
  519. }, 30_000)
  520. })
  521. })