Files
deepseek-harness/packages/ui/app-boot/tests/config-dump.spec.ts
Yichen Jiang 8ddc53f7a0 feat(cli)!: complete --config on every surface and delete the personal overlay
$DSH_HOME/config.yaml was an implicit composition layer: if the file existed,
every launch applied an arbitrary Loader patch graph over the shipped tree,
kept live by a dedicated HMR watcher. Three costs came from the implicitness,
not the capability. A patch replaces its target row's whole config, so a file
written months ago pins that row to the field set it knew and every default
the shipped tree later adds silently stops applying. It competed with the
typed settings namespaces llm-deepseek and llm-pi-ai already register, so
which one wins was a function of layer order rather than meaning. And the
explicit escape hatch it was supposedly redundant with did not exist on every
surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them
the implicit file was the only composition route at all.

Complete the explicit layer first: --config and --config-replace now work on
every booting surface. A headless --config-replace tree must still mount a
webserver row, because that surface reaches its own agent over the same HTTP
gateway the browser uses; AppCLIEntry names that contract in the failure
instead of reporting a bare missing service.

Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches,
watchPersonalPatches, and the config-only HMR row mounted for it are gone; a
file left at that path is inert, and --dump-config no longer reads the Harness
home. --config therefore stops *replacing* the personal overlay and simply
*is* the user overlay.

No migration: a user who wants the old behavior names the same file
(dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent.
2026-08-04 15:25:04 +08:00

188 lines
7.2 KiB
TypeScript

/**
* `renderConfigDump` behavior: the offline composition must equal what
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
* expressions verbatim, separate provenance runs with comment lines while
* staying one loadable YAML document, and report skipped patches through
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
* shared overlay whose row exists only on another surface.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@cordisjs/plugin-include'
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
function writeBase(dir: string): string {
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: shared',
' name: ./noop.mjs',
' config:',
' value: base',
' key: !!js process.env.DSH_DUMP_SPEC',
'- id: untouched',
' name: ./noop.mjs',
'',
].join('\n'))
return base
}
describe('renderConfigDump', () => {
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
const dir = tmp()
const base = writeBase(dir)
const surface = join(dir, 'surface.yml')
writeFileSync(surface, [
'- id: shared',
' config:',
' value: surface',
' key: !!js process.env.DSH_DUMP_SPEC',
'- insert:',
' - id: surface-extra',
' name: ./noop.mjs',
'',
].join('\n'))
const user = join(dir, 'user.yml')
writeFileSync(user, [
'- id: surface-extra',
' config:',
' value: user',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
{ label: 'user.yml', patches: loadOverlayPatches(NAME, user) },
], () => {})
// Comments do not break loadability: the dump parses as one document
// equal to what boot() would mount.
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
id: string
config?: Record<string, unknown>
}[]
expect(parsed).toEqual([
{
id: 'shared',
name: './noop.mjs',
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
},
{ id: 'untouched', name: './noop.mjs' },
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'user' } },
])
// Unevaluated: the expression text round-trips as a !!js scalar.
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
// Provenance separators: origin file, plus every layer that changed the
// row; an inserted row carries the inserting layer as its origin.
expect(dump).toContain('# == base.yml, patched by surface.yml')
expect(dump).toContain('# == base.yml\n- id: untouched')
expect(dump).toContain('# == surface.yml, patched by user.yml\n- id: surface-extra')
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
})
it('groups contiguous same-provenance rows under one separator', () => {
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: a',
' name: ./noop.mjs',
'- id: b',
' name: ./noop.mjs',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [], () => {})
expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
expect(dump).toContain('# == base.yml\n- id: a')
})
it('composes all layers as one flattened patch list, exactly like boot()', () => {
// boot() flattens every layer into ONE applyEntryPatches call, whose id
// index sees inserted rows but NOT children introduced by a plain group
// `config` replacement. A per-layer composition would rebuild the index
// between layers and let the second layer patch that child — a tree the
// real boot never mounts. Pin the single-call semantics: the child patch
// is skipped (with the layer-labeled warning), matching boot.
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: g',
' name: ./group.mjs',
' group: true',
' config: []',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(NAME, base, [
{
label: 'a.yml',
patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
},
{ label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
], line => void warnings.push(line))
expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
config?: { config?: { v?: number } }[]
}[]
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
// The skipped layer did not change the row, so it is not in provenance.
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
expect(dump).not.toContain('b.yml\n- id: g')
})
it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
const dir = tmp()
const base = writeBase(dir)
const overlay = join(dir, 'overlay.yml')
writeFileSync(overlay, [
'- id: only-on-another-surface',
' config:',
' value: ignored',
'- id: shared',
' config:',
' value: patched',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(
NAME, base,
[{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
line => void warnings.push(line),
)
expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
expect(parsed[0]?.config?.value).toBe('patched')
})
it('defaults its warn sink to one stderr line per skipped patch', () => {
const dir = tmp()
const base = writeBase(dir)
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
} finally {
write.mockRestore()
}
})
it('fails loud on a missing, unparsable, or non-array base config', () => {
const dir = tmp()
expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to read config `))
const invalid = join(dir, 'invalid.yml')
writeFileSync(invalid, 'invalid: [unclosed\n')
expect(() => renderConfigDump(NAME, invalid, [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to parse config `))
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, 'id: not-a-list\n')
expect(() => renderConfigDump(NAME, scalar, [], () => {}))
.toThrow('must be a top-level YAML array of entries')
})
})