built-bin.e2e.ts 23 KB

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