mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-23 07:54:11 +00:00
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.
142 lines
4.6 KiB
TypeScript
142 lines
4.6 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';
|
|
import { RootErrorFallback } from './RootErrorFallback';
|
|
import type { WebShellLanguage } from '../i18n';
|
|
|
|
(
|
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
|
|
|
function mount(node: React.ReactNode): { container: HTMLElement; root: Root } {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
act(() => root.render(node));
|
|
const entry = { container, root };
|
|
mounted.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const { root, container } of mounted.splice(0)) {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
}
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
// Mirror the top-level wiring in index.tsx / main.tsx: the boundary wraps the
|
|
// whole App and renders RootErrorFallback (with retry) on a render-phase crash.
|
|
function RootBoundary({
|
|
children,
|
|
language,
|
|
}: {
|
|
children: React.ReactNode;
|
|
language?: WebShellLanguage;
|
|
}) {
|
|
return (
|
|
<ErrorBoundary
|
|
label="web-shell-root"
|
|
fallback={(error, reset) => (
|
|
<RootErrorFallback error={error} onRetry={reset} language={language} />
|
|
)}
|
|
>
|
|
{children}
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
describe('RootErrorFallback', () => {
|
|
it('renders English copy by default', () => {
|
|
const { container } = mount(
|
|
<RootErrorFallback error={new Error('boom detail')} onRetry={() => {}} />,
|
|
);
|
|
const alert = container.querySelector('[role="alert"]');
|
|
expect(alert?.textContent).toContain('Something went wrong');
|
|
expect(alert?.textContent).toContain('Try again');
|
|
});
|
|
|
|
it('shows the raw error message only in dev, never in production', () => {
|
|
vi.stubEnv('DEV', true);
|
|
const dev = mount(
|
|
<RootErrorFallback error={new Error('boom detail')} onRetry={() => {}} />,
|
|
);
|
|
expect(dev.container.textContent).toContain('boom detail');
|
|
|
|
vi.stubEnv('DEV', false);
|
|
const prod = mount(
|
|
<RootErrorFallback error={new Error('boom detail')} onRetry={() => {}} />,
|
|
);
|
|
expect(prod.container.textContent).not.toContain('boom detail');
|
|
// The user-facing copy still renders in production.
|
|
expect(prod.container.textContent).toContain('Something went wrong');
|
|
expect(prod.container.textContent).toContain('Try again');
|
|
});
|
|
|
|
it('renders zh-CN copy when language is zh-CN', () => {
|
|
const { container } = mount(
|
|
<RootErrorFallback
|
|
error={new Error('x')}
|
|
onRetry={() => {}}
|
|
language="zh-CN"
|
|
/>,
|
|
);
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
|
'出了点问题',
|
|
);
|
|
});
|
|
|
|
it('catches an App-level render crash instead of white-screening', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
function BrokenApp(): React.ReactElement {
|
|
throw new Error('app render exploded');
|
|
}
|
|
const { container } = mount(
|
|
<RootBoundary>
|
|
<BrokenApp />
|
|
</RootBoundary>,
|
|
);
|
|
const alert = container.querySelector('[role="alert"]');
|
|
expect(alert).not.toBeNull();
|
|
expect(alert?.textContent).toContain('Something went wrong');
|
|
expect(alert?.textContent).toContain('app render exploded');
|
|
});
|
|
|
|
it('recovers when the user clicks "Try again" after the cause is gone', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
// External switch the child reads at render time. The boundary's reset()
|
|
// re-renders the children; flipping this before the click lets the now-
|
|
// healthy App mount.
|
|
let shouldThrow = true;
|
|
function FlakyApp(): React.ReactElement {
|
|
if (shouldThrow) throw new Error('transient');
|
|
return <div data-testid="app">recovered</div>;
|
|
}
|
|
|
|
const { container } = mount(
|
|
<RootBoundary>
|
|
<FlakyApp />
|
|
</RootBoundary>,
|
|
);
|
|
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
|
expect(container.querySelector('[data-testid="app"]')).toBeNull();
|
|
|
|
shouldThrow = false;
|
|
const retryButton = container.querySelector('button');
|
|
expect(retryButton).not.toBeNull();
|
|
act(() => {
|
|
retryButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
|
|
expect(container.querySelector('[data-testid="app"]')?.textContent).toBe(
|
|
'recovered',
|
|
);
|
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
|
});
|
|
});
|