Merge remote-tracking branch 'origin/worktree/ci-native-windows-20260808' into worktree/ci-native-windows-coverage-20260808
This commit is contained in:
737
packages/boot/app-boot/tests/app-boot.spec.ts
Normal file
737
packages/boot/app-boot/tests/app-boot.spec.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
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, loadLayeredEnv, 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('loadLayeredEnv', () => {
|
||||
const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const
|
||||
|
||||
function clear(): void {
|
||||
for (const name of NAMES) Reflect.deleteProperty(process.env, name)
|
||||
}
|
||||
|
||||
it('layers user under project under the inherited environment', () => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
writeFileSync(join(home, '.env'), [
|
||||
`${NAMES[0]}=user`,
|
||||
`${NAMES[1]}=user-only`,
|
||||
'APP_BOOT_LAYERED_INHERITED=user-loses',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(project, '.env'), [
|
||||
`${NAMES[0]}=project`,
|
||||
`${NAMES[2]}=project-only`,
|
||||
'APP_BOOT_LAYERED_INHERITED=project-loses',
|
||||
'',
|
||||
].join('\n'))
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
|
||||
const warn = vi.fn()
|
||||
try {
|
||||
loadLayeredEnv(NAME, project, warn)
|
||||
expect(process.env[NAMES[0]]).toBe('project')
|
||||
expect(process.env[NAMES[1]]).toBe('user-only')
|
||||
expect(process.env[NAMES[2]]).toBe('project-only')
|
||||
expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'],
|
||||
['the executable search path', 'PATH=/tmp/evil\n'],
|
||||
['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'],
|
||||
['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'],
|
||||
['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'],
|
||||
['a lowercase network proxy', 'https_proxy=http://attacker.example\n'],
|
||||
])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`)
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
try {
|
||||
expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
|
||||
expect(process.env[NAMES[1]]).toBeUndefined()
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports each file value with its absolute path', () => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`)
|
||||
writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`)
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
try {
|
||||
const snapshot = loadLayeredEnv(NAME, project, vi.fn())
|
||||
expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') })
|
||||
expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') })
|
||||
expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined()
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the harness home from the inherited environment, never from a file', () => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`)
|
||||
writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`)
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
try {
|
||||
loadLayeredEnv(NAME, project, vi.fn())
|
||||
expect(process.env[NAMES[1]]).toBe('real-home')
|
||||
expect(process.env[NAMES[2]]).toBe('set-by-project')
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('warns and continues when a layer exists but cannot be read', () => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
// A directory named `.env` is a present-but-unreadable layer.
|
||||
mkdirSync(join(home, '.env'))
|
||||
writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
const warn = vi.fn()
|
||||
try {
|
||||
const snapshot = loadLayeredEnv(NAME, project, warn)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
|
||||
expect(snapshot.get(NAMES[1])).toBeUndefined()
|
||||
expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
|
||||
expect(process.env[NAMES[2]]).toBe('project-only')
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports to stderr when the caller supplies no reporter', () => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
mkdirSync(join(home, '.env'))
|
||||
writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
const snapshot = loadLayeredEnv(NAME, project)
|
||||
expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
|
||||
expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
|
||||
expect(process.env[NAMES[2]]).toBe('project-only')
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('passes over an absent layer without reporting it', () => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
const warn = vi.fn()
|
||||
try {
|
||||
const snapshot = loadLayeredEnv(NAME, project, warn)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('carries only the inherited environment when neither file exists', () => {
|
||||
const home = tmp()
|
||||
const project = tmp()
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
|
||||
try {
|
||||
const snapshot = loadLayeredEnv(NAME, project, vi.fn())
|
||||
expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' })
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a harness home that is also the invocation directory exactly once', () => {
|
||||
const both = tmp()
|
||||
writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`)
|
||||
clear()
|
||||
vi.stubEnv('DSH_HOME', both)
|
||||
try {
|
||||
const snapshot = loadLayeredEnv(NAME, both, vi.fn())
|
||||
expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') })
|
||||
} finally {
|
||||
clear()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
|
||||
// One rejection is reported per install: the first is the diagnosis, so each
|
||||
// formatting case needs its own handler rather than reusing a latched one.
|
||||
it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
|
||||
const plain = fakeProc()
|
||||
installFailLoud(NAME, plain)
|
||||
plain.handlers[0]!('plain failure')
|
||||
expect(plain.written[0]).toContain('plain failure')
|
||||
expect(plain.exits).toEqual([1])
|
||||
|
||||
const stackless = new Error('no stack')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
const bare = fakeProc()
|
||||
installFailLoud(NAME, bare)
|
||||
bare.handlers[0]!(stackless)
|
||||
expect(bare.written[0]).toContain('no stack')
|
||||
expect(bare.exits).toEqual([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()
|
||||
}
|
||||
})
|
||||
|
||||
// Loader failures arrive in bursts, and teardown's own disposers may reject.
|
||||
// Only the first rejection is the diagnosis; the handler must stay installed
|
||||
// so a later one cannot become uncaught and kill the process mid-teardown.
|
||||
it('reports only the first rejection and keeps handling later ones during the release', async () => {
|
||||
const proc = fakeProc()
|
||||
let released = false
|
||||
installFailLoud(NAME, proc, async () => {
|
||||
await Promise.resolve()
|
||||
released = true
|
||||
})
|
||||
proc.handlers[0]!(new Error('first rejection'))
|
||||
proc.handlers[0]!(new Error('second rejection'))
|
||||
expect(proc.handlers).toHaveLength(1)
|
||||
expect(proc.written).toHaveLength(1)
|
||||
expect(proc.written[0]).toContain('first rejection')
|
||||
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
|
||||
expect(released).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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('disposes partial host setup and labels non-Error preparation failures', async () => {
|
||||
const dir = tmp()
|
||||
const failure = 42
|
||||
let disposed = false
|
||||
const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
|
||||
ctx.effect(() => () => { disposed = true })
|
||||
throw failure
|
||||
})
|
||||
|
||||
await expect(task).rejects.toMatchObject({
|
||||
message: `${NAME}: host preparation failed: ${failure}`,
|
||||
cause: failure,
|
||||
})
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
// A surface can dispose the root fiber while boot() is still awaiting the
|
||||
// Loader, 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 tree failed to load: failed to apply loader entry`,
|
||||
)
|
||||
})
|
||||
|
||||
it('appends the deepest cause with its original stack to the load failure', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'failing.mjs'), [
|
||||
'export function apply() {',
|
||||
" const failure = new Error('pinned activation failure')",
|
||||
" failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
|
||||
' throw failure',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
|
||||
String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
|
||||
String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
|
||||
].join('')))
|
||||
})
|
||||
|
||||
it('falls back to the deepest cause message when its stack was erased', async () => {
|
||||
const dir = tmp()
|
||||
const deepest = new Error('stackless deep failure')
|
||||
delete (deepest as { stack?: string }).stack
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
|
||||
throw new Error('wrapped setup failure', { cause: deepest })
|
||||
})).rejects.toThrow(
|
||||
`${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
|
||||
)
|
||||
})
|
||||
|
||||
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 = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
|
||||
|
||||
it('distinguishes the source path from the current workdir between identity and 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
187
packages/boot/app-boot/tests/config-dump.spec.ts
Normal file
187
packages/boot/app-boot/tests/config-dump.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* `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')
|
||||
})
|
||||
})
|
||||
388
packages/boot/app-boot/tests/config-reload.spec.ts
Normal file
388
packages/boot/app-boot/tests/config-reload.spec.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Transactional config replacement through the booted Include and Loader tree.
|
||||
* HMR contains rejected refreshes; direct callers receive the error after the
|
||||
* previous generation has been retained or restored.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Include } from '@cordisjs/plugin-include'
|
||||
import { Group } from '@cordisjs/plugin-loader'
|
||||
import { boot } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
|
||||
|
||||
interface TreeFixture {
|
||||
ctx: Context
|
||||
dir: string
|
||||
include: Include
|
||||
}
|
||||
|
||||
async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
|
||||
writeFileSync(join(dir, 'cordis.yml'), configBody)
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
|
||||
if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
|
||||
return { ctx, dir, include: entry.subtree as Include }
|
||||
}
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
function entryById(ctx: Context, id: string) {
|
||||
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
|
||||
if (!entry) throw new Error(`missing loader entry ${id}`)
|
||||
return entry
|
||||
}
|
||||
|
||||
function plugin(name: string, body = ''): string {
|
||||
return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
|
||||
}
|
||||
|
||||
async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
|
||||
try {
|
||||
await task
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
|
||||
return
|
||||
}
|
||||
throw new Error(`expected loader update to fail during ${stage}`)
|
||||
}
|
||||
|
||||
describe('include refresh with an invalid file', () => {
|
||||
it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
||||
await expect(include.refresh()).rejects.toThrow('failed to parse config file')
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
// An empty file parses to `undefined` without a YAML error; it must be
|
||||
// treated exactly like a parse failure, not crash the entry walk.
|
||||
writeFileSync(join(dir, 'cordis.yml'), '')
|
||||
await expect(include.refresh()).rejects.toThrow('failed to validate config file')
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader entry replacement', () => {
|
||||
it('imports a changed name before replacing the running plugin', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'new.mjs': plugin('newPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
await entry.update({ name: './new.mjs' })
|
||||
expect(entry.options.name).toBe('./new.mjs')
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains the running plugin when the replacement cannot be imported', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous plugin after replacement application fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const previous = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === previous).toBe(false)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous config when an in-place restart fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not persist a failed direct fiber update', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
if (!fiber) throw new Error('target entry has no fiber')
|
||||
await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader tree replacement', () => {
|
||||
it('rolls back earlier updates and additions when a later entry fails', async () => {
|
||||
const { ctx, dir, include } = await bootTree([
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: old',
|
||||
'',
|
||||
].join('\n'), {
|
||||
'configurable.mjs': plugin('configurablePlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: candidate',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'- id: bad',
|
||||
' name: ./bad.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: committed',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await include.refresh()
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
|
||||
expect(entryById(ctx, 'added').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const config = (disabled: boolean) => [
|
||||
'- id: parent',
|
||||
' name: cordis:group',
|
||||
' group: true',
|
||||
` disabled: ${disabled}`,
|
||||
' config:',
|
||||
' - id: child',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(true))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeUndefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores a programmatic entry move when its update fails', async () => {
|
||||
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
|
||||
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
|
||||
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
|
||||
const target = entryById(ctx, targetId)
|
||||
const source = target.parent
|
||||
const sourceIndex = source.data.indexOf(target.options)
|
||||
const destination = entryById(ctx, groupId).subgroup
|
||||
if (!destination) throw new Error('created loader group has no subgroup')
|
||||
|
||||
await expectUpdateFailure(
|
||||
ctx.loader.update(targetId, { config: { fail: true } }, groupId),
|
||||
'apply',
|
||||
)
|
||||
|
||||
expect(target.parent).toBe(source)
|
||||
expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
|
||||
expect(source.data.indexOf(target.options)).toBe(sourceIndex)
|
||||
expect(destination.data).not.toContain(target.options)
|
||||
expect(target.options.config).toEqual({ fail: false })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include refresh with overlay patches', () => {
|
||||
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: base',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
' - id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: patched',
|
||||
' - insert:',
|
||||
' - id: extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
|
||||
if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
|
||||
const include = entry.subtree as Include
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
||||
expect(entryConfig(ctx, 'extra')).toBeUndefined()
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
||||
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
||||
|
||||
// Hot-update of the include entry's own config (the `internal/update`
|
||||
// path): the new patches must apply now AND stick for later re-reads —
|
||||
// the listener vetoes the fiber restart, so it must persist the new
|
||||
// config itself or the next refresh() re-applies the old overlay.
|
||||
await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
|
||||
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
|
||||
// Omitting the patch list must remove the overlay rather than reuse the
|
||||
// Include's previous config through a default parameter.
|
||||
await entry.update({ config: { path: './base.yml' } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include patches layered over one base', () => {
|
||||
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
|
||||
// The bundle/user-layer/`--patch` composition: `dsh` includes one root
|
||||
// and applies each source as its own patch list at the SAME include
|
||||
// level, because patches never cross an include boundary. A later layer
|
||||
// must therefore be able to reach a row an earlier layer inserted, or
|
||||
// bundle-only rows would be invisible to the user's patch layer.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: base',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
// Layer 1 (a bundle layer): patch a base row and add two of its own.
|
||||
' - id: shared',
|
||||
' config:',
|
||||
' value: bundle',
|
||||
' - insert:',
|
||||
' - id: bundle-kept',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: bundle-default',
|
||||
' - id: bundle-dropped',
|
||||
' name: ./noop.mjs',
|
||||
// Layer 2 (the user): reconfigure one inserted row and disable the other.
|
||||
' - id: bundle-kept',
|
||||
' config:',
|
||||
' value: user',
|
||||
' - id: bundle-dropped',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' })
|
||||
expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped')
|
||||
expect(dropped?.options.disabled).toBe(true)
|
||||
expect(dropped?.fiber).toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
182
packages/boot/app-boot/tests/hmr-config.spec.ts
Normal file
182
packages/boot/app-boot/tests/hmr-config.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
async function bootHmr(dir: string, root: string[] = []): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dir).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root, ignored: [], debounce: 0 })
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('HMR exact config paths', () => {
|
||||
it('observes module changes when its watch base is a filesystem alias', { timeout: 20_000 }, async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
const filename = join(alias, 'module.ts')
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
writeFileSync(filename, 'export const generation = 0\n')
|
||||
const ctx = await bootHmr(alias, ['.'])
|
||||
const expected = pathToFileURL(filename).href
|
||||
const observed: string[] = []
|
||||
ctx.on('hmr/change', (url) => { observed.push(url) })
|
||||
try {
|
||||
const deadline = Date.now() + 10_000
|
||||
for (let generation = 1; !observed.includes(expected); generation += 1) {
|
||||
if (Date.now() >= deadline) throw new Error('HMR did not observe a module change through the alias')
|
||||
writeFileSync(filename, `export const generation = ${generation}\n`)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(alias, { force: true })
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('collapses filesystem aliases before registering an exact watch', async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const ctx = await bootHmr(alias)
|
||||
try {
|
||||
await ctx.hmr.registerConfig('plugins.yml', () => {})
|
||||
await expect(ctx.hmr.registerConfig(join(realpathSync(target), 'plugins.yml'), () => {}))
|
||||
.rejects.toThrow('config path already registered')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(alias, { force: true })
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
try {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
observed.push('missing')
|
||||
}
|
||||
})
|
||||
|
||||
writeFileSync(filename, 'one', { flag: 'wx' })
|
||||
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
|
||||
writeFileSync(filename, 'two')
|
||||
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
|
||||
unlinkSync(filename)
|
||||
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const dir = join(root, 'later')
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(root)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
})
|
||||
mkdirSync(dir)
|
||||
writeFileSync(filename, 'created')
|
||||
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
writeFileSync(filename, 'one')
|
||||
const ctx = await bootHmr(dir)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const observed: string[] = []
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
try {
|
||||
const dispose = await ctx.hmr.registerConfig(filename, async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
if (observed.length === 1) {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
active -= 1
|
||||
})
|
||||
await started.promise
|
||||
writeFileSync(filename, 'two')
|
||||
// Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
|
||||
// that window so this edit is queued before registration disposal.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await disposal
|
||||
expect(maxActive).toBe(1)
|
||||
expect(observed).toEqual(['one', 'two'])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const failure = Promise.withResolvers<{ filename: string; error: Error }>()
|
||||
let failureCount = 0
|
||||
try {
|
||||
ctx.on('hmr/config-update-failed', () => {
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failureCount += 1
|
||||
failure.resolve({ filename: failedFilename, error })
|
||||
})
|
||||
await ctx.hmr.registerConfig(filename, () => { throw 42 })
|
||||
writeFileSync(filename, 'invalid')
|
||||
|
||||
const observed = await failure.promise
|
||||
expect(observed.filename).toBe(filename)
|
||||
expect(observed.error).toBeInstanceOf(Error)
|
||||
expect(observed.error.message).toBe('42')
|
||||
|
||||
writeFileSync(filename, 'invalid again')
|
||||
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
245
packages/boot/app-boot/tests/profile.spec.ts
Normal file
245
packages/boot/app-boot/tests/profile.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Profile machinery of `dsh-app-boot`: directory resolution and init,
|
||||
* manifest round-trips, two-anchor bundle resolution, patch-layer loading,
|
||||
* empty-root composition, and the installation module-fallback healing.
|
||||
*/
|
||||
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
initProfile,
|
||||
loadProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
PROFILE_TEMPLATES,
|
||||
readProfileManifest,
|
||||
resolveBundleDir,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
|
||||
|
||||
/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
|
||||
function stageInstallation(bundles: Record<string, { patch?: string; deps?: Record<string, string> }>): string {
|
||||
const root = tmp()
|
||||
const appDir = join(root, 'app')
|
||||
mkdirSync(join(appDir, 'node_modules'), { recursive: true })
|
||||
const appDeps: Record<string, string> = {}
|
||||
for (const [name, spec] of Object.entries(bundles)) {
|
||||
appDeps[name] = '0.0.0'
|
||||
const dir = join(appDir, 'node_modules', name)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name,
|
||||
version: '0.0.0',
|
||||
dependencies: spec.deps ?? {},
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
|
||||
}))
|
||||
if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
|
||||
}
|
||||
writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps }))
|
||||
return join(appDir, 'package.json')
|
||||
}
|
||||
|
||||
describe('resolveProfileDir', () => {
|
||||
it('joins the home and rejects traversal-shaped names', () => {
|
||||
const home = tmp()
|
||||
expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui'))
|
||||
for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
|
||||
expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('initProfile', () => {
|
||||
it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => {
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('tui', home)
|
||||
initProfile(dir, ['@deepseek-ai/dsh-base'])
|
||||
const manifest = readProfileManifest('t', dir)
|
||||
expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
|
||||
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
|
||||
// Re-init keeps user edits.
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
|
||||
initProfile(dir, ['other'])
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest round-trip', () => {
|
||||
it('writes and reads back, and fails loud on a broken manifest', () => {
|
||||
const dir = tmp()
|
||||
writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } })
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a'])
|
||||
writeFileSync(join(dir, 'package.json'), '[]')
|
||||
expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
|
||||
expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBundleDir', () => {
|
||||
it('prefers the installation anchor, falls back to the profile, and fails loud', () => {
|
||||
const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } })
|
||||
const profileDir = tmp()
|
||||
mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true })
|
||||
writeFileSync(join(profileDir, 'package.json'), '{}')
|
||||
writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' }))
|
||||
expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box')
|
||||
expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
|
||||
expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
|
||||
})
|
||||
|
||||
it('resolves a package whose exports map omits ./package.json', () => {
|
||||
// Common on npm: an exports map without "./package.json" makes
|
||||
// require.resolve('<pkg>/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED;
|
||||
// resolution must fall through to the paths probe instead of misreporting
|
||||
// the installed package as missing.
|
||||
const anchor = stageInstallation({})
|
||||
const profileDir = tmp()
|
||||
writeFileSync(join(profileDir, 'package.json'), '{}')
|
||||
const dir = join(profileDir, 'node_modules', 'sealed-bundle')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'sealed-bundle',
|
||||
version: '0.0.0',
|
||||
exports: { '.': './index.js' },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}))
|
||||
writeFileSync(join(dir, 'index.js'), '')
|
||||
writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
|
||||
expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadProfile', () => {
|
||||
it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
|
||||
'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
|
||||
})
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['bundle-a', 'bundle-b'])
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n')
|
||||
const profile = loadProfile('t', 'demo', anchor, home)
|
||||
expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
|
||||
expect(profile.patches).toHaveLength(1)
|
||||
const entries = composeEntries([
|
||||
...profile.layers.map(layer => layer.patches),
|
||||
profile.patches,
|
||||
])
|
||||
expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }])
|
||||
// A hand-made profile without the user layer file or dsh section: empty layers, no throw.
|
||||
rmSync(join(dir, PROFILE_PATCH_FILENAME))
|
||||
expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([])
|
||||
writeProfileManifest(dir, { name: 'bare' })
|
||||
const bare = loadProfile('t', 'demo', anchor, home)
|
||||
expect(bare.layers).toEqual([])
|
||||
})
|
||||
|
||||
it('auto-initializes only shipped templates and fails loud otherwise', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
expect(() => loadProfile('t', 'custom', anchor, home))
|
||||
.toThrow('profile "custom" does not exist')
|
||||
// The web template auto-initializes on first load. Bundle resolution
|
||||
// cannot be asserted to fail here: the source-plane test runner resolves
|
||||
// @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
|
||||
expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base')
|
||||
try {
|
||||
loadProfile('t', 'web', anchor, home)
|
||||
} catch {
|
||||
// Resolution failure is the plain-Node outcome for this empty anchor.
|
||||
}
|
||||
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
|
||||
.toEqual([...PROFILE_TEMPLATES.web ?? []])
|
||||
})
|
||||
|
||||
it('fails loud when a listed bundle declares no dsh.bundle', () => {
|
||||
const anchor = stageInstallation({ 'not-a-bundle': {} })
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['not-a-bundle'])
|
||||
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeEntries', () => {
|
||||
it('applies layers over an empty root and reports skipped patches', () => {
|
||||
const warnings: string[] = []
|
||||
const entries = composeEntries([
|
||||
[{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],
|
||||
[{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],
|
||||
], message => warnings.push(message))
|
||||
expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])
|
||||
expect(warnings.join('\n')).toContain('"missing"')
|
||||
// Default warn sink: skipped patches are silently dropped (boot repeats them).
|
||||
expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('healProfilesModuleFallback', () => {
|
||||
it('links the app and bundle dependency surface flat under profiles/node_modules', () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
|
||||
'plain-lib': {},
|
||||
})
|
||||
// An app dependency that is declared but not installed: skipped, not fatal.
|
||||
const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
|
||||
appManifest.dependencies['never-installed'] = '0.0.0'
|
||||
writeFileSync(anchor, JSON.stringify(appManifest))
|
||||
// dep-of-a lives in the installation's node_modules too.
|
||||
const modules = join(anchor, '..', 'node_modules')
|
||||
mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
|
||||
writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
// App deps, the bundle's own deps, and the bundle itself are linked; the
|
||||
// plain library is linked as an app dep (harmless), the app itself too.
|
||||
for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) {
|
||||
expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
|
||||
}
|
||||
// Idempotent, and a moved target is re-pointed.
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
const before = readlinkSync(join(fallback, 'dep-of-a'))
|
||||
expect(before).toContain('dep-of-a')
|
||||
})
|
||||
|
||||
it('throws when a fallback entry is a real directory', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true })
|
||||
expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink')
|
||||
})
|
||||
|
||||
it('replaces a wrong symlink', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
mkdirSync(fallback, { recursive: true })
|
||||
symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
|
||||
})
|
||||
|
||||
it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => {
|
||||
// The EEXIST arm: a second process wrote the link between our lstat miss
|
||||
// and symlinkSync. Simulated by pre-creating the correct link and calling
|
||||
// the internal path through a stale-lstat shim is not possible from
|
||||
// outside, so probe the observable contract: healing twice concurrently
|
||||
// is a no-op, and a foreign REAL directory still fails loud.
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
healProfilesModuleFallback(anchor, home) // second healer sees the correct link
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
})
|
||||
162
packages/boot/app-boot/tests/repository-cache.spec.ts
Normal file
162
packages/boot/app-boot/tests/repository-cache.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const roots: string[] = []
|
||||
|
||||
/** Normalize Git's platform checkout line endings for source-content assertions. */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
async function temporaryRoot(name: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function fakePackage(directory: string): Promise<void> {
|
||||
const target = join(directory, 'node_modules', 'repository')
|
||||
await mkdir(target, { recursive: true })
|
||||
await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('RepositoryCache', () => {
|
||||
it('single-flights and permanently reuses an exact specifier', async () => {
|
||||
const root = await temporaryRoot('repository-cache')
|
||||
const calls: string[] = []
|
||||
const install: RepositoryInstall = async (directory) => {
|
||||
calls.push(directory)
|
||||
await fakePackage(directory)
|
||||
}
|
||||
const cache = new RepositoryCache(root, install)
|
||||
const specifier = 'github:owner/repository#0123456789abcdef'
|
||||
|
||||
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
|
||||
expect(concurrent).toBe(first)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
|
||||
expect(await reopened.resolve(specifier)).toBe(first)
|
||||
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
|
||||
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
|
||||
dependencies: { repository: specifier },
|
||||
})
|
||||
|
||||
const second = await cache.resolve('github:owner/repository#fedcba9876543210')
|
||||
expect(second).not.toBe(first)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('accepts the valid winner when independent cache instances race', async () => {
|
||||
const root = await temporaryRoot('repository-race')
|
||||
const bothStarted = Promise.withResolvers<undefined>()
|
||||
let starts = 0
|
||||
const install: RepositoryInstall = async (directory) => {
|
||||
await fakePackage(directory)
|
||||
starts += 1
|
||||
if (starts === 2) bothStarted.resolve(undefined)
|
||||
await bothStarted.promise
|
||||
}
|
||||
const specifier = 'github:owner/repository#race'
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
])
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(starts).toBe(2)
|
||||
expect(await readdir(root)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removes a failed staging tree and permits an exact retry', async () => {
|
||||
const root = await temporaryRoot('repository-retry')
|
||||
let attempts = 0
|
||||
const cache = new RepositoryCache(root, async (directory) => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('install failed')
|
||||
await fakePackage(directory)
|
||||
})
|
||||
|
||||
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
|
||||
expect(await readdir(root)).toEqual([])
|
||||
await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules')
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects empty or padded specifiers before touching the cache', async () => {
|
||||
const root = await temporaryRoot('repository-input')
|
||||
const cache = new RepositoryCache(root, fakePackage)
|
||||
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
|
||||
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
|
||||
await expect(readdir(root)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('fails loud on a corrupt published marker instead of reinstalling it', async () => {
|
||||
const root = await temporaryRoot('repository-corrupt')
|
||||
const specifier = 'github:owner/repository#corrupt'
|
||||
const key = createHash('sha256').update(specifier).digest('hex')
|
||||
const entry = join(root, key)
|
||||
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
|
||||
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
|
||||
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
|
||||
|
||||
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
|
||||
})
|
||||
|
||||
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
|
||||
const root = await temporaryRoot('repository-pnpm')
|
||||
const repository = join(root, 'source')
|
||||
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
|
||||
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
|
||||
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-fixture',
|
||||
version: '1.0.0',
|
||||
})}\n`)
|
||||
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
|
||||
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-plugin-fixture',
|
||||
version: '1.0.0',
|
||||
scripts: { prepare: 'node prepare.mjs' },
|
||||
dsh: { skills: ['../skills'] },
|
||||
})}\n`)
|
||||
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
|
||||
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
|
||||
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
|
||||
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
|
||||
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
|
||||
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
|
||||
'',
|
||||
].join('\n'))
|
||||
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
|
||||
await execFileAsync('git', ['add', '.'], { cwd: repository })
|
||||
await execFileAsync('git', [
|
||||
'-c', 'user.name=Repository Fixture',
|
||||
'-c', 'user.email=repository@example.invalid',
|
||||
'commit', '--quiet', '-m', 'fixture',
|
||||
], { cwd: repository })
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' })
|
||||
const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
|
||||
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
|
||||
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
|
||||
|
||||
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
|
||||
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
|
||||
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
|
||||
expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')))
|
||||
.toBe('repository skill source\n')
|
||||
await expect(readFile(join(installed, 'package.json'), 'utf8'))
|
||||
.resolves.toContain('repository-plugin-fixture')
|
||||
})
|
||||
})
|
||||
263
packages/boot/app-boot/tests/user-patches.spec.ts
Normal file
263
packages/boot/app-boot/tests/user-patches.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
|
||||
* (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
|
||||
* a real Loader tree, kept live through transactional HMR.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import {
|
||||
boot,
|
||||
loadOptionalPatches,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
watchUserPatches,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))
|
||||
|
||||
describe('loadOptionalPatches', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DSH_HOME
|
||||
})
|
||||
|
||||
it('returns undefined when no user patch file exists', () => {
|
||||
expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [
|
||||
'- id: agent-loop',
|
||||
" name: '@deepseek-ai/dsh-agent-loop'",
|
||||
' config:',
|
||||
' model: !!js process.env.DSH_SPEC_MODEL',
|
||||
'- insert:',
|
||||
' - id: llm',
|
||||
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
||||
'',
|
||||
].join('\n'))
|
||||
const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))
|
||||
expect(patches).toHaveLength(2)
|
||||
expect(patches?.[0]).toMatchObject({
|
||||
id: 'agent-loop',
|
||||
config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
|
||||
})
|
||||
expect(patches?.[1]?.insert).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => {
|
||||
const dir = tmp()
|
||||
mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read patches `))
|
||||
})
|
||||
|
||||
it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config:\n a: !!js\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
|
||||
})
|
||||
|
||||
it('fails loud when the file is not a top-level array or an entry is not an object', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow('must be a top-level YAML array of loader patch entries')
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(`${NAME}: patches entry 1 in`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with user patches', () => {
|
||||
function writeTree(dir: string): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), [
|
||||
'export const name = "noop"',
|
||||
'export function apply(_ctx, config = {}) {',
|
||||
' if (config.fail) throw new Error("candidate config failed")',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
return join(dir, 'cordis.yml')
|
||||
}
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
||||
const dir = tmp()
|
||||
const userDir = tmp()
|
||||
writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [
|
||||
'- id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: !!js process.env.DSH_APP_BOOT_USER_SPEC',
|
||||
'- insert:',
|
||||
' - id: user-extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value'
|
||||
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
|
||||
// The mounted plugin received the interpolated environment value.
|
||||
expect(noop?.fiber?.config).toEqual({ value: 'user-value' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
delete process.env['DSH_APP_BOOT_USER_SPEC']
|
||||
}
|
||||
})
|
||||
|
||||
it('mounts no patch layer for an absent or empty user layer', async () => {
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
const empty = tmp()
|
||||
writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n')
|
||||
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
await ctxEmpty.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
|
||||
const dir = tmp()
|
||||
const userDir = tmp()
|
||||
const filename = join(userDir, PROFILE_PATCH_FILENAME)
|
||||
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
|
||||
const ctx = await boot(NAME, writeTree(dir), basePatches)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const failures: Array<{ filename: string; error: Error }> = []
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failures.push({ filename: failedFilename, error })
|
||||
})
|
||||
const dispose = await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename,
|
||||
compose: userPatches => [...basePatches, ...userPatches],
|
||||
})
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied')
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n fail: true\n')
|
||||
await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
|
||||
expect(failures[0]).toMatchObject({ filename })
|
||||
expect(failures[0]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
writeFileSync(filename, 'invalid: [unclosed\n')
|
||||
await eventually(() => failures.length === 2, 'parse failure was not broadcast')
|
||||
expect(failures[1]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: recovered\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
unlinkSync(filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
|
||||
expect(failures).toHaveLength(2)
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
// Default compose: the user layer IS the whole patch list, so a
|
||||
// fresh generation replaces the app-owned layer instead of stacking on it.
|
||||
await dispose()
|
||||
const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
|
||||
} finally {
|
||||
await disposeDefault()
|
||||
}
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
|
||||
const dir = tmp()
|
||||
const withoutHmr = await boot(NAME, writeTree(dir))
|
||||
await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service')
|
||||
await withoutHmr.fiber.dispose()
|
||||
|
||||
const withoutInclude = new Context()
|
||||
withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href
|
||||
await withoutInclude.plugin(Loader)
|
||||
await withoutInclude.plugin(Timer)
|
||||
await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry')
|
||||
await withoutInclude.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
|
||||
// A surface can dispose the whole tree while registerConfig's effect
|
||||
// registration is still in flight (the HMR effect then fails with
|
||||
// INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher
|
||||
// must not crash the process. The stub makes the race deterministic — the
|
||||
// live-teardown ordering itself is not stageable.
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
|
||||
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates registration failures other than mid-teardown', async () => {
|
||||
const dir = tmp()
|
||||
const filename = join(tmp(), PROFILE_PATCH_FILENAME)
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
// Same user-layer path registered twice: HMR refuses; not a teardown race.
|
||||
await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
|
||||
await dispose()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user