|
|
@@ -1,15 +1,14 @@
|
|
|
-import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
import { execa } from 'execa'
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
|
|
-/** Published-entry acceptance for raw argument errors and boot-free config dumps. */
|
|
|
+/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
|
|
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
|
|
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
|
|
-const rawOverlay = fileURLToPath(new URL('./fixtures/raw-overlay.cordis.yml', import.meta.url))
|
|
|
-const rawInvalidProvider = fileURLToPath(new URL('./fixtures/raw-invalid-provider.cordis.yml', import.meta.url))
|
|
|
+const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
|
|
|
|
|
|
async function runBuiltBin(
|
|
|
args: readonly string[] = [],
|
|
|
@@ -31,60 +30,95 @@ async function runBuiltBin(
|
|
|
async function waitForFile(file: string): Promise<void> {
|
|
|
const deadline = Date.now() + 20_000
|
|
|
while (!existsSync(file)) {
|
|
|
- if (Date.now() >= deadline) throw new Error(`dsh raw lifecycle marker did not appear: ${file}`)
|
|
|
+ if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
|
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-interface RawLifecycleFixture {
|
|
|
+interface ProfileLifecycleFixture {
|
|
|
home: string
|
|
|
ready: string
|
|
|
settled: string
|
|
|
disposed: string
|
|
|
- overlay: string
|
|
|
}
|
|
|
|
|
|
-function createRawLifecycleFixture(): RawLifecycleFixture {
|
|
|
- const home = mkdtempSync(join(tmpdir(), 'dsh-raw-lifecycle-'))
|
|
|
+/**
|
|
|
+ * A minimal custom profile: one lifecycle-marker plugin bundle listed in
|
|
|
+ * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
|
|
|
+ * booting the entire product tree.
|
|
|
+ */
|
|
|
+function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
|
|
+ const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
|
|
|
const ready = join(home, 'ready')
|
|
|
const settled = join(home, 'settled')
|
|
|
const disposed = join(home, 'disposed')
|
|
|
- const plugin = join(home, 'lifecycle.mjs')
|
|
|
- const overlay = join(home, 'overlay.cordis.yml')
|
|
|
- writeFileSync(plugin, [
|
|
|
+ const bundleDir = join(home, 'lifecycle-bundle')
|
|
|
+ mkdirSync(bundleDir, { recursive: true })
|
|
|
+ writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
|
|
"import { writeFileSync } from 'node:fs'",
|
|
|
- "export const name = 'raw-lifecycle-fixture'",
|
|
|
- "export const inject = ['sessionQuery']",
|
|
|
- 'export function apply(ctx) {',
|
|
|
+ "import { join } from 'node:path'",
|
|
|
+ "export const name = 'profile-lifecycle-fixture'",
|
|
|
+ 'export function apply(ctx, config = {}) {',
|
|
|
' let active = true',
|
|
|
+ ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
|
|
|
+ ' const heartbeat = setInterval(() => {}, 1000)',
|
|
|
+ ' // Echo the mounted generation so the hot-reload e2e can assert both an',
|
|
|
+ ' // applied override and its removal reverting to this bundle default.',
|
|
|
+ " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
|
|
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
|
|
|
' void ctx.loader.await().then(() => {',
|
|
|
" if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
|
|
|
' })',
|
|
|
' ctx.effect(() => () => {',
|
|
|
' active = false',
|
|
|
+ ' clearInterval(heartbeat)',
|
|
|
" writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
|
|
|
' })',
|
|
|
'}',
|
|
|
'',
|
|
|
].join('\n'))
|
|
|
- writeFileSync(overlay, [
|
|
|
+ writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
|
|
|
'- insert:',
|
|
|
- ' - id: raw-lifecycle-fixture',
|
|
|
- ` name: ${pathToFileURL(plugin).href}`,
|
|
|
+ ' - id: profile-lifecycle-fixture',
|
|
|
+ ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
|
|
|
'',
|
|
|
].join('\n'))
|
|
|
- return { home, ready, settled, disposed, overlay }
|
|
|
+ writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
|
|
|
+ name: 'dsh-lifecycle-bundle',
|
|
|
+ version: '0.0.0',
|
|
|
+ type: 'module',
|
|
|
+ dsh: { bundle: { patch: './cordis.patch.yml' } },
|
|
|
+ }, undefined, 2))
|
|
|
+ const profileDir = join(home, 'profiles', 'lifecycle')
|
|
|
+ mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
|
|
|
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
|
|
+ name: 'dsh-profile-lifecycle',
|
|
|
+ private: true,
|
|
|
+ dependencies: {},
|
|
|
+ dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
|
|
|
+ }, undefined, 2))
|
|
|
+ // Hand-place the "installed" bundle where profile resolution finds it.
|
|
|
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
|
|
|
+ const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
|
|
|
+ mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
|
|
|
+ try {
|
|
|
+ rmSync(linkTarget, { recursive: true, force: true })
|
|
|
+ } catch { /* fresh dir */ }
|
|
|
+ // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
|
|
|
+ mkdirSync(linkTarget, { recursive: true })
|
|
|
+ for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
|
|
|
+ writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
|
|
|
+ }
|
|
|
+ return { home, ready, settled, disposed }
|
|
|
}
|
|
|
|
|
|
-function startRawLifecycle(fixture: RawLifecycleFixture) {
|
|
|
- return execa(process.execPath, [dshBin, '--config', fixture.overlay], {
|
|
|
+function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
|
|
+ return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
|
|
|
cwd: fixture.home,
|
|
|
input: '',
|
|
|
reject: false,
|
|
|
env: {
|
|
|
DSH_HOME: fixture.home,
|
|
|
- DSH_TELEMETRY_DISABLED: '1',
|
|
|
RAW_READY_FILE: fixture.ready,
|
|
|
RAW_SETTLED_FILE: fixture.settled,
|
|
|
RAW_DISPOSED_FILE: fixture.disposed,
|
|
|
@@ -93,35 +127,57 @@ function startRawLifecycle(fixture: RawLifecycleFixture) {
|
|
|
}
|
|
|
|
|
|
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
|
|
- it('requires --config for the raw command and rejects removed commands', async () => {
|
|
|
+ it('requires --profile and rejects removed commands', async () => {
|
|
|
const bare = await runBuiltBin()
|
|
|
expect(bare.code).toBe(1)
|
|
|
expect(bare.stdout).toBe('')
|
|
|
- expect(bare.stderr).toContain('--config <path> is required')
|
|
|
+ expect(bare.stderr).toContain('--profile <name> is required')
|
|
|
const help = await runBuiltBin(['--help'])
|
|
|
expect(help.code).toBe(0)
|
|
|
- expect(help.stdout).toContain('dsh --config ./app.cordis.yml')
|
|
|
+ expect(help.stdout).toContain('dsh --profile web')
|
|
|
+ expect(help.stdout).toContain('dsh plugin --profile')
|
|
|
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
|
|
|
- for (const command of ['tui', 'meta', 'upgrade']) {
|
|
|
- const removed = await runBuiltBin([command])
|
|
|
- expect(removed.code).toBe(1)
|
|
|
- expect(removed.stderr).not.toContain('experimental')
|
|
|
+ for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) {
|
|
|
+ const result = await runBuiltBin(removed)
|
|
|
+ expect(result.code).toBe(1)
|
|
|
+ }
|
|
|
+ }, 30_000)
|
|
|
+
|
|
|
+ it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
|
|
|
+ const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
|
|
|
+ try {
|
|
|
+ const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
|
|
|
+ expect(result.code).toBe(1)
|
|
|
+ expect(result.stderr).toContain('profile "nope" does not exist')
|
|
|
+ expect(result.stderr).toContain('dsh plugin --profile nope add')
|
|
|
+ } finally {
|
|
|
+ rmSync(home, { recursive: true, force: true })
|
|
|
}
|
|
|
}, 30_000)
|
|
|
|
|
|
- it('reports a raw overlay boot failure without hanging', async () => {
|
|
|
- const result = await runBuiltBin(['--config', rawInvalidProvider], {
|
|
|
- DEEPSEEK_API_KEY: 'keyless-invalid-config',
|
|
|
- DSH_TELEMETRY_DISABLED: '1',
|
|
|
- })
|
|
|
- expect(result.code).toBe(1)
|
|
|
- expect(result.stdout).toBe('')
|
|
|
- expect(result.stderr).toContain('llm-pi-ai')
|
|
|
+ it('reports a patch-overlay boot failure without hanging', async () => {
|
|
|
+ // The HMR main watcher's initial scan once refreshed the include
|
|
|
+ // mid-initial-apply, deadlocking the failing apply's rollback against the
|
|
|
+ // refresh drain: dsh exited 13 with no diagnostic instead of settling
|
|
|
+ // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
|
|
|
+ const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
|
|
|
+ try {
|
|
|
+ const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
|
|
|
+ DSH_HOME: home,
|
|
|
+ DEEPSEEK_API_KEY: 'keyless-invalid-config',
|
|
|
+ DSH_TELEMETRY_DISABLED: '1',
|
|
|
+ })
|
|
|
+ expect(result.code).toBe(1)
|
|
|
+ expect(result.stdout).toBe('')
|
|
|
+ expect(result.stderr).toContain('llm-pi-ai')
|
|
|
+ } finally {
|
|
|
+ rmSync(home, { recursive: true, force: true })
|
|
|
+ }
|
|
|
}, 30_000)
|
|
|
|
|
|
- it('applies an inserted raw plugin and disposes it on a startup-time signal', async () => {
|
|
|
- const fixture = createRawLifecycleFixture()
|
|
|
- const child = startRawLifecycle(fixture)
|
|
|
+ it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
|
|
|
+ const fixture = createProfileLifecycleFixture()
|
|
|
+ const child = startProfileLifecycle(fixture)
|
|
|
try {
|
|
|
await waitForFile(fixture.ready)
|
|
|
child.kill('SIGTERM')
|
|
|
@@ -135,11 +191,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
}
|
|
|
}, 30_000)
|
|
|
|
|
|
- it('fully settles a valid raw overlay and disposes it on a signal', async () => {
|
|
|
- const fixture = createRawLifecycleFixture()
|
|
|
- const child = startRawLifecycle(fixture)
|
|
|
+ it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
|
|
|
+ const fixture = createProfileLifecycleFixture()
|
|
|
+ const child = startProfileLifecycle(fixture)
|
|
|
+ const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
|
|
|
+ const configFile = join(fixture.home, 'config-echo')
|
|
|
try {
|
|
|
await waitForFile(fixture.settled)
|
|
|
+ // The live profile layer: even without an hmr row in the composition,
|
|
|
+ // the launcher mounts a config-only watcher, so an edited
|
|
|
+ // cordis.patch.yml lands in the running tree (the reload disposes the
|
|
|
+ // patched row's old fiber — observable as the disposed marker — and
|
|
|
+ // mounts the new config, which echoes its generation and re-writes the
|
|
|
+ // ready marker).
|
|
|
+ rmSync(fixture.ready)
|
|
|
+ writeFileSync(profilePatch, [
|
|
|
+ '- id: profile-lifecycle-fixture',
|
|
|
+ ' config:',
|
|
|
+ ' generation: 2',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ await waitForFile(fixture.ready)
|
|
|
+ expect(readFileSync(configFile, 'utf8')).toBe('2')
|
|
|
+ // Removal reverts: the bundle's inserted row must return to its own
|
|
|
+ // default config, not keep the removed override — the insert-aliasing
|
|
|
+ // regression (a shared patch object mutated in place by a former
|
|
|
+ // generation would make this impossible).
|
|
|
+ rmSync(fixture.ready)
|
|
|
+ writeFileSync(profilePatch, '[]\n')
|
|
|
+ await waitForFile(fixture.ready)
|
|
|
+ expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
|
|
|
+ // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
|
|
|
+ // and outranks the per-profile layer.
|
|
|
+ rmSync(fixture.ready)
|
|
|
+ writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
|
|
|
+ '- id: profile-lifecycle-fixture',
|
|
|
+ ' config:',
|
|
|
+ ' generation: home',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ await waitForFile(fixture.ready)
|
|
|
+ expect(readFileSync(configFile, 'utf8')).toBe('home')
|
|
|
child.kill('SIGTERM')
|
|
|
const result = await child
|
|
|
expect(result.exitCode).toBe(0)
|
|
|
@@ -151,55 +243,129 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
}
|
|
|
}, 30_000)
|
|
|
|
|
|
+ it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
|
|
+ // `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
|
|
+ // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
|
|
|
+ // would self-link the profile.
|
|
|
+ const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
|
|
|
+ const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
|
|
|
+ try {
|
|
|
+ writeFileSync(join(checkout, 'package.json'), JSON.stringify({
|
|
|
+ name: 'anchored-bundle',
|
|
|
+ version: '1.0.0',
|
|
|
+ dsh: { bundle: { patch: './cordis.patch.yml' } },
|
|
|
+ }))
|
|
|
+ writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
|
|
|
+ const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
|
|
|
+ cwd: checkout,
|
|
|
+ input: '',
|
|
|
+ timeout: 60_000,
|
|
|
+ killSignal: 'SIGKILL',
|
|
|
+ reject: false,
|
|
|
+ env: { DSH_HOME: home },
|
|
|
+ })
|
|
|
+ expect(result.exitCode).toBe(0)
|
|
|
+ const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
|
|
|
+ dependencies: Record<string, string>
|
|
|
+ dsh: { profile: { bundles: string[] } }
|
|
|
+ }
|
|
|
+ expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
|
|
|
+ expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
|
|
|
+ } finally {
|
|
|
+ rmSync(home, { recursive: true, force: true })
|
|
|
+ rmSync(checkout, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ }, 90_000)
|
|
|
+
|
|
|
+ it('activates a dependency that gained dsh.bundle in a later update', async () => {
|
|
|
+ // Reconcile runs against the INSTALLED state on every successful pnpm
|
|
|
+ // run, so `update` (not only `add`) activates a package whose newer
|
|
|
+ // version declares dsh.bundle. Simulated without a registry: hand-place
|
|
|
+ // the installed package, flip its manifest, and run a benign pnpm verb.
|
|
|
+ const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
|
|
|
+ try {
|
|
|
+ const profileDir = join(home, 'profiles', 'up')
|
|
|
+ const installed = join(profileDir, 'node_modules', 'late-bundle')
|
|
|
+ mkdirSync(installed, { recursive: true })
|
|
|
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
|
|
+ name: 'dsh-profile-up',
|
|
|
+ private: true,
|
|
|
+ dependencies: { 'late-bundle': 'file:./late-bundle' },
|
|
|
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
|
|
|
+ }))
|
|
|
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
|
|
|
+ // v1: no dsh manifest — a plain dependency.
|
|
|
+ writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
|
|
|
+ const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
|
|
|
+ expect(first.code).toBe(0)
|
|
|
+ let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
|
|
|
+ expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
|
|
+ // v2: the installed package now declares dsh.bundle (an update landed).
|
|
|
+ writeFileSync(join(installed, 'package.json'), JSON.stringify({
|
|
|
+ name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
|
|
|
+ }))
|
|
|
+ writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
|
|
|
+ const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
|
|
|
+ expect(second.code).toBe(0)
|
|
|
+ manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
|
|
|
+ expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
|
|
|
+ } finally {
|
|
|
+ rmSync(home, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ }, 30_000)
|
|
|
+
|
|
|
describe('config dump', () => {
|
|
|
let home: string
|
|
|
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
|
|
|
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
|
|
|
|
|
|
- it('prints the shipped base without a user layer', async () => {
|
|
|
- const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
|
|
|
+ it('prints the web profile bundle layers without a user layer', async () => {
|
|
|
+ const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
|
|
|
expect(code).toBe(0)
|
|
|
expect(stderr).toBe('')
|
|
|
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
|
|
|
expect(stdout).toContain('agents: []')
|
|
|
- expect(stdout).toContain('# == base.cordis.yml')
|
|
|
+ expect(stdout).toContain('# == @deepseek-ai/dsh-base')
|
|
|
+ expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
|
|
}, 30_000)
|
|
|
|
|
|
- it('composes the required raw overlay directly over the base', async () => {
|
|
|
- writeFileSync(join(home, 'config.yaml'), [
|
|
|
+ it('composes the profile user layer and a --patch overlay in order', async () => {
|
|
|
+ // Auto-init the web profile first, then write its user layer.
|
|
|
+ const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
|
|
|
+ expect(init.code).toBe(0)
|
|
|
+ const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
|
|
|
+ writeFileSync(profilePatch, [
|
|
|
'- id: agent-loop',
|
|
|
' config:',
|
|
|
' agents:',
|
|
|
' - id: personal',
|
|
|
' provider: personal-provider',
|
|
|
' model: personal-model',
|
|
|
+ '- id: absent-row',
|
|
|
+ ' config:',
|
|
|
+ ' x: 1',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ const overlay = join(home, 'overlay.cordis.yml')
|
|
|
+ writeFileSync(overlay, [
|
|
|
+ '- id: agent-loop',
|
|
|
+ ' config:',
|
|
|
+ ' agents:',
|
|
|
+ ' - id: configured',
|
|
|
+ ' provider: configured-provider',
|
|
|
+ ' model: configured-model',
|
|
|
'',
|
|
|
].join('\n'))
|
|
|
const { stdout, code, stderr } = await runBuiltBin(
|
|
|
- ['--config', rawOverlay, '--dump-config'],
|
|
|
+ ['--profile', 'web', '--patch', overlay, '--dump-config'],
|
|
|
{ DSH_HOME: home },
|
|
|
)
|
|
|
expect(code).toBe(0)
|
|
|
expect(stdout).toContain('provider: configured-provider')
|
|
|
expect(stdout).not.toContain('personal-provider')
|
|
|
- expect(stdout).toContain(`patched by ${rawOverlay}`)
|
|
|
+ // Both layers patched the row; provenance lists them in application order.
|
|
|
+ expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
|
|
expect(stderr).toContain('patch: entry "absent-row" not found')
|
|
|
}, 30_000)
|
|
|
-
|
|
|
- it('keeps the Web overlay and personal layer on the Web command', async () => {
|
|
|
- writeFileSync(join(home, 'config.yaml'), [
|
|
|
- '- id: agent-loop',
|
|
|
- ' config:',
|
|
|
- ' agents:',
|
|
|
- ' - id: personal',
|
|
|
- ' provider: personal-provider',
|
|
|
- ' model: personal-model',
|
|
|
- '',
|
|
|
- ].join('\n'))
|
|
|
- const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
|
|
|
- expect(code).toBe(0)
|
|
|
- expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
|
|
- expect(stdout).toContain('provider: personal-provider')
|
|
|
- }, 30_000)
|
|
|
})
|
|
|
})
|