qwen-code/packages/web-shell/client/components/ErrorBoundary.test.tsx
carffuca 0dc631b631
feat(web-shell): add error boundaries so a render crash can't white-screen the embed (#5943)
web-shell ships as an embeddable React component, so an uncaught render
error anywhere in the tree (Markdown / KaTeX / Mermaid / a tool panel)
unmounts the whole React root and blanks the host page. Add two layers of
isolation plus a generic boundary:

- ErrorBoundary (components/ErrorBoundary.tsx): generic class boundary with
  reset-on-key recovery, so a streamed/edited/retried subtree recovers on its
  own while a stably-broken one stays on the fallback without looping.
- Message level: each MessageItem body is wrapped, so one bad message degrades
  to an inline notice instead of taking down the transcript. The notice is
  role-aware (right-aligned for user messages, left for assistant) so it still
  reads as the right turn and doesn't blur turn boundaries.
- Top level: the WebShell lib entry and the standalone main.tsx wrap <App> in a
  self-contained RootErrorFallback (no provider/theme/i18n dependency, inherits
  host text color) offering a retry, so an App-level crash shows a recoverable
  surface instead of a blank page.

Adds message.renderError strings (en + zh-CN) and unit tests covering the
boundary mechanism and the root fallback + retry path.
2026-06-28 01:12:42 +00:00

152 lines
4.8 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ErrorBoundary } from './ErrorBoundary';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
function render(node: React.ReactNode): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
mounted.push({ root, container });
return container;
}
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.restoreAllMocks();
});
function Boom({ explode }: { explode: boolean }): React.ReactElement {
if (explode) throw new Error('kaboom');
return <div data-testid="ok">healthy</div>;
}
describe('ErrorBoundary', () => {
it('renders children when nothing throws', () => {
const container = render(
<ErrorBoundary fallback={<div data-testid="fallback" />}>
<Boom explode={false} />
</ErrorBoundary>,
);
expect(container.querySelector('[data-testid="ok"]')).not.toBeNull();
expect(container.querySelector('[data-testid="fallback"]')).toBeNull();
});
it('renders the fallback when a child throws', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const container = render(
<ErrorBoundary fallback={<div data-testid="fallback">down</div>}>
<Boom explode={true} />
</ErrorBoundary>,
);
expect(container.querySelector('[data-testid="fallback"]')).not.toBeNull();
expect(container.querySelector('[data-testid="ok"]')).toBeNull();
});
it('passes the captured error to a render-prop fallback', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const container = render(
<ErrorBoundary
fallback={(error) => <div data-testid="fallback">{error.message}</div>}
>
<Boom explode={true} />
</ErrorBoundary>,
);
expect(
container.querySelector('[data-testid="fallback"]')?.textContent,
).toBe('kaboom');
});
it('logs the error with the configured label prefix', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary label="message:assistant" fallback={<div />}>
<Boom explode={true} />
</ErrorBoundary>,
);
expect(
spy.mock.calls.some(
([first]) =>
typeof first === 'string' &&
first.includes('[web-shell] message:assistant failed:'),
),
).toBe(true);
});
it('recovers when resetKeys change after an error', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() =>
root.render(
<ErrorBoundary
resetKeys={[1]}
fallback={<div data-testid="fallback" />}
>
<Boom explode={true} />
</ErrorBoundary>,
),
);
expect(container.querySelector('[data-testid="fallback"]')).not.toBeNull();
// Same key + now-healthy child: the boundary is still latched on the error,
// so the fallback persists until a reset key actually changes.
act(() =>
root.render(
<ErrorBoundary
resetKeys={[1]}
fallback={<div data-testid="fallback" />}
>
<Boom explode={false} />
</ErrorBoundary>,
),
);
expect(container.querySelector('[data-testid="fallback"]')).not.toBeNull();
expect(container.querySelector('[data-testid="ok"]')).toBeNull();
// Changed key clears the error and re-mounts the (now healthy) child.
act(() =>
root.render(
<ErrorBoundary
resetKeys={[2]}
fallback={<div data-testid="fallback" />}
>
<Boom explode={false} />
</ErrorBoundary>,
),
);
expect(container.querySelector('[data-testid="ok"]')).not.toBeNull();
expect(container.querySelector('[data-testid="fallback"]')).toBeNull();
});
it('keeps showing the fallback when a stable broken child never changes', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
const tree = (
<ErrorBoundary resetKeys={[1]} fallback={<div data-testid="fallback" />}>
<Boom explode={true} />
</ErrorBoundary>
);
act(() => root.render(tree));
act(() => root.render(tree));
expect(container.querySelector('[data-testid="fallback"]')).not.toBeNull();
});
});