built-bin.e2e.ts 49 KB

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