| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- import { existsSync, mkdtempSync, 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. */
- 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))
- async function runBuiltBin(
- args: readonly string[] = [],
- env: Record<string, string> = {},
- ): Promise<{ stdout: string; code: number; stderr: string }> {
- const result = await execa(process.execPath, [dshBin, ...args], {
- input: '',
- timeout: 25_000,
- killSignal: 'SIGKILL',
- reject: false,
- env,
- })
- if (result.timedOut) {
- throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
- }
- return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
- }
- 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}`)
- await new Promise(resolve => setTimeout(resolve, 20))
- }
- }
- interface RawLifecycleFixture {
- home: string
- ready: string
- settled: string
- disposed: string
- overlay: string
- }
- function createRawLifecycleFixture(): RawLifecycleFixture {
- const home = mkdtempSync(join(tmpdir(), 'dsh-raw-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, [
- "import { writeFileSync } from 'node:fs'",
- "export const name = 'raw-lifecycle-fixture'",
- "export const inject = ['sessionQuery']",
- 'export function apply(ctx) {',
- ' let active = true',
- " 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',
- " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
- ' })',
- '}',
- '',
- ].join('\n'))
- writeFileSync(overlay, [
- '- insert:',
- ' - id: raw-lifecycle-fixture',
- ` name: ${pathToFileURL(plugin).href}`,
- '',
- ].join('\n'))
- return { home, ready, settled, disposed, overlay }
- }
- function startRawLifecycle(fixture: RawLifecycleFixture) {
- return execa(process.execPath, [dshBin, '--config', fixture.overlay], {
- 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,
- },
- })
- }
- 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 () => {
- const bare = await runBuiltBin()
- expect(bare.code).toBe(1)
- expect(bare.stdout).toBe('')
- expect(bare.stderr).toContain('--config <path> is required')
- const help = await runBuiltBin(['--help'])
- expect(help.code).toBe(0)
- expect(help.stdout).toContain('dsh --config ./app.cordis.yml')
- 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')
- }
- }, 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')
- }, 30_000)
- it('applies an inserted raw plugin and disposes it on a startup-time signal', async () => {
- const fixture = createRawLifecycleFixture()
- const child = startRawLifecycle(fixture)
- try {
- await waitForFile(fixture.ready)
- child.kill('SIGTERM')
- const result = await child
- expect(result.exitCode).toBe(0)
- expect(result.signal).toBeUndefined()
- expect(existsSync(fixture.disposed)).toBe(true)
- } finally {
- child.kill('SIGKILL')
- rmSync(fixture.home, { recursive: true, force: true })
- }
- }, 30_000)
- it('fully settles a valid raw overlay and disposes it on a signal', async () => {
- const fixture = createRawLifecycleFixture()
- const child = startRawLifecycle(fixture)
- try {
- await waitForFile(fixture.settled)
- child.kill('SIGTERM')
- const result = await child
- expect(result.exitCode).toBe(0)
- expect(result.signal).toBeUndefined()
- expect(existsSync(fixture.disposed)).toBe(true)
- } finally {
- child.kill('SIGKILL')
- rmSync(fixture.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 })
- 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')
- }, 30_000)
- it('composes the required raw overlay directly over the base', async () => {
- writeFileSync(join(home, 'config.yaml'), [
- '- id: agent-loop',
- ' config:',
- ' agents:',
- ' - id: personal',
- ' provider: personal-provider',
- ' model: personal-model',
- '',
- ].join('\n'))
- const { stdout, code, stderr } = await runBuiltBin(
- ['--config', rawOverlay, '--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}`)
- 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)
- })
- })
|