Files
deepseek-harness/packages/ui/app-boot/tests/app-boot.spec.ts
Turtle 70f37206d2 fix(app-boot): release the terminal before a fatal load exit
A dsh launch whose config failed validation returned the user to a broken
shell: typing was invisible and the next command was mangled by a stray
Device Attributes reply (1;2;4cecho ...).

The Loader mounts entries concurrently, so ui-tui can already hold the
terminal (raw mode, bracketed paste, keyboard protocol, plus an in-flight
DA query) when a sibling entry rejects on its own config. installFailLoud
wrote its diagnostic and exited immediately, so nothing disposed the tree
and ProcessTerminal.stop() never ran.

Give installFailLoud an optional release teardown, awaited between the
diagnostic and the exit and bounded by FAIL_LOUD_RELEASE_TIMEOUT_MS. The
TUI launcher passes one that disposes the root context, reaching the same
shutdown() the /exit path already uses (drainInput() + ui.stop()). The
context is captured in boot()'s prepare hook because the rejection arrives
while boot() is still in flight.

Bins that pass no release keep the previous behavior exactly.
2026-07-31 20:37:16 +08:00

495 lines
20 KiB
TypeScript

import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
describe('resolveConfigPath', () => {
it('resolves relative to the given cwd outside replay mode', () => {
expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
})
it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
})
it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
const warn = vi.fn()
loadEnv(NAME, dir, warn)
expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
expect(warn).not.toHaveBeenCalled()
delete process.env['DSH_APP_BOOT_SPEC_VAR']
})
it('stays silent when no .env exists (ambient environment wins)', () => {
const warn = vi.fn()
loadEnv(NAME, tmp(), warn)
expect(warn).not.toHaveBeenCalled()
})
it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
const dir = tmp()
mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
const warn = vi.fn()
loadEnv(NAME, dir, warn)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
})
it('defaults dir to the process cwd and warn to a stderr write', () => {
const dir = tmp()
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
const previous = process.cwd()
process.chdir(dir)
try {
loadEnv(NAME) // happy path: the default warn sink is never invoked
} finally {
process.chdir(previous)
}
expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
// The default warn sink itself: point it at a broken .env with stderr
// spied, so the arrow body runs without polluting the test output.
const broken = tmp()
mkdirSync(join(broken, '.env'))
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
let written: string[]
try {
loadEnv(NAME, broken)
written = write.mock.calls.map(call => String(call[0]))
} finally {
write.mockRestore()
}
expect(written).toHaveLength(1)
expect(written[0]).toContain(`${NAME}: failed to load .env: `)
})
})
describe('installFailLoud', () => {
function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
const handlers: Array<(err: unknown) => void> = []
const written: string[] = []
const exits: number[] = []
return {
handlers, written, exits,
on: (_event, handler) => { handlers.push(handler) },
off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
stderr: { write: (chunk: string) => { written.push(chunk) } },
exit: (code: number) => { exits.push(code) },
}
}
it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('boom')
proc.handlers[0]!(error)
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
expect(proc.written[0]).toContain(error.stack)
expect(proc.exits).toEqual([1])
})
it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
proc.handlers[0]!('plain failure')
expect(proc.written[0]).toContain('plain failure')
const stackless = new Error('no stack')
delete (stackless as { stack?: string }).stack
proc.handlers[0]!(stackless)
expect(proc.written[1]).toContain('no stack')
expect(proc.exits).toEqual([1, 1])
})
it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
const proc = fakeProc()
const uninstall = installFailLoud(NAME, proc)
expect(proc.handlers).toHaveLength(1)
uninstall()
expect(proc.handlers).toHaveLength(0)
// Default-proc arm: install on the real process, then immediately uninstall
// so the suite leaks no handler and can never exit the runner.
const before = process.listenerCount('unhandledRejection')
const uninstallReal = installFailLoud(NAME)
expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
uninstallReal()
expect(process.listenerCount('unhandledRejection')).toBe(before)
})
it('does not report an activation rejection shared by entries in the boot audit', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('assembled activation failure')
const audit = assertEntriesActivated({
loader: {
entries: () => ['broken-a', 'broken-b'].map(name => ({
options: { name },
fiber: {
state: 3,
inject: {},
ctx: { get: () => undefined },
await: async () => { throw error },
},
})),
},
} as unknown as Context, NAME)
await Promise.resolve()
await Promise.resolve()
proc.handlers[0]!(error)
expect(proc.written).toEqual([])
expect(proc.exits).toEqual([])
await expect(audit).rejects.toThrow('assembled activation failure')
proc.handlers[0]!(error)
expect(proc.exits).toEqual([1])
})
// The Loader mounts entries concurrently, so a terminal-owning surface can
// already hold raw mode when a sibling entry rejects. Exiting without running
// its teardown strands the terminal on the user's shell.
it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
const proc = fakeProc()
const order: string[] = []
installFailLoud(NAME, proc, async () => {
await Promise.resolve()
order.push('released')
})
proc.handlers[0]!(new Error('sibling entry rejected'))
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
// The release is in flight, so the exit has not committed yet.
expect(proc.exits).toEqual([])
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
expect(order).toEqual(['released'])
})
it('still exits when the release hook rejects', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
proc.handlers[0]!(new Error('boom'))
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
})
it('exits without waiting when a release hook never settles', async () => {
vi.useFakeTimers()
try {
const proc = fakeProc()
installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
proc.handlers[0]!(new Error('boom'))
expect(proc.exits).toEqual([])
await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
expect(proc.exits).toEqual([1])
} finally {
vi.useRealTimers()
}
})
// Teardown runs plugin disposers, whose own rejection must not be reported as
// a second fatal load failure over the real one.
it('uninstalls the handler before releasing, so teardown cannot re-enter it', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc, () => {})
proc.handlers[0]!(new Error('boom'))
expect(proc.handlers).toHaveLength(0)
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
expect(proc.written).toHaveLength(1)
})
})
describe('assertEntriesLoaded', () => {
const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
({ loader: { entries: () => entries } }) as unknown as Context
it('passes when every enabled entry has a fiber', () => {
expect(() => { assertEntriesLoaded(ctxWith([
{ fiber: {}, options: { name: 'a' } },
{ disabled: true, options: { name: 'off' } },
]), NAME) }).not.toThrow()
})
it('throws naming every enabled fiber-less entry', () => {
expect(() => { assertEntriesLoaded(ctxWith([
{ fiber: {}, options: { name: 'ok' } },
{ options: { name: 'broken-a' } },
{ options: { name: 'broken-b' } },
]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
})
})
describe('assertEntriesActivated', () => {
interface FakeFiber {
state: number
inject: Record<string, unknown>
ctx: { get(name: string): unknown }
await(): Promise<unknown>
}
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
loader: { entries: () => entries },
}) as unknown as Context
const fiber = (
state: number,
error?: unknown,
inject: Record<string, unknown> = {},
services: string[] = [],
): FakeFiber => ({
state,
inject,
ctx: { get: name => services.includes(name) ? {} : undefined },
await: error === undefined ? async () => undefined : async () => { throw error },
})
it('passes active entries and ignores disabled entries', async () => {
let awaitCalls = 0
const active = fiber(2)
active.await = async () => {
awaitCalls++
return undefined
}
const disabled = fiber(3, new Error('disabled failure'))
disabled.await = async () => {
awaitCalls++
throw new Error('disabled failure')
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: active, options: { name: 'active' } },
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
]), NAME)).resolves.toBeUndefined()
expect(awaitCalls).toBe(0)
})
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
const original = new Error('actual plugin failure')
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
})
it('formats stackless and non-Error activation failures', async () => {
const stackless = new Error('stackless failure')
delete (stackless as { stack?: string }).stack
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
})
it('reports unresolved services for pending entries', async () => {
let awaitCalls = 0
const expected = [
`${NAME}: 3 entries did not activate`,
'waiting: pending (waiting for services: missingA, missingB)',
'single-wait: pending (waiting for service: missing)',
'unknown-wait: pending (waiting for services: unknown)',
].join('\n')
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
const singleWait = fiber(0, undefined, { missing: {} })
const unknownWait = fiber(0)
for (const item of [waiting, singleWait, unknownWait]) {
item.await = async () => {
awaitCalls++
return undefined
}
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: waiting, options: { name: 'waiting' } },
{ fiber: singleWait, options: { name: 'single-wait' } },
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
]), NAME)).rejects.toThrow(expected)
expect(awaitCalls).toBe(0)
})
it('retains the numeric diagnostic for a settled unexpected state', async () => {
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(4), options: { name: 'disposed' } },
]), NAME)).rejects.toThrow('disposed: fiber state 4')
})
})
describe('loadOverlayPatches', () => {
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
const dir = tmp()
const valid = join(dir, 'valid.yml')
writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
const malformed = join(dir, 'malformed.yml')
writeFileSync(malformed, ': bad')
expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
const mapping = join(dir, 'mapping.yml')
writeFileSync(mapping, 'id: target\n')
expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, '- scalar\n')
expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
})
})
describe('boot', () => {
it('boots a leaf config through the real Loader and settles the tree', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const entries = [...ctx.loader.entries()]
expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('runs host preparation before the Loader tree mounts', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const prepared: Context[] = []
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
expect(hostCtx.loader).toBeDefined()
expect([...hostCtx.loader.entries()]).toEqual([])
prepared.push(hostCtx)
})
try {
expect(prepared).toEqual([ctx])
} finally {
await ctx.fiber.dispose()
}
})
it('exposes dshHomePath to Loader config expressions', async () => {
const dir = tmp()
const dshHome = join(dir, 'home')
vi.stubEnv('DSH_HOME', dshHome)
writeFileSync(join(dir, 'capture.mjs'), [
'export const name = "capture"',
'export function apply(ctx, config) {',
' ctx.provide("capturedPath", config.path)',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), [
'- id: capture',
' name: ./capture.mjs',
' config:',
" path: !!js dshHomePath('sessions')",
'',
].join('\n'))
let ctx: Context | undefined
try {
ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
} finally {
await ctx?.fiber.dispose()
vi.unstubAllEnvs()
}
})
it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
// What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
// fiber, which lands while boot() is still awaiting the Loader whenever the
// surface renders before the last entry settles. The Loader service goes
// with the tree, so reading it for the post-boot assertions would crash an
// app that exited exactly as the user asked.
const dir = tmp()
writeFileSync(join(dir, 'exiting.mjs'), [
'export const name = "exiting"',
'export function apply(ctx) {',
' void ctx.root.fiber.dispose()',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('loader')).toBeUndefined()
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
const dir = tmp()
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
`${NAME}: 1 entry did not activate`,
'./waiting.mjs: pending (waiting for service: neverProvided)',
].join('\n'))
})
})
describe('addHarnessSourceSection', () => {
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
it('adds the source path between the harness identity and the deployment persona', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
expect(dispose).toBeTypeOf('function')
const systemPrompt = ctx.get('systemPrompt')!
const rendered = renderPrompt(await systemPrompt.assemble())
expect(rendered).toContain(EXPECTED)
// Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
// keep a drifted opener/persona string from a false pass through `-1 < n`.
const identityAt = rendered.indexOf('You are an AI agent powered by the DeepSeek Harness SDK.')
const sourceAt = rendered.indexOf(EXPECTED)
const personaAt = rendered.indexOf('You are a coding agent.')
expect(identityAt).toBeGreaterThanOrEqual(0)
expect(personaAt).toBeGreaterThanOrEqual(0)
expect(identityAt).toBeLessThan(sourceAt)
expect(sourceAt).toBeLessThan(personaAt)
} finally {
await ctx.fiber.dispose()
}
})
it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
const ctx = new Context()
try {
expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, {})
const systemPrompt = ctx.get('systemPrompt')!
const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
const present = await systemPrompt.assemble()
expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
dispose()
const gone = await systemPrompt.assemble()
expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
} finally {
await ctx.fiber.dispose()
}
})
})