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(); }); });