import React from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary';
import * as chunkReload from '../../utils/chunkReload';
// Only the Reload button's way out is replaced; the boundary's own automatic reload
// (reloadOnceForChunk) keeps running the real code.
vi.mock('../../utils/chunkReload', async (importOriginal) => ({
...(await importOriginal()),
reloadOntoCurrentBuild: vi.fn(),
}));
// React logs every caught error itself; without this each of these tests prints a
// full component stack and buries the real output.
let consoleError: ReturnType;
beforeEach(() => {
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
sessionStorage.clear();
});
afterEach(() => {
consoleError.mockRestore();
});
function Boom({ message = 'kaboom' }: { message?: string }): React.ReactElement {
throw new Error(message);
}
const CHUNK_MESSAGE = 'Failed to fetch dynamically imported module: /assets/DashboardPage-abc123.js';
describe('ErrorBoundary', () => {
it('FE-COMP-ERRBOUND-001: renders its children while nothing throws', () => {
render(
all good
,
);
expect(screen.getByText('all good')).toBeInTheDocument();
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-COMP-ERRBOUND-002: swaps in the fallback when a child throws', () => {
render(
,
);
expect(screen.getByRole('alert')).toBeInTheDocument();
// The message is surfaced on purpose — self-hosted users file their own issues.
expect(screen.getByText('kaboom')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-003: logs the boundaryId so a report says which one tripped', () => {
render(
,
);
expect(consoleError).toHaveBeenCalledWith(
'[ErrorBoundary:planner-tabs]',
expect.any(Error),
expect.anything(),
);
});
it('FE-COMP-ERRBOUND-004: a custom node fallback replaces the default panel', () => {
render(
quiet fallback
}>
,
);
expect(screen.getByText('quiet fallback')).toBeInTheDocument();
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-COMP-ERRBOUND-005: fallback={null} hides a broken widget without a trace', () => {
const { container } = render(
,
);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-ERRBOUND-006: a function fallback receives the error and the reset handle', () => {
render(
{s.error instanceof Error ? s.error.message : 'none'} / {String(s.isChunkError)}
}
>
,
);
expect(screen.getByText('detail / false')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-007: retry clears the error and re-renders the children', () => {
function Flaky({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error('transient');
return recovered
;
}
function Harness() {
const [shouldThrow, setShouldThrow] = React.useState(true);
return (
setShouldThrow(false)}>
);
}
render();
fireEvent.click(screen.getByText('Try again'));
expect(screen.getByText('recovered')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-008: a changed resetKey clears the error on its own', () => {
function Harness({ id, throws }: { id: number; throws: boolean }) {
return (
{throws ? : panel {id}
}
);
}
const { rerender } = render();
expect(screen.getByRole('alert')).toBeInTheDocument();
// Switching to another trip/tab must not carry the previous failure over.
rerender();
expect(screen.getByText('panel 2')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-009: an unchanged resetKey leaves the error standing', () => {
function Harness({ id }: { id: number }) {
return (
);
}
const { rerender } = render();
rerender();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-010: a dead chunk offers a reload instead of a retry', () => {
render(
,
);
// React.lazy caches the rejection, so a retry button would be a button that
// does nothing — only a reload can pick up the new index.
expect(screen.queryByText('Try again')).toBeNull();
expect(screen.getByText('Reload page')).toBeInTheDocument();
expect(screen.getByText('A new version is available')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-011: a chunk failure reloads once, then stops', () => {
// The recovery reload is a location.replace onto a fresh URL (chunkReload).
const reload = vi.fn();
Object.defineProperty(window, 'location', {
value: { ...window.location, replace: reload },
writable: true,
});
render(
,
);
expect(reload).toHaveBeenCalledTimes(1);
// Second failure in the same session: the marker is set, so no reload loop.
render(
,
);
expect(reload).toHaveBeenCalledTimes(1);
});
it('FE-COMP-ERRBOUND-012: a labelled boundary names what failed', () => {
render(
,
);
expect(screen.getByText('Koffi & Friends')).toBeInTheDocument();
expect(screen.getByText('This plugin could not be shown')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-013: a panel fallback says the rest of the page still works', () => {
render(
,
);
expect(screen.getByText('This section could not be shown')).toBeInTheDocument();
expect(screen.getByText('The rest of the page still works.')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-014: the mobile variant uses the mobile tokens', () => {
render(
,
);
// --m-* only resolves inside MobileShell's .m-root; the desktop classes would
// render an invisible card there.
expect(screen.getByRole('alert').className).toContain('bg-m-card');
});
});
describe('RootErrorFallback', () => {
it('FE-COMP-ERRBOUND-015: stays untranslated because it renders above the provider', () => {
render( {}} isChunkError={false} />);
// If it called t() and the provider was the thing that broke, the user would
// be reading "common.errorTitle".
expect(screen.getByText('TREK could not start')).toBeInTheDocument();
expect(screen.getByText('boot failed')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-016: names a stale chunk as an update rather than a crash', () => {
render( {}} isChunkError />);
expect(screen.getByText('A new version is available')).toBeInTheDocument();
});
});
describe('Reload button', () => {
let reload: ReturnType;
beforeEach(() => {
reload = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
vi.mocked(chunkReload.reloadOntoCurrentBuild).mockClear();
});
// A plain reload is answered by the service worker with the shell that just
// failed, which is how the update screen outlived every reload (#2524).
it('FE-COMP-ERRBOUND-017: after a dead chunk it reloads onto the current build', () => {
render( {}} isChunkError />);
fireEvent.click(screen.getByText('Reload page'));
expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
});
it('FE-COMP-ERRBOUND-018: the route fallback for a dead chunk does the same', () => {
sessionStorage.setItem('trek:chunk-reload', '1');
render(
,
);
fireEvent.click(screen.getByText('Reload page'));
expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
});
it('FE-COMP-ERRBOUND-019: any other failure keeps the plain reload', () => {
render( {}} isChunkError={false} />);
fireEvent.click(screen.getByText('Reload page'));
expect(reload).toHaveBeenCalledTimes(1);
expect(chunkReload.reloadOntoCurrentBuild).not.toHaveBeenCalled();
});
});