|
|
@@ -2,11 +2,12 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod
|
|
|
import { tmpdir } from 'node:os'
|
|
|
import { join, resolve, sep } from 'node:path'
|
|
|
import { pathToFileURL } from 'node:url'
|
|
|
+import { inspect } from 'node:util'
|
|
|
import { afterAll, describe, expect, it, vi } from 'vitest'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
|
|
import {
|
|
|
- addHarnessSourceSection, auditStartupEntries, boot,
|
|
|
+ addHarnessSourceSection, auditStartupEntries, boot, StartupError,
|
|
|
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
|
|
|
installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
|
|
} from '../src/index.ts'
|
|
|
@@ -587,7 +588,7 @@ describe('auditStartupEntries', () => {
|
|
|
}]), NAME, warn)
|
|
|
const detail = `${id} (./plugin.mjs): disabled expression failed: ${error.stack!}`
|
|
|
if (required) {
|
|
|
- await expect(result).rejects.toThrow(`required startup failure: 1 entry did not activate\n${detail}`)
|
|
|
+ await expect(result).rejects.toThrow(` ${id} (required)\n Package: ./plugin.mjs\n disabled expression failed: ${error.stack!.replaceAll('\n', '\n ')}`)
|
|
|
expect(warn).not.toHaveBeenCalled()
|
|
|
} else {
|
|
|
await expect(result).resolves.toBeUndefined()
|
|
|
@@ -670,18 +671,97 @@ describe('auditStartupEntries', () => {
|
|
|
expect(diagnostic).toContain('unexpected-state (./unexpected-state.mjs): fiber state 1')
|
|
|
})
|
|
|
|
|
|
- it.each(requiredIds)('rejects required %s failures after warning about optional failures', async (id) => {
|
|
|
+ it.each(requiredIds)('combines required %s and optional failures without a separate warning', async (id) => {
|
|
|
const warn = vi.fn()
|
|
|
const requiredError = new Error('address already in use')
|
|
|
const optionalError = new Error('todo unavailable')
|
|
|
- await expect(auditStartupEntries(ctxWith([
|
|
|
+ const error = await auditStartupEntries(ctxWith([
|
|
|
{ fiber: fiber(3, requiredError), options: { id, name: './required.mjs' } },
|
|
|
{ fiber: fiber(3, optionalError), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
|
|
|
- ]), NAME, warn)).rejects.toThrow([
|
|
|
- 'required startup failure: 1 entry did not activate',
|
|
|
- `${id} (./required.mjs): ${requiredError.stack!}`,
|
|
|
- ].join('\n'))
|
|
|
- expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`)
|
|
|
+ ]), NAME, warn).catch((error: unknown) => error)
|
|
|
+ expect(error).toBeInstanceOf(StartupError)
|
|
|
+ expect((error as Error).message).toContain(`${NAME}: startup failed: 1 required plugin did not activate`)
|
|
|
+ expect((error as Error).message).toContain(` ${id} (required)\n Package: ./required.mjs`)
|
|
|
+ expect((error as Error).message).toContain(' tool-todo\n Package: @deepseek-ai/dsh-tool-todo')
|
|
|
+ expect(((error as Error).cause as AggregateError).errors).toEqual([requiredError, optionalError])
|
|
|
+ expect(warn).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('omits an error cause when required plugins are only waiting for services', async () => {
|
|
|
+ const error = await auditStartupEntries(ctxWith([
|
|
|
+ { fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
|
|
|
+ ]), NAME, vi.fn()).catch((error: unknown) => error)
|
|
|
+ expect(error).toBeInstanceOf(StartupError)
|
|
|
+ expect(Object.hasOwn(error as StartupError, 'cause')).toBe(false)
|
|
|
+ expect(inspect(error)).not.toContain('AggregateError')
|
|
|
+ expect((error as StartupError).message).toContain('Plugins waiting for services (1):')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps diagnostic metadata available without expanding it in ordinary error inspection', () => {
|
|
|
+ const entries = [{
|
|
|
+ id: 'connection', module: './connection.mjs', required: true, fiberState: 0,
|
|
|
+ outcome: { kind: 'pending' as const, missing: ['webRuntime'] },
|
|
|
+ }]
|
|
|
+ const error = new StartupError('waiting for webRuntime', entries)
|
|
|
+ const startup = { configurationPath: '/private/cordis.yml', messages: [
|
|
|
+ { ts: 1, name: 'loader', type: 'warn', args: ['raw diagnostic argument'] },
|
|
|
+ ] }
|
|
|
+ error.startup = startup
|
|
|
+ expect(error.entries).toBe(entries)
|
|
|
+ expect(error.startup).toBe(startup)
|
|
|
+ const output = inspect(error)
|
|
|
+ expect(output).toContain('waiting for webRuntime')
|
|
|
+ expect(output).not.toContain('connection.mjs')
|
|
|
+ expect(output).not.toContain('/private/cordis.yml')
|
|
|
+ expect(output).not.toContain('raw diagnostic argument')
|
|
|
+ const full = inspect(error, { showHidden: true, depth: null })
|
|
|
+ expect(full).toContain('connection.mjs')
|
|
|
+ expect(full).toContain('/private/cordis.yml')
|
|
|
+ expect(full).toContain('raw diagnostic argument')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('retains nested and shared errors in a fatal diagnostic without duplicating them', async () => {
|
|
|
+ const leaf = new Error('leaf failure')
|
|
|
+ leaf.stack = 'Error: leaf failure\n at plugin.mjs:1:2'
|
|
|
+ const aggregate = new AggregateError([leaf, 'plain failure'], 'activation failed', { cause: leaf })
|
|
|
+ aggregate.stack = 'AggregateError: activation failed\n at plugin.mjs:3:4'
|
|
|
+ const error = await auditStartupEntries(ctxWith([
|
|
|
+ { fiber: fiber(3, aggregate), options: { id: 'webserver', name: './plugin.mjs' } },
|
|
|
+ ]), NAME, vi.fn()).catch((error: unknown) => error)
|
|
|
+ expect((error as Error).message).toContain('AggregateError: activation failed')
|
|
|
+ expect((error as Error).message).toContain(' Error: leaf failure\n at plugin.mjs:1:2')
|
|
|
+ expect((error as Error).message.match(/leaf failure/gu)).toHaveLength(1)
|
|
|
+ expect((error as Error).message).toContain(' plain failure')
|
|
|
+ expect(((error as Error).cause as AggregateError).errors).toEqual([aggregate])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('groups original failure stacks and pending services in one startup diagnostic', async () => {
|
|
|
+ const original = new Error('listen EADDRINUSE: address already in use 127.0.0.1:3080')
|
|
|
+ original.stack = `${original.name}: ${original.message}\n at Server.listen (node:net:1:2)`
|
|
|
+ const warn = vi.fn()
|
|
|
+ const error = await auditStartupEntries(ctxWith([
|
|
|
+ { fiber: fiber(0, undefined, { webServer: {} }), options: { id: 'web-runtime', name: './web.mjs' } },
|
|
|
+ { fiber: fiber(3, original), options: { id: 'webserver', name: '@deepseek-ai/dsh-host-webserver' } },
|
|
|
+ { fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
|
|
|
+ { fiber: fiber(0), options: { id: 'unknown', name: './unknown.mjs' } },
|
|
|
+ ]), NAME, warn).catch((error: unknown) => error)
|
|
|
+ expect(error).toBeInstanceOf(StartupError)
|
|
|
+ expect((error as Error).message).toMatchInlineSnapshot(`
|
|
|
+ "dsh-test-bin: startup failed: 2 required plugins did not activate
|
|
|
+
|
|
|
+ Failed plugins (1):
|
|
|
+ webserver (required)
|
|
|
+ Package: @deepseek-ai/dsh-host-webserver
|
|
|
+ Error: listen EADDRINUSE: address already in use 127.0.0.1:3080
|
|
|
+ at Server.listen (node:net:1:2)
|
|
|
+
|
|
|
+ Plugins waiting for services (3):
|
|
|
+ Plugin Missing services
|
|
|
+ connection (required) webRuntime
|
|
|
+ web-runtime webServer
|
|
|
+ unknown unknown"
|
|
|
+ `)
|
|
|
+ expect(warn).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
it('rejects a required entry pending on an injected service', async () => {
|
|
|
@@ -689,7 +769,7 @@ describe('auditStartupEntries', () => {
|
|
|
fiber: fiber(0, undefined, { headlessStartup: {} }),
|
|
|
options: { id: 'headless-runner', name: '@deepseek-ai/dsh-headless' },
|
|
|
}]), NAME, vi.fn())).rejects.toThrow(
|
|
|
- 'headless-runner (@deepseek-ai/dsh-headless): pending (waiting for service: headlessStartup)',
|
|
|
+ 'headless-runner (required) headlessStartup',
|
|
|
)
|
|
|
})
|
|
|
})
|
|
|
@@ -714,6 +794,78 @@ describe('loadOverlayPatches', () => {
|
|
|
})
|
|
|
|
|
|
describe('boot', () => {
|
|
|
+ it('retains import errors and inactive-entry metadata after disposing the startup tree', async () => {
|
|
|
+ const dir = tmp()
|
|
|
+ const config = join(dir, 'cordis.yml')
|
|
|
+ writeFileSync(config, '- id: webserver\n name: ./missing.mjs\n')
|
|
|
+ const failure = await boot(NAME, config).catch((error: unknown) => error)
|
|
|
+ expect(failure).toBeInstanceOf(StartupError)
|
|
|
+ const error = failure as StartupError
|
|
|
+ expect(error.entries).toEqual([{
|
|
|
+ id: 'webserver', module: './missing.mjs', required: true, fiberState: undefined,
|
|
|
+ outcome: { kind: 'failed', error: 'failed to import' },
|
|
|
+ }])
|
|
|
+ expect(error.startup?.configurationPath).toBe(config)
|
|
|
+ expect(error.startup?.messages.some(message => message.args.some(arg => arg instanceof Error && arg.message.includes('missing.mjs')))).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('retains warnings and errors from asynchronous failed-startup cleanup', async () => {
|
|
|
+ const dir = tmp()
|
|
|
+ const marker = join(dir, 'cleanup.txt')
|
|
|
+ writeFileSync(join(dir, 'cleanup.mjs'), `
|
|
|
+ import { writeFileSync } from 'node:fs'
|
|
|
+ export function apply(ctx) {
|
|
|
+ ctx.effect(() => async () => {
|
|
|
+ await Promise.resolve()
|
|
|
+ writeFileSync(${JSON.stringify(marker)}, 'ran')
|
|
|
+ ctx.logger.warn('plugin cleanup warning')
|
|
|
+ throw new Error('plugin cleanup error')
|
|
|
+ })
|
|
|
+ }
|
|
|
+ `)
|
|
|
+ const config = join(dir, 'cordis.yml')
|
|
|
+ writeFileSync(config, '- id: cleanup\n name: ./cleanup.mjs\n- id: webserver\n name: ./missing.mjs\n')
|
|
|
+ let root!: Context
|
|
|
+ const failure = await boot(NAME, config, undefined, (ctx) => {
|
|
|
+ root = ctx
|
|
|
+ ctx.effect(() => async () => {
|
|
|
+ await Promise.resolve()
|
|
|
+ ctx.logger.warn('root cleanup warning')
|
|
|
+ })
|
|
|
+ }).catch((error: unknown) => error)
|
|
|
+ expect(failure).toBeInstanceOf(StartupError)
|
|
|
+ expect(readFileSync(marker, 'utf8')).toBe('ran')
|
|
|
+ const messages = (failure as StartupError).startup!.messages
|
|
|
+ const args = messages.flatMap(message => message.args)
|
|
|
+ expect(args).toContain('plugin cleanup warning')
|
|
|
+ expect(args).toContain('root cleanup warning')
|
|
|
+ expect(args.some(value => value instanceof Error && value.message.includes('plugin cleanup error'))).toBe(true)
|
|
|
+ const count = messages.length
|
|
|
+ root.logger.warn('after boot rejected')
|
|
|
+ expect(messages).toHaveLength(count)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('stops collecting startup diagnostics after a successful boot', async () => {
|
|
|
+ const dir = tmp()
|
|
|
+ const config = join(dir, 'cordis.yml')
|
|
|
+ writeFileSync(config, '[]\n')
|
|
|
+ let exporters = 0
|
|
|
+ const messages: unknown[][] = []
|
|
|
+ const ctx = await boot(NAME, config, undefined, (host) => {
|
|
|
+ host.logger.exporter({ levels: { default: 2 }, export: ({ args }) => { messages.push(args) } })
|
|
|
+ exporters = host.logger.exporters.size
|
|
|
+ host.logger.info('startup information')
|
|
|
+ host.logger.warn('startup warning')
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ expect(ctx.logger.exporters.size).toBe(exporters - 1)
|
|
|
+ ctx.logger.warn('warning after startup')
|
|
|
+ expect(messages).toEqual([['startup information'], ['startup warning'], ['warning after startup']])
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
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')
|
|
|
@@ -960,7 +1112,7 @@ describe('boot', () => {
|
|
|
['import', undefined, '', 'failed to import'],
|
|
|
['config schema', 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate() { return { issues: [{ message: "schema failure" }] } } } }\nexport function apply() {}\n', '', 'schema failure'],
|
|
|
['config expression', 'export function apply() {}\n', ' config: { value: !!js "JSON.parse(\'invalid\')" }\n', 'SyntaxError'],
|
|
|
- ['disabled expression', 'export function apply() {}\n', ' disabled: !!js "JSON.parse(\'invalid\')"\n', 'required startup failure: 1 entry did not activate\nwebserver (./required.mjs): disabled expression failed: SyntaxError'],
|
|
|
+ ['disabled expression', 'export function apply() {}\n', ' disabled: !!js "JSON.parse(\'invalid\')"\n', 'disabled expression failed: SyntaxError'],
|
|
|
['sync apply', 'export function apply() { throw new Error("sync failure") }\n', '', 'sync failure'],
|
|
|
['async apply', 'export async function apply() { await Promise.resolve(); throw new Error("async failure") }\n', '', 'async failure'],
|
|
|
['missing dependency', 'export const inject = ["missingRequiredService"]\nexport function apply() {}\n', '', 'missingRequiredService'],
|
|
|
@@ -995,8 +1147,8 @@ describe('boot', () => {
|
|
|
].join('\n'))
|
|
|
|
|
|
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
|
|
|
- 'plugin tree failed to load: required startup failure: 1 entry did not activate',
|
|
|
- String.raw`webserver \(\.\/required-failure\.mjs\):`,
|
|
|
+ 'startup failed: 1 required plugin did not activate',
|
|
|
+ String.raw`webserver \(required\)`,
|
|
|
'required apply failure',
|
|
|
].join(String.raw`[\s\S]*`)))
|
|
|
disposed = (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ ?? false
|