Merge remote-tracking branch 'origin/master' into worktree/align-core-web-rl-prompt
# Conflicts: # apps/cli/reference/README.i18n.yaml # apps/cli/reference/README.md # apps/cli/reference/README.zh.md # apps/cli/src/app-cli-entry.ts # apps/cli/src/dump-config.ts # apps/cli/src/web.ts # apps/cli/tests/built-bin.e2e.ts # apps/cli/tests/web-prompt-context.spec.ts # apps/web/tests/scaffold.ts
This commit is contained in:
@@ -21,49 +21,71 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes the required raw config, one-shot prompt, and Web command', () => {
|
||||
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'config', config: 'custom.yml' })
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
|
||||
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
|
||||
it('routes profile boots, one-shot tasks, and the web alias', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
|
||||
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' })
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] })
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
})
|
||||
|
||||
it('routes raw and Web config dumps', () => {
|
||||
expect(parse(['--config', 'c.yml', '--dump-config']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'config', defaultOnly: false, config: 'c.yml' })
|
||||
expect(parse(['--dump-default-config']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'config', defaultOnly: true })
|
||||
it('routes the plugin pnpm forwarder', () => {
|
||||
expect(parse(['plugin', '--profile', 'tui', 'add', 'turtle-ui']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'why', 'cordis']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['why', 'cordis'] })
|
||||
// Unknown pnpm flags forward verbatim.
|
||||
expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
|
||||
})
|
||||
|
||||
it('routes profile and web config dumps', () => {
|
||||
expect(parse(['--profile', 'web', '--dump-config']))
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] })
|
||||
expect(parse(['--profile', 'web', '--dump-default-config']))
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
expect(parse(['--profile', 'tui', '--dump-config', '--patch', 'x.yml']))
|
||||
.toEqual({ mode: 'dump-config', profile: 'tui', defaultOnly: false, patches: ['x.yml'] })
|
||||
expect(parse(['web', '--dump-config']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false })
|
||||
expect(parse(['web', '--dump-config', '--config', 'w.yml']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' })
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] })
|
||||
expect(parse(['web', '--dump-default-config']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true })
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing config, removed commands, and contradictory inputs', () => {
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
expect(exitCode([])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1)
|
||||
expect(exitCode(['meta'])).toBe(1)
|
||||
expect(exitCode(['upgrade'])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
expect(exitCode(['--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
|
||||
expect(exitCode(['--dump-config'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '--dump-default-config', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '--config', 'c.yml', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['-p', ''])).toBe(1)
|
||||
expect(exitCode(['--config='])).toBe(1)
|
||||
expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--dump-default-config', '--patch', 'p.yml'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--dump-config', 'task'])).toBe(1)
|
||||
expect(exitCode(['--bogus'])).toBe(1)
|
||||
expect(exitCode(['bogus-positional'])).toBe(1)
|
||||
expect(exitCode(['web', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', 'web'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--config='])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--patch='])).toBe(1)
|
||||
// Boot-free dumps derive no flag patches; silently dropping the flags
|
||||
// would print a tree that differs from the same invocation's boot.
|
||||
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
|
||||
// A non-numeric port fails at the flag, not deep in the webserver schema.
|
||||
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
|
||||
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
|
||||
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
|
||||
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for help and version', () => {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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 coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url))
|
||||
const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
|
||||
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
@@ -32,60 +31,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,
|
||||
@@ -94,35 +128,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('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('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('applies an inserted raw plugin and disposes it on a startup-time signal', async () => {
|
||||
const fixture = createRawLifecycleFixture()
|
||||
const child = startRawLifecycle(fixture)
|
||||
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 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')
|
||||
@@ -136,11 +192,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)
|
||||
@@ -152,66 +244,140 @@ 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')
|
||||
expect(stdout).toMatch(/- id: web-runtime-context\n name: cordis:web-runtime-context\n disabled: false/u)
|
||||
}, 30_000)
|
||||
|
||||
it('lets an explicit Web profile override launcher activation', async () => {
|
||||
it('shows the RL Web patch disabling runtime surface context', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['web', '--dump-config', '--config', coreWebOverlay],
|
||||
['web', '--patch', coreWebOverlay, '--dump-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toMatch(/- id: web-runtime-context\n name: cordis:web-runtime-context\n disabled: true/u)
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).toContain('surfaceContext: false')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Invalid raw overlay used to prove boot failures settle and exit.
|
||||
# Invalid `--patch` overlay used to prove boot failures settle and exit.
|
||||
|
||||
- id: llm-pi-ai
|
||||
config:
|
||||
12
apps/cli/tests/fixtures/raw-overlay.cordis.yml
vendored
12
apps/cli/tests/fixtures/raw-overlay.cordis.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# Raw CLI overlay used by the built config-dump acceptance test.
|
||||
|
||||
- id: agent-loop
|
||||
config:
|
||||
agents:
|
||||
- id: configured
|
||||
provider: configured-provider
|
||||
model: configured-model
|
||||
|
||||
- id: absent-row
|
||||
config:
|
||||
value: unmatched
|
||||
@@ -65,8 +65,17 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
|
||||
try {
|
||||
const home = join(cwd, '.dsh')
|
||||
await mkdir(home, { recursive: true })
|
||||
await writeFile(join(home, 'config.yaml'), [
|
||||
// Pre-initialize the headless profile with the never-dispose row in its
|
||||
// user patch layer (the same file `dsh --profile headless` hot-reloads).
|
||||
const profileDir = join(home, 'profiles', 'headless')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
await writeFile(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-headless',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] } },
|
||||
}, undefined, 2))
|
||||
await writeFile(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: never-dispose',
|
||||
` name: '${neverDisposePlugin}'`,
|
||||
@@ -74,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['-p', 'never complete'],
|
||||
configArgs: ['--profile', 'headless', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Only the dedicated Node compatibility gate opts this test in after building
|
||||
* both artifacts; ordinary Vitest inventory deterministically skips it.
|
||||
* The child runs built artifacts under plain Node with the real shipped
|
||||
* config (base.cordis.yml + the web.cordis.yml overlay).
|
||||
* Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the
|
||||
* web profile (dsh-base + dsh-web-app bundle patches, auto-initialized).
|
||||
* Its URL line follows the settled profile boot; SIGTERM then exercises the
|
||||
* shipped quiescent disposer.
|
||||
*/
|
||||
|
||||
@@ -21,8 +21,8 @@ import { describe, expect, it } from 'vitest'
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const webDist = join(repoRoot, 'apps/web/dist/index.html')
|
||||
// The web overlay owns the session-query-sqlite lazy-open patch row.
|
||||
const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml')
|
||||
// The web bundle's patch owns the session-query-sqlite lazy-open row.
|
||||
const configPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
|
||||
|
||||
interface ConfigRow {
|
||||
|
||||
@@ -16,7 +16,7 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshSourceBin = 'apps/cli/src/bin.ts'
|
||||
|
||||
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
it('boots the source entry and requires the raw config overlay', async () => {
|
||||
it('boots the source entry and requires a profile', async () => {
|
||||
const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
|
||||
cwd: repoRoot,
|
||||
input: '',
|
||||
@@ -28,7 +28,7 @@ describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toContain('--config <path> is required')
|
||||
expect(result.stderr).toContain('--profile <name> is required')
|
||||
expect(result.stdout).toBe('')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTelemetryPatch } from '../src/app-cli-entry.ts'
|
||||
import { resolveTelemetryPatch } from '../src/profile-boot.ts'
|
||||
|
||||
describe('resolveTelemetryPatch', () => {
|
||||
it('keeps telemetry enabled when the switch is unset or empty', () => {
|
||||
@@ -13,11 +13,10 @@ describe('resolveTelemetryPatch', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud when the switch is set but the row is absent', () => {
|
||||
expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition')
|
||||
})
|
||||
|
||||
it('ignores a missing row while the switch is unset', () => {
|
||||
it('is trivially satisfied by a composition without the telemetry row', () => {
|
||||
// A custom profile need not mount telemetry: nothing exports, so the
|
||||
// privacy switch has nothing to disable and generates no patch.
|
||||
expect(resolveTelemetryPatch('1', false)).toBeUndefined()
|
||||
expect(resolveTelemetryPatch(undefined, false)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveLanTrust } from '../src/app-cli-entry.ts'
|
||||
import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts'
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
networkInterfaces: () => ({
|
||||
@@ -31,3 +31,15 @@ describe('resolveLanTrust', () => {
|
||||
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSurfaceContextEnabled', () => {
|
||||
it('defaults to enabled and honors an explicit complete-prompt disable', () => {
|
||||
expect(webSurfaceContextEnabled(new Map())).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { mode: 'production' } }],
|
||||
]))).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { surfaceContext: false } }],
|
||||
]))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { sep } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { prepareWebRuntimeContext } from '../src/web.ts'
|
||||
|
||||
describe('prepareWebRuntimeContext', () => {
|
||||
it('registers the config-tree builtin that installs both prompt sections', async () => {
|
||||
const ctx = new Context()
|
||||
const sourceRoot = `${sep}opt${sep}harness-src`
|
||||
let observedSections: { name: string; text: string }[] | undefined
|
||||
try {
|
||||
await ctx.plugin(Loader)
|
||||
prepareWebRuntimeContext(ctx, sourceRoot, 'production')
|
||||
expect(() => {
|
||||
prepareWebRuntimeContext(ctx, sourceRoot, 'production')
|
||||
}).toThrow(
|
||||
'Loader builtin "web-runtime-context" is already registered',
|
||||
)
|
||||
await ctx.loader.create({ name: 'cordis:web-runtime-context' })
|
||||
ctx.provide('httpServer', { port: 3080 } as Context['httpServer'])
|
||||
const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => {
|
||||
const assembly = await promptCtx.systemPrompt.assemble()
|
||||
observedSections = assembly.sections
|
||||
})
|
||||
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
|
||||
await consumer
|
||||
|
||||
expect(observedSections?.map(section => section.name)).toContain(HARNESS_SOURCE_SECTION)
|
||||
expect(observedSections?.find(section => section.name === 'app:web-surface')?.text)
|
||||
.toContain('http://127.0.0.1:3080')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user