Files
dsh-context/tests/client/components/errorBoundary.spec.ts
T
Bowen Liang 4d144147a0 refactor(client): import React members directly, dropping the central react module
Every client file reached hooks and element types through the central
require seam (src/client/react.ts) and spelled them React.useState /
ReactNS.ReactElement. Import the members directly instead — named value
imports for hooks, inline type-only imports for element types — and let
the bundler own the single require('react'), which is exactly what the
platform module table answers.

This needs JSX off the classic runtime (it required a React identifier
in every tsx scope): tsconfig moves to jsx: react-jsx, so the bundle
sources jsx from 'react/jsx-runtime', already seeded by every supported
harness (verified against the dsh-v0.1.2-rc.1 platform.ts). The bundle
smoke's fake module table gains the specifier to stay a faithful shell
stand-in. h() survives as a local createElement alias at the slot
render-prop seams and the lightbox portal imports createPortal from
react-dom directly.

Verified: the built client.js requires react, react/jsx-runtime, and
react-dom exactly once each; the compat lane (bundle smoke + the
real-baseline matrix) passes against the fresh artifacts; the full gate
stays green with per-file coverage at 100%.
2026-09-07 08:59:03 +08:00

67 lines
2.4 KiB
TypeScript

// ErrorBoundary (src/client/components/errorBoundary.tsx): real subtree
// errors degrade to the styled error card; Retry resets the boundary.
// React logs caught errors to console and replays the failed render through a
// fake DOM event — silenced with a window-error preventer and a spy, never
// mocked.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { makeErrorBoundary } from '../../../src/client/components/errorBoundary'
import { click, makeKit, mount, query, silenceWindowErrors, text } from '../helpers/kit'
const kit = makeKit()
const ErrorBoundary = makeErrorBoundary(kit.t)
let consoleSpy: ReturnType<typeof vi.spyOn> | null = null
let silenceErrors: (() => void) | null = null
afterEach(() => {
consoleSpy?.mockRestore()
consoleSpy = null
silenceErrors?.()
silenceErrors = null
})
function silenceRenderErrors(): void {
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
silenceErrors = silenceWindowErrors()
}
describe('ErrorBoundary', () => {
test('passes children through when nothing throws', async () => {
const m = await mount(h(ErrorBoundary, {}, h('div', { className: 'healthy' }, 'all good')))
assert.equal(text(m.container), 'all good')
await m.unmount()
})
test('a render error degrades to the error card; Retry resumes a healthy child', async () => {
silenceRenderErrors()
let shouldThrow = true
function Bomb() {
if (shouldThrow) throw new Error('kaboom')
return h('div', { className: 'healthy' }, 'recovered')
}
const m = await mount(h(ErrorBoundary, {}, h(Bomb, {})))
const card = query(m.container, '.lc-error')
assert.ok(text(card).includes('Failed to read context data:'))
assert.equal(query(card, '.lc-error-msg').textContent, 'kaboom')
const retry = query(card, '.lc-error-retry')
assert.equal(retry.textContent, 'Retry')
shouldThrow = false
await click(retry)
assert.equal(text(m.container), 'recovered')
await m.unmount()
})
test('a non-Error throw is stringified into the card', async () => {
silenceRenderErrors()
function Bomb(): never {
throw 'string failure' // deliberate non-Error throw
}
const m = await mount(h(ErrorBoundary, {}, h(Bomb, {})))
assert.equal(query(m.container, '.lc-error-msg').textContent, 'string failure')
await m.unmount()
})
})