Files
dsh-web/shared/tests/run-guarded.spec.ts
zhu1090093659 4b25dd7714 feat(aggregate): isolate per-plugin boot failures behind the dsh-web-all shell
The DSH loader mounts every patch row as one transactional group: a single
plugin that fails to import or start rolls back the whole family and aborts
'dsh web'. Redefine the fault unit to one plugin.

- scripts/aggregate.mjs emits each family insert row with the aggregate's own
  never-failing shell module as the row name and the real plugin package in
  the row config (config.plugin, original config under config.config);
  dsh-i18n and all external npm rows keep mounting directly
- @linxin666/dsh-web-all main entry is the shell: it imports the real module
  at start time, contains import/shape/activation failures to that entry
  (logged + recorded), and mounts the real plugin as a nested plugin so
  cordis service scoping, lifecycle, and retraction semantics stay intact
- loopback-only GET /api/dsh-web-all/degraded reports the degradation ledger
  for doctor/monitoring consumption
- shared/host/run-guarded.ts (+ synced copies in the four packages with
  in-process HTTP/poll faces) converts fire-and-forget rejections into logged
  errors against the host's installFailLoud whole-process exit
- tests: shell-isolation.spec.ts runs the real installed host boot through
  start-failure / import-failure / no-webServer scenarios plus the control
  case proving today's direct-mount shape still kills the boot

Evidence: pnpm typecheck, pnpm test (22 packages), pnpm docs:check,
pnpm i18n:check, pnpm aggregate:check, pnpm test:scripts (234), and full
pnpm build all pass. Bundle-layer change: needs a dsh web restart.
2026-09-01 20:34:14 +08:00

62 lines
2.1 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { guardedHandler, runGuarded } from '../host/run-guarded.js'
describe('runGuarded', () => {
it('consumes rejections of fire-and-forget promises', async () => {
const log = vi.fn()
const boom = Promise.reject(new Error('boom'))
const returned = runGuarded(boom, 'site', log)
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledTimes(1)
expect(String(log.mock.calls[0][0])).toContain('[site] unhandled async failure: Error: boom')
expect(returned).toBe(boom)
})
it('leaves resolved values untouched', async () => {
const log = vi.fn()
const value = await runGuarded(Promise.resolve(42), 'site', log)
expect(value).toBe(42)
expect(log).not.toHaveBeenCalled()
})
})
describe('guardedHandler', () => {
it('guards sync throws per invocation', () => {
const log = vi.fn()
const handler = guardedHandler('site', () => {
throw new Error('sync boom')
}, log)
expect(handler()).toBeUndefined()
expect(log).toHaveBeenCalledTimes(1)
expect(String(log.mock.calls[0][0])).toContain('sync failure')
})
it('guards async rejections per invocation', async () => {
const log = vi.fn()
const handler = guardedHandler('site', async () => {
throw new Error('async boom')
}, log)
expect(handler()).toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledTimes(1)
expect(String(log.mock.calls[0][0])).toContain('async boom')
})
it('passes sync return values through', () => {
const handler = guardedHandler('site', (a: number) => a * 2, vi.fn())
expect(handler(21)).toBe(42)
})
it('guards each invocation independently', async () => {
const log = vi.fn()
const handler = guardedHandler('site', (fail: boolean) => {
if (fail) throw new Error('no')
return 'ok'
}, log)
expect(handler(false)).toBe('ok')
expect(handler(true)).toBeUndefined()
expect(handler(false)).toBe('ok')
expect(log).toHaveBeenCalledTimes(1)
expect(log.mock.calls[0]).toBeTruthy()
})
})