built-bin.e2e.ts 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039
  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 { createInterface } from 'node:readline'
  5. import { Readable, Writable } from 'node:stream'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import {
  8. client as createAcpClientApp,
  9. methods,
  10. ndJsonStream,
  11. PROTOCOL_VERSION,
  12. type SessionNotification,
  13. } from '@agentclientprotocol/sdk'
  14. import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  15. import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
  16. import { execa } from 'execa'
  17. import * as yaml from 'js-yaml'
  18. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  19. /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
  20. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  21. // The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the
  22. // execa deadline, its error text, the outer vitest case budget, and waitForFile all
  23. // share this value so a widening cannot leave a stale 25s diagnostic behind.
  24. const SPAWN_TIMEOUT_MS = 60_000
  25. // The release version, including a prerelease such as 0.0.1-rc.1: `--version`
  26. // prints what this manifest carries, so no test may pin it to a literal.
  27. const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
  28. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  29. const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
  30. async function runBuiltBin(
  31. args: readonly string[] = [],
  32. env: Readonly<Record<string, string | undefined>> = {},
  33. cwd?: string,
  34. ): Promise<{ stdout: string; code: number; stderr: string }> {
  35. const childEnv = Object.fromEntries(
  36. Object.entries({ ...process.env, ...env })
  37. .filter((entry): entry is [string, string] => entry[1] !== undefined),
  38. )
  39. const result = await execa(process.execPath, [dshBin, ...args], {
  40. input: '',
  41. timeout: SPAWN_TIMEOUT_MS,
  42. killSignal: 'SIGKILL',
  43. reject: false,
  44. env: childEnv,
  45. extendEnv: false,
  46. ...cwd === undefined ? {} : { cwd },
  47. })
  48. if (result.timedOut) {
  49. throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  50. }
  51. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  52. }
  53. async function waitForFile(file: string): Promise<void> {
  54. const deadline = Date.now() + SPAWN_TIMEOUT_MS
  55. while (!existsSync(file)) {
  56. if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
  57. await new Promise(resolve => setTimeout(resolve, 20))
  58. }
  59. }
  60. interface ProfileLifecycleFixture {
  61. home: string
  62. ready: string
  63. settled: string
  64. disposed: string
  65. interrupt: string
  66. }
  67. /**
  68. * A minimal custom profile: one lifecycle-marker plugin bundle listed in
  69. * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
  70. * booting the entire product tree.
  71. */
  72. function createProfileLifecycleFixture(): ProfileLifecycleFixture {
  73. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
  74. const ready = join(home, 'ready')
  75. const settled = join(home, 'settled')
  76. const disposed = join(home, 'disposed')
  77. const interrupt = join(home, 'interrupt')
  78. const bundleDir = join(home, 'lifecycle-bundle')
  79. mkdirSync(bundleDir, { recursive: true })
  80. writeFileSync(join(bundleDir, 'plugin.mjs'), [
  81. "import { existsSync, writeFileSync } from 'node:fs'",
  82. "import { join } from 'node:path'",
  83. "export const name = 'profile-lifecycle-fixture'",
  84. 'export function apply(ctx, config = {}) {',
  85. ' let active = true',
  86. ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
  87. ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
  88. ' let interrupted = false',
  89. ' const heartbeat = setInterval(() => {',
  90. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  91. ' interrupted = true',
  92. " process.emit('SIGTERM')",
  93. ' }, 20)',
  94. ' // Echo the mounted generation so the hot-reload e2e can assert both an',
  95. ' // applied override and its removal reverting to this bundle default.',
  96. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  97. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  98. ' void ctx.loader.await().then(() => {',
  99. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  100. ' })',
  101. ' ctx.effect(() => () => {',
  102. ' active = false',
  103. ' clearInterval(heartbeat)',
  104. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  105. ' })',
  106. '}',
  107. '',
  108. ].join('\n'))
  109. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  110. '- insert:',
  111. ' - id: profile-lifecycle-fixture',
  112. ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
  113. '',
  114. ].join('\n'))
  115. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  116. name: 'dsh-lifecycle-bundle',
  117. version: '0.0.0',
  118. type: 'module',
  119. dsh: { bundle: { patch: './cordis.patch.yml' } },
  120. }, undefined, 2))
  121. const profileDir = join(home, 'profiles', 'lifecycle')
  122. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  123. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  124. name: 'dsh-profile-lifecycle',
  125. private: true,
  126. dependencies: {},
  127. dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
  128. }, undefined, 2))
  129. // Hand-place the "installed" bundle where profile resolution finds it.
  130. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  131. const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
  132. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  133. try {
  134. rmSync(linkTarget, { recursive: true, force: true })
  135. } catch { /* fresh dir */ }
  136. // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
  137. mkdirSync(linkTarget, { recursive: true })
  138. for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
  139. writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
  140. }
  141. return { home, ready, settled, disposed, interrupt }
  142. }
  143. function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
  144. return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
  145. cwd: fixture.home,
  146. input: '',
  147. timeout: SPAWN_TIMEOUT_MS,
  148. killSignal: 'SIGKILL',
  149. reject: false,
  150. env: {
  151. DSH_HOME: fixture.home,
  152. RAW_READY_FILE: fixture.ready,
  153. RAW_SETTLED_FILE: fixture.settled,
  154. RAW_DISPOSED_FILE: fixture.disposed,
  155. RAW_INTERRUPT_FILE: fixture.interrupt,
  156. },
  157. })
  158. }
  159. function requestProfileShutdown(
  160. child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
  161. fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
  162. ): void {
  163. if (process.platform === 'win32') {
  164. writeFileSync(fixture.interrupt, 'interrupt')
  165. return
  166. }
  167. child.kill('SIGTERM')
  168. }
  169. function createEnvironmentProbeProfile(home: string, project: string): void {
  170. const pluginFile = join(project, 'environment-probe.mjs')
  171. writeFileSync(pluginFile, [
  172. "export const name = 'environment-probe'",
  173. "export const inject = ['llm']",
  174. 'export function apply(ctx) {',
  175. ' void ctx.loader.await().then(async () => {',
  176. " let text = ''",
  177. ' for await (const chunk of ctx.llm.stream({',
  178. " provider: 'deepseek-official',",
  179. " model: 'deepseek-v4-flash',",
  180. ' messages: [],',
  181. ' maxTokens: 32,',
  182. ' })) {',
  183. " if (chunk.type === 'text-delta') text += chunk.text",
  184. ' }',
  185. ' process.stdout.write(`${text}\\n`)',
  186. " if (process.platform === 'win32') process.emit('SIGTERM')",
  187. " else process.kill(process.pid, 'SIGTERM')",
  188. ' })',
  189. '}',
  190. '',
  191. ].join('\n'))
  192. const profileDir = join(home, 'profiles', 'environment-probe')
  193. mkdirSync(profileDir, { recursive: true })
  194. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  195. name: 'dsh-profile-environment-probe',
  196. private: true,
  197. dependencies: {},
  198. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  199. }, undefined, 2))
  200. writeFileSync(join(profileDir, 'cordis.patch.yml'), [
  201. '- insert:',
  202. ' - id: environment-probe',
  203. ` name: ${pathToFileURL(pluginFile).href}`,
  204. '',
  205. ].join('\n'))
  206. }
  207. interface StartupFixture {
  208. home: string
  209. ready: string
  210. echo: string
  211. interrupt: string
  212. /** An always-running row's echo, used to observe that a user patch reload landed. */
  213. witness: string
  214. }
  215. /**
  216. * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
  217. * a row that reads its app-owned service through a `!!js` config expression.
  218. * Both plugin modules resolve
  219. * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
  220. * fallback, exactly as an installed out-of-tree bundle does.
  221. */
  222. function createStartupFixture(): StartupFixture {
  223. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
  224. const profileDir = join(home, 'profiles', 'startup')
  225. // Written straight into the installed location: a row module resolves its
  226. // own imports from where it is installed, and only inside the profile does
  227. // Node's parent walk reach the installation fallback these plugins need.
  228. const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
  229. mkdirSync(bundleDir, { recursive: true })
  230. writeFileSync(join(bundleDir, 'startup.mjs'), [
  231. "import { Command } from 'commander'",
  232. "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
  233. "export const name = 'fixture-startup'",
  234. "export const inject = ['cmdlineArgs']",
  235. 'export function apply(ctx) {',
  236. " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
  237. " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))",
  238. ' parseCmdline(ctx, program)',
  239. '}',
  240. '',
  241. ].join('\n'))
  242. writeFileSync(join(bundleDir, 'waiting.mjs'), [
  243. "import { existsSync, writeFileSync } from 'node:fs'",
  244. "import { join } from 'node:path'",
  245. "export const name = 'startup-fixture'",
  246. 'export function apply(ctx, config = {}) {',
  247. ' let interrupted = false',
  248. ' const heartbeat = setInterval(() => {',
  249. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  250. ' interrupted = true',
  251. " process.emit('SIGTERM')",
  252. ' }, 20)',
  253. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  254. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  255. ' ctx.effect(() => () => { clearInterval(heartbeat) })',
  256. '}',
  257. '',
  258. ].join('\n'))
  259. writeFileSync(join(bundleDir, 'witness.mjs'), [
  260. "import { writeFileSync } from 'node:fs'",
  261. "import { join } from 'node:path'",
  262. "export const name = 'reload-witness'",
  263. 'export function apply(ctx, config = {}) {',
  264. " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
  265. '}',
  266. '',
  267. ].join('\n'))
  268. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  269. '- insert:',
  270. ' - id: startup-fixture',
  271. ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
  272. ' inject: [fixtureStartup]',
  273. ' config:',
  274. // Lazy interpolation runs only after the provider's service is injected.
  275. " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
  276. ' - id: fixture-startup',
  277. ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
  278. ' - id: reload-witness',
  279. ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
  280. '',
  281. ].join('\n'))
  282. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  283. name: 'dsh-startup-bundle',
  284. version: '0.0.0',
  285. type: 'module',
  286. dsh: { bundle: { patch: './cordis.patch.yml' } },
  287. }, undefined, 2))
  288. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  289. name: 'dsh-profile-startup',
  290. private: true,
  291. dependencies: {},
  292. dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
  293. }, undefined, 2))
  294. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  295. return {
  296. home,
  297. ready: join(home, 'ready'),
  298. echo: join(home, 'config-echo'),
  299. interrupt: join(home, 'interrupt'),
  300. witness: join(home, 'witness'),
  301. }
  302. }
  303. function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
  304. return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
  305. cwd: fixture.home,
  306. input: '',
  307. reject: false,
  308. timeout: SPAWN_TIMEOUT_MS,
  309. killSignal: 'SIGKILL',
  310. env: {
  311. DSH_HOME: fixture.home,
  312. RAW_READY_FILE: fixture.ready,
  313. RAW_INTERRUPT_FILE: fixture.interrupt,
  314. },
  315. })
  316. }
  317. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  318. it('requires --profile and rejects removed commands', async () => {
  319. const bare = await runBuiltBin()
  320. expect(bare.code).toBe(1)
  321. expect(bare.stdout).toBe('')
  322. expect(bare.stderr).toContain('--profile <name> is required')
  323. const help = await runBuiltBin(['--help'])
  324. expect(help.code).toBe(0)
  325. expect(help.stdout).toContain('dsh --profile web')
  326. expect(help.stdout).toContain('dsh plugin --profile')
  327. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  328. for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
  329. const result = await runBuiltBin(removed)
  330. expect(result.code).toBe(1)
  331. }
  332. }, SPAWN_TIMEOUT_MS * 3 + 30_000)
  333. it('routes help and usage errors without activating startup-dependent rows', async () => {
  334. const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
  335. try {
  336. const web = await runBuiltBin(['--profile', 'web', '--help'], {
  337. DSH_HOME: home,
  338. DSH_TELEMETRY_DISABLED: '1',
  339. })
  340. expect(web.code).toBe(0)
  341. expect(web.stderr).toBe('')
  342. expect(web.stdout).toContain('Usage: dsh --profile web')
  343. expect(web.stdout).toContain('--port <port>')
  344. expect(web.stdout).not.toContain('dsh web: http://')
  345. const wildcardHost = await runBuiltBin(['web', '--host', '0.0.0.0'], {
  346. DSH_HOME: home,
  347. DSH_TELEMETRY_DISABLED: '1',
  348. })
  349. expect(wildcardHost.code).toBe(1)
  350. expect(wildcardHost.stdout).toBe('')
  351. 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')
  352. expect(wildcardHost.stderr).not.toContain('dsh web: http://')
  353. const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
  354. DSH_HOME: home,
  355. DSH_TELEMETRY_DISABLED: '1',
  356. })
  357. expect(headlessHelp.code).toBe(0)
  358. expect(headlessHelp.stderr).toBe('')
  359. expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
  360. const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
  361. DSH_HOME: home,
  362. DSH_TELEMETRY_DISABLED: '1',
  363. })
  364. expect(sdkHelp.code).toBe(0)
  365. expect(sdkHelp.stderr).toBe('')
  366. expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
  367. const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
  368. DSH_HOME: home,
  369. DSH_TELEMETRY_DISABLED: '1',
  370. })
  371. expect(acpHelp.code).toBe(0)
  372. expect(acpHelp.stderr).toBe('')
  373. expect(acpHelp.stdout).toContain('Usage: dsh --profile acp')
  374. const missingTask = await runBuiltBin(['--profile', 'headless'], {
  375. DSH_HOME: home,
  376. DSH_TELEMETRY_DISABLED: '1',
  377. })
  378. expect(missingTask.code).toBe(1)
  379. expect(missingTask.stderr).toContain('a task is required')
  380. } finally {
  381. rmSync(home, { recursive: true, force: true })
  382. }
  383. }, SPAWN_TIMEOUT_MS * 3 + 30_000)
  384. it('reports SDK startup failure when stdin reaches EOF first', async () => {
  385. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
  386. const patch = join(home, 'broken-sdk.cordis.yml')
  387. writeFileSync(patch, [
  388. '- insert:',
  389. ' - id: missing-sdk-startup-plugin',
  390. ' name: "@deepseek-ai/dsh-missing-sdk-startup-plugin"',
  391. '',
  392. ].join('\n'))
  393. try {
  394. const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], {
  395. DSH_HOME: home,
  396. DSH_TELEMETRY_DISABLED: '1',
  397. DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
  398. }, home)
  399. expect(result.code).toBe(1)
  400. expect(result.stdout).toBe('')
  401. expect(result.stderr).toContain('plugin tree failed to load')
  402. expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
  403. } finally {
  404. rmSync(home, { recursive: true, force: true })
  405. }
  406. }, SPAWN_TIMEOUT_MS + 30_000)
  407. it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
  408. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
  409. const pluginPath = join(home, 'plugin #100%.mjs')
  410. const marker = join(home, 'plugin-loaded')
  411. writeFileSync(pluginPath, [
  412. "import { writeFileSync } from 'node:fs'",
  413. 'export function apply(ctx, config) { writeFileSync(config.marker, "loaded") }',
  414. '',
  415. ].join('\n'))
  416. const patch = join(home, 'absolute.patch.yml')
  417. writeFileSync(patch, JSON.stringify([{ insert: [
  418. { id: 'absolute-plugin', name: pluginPath, config: { marker } },
  419. ] }]))
  420. const child = execa(process.execPath, [dshBin, '--profile', 'sdk', '--patch', patch], {
  421. cwd: home,
  422. reject: false,
  423. timeout: SPAWN_TIMEOUT_MS,
  424. killSignal: 'SIGKILL',
  425. env: {
  426. ...process.env,
  427. DSH_HOME: home,
  428. DSH_TELEMETRY_DISABLED: '1',
  429. DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
  430. },
  431. extendEnv: false,
  432. })
  433. const stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator]()
  434. let stderr = ''
  435. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  436. const response = async (id: number): Promise<Record<string, unknown>> => {
  437. for (;;) {
  438. const line = await stdoutLines.next()
  439. if (line.done) throw new Error(`SDK profile stdout closed before response ${String(id)}; stderr=${stderr}`)
  440. let value: Record<string, unknown>
  441. try {
  442. value = JSON.parse(line.value) as Record<string, unknown>
  443. } catch {
  444. throw new Error(`SDK profile wrote non-JSON stdout: ${line.value}`)
  445. }
  446. if (value.id === id) return value
  447. }
  448. }
  449. try {
  450. child.stdin.write(`${JSON.stringify({
  451. jsonrpc: '2.0',
  452. id: 1,
  453. method: 'initialize',
  454. params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  455. })}\n`)
  456. const initialized = await response(1)
  457. expect(initialized, `${JSON.stringify(initialized)}\n${stderr}`).toMatchObject({
  458. jsonrpc: '2.0',
  459. id: 1,
  460. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  461. })
  462. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
  463. expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
  464. const result = await child
  465. expect(result.timedOut, stderr).toBe(false)
  466. expect(result.signal, stderr).toBeUndefined()
  467. expect(result.exitCode, stderr).toBe(0)
  468. expect(stderr).toBe('')
  469. expect(readFileSync(marker, 'utf8')).toBe('loaded')
  470. } finally {
  471. child.kill('SIGKILL')
  472. await child
  473. rmSync(home, { recursive: true, force: true })
  474. }
  475. }, SPAWN_TIMEOUT_MS + 30_000)
  476. it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => {
  477. const apiKey = 'built-acp-profile-key'
  478. const server = await startMockLlmServer({
  479. sequence: ['success'],
  480. apiKey,
  481. successText: 'ACP BUILT PROFILE OK',
  482. })
  483. const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
  484. const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
  485. cwd: home,
  486. reject: false,
  487. timeout: SPAWN_TIMEOUT_MS,
  488. killSignal: 'SIGKILL',
  489. env: {
  490. ...process.env,
  491. DSH_HOME: home,
  492. DSH_TELEMETRY_DISABLED: '1',
  493. DEEPSEEK_API_KEY: apiKey,
  494. DEEPSEEK_BASE_URL: server.baseURL,
  495. DSH_PERMISSION_MODE: 'danger-full-access',
  496. },
  497. extendEnv: false,
  498. })
  499. const rawOut: string[] = []
  500. const passthrough = new Readable({ read() {} })
  501. child.stdout.on('data', (chunk: Buffer) => {
  502. rawOut.push(chunk.toString('utf8'))
  503. passthrough.push(chunk)
  504. })
  505. child.stdout.on('end', () => { passthrough.push(null) })
  506. const stream = ndJsonStream(
  507. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  508. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  509. )
  510. const updates: SessionNotification['update'][] = []
  511. const clientApp = createAcpClientApp({ name: 'dsh-built-acp-profile' })
  512. .onNotification(methods.client.session.update, ({ params }) => {
  513. updates.push(params.update)
  514. return Promise.resolve()
  515. })
  516. .onRequest(methods.client.session.requestPermission, () => {
  517. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  518. })
  519. const client = clientApp.connect(stream).agent
  520. try {
  521. const initialized = await client.request(methods.agent.initialize, {
  522. protocolVersion: PROTOCOL_VERSION,
  523. clientCapabilities: {},
  524. })
  525. expect(initialized.agentInfo).toMatchObject({ name: 'deepseek-harness-acp' })
  526. expect(initialized.agentCapabilities).toEqual({
  527. mcpCapabilities: { http: true },
  528. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  529. sessionCapabilities: { close: {}, list: {}, resume: {} },
  530. })
  531. expect('_meta' in initialized).toBe(false)
  532. const session = await client.request(methods.agent.session.new, { cwd: home, mcpServers: [] })
  533. expect(session.sessionId).toBeTruthy()
  534. expect(await client.request(methods.agent.session.prompt, {
  535. sessionId: session.sessionId,
  536. prompt: [{ type: 'text', text: 'reply from the built ACP profile' }],
  537. })).toEqual({ stopReason: 'end_turn' })
  538. expect(updates).toContainEqual(expect.objectContaining({
  539. sessionUpdate: 'agent_message_chunk',
  540. content: { type: 'text', text: 'ACP BUILT PROFILE OK' },
  541. }))
  542. const message = updates.find(update => update.sessionUpdate === 'agent_message_chunk')
  543. expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true)
  544. expect(server.requests).toHaveLength(1)
  545. child.stdin.end()
  546. const result = await child
  547. expect(result.exitCode, `signal=${String(result.signal)}; stderr=${result.stderr}`).toBe(0)
  548. expect(result.stderr).toBe('')
  549. for (const line of rawOut.join('').split('\n').filter(value => value.trim() !== '')) {
  550. expect(() => JSON.parse(line) as unknown).not.toThrow()
  551. }
  552. } finally {
  553. child.kill('SIGKILL')
  554. await child
  555. await server.close()
  556. rmSync(home, { recursive: true, force: true })
  557. }
  558. }, SPAWN_TIMEOUT_MS + 30_000)
  559. it('runs the headless profile through its app-owned task positional', async () => {
  560. const apiKey = 'built-dsh-headless-key'
  561. const server = await startMockLlmServer({
  562. sequence: ['reasoning_success'],
  563. apiKey,
  564. reasoningText: 'Inspecting the published entry.',
  565. successText: 'published headless profile reached the mock',
  566. })
  567. const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
  568. try {
  569. const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
  570. DSH_HOME: home,
  571. DSH_TELEMETRY_DISABLED: '1',
  572. DEEPSEEK_API_KEY: apiKey,
  573. DEEPSEEK_BASE_URL: server.baseURL,
  574. })
  575. expect(result.code, result.stderr).toBe(0)
  576. expect(result.stdout).toBe('published headless profile reached the mock')
  577. expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
  578. expect(server.requests.length).toBeGreaterThan(0)
  579. expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
  580. expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
  581. } finally {
  582. await server.close()
  583. rmSync(home, { recursive: true, force: true })
  584. }
  585. }, SPAWN_TIMEOUT_MS + 30_000)
  586. it('does not load a project environment for --version', async () => {
  587. const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
  588. writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
  589. try {
  590. const result = await runBuiltBin(['--version'], {}, project)
  591. expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
  592. } finally {
  593. rmSync(project, { recursive: true, force: true })
  594. }
  595. })
  596. it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
  597. const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
  598. try {
  599. const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
  600. expect(result.code).toBe(1)
  601. expect(result.stderr).toContain('profile "nope" does not exist')
  602. expect(result.stderr).toContain('dsh plugin --profile nope add')
  603. } finally {
  604. rmSync(home, { recursive: true, force: true })
  605. }
  606. }, SPAWN_TIMEOUT_MS + 30_000)
  607. it('uses the launching endpoint and managed credential through the published entry', async () => {
  608. const apiKey = 'built-home-layer-key'
  609. const server = await startMockLlmServer({
  610. sequence: ['success'],
  611. apiKey,
  612. successText: 'launching endpoint reached the mock',
  613. })
  614. const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
  615. const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
  616. writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
  617. createEnvironmentProbeProfile(home, project)
  618. try {
  619. const result = await runBuiltBin(
  620. ['--profile', 'environment-probe'],
  621. {
  622. DSH_HOME: home,
  623. DSH_TELEMETRY_DISABLED: '1',
  624. DEEPSEEK_API_KEY: undefined,
  625. DEEPSEEK_BASE_URL: server.baseURL,
  626. },
  627. project,
  628. )
  629. expect(
  630. result.code,
  631. `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
  632. ).toBe(0)
  633. expect(result.stdout).toBe('launching endpoint reached the mock')
  634. expect(result.stdout).not.toContain(apiKey)
  635. expect(result.stderr).not.toContain(apiKey)
  636. expect(server.requests).toHaveLength(1)
  637. expect(server.requests[0]?.path).toBe('/chat/completions')
  638. expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
  639. expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
  640. } finally {
  641. await server.close()
  642. rmSync(home, { recursive: true, force: true })
  643. rmSync(project, { recursive: true, force: true })
  644. }
  645. }, SPAWN_TIMEOUT_MS + 30_000)
  646. it('reports a patch-overlay boot failure without hanging', async () => {
  647. // The HMR main watcher's initial scan once refreshed the include
  648. // mid-initial-apply, deadlocking the failing apply's rollback against the
  649. // refresh drain: dsh exited 13 with no diagnostic instead of settling
  650. // ([vendor/README.md](../../../vendor/README.md)).
  651. const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
  652. try {
  653. const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
  654. DSH_HOME: home,
  655. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  656. DSH_TELEMETRY_DISABLED: '1',
  657. })
  658. expect(result.code).toBe(1)
  659. expect(result.stdout).toBe('')
  660. expect(result.stderr).toContain('llm-pi-ai')
  661. } finally {
  662. rmSync(home, { recursive: true, force: true })
  663. }
  664. }, SPAWN_TIMEOUT_MS + 30_000)
  665. it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
  666. const fixture = createProfileLifecycleFixture()
  667. const child = startProfileLifecycle(fixture, ['--unclaimed'])
  668. try {
  669. await waitForFile(fixture.ready)
  670. requestProfileShutdown(child, fixture)
  671. const result = await child
  672. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  673. expect(result.signal).toBeUndefined()
  674. expect(existsSync(fixture.disposed)).toBe(true)
  675. } finally {
  676. child.kill('SIGKILL')
  677. rmSync(fixture.home, { recursive: true, force: true })
  678. }
  679. }, SPAWN_TIMEOUT_MS + 30_000)
  680. it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
  681. const fixture = createProfileLifecycleFixture()
  682. const child = startProfileLifecycle(fixture)
  683. const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
  684. const configFile = join(fixture.home, 'config-echo')
  685. try {
  686. await waitForFile(fixture.settled)
  687. // The live profile layer: even without an hmr row in the composition,
  688. // the launcher mounts a config-only watcher, so an edited
  689. // cordis.patch.yml lands in the running tree (the reload disposes the
  690. // patched row's old fiber — observable as the disposed marker — and
  691. // mounts the new config, which echoes its generation and re-writes the
  692. // ready marker).
  693. rmSync(fixture.ready)
  694. writeFileSync(profilePatch, [
  695. '- id: profile-lifecycle-fixture',
  696. ' config:',
  697. ' generation: 2',
  698. '',
  699. ].join('\n'))
  700. await waitForFile(fixture.ready)
  701. expect(readFileSync(configFile, 'utf8')).toBe('2')
  702. // Unlink exercises layer removal without racing Chokidar's change-event
  703. // suppression window after the preceding edit. The bundle default must return.
  704. rmSync(fixture.ready)
  705. rmSync(profilePatch)
  706. await waitForFile(fixture.ready)
  707. expect(existsSync(profilePatch)).toBe(false)
  708. expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
  709. // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
  710. // and outranks the per-profile layer.
  711. rmSync(fixture.ready)
  712. writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
  713. '- id: profile-lifecycle-fixture',
  714. ' config:',
  715. ' generation: home',
  716. '',
  717. ].join('\n'))
  718. await waitForFile(fixture.ready)
  719. expect(readFileSync(configFile, 'utf8')).toBe('home')
  720. requestProfileShutdown(child, fixture)
  721. const result = await child
  722. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  723. expect(result.signal).toBeUndefined()
  724. expect(existsSync(fixture.disposed)).toBe(true)
  725. } finally {
  726. child.kill('SIGKILL')
  727. await child
  728. rmSync(fixture.home, { recursive: true, force: true })
  729. }
  730. }, SPAWN_TIMEOUT_MS + 30_000)
  731. it('hands the app arguments to the profile, which applies them before its rows start', async () => {
  732. const fixture = createStartupFixture()
  733. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  734. try {
  735. await waitForFile(fixture.ready)
  736. // The consumer started once, already carrying the flag value: the
  737. // launcher never saw --generation, and the app provider resolved it first.
  738. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  739. requestProfileShutdown(child, fixture)
  740. expect((await child).exitCode).toBe(0)
  741. } finally {
  742. child.kill('SIGKILL')
  743. rmSync(fixture.home, { recursive: true, force: true })
  744. }
  745. }, SPAWN_TIMEOUT_MS + 30_000)
  746. it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
  747. const fixture = createStartupFixture()
  748. const child = startStartupProfile(fixture, [])
  749. try {
  750. await waitForFile(fixture.ready)
  751. expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
  752. requestProfileShutdown(child, fixture)
  753. expect((await child).exitCode).toBe(0)
  754. } finally {
  755. child.kill('SIGKILL')
  756. rmSync(fixture.home, { recursive: true, force: true })
  757. }
  758. }, SPAWN_TIMEOUT_MS + 30_000)
  759. it('keeps the app arguments across a user patch reload', async () => {
  760. // A live edit recomposes every row while the provider service remains
  761. // active, so each config expression reads the same invocation value (a
  762. // served port does not move back to its composed fallback).
  763. const fixture = createStartupFixture()
  764. const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
  765. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  766. try {
  767. // Both rows: the waiting one carries the flag value, and the witness is
  768. // what a reload will re-mount. They start independently, so neither
  769. // marker implies the other.
  770. await waitForFile(fixture.ready)
  771. await waitForFile(fixture.witness)
  772. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  773. // An edit to an unrelated row: the witness re-mounts, which is how this
  774. // test knows the whole tree was recomposed.
  775. rmSync(fixture.witness)
  776. writeFileSync(profilePatch, [
  777. '- id: reload-witness',
  778. ' config:',
  779. ' generation: reloaded',
  780. '',
  781. ].join('\n'))
  782. await waitForFile(fixture.witness)
  783. expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
  784. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  785. requestProfileShutdown(child, fixture)
  786. expect((await child).exitCode).toBe(0)
  787. } finally {
  788. child.kill('SIGKILL')
  789. rmSync(fixture.home, { recursive: true, force: true })
  790. }
  791. }, SPAWN_TIMEOUT_MS + 30_000)
  792. it("prints the app's own help, starts none of its rows, and exits", async () => {
  793. const fixture = createStartupFixture()
  794. try {
  795. const result = await startStartupProfile(fixture, ['--help'])
  796. expect(result.exitCode).toBe(0)
  797. expect(result.stdout).toContain('Usage: fixture')
  798. expect(result.stdout).toContain('--generation')
  799. expect(existsSync(fixture.ready)).toBe(false)
  800. } finally {
  801. rmSync(fixture.home, { recursive: true, force: true })
  802. }
  803. }, SPAWN_TIMEOUT_MS + 30_000)
  804. it('anchors a relative add spec to the invoking directory, not the profile', async () => {
  805. // `dsh plugin --profile x add .` from a plugin checkout must install THAT
  806. // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
  807. // would self-link the profile.
  808. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
  809. const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
  810. try {
  811. writeFileSync(join(checkout, 'package.json'), JSON.stringify({
  812. name: 'anchored-bundle',
  813. version: '1.0.0',
  814. dsh: { bundle: { patch: './cordis.patch.yml' } },
  815. }))
  816. writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
  817. const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
  818. cwd: checkout,
  819. input: '',
  820. timeout: SPAWN_TIMEOUT_MS,
  821. killSignal: 'SIGKILL',
  822. reject: false,
  823. env: { DSH_HOME: home },
  824. })
  825. expect(result.exitCode).toBe(0)
  826. const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
  827. dependencies: Record<string, string>
  828. dsh: { profile: { bundles: string[] } }
  829. }
  830. expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
  831. expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
  832. const removed = await runBuiltBin(
  833. ['plugin', '--profile', 'anchor', 'remove', 'anchored-bundle'],
  834. { DSH_HOME: home },
  835. checkout,
  836. )
  837. expect(removed.code).toBe(0)
  838. const afterRemove = JSON.parse(
  839. readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8'),
  840. ) as {
  841. dependencies?: Record<string, string>
  842. dsh: { profile: { bundles: string[] } }
  843. }
  844. expect(Object.keys(afterRemove.dependencies ?? {})).toEqual([])
  845. expect(afterRemove.dsh.profile.bundles).not.toContain('anchored-bundle')
  846. } finally {
  847. rmSync(home, { recursive: true, force: true })
  848. rmSync(checkout, { recursive: true, force: true })
  849. }
  850. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  851. it('activates a dependency that gained dsh.bundle in a later update', async () => {
  852. // Reconcile runs against the INSTALLED state on every successful pnpm
  853. // run, so `update` (not only `add`) activates a package whose newer
  854. // version declares dsh.bundle. Simulated without a registry: hand-place
  855. // the installed package, flip its manifest, and run a benign pnpm verb.
  856. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
  857. try {
  858. const profileDir = join(home, 'profiles', 'up')
  859. const installed = join(profileDir, 'node_modules', 'late-bundle')
  860. mkdirSync(installed, { recursive: true })
  861. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  862. name: 'dsh-profile-up',
  863. private: true,
  864. dependencies: { 'late-bundle': 'file:./late-bundle' },
  865. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  866. }))
  867. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  868. // v1: no dsh manifest — a plain dependency.
  869. writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
  870. const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  871. expect(first.code).toBe(0)
  872. let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  873. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
  874. // v2: the installed package now declares dsh.bundle (an update landed).
  875. writeFileSync(join(installed, 'package.json'), JSON.stringify({
  876. name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
  877. }))
  878. writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
  879. const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  880. expect(second.code).toBe(0)
  881. manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  882. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
  883. } finally {
  884. rmSync(home, { recursive: true, force: true })
  885. }
  886. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  887. describe('config dump', () => {
  888. let home: string
  889. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  890. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  891. it('prints the web profile bundle layers without a user layer', async () => {
  892. const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  893. expect(code).toBe(0)
  894. expect(stderr).toBe('')
  895. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  896. expect(stdout).toContain('agents: []')
  897. expect(stdout).toContain('# == @deepseek-ai/dsh-base')
  898. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  899. expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
  900. }, SPAWN_TIMEOUT_MS + 30_000)
  901. it('prints the headless profile without Host or browser layers', async () => {
  902. const { stdout, code, stderr } = await runBuiltBin(
  903. ['--profile', 'headless', '--dump-default-config'],
  904. { DSH_HOME: home },
  905. )
  906. expect(code).toBe(0)
  907. expect(stderr).toBe('')
  908. expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
  909. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
  910. expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
  911. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
  912. }, SPAWN_TIMEOUT_MS + 30_000)
  913. it('prints the exact standalone sdk-minimal tree without dsh-base', async () => {
  914. const { stdout, code, stderr } = await runBuiltBin(
  915. ['--profile', 'sdk-minimal', '--dump-default-config'],
  916. { DSH_HOME: home },
  917. )
  918. expect(code).toBe(0)
  919. expect(stderr).toBe('')
  920. const rows = yaml.load(stdout, { schema: entryListSchema }) as Array<{ id?: string; name?: string }>
  921. expect(rows.map(row => [row.id, row.name])).toEqual([
  922. ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'],
  923. ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'],
  924. ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'],
  925. ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'],
  926. ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'],
  927. ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'],
  928. ['sandbox', '@deepseek-ai/dsh-sandbox-local'],
  929. ['session-projection', '@deepseek-ai/dsh-session-projection'],
  930. ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'],
  931. ['subprocess', '@deepseek-ai/dsh-subprocess-local'],
  932. ['pty', '@deepseek-ai/dsh-terminal'],
  933. ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'],
  934. ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'],
  935. ['fs-local', '@deepseek-ai/dsh-fs-local'],
  936. ['timer', '@deepseek-ai/cordis-plugin-timer'],
  937. ['llm', '@deepseek-ai/dsh-llm'],
  938. ['session', '@deepseek-ai/dsh-session'],
  939. ['session-title', '@deepseek-ai/dsh-session-title'],
  940. ['system-prompt', '@deepseek-ai/dsh-system-prompt'],
  941. ['tools', '@deepseek-ai/dsh-tools'],
  942. ['agent', '@deepseek-ai/dsh-agent'],
  943. ['llm-retry', '@deepseek-ai/dsh-llm-retry'],
  944. ['jobs', '@deepseek-ai/dsh-jobs-local'],
  945. ['invariants', '@deepseek-ai/dsh-invariants'],
  946. ['session-invariant', '@deepseek-ai/dsh-session/invariant'],
  947. ['agent-invariant', '@deepseek-ai/dsh-agent/invariant'],
  948. ['scope-invariant', '@deepseek-ai/dsh-scope/invariant'],
  949. ['agent-loop-invariant', '@deepseek-ai/dsh-agent-loop/invariant'],
  950. ['agent-loop', '@deepseek-ai/dsh-agent-loop'],
  951. ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
  952. ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
  953. ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'],
  954. ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
  955. ])
  956. expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
  957. expect(stdout).not.toContain('@deepseek-ai/dsh-base')
  958. expect(stdout).not.toContain('@deepseek-ai/dsh-web-app')
  959. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  960. it('composes the profile user layer and a --patch overlay in order', async () => {
  961. // Auto-init the web profile first, then write its user layer.
  962. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  963. expect(init.code).toBe(0)
  964. const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
  965. writeFileSync(profilePatch, [
  966. '- id: agent-loop',
  967. ' config:',
  968. ' agents:',
  969. ' - id: personal',
  970. ' provider: personal-provider',
  971. ' model: personal-model',
  972. '- id: absent-row',
  973. ' config:',
  974. ' x: 1',
  975. '',
  976. ].join('\n'))
  977. const overlay = join(home, 'overlay.cordis.yml')
  978. writeFileSync(overlay, [
  979. '- id: agent-loop',
  980. ' config:',
  981. ' agents:',
  982. ' - id: configured',
  983. ' provider: configured-provider',
  984. ' model: configured-model',
  985. '',
  986. ].join('\n'))
  987. const { stdout, code, stderr } = await runBuiltBin(
  988. ['--profile', 'web', '--patch', overlay, '--dump-config'],
  989. { DSH_HOME: home },
  990. )
  991. expect(code).toBe(0)
  992. expect(stdout).toContain('provider: configured-provider')
  993. expect(stdout).not.toContain('personal-provider')
  994. // Both layers patched the row; the comment lists them in application order.
  995. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
  996. expect(stderr).toContain('patch: entry "absent-row" not found')
  997. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  998. })
  999. })