import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest';
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ErrorBoundary, withErrorBoundary } from '@/components/error-boundary';
function Boom({ message = 'render blew up' }: { message?: string }): never {
throw new Error(message);
}
/**
* Throws while the shared flag is set. React retries a failed render before it
* gives up, so a counter would be consumed by the retry; a flag the test flips
* explicitly keeps "Try again" deterministic.
*/
function ConditionalBoom({ shouldThrow }: { shouldThrow: { value: boolean } }) {
if (shouldThrow.value) {
throw new Error('transient');
}
return
Recovered content
;
}
let consoleError: ReturnType;
beforeEach(() => {
// React itself logs every caught error, on top of the boundary's own log.
// Silence both; the assertions below check the boundary's log explicitly.
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('ErrorBoundary', () => {
it('renders its children while nothing throws', () => {
render(
Healthy content
);
expect(screen.getByText('Healthy content')).toBeInTheDocument();
expect(consoleError).not.toHaveBeenCalled();
});
it('replaces a crashed subtree with the recovery fallback', () => {
render(
);
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reload page' })).toBeInTheDocument();
});
it('names the crashed area when given a context', () => {
render(
);
expect(screen.getByRole('heading', { name: 'Assets pane crashed' })).toBeInTheDocument();
expect(
screen.getByText(
'An unexpected error occurred. Try resetting the component or reload the page.'
)
).toBeInTheDocument();
});
it('offers video-specific guidance for a video context', () => {
render(
);
expect(screen.getByRole('heading', { name: 'VideoPlayer crashed' })).toBeInTheDocument();
expect(
screen.getByText(
'The video player encountered an error. Try reloading or go back to the project.'
)
).toBeInTheDocument();
});
it('does not swallow the error: it reports it to onError', () => {
const onError = vi.fn();
render(
);
expect(onError).toHaveBeenCalledTimes(1);
const [error, errorInfo] = onError.mock.calls[0];
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('player adapter missing');
expect(errorInfo).toHaveProperty('componentStack');
expect(String((errorInfo as { componentStack: string }).componentStack)).toContain('Boom');
});
it('does not swallow the error: it logs it with the context', () => {
render(
);
expect(consoleError).toHaveBeenCalledWith(
'ErrorBoundary [VideoPlayer] caught an error:',
expect.objectContaining({ message: 'player adapter missing' }),
expect.anything()
);
});
it('logs without a context prefix when none was given', () => {
render(
);
expect(consoleError).toHaveBeenCalledWith(
'ErrorBoundary caught an error:',
expect.objectContaining({ message: 'nameless' }),
expect.anything()
);
});
it('renders a custom fallback instead of the built-in one', () => {
const onError = vi.fn();
render(
Could not load the timeline} onError={onError}>
);
expect(screen.getByText('Could not load the timeline')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
// The error still propagates to the caller even with a custom fallback.
expect(onError).toHaveBeenCalledTimes(1);
});
it('re-renders the children when Try again is pressed', async () => {
const shouldThrow = { value: true };
render(
);
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.queryByText('Recovered content')).not.toBeInTheDocument();
shouldThrow.value = false;
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(screen.getByText('Recovered content')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Something went wrong' })).not.toBeInTheDocument();
});
it('shows the fallback again if the retry crashes too', async () => {
const shouldThrow = { value: true };
render(
);
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.queryByText('Recovered content')).not.toBeInTheDocument();
});
it('reloads the page when Reload page is pressed', async () => {
const reload = vi.fn();
// Restored by hand. vi.restoreAllMocks() undoes spies, not a
// defineProperty, so without this the whole file runs on a fake
// window.location from here on and the next test to touch it would be
// reading a stub left behind by this one.
const realLocation = Object.getOwnPropertyDescriptor(window, 'location');
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, reload },
});
onTestFinished(() => {
if (realLocation) {
Object.defineProperty(window, 'location', realLocation);
}
});
render(
);
await userEvent.click(screen.getByRole('button', { name: 'Reload page' }));
expect(reload).toHaveBeenCalledTimes(1);
});
it('keeps a healthy sibling boundary mounted when one crashes', () => {
render(
Right pane still here
);
expect(screen.getByRole('heading', { name: 'Left crashed' })).toBeInTheDocument();
expect(screen.getByText('Right pane still here')).toBeInTheDocument();
});
it('catches an error thrown from a state updater, not just from render', async () => {
function ThrowOnClick() {
const [, setState] = useState(0);
return (
);
}
render(
);
await userEvent.click(screen.getByRole('button', { name: 'Break it' }));
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Break it' })).not.toBeInTheDocument();
});
});
describe('withErrorBoundary', () => {
it('wraps a component and forwards its props', () => {
function Panel({ label }: { label: string }) {
return