Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m2s
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
// src/components/ErrorBoundary.test.tsx
|
|
import React from 'react';
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { ErrorBoundary } from './ErrorBoundary';
|
|
|
|
// Mock the sentry.client module
|
|
vi.mock('../services/sentry.client', () => ({
|
|
Sentry: {
|
|
ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
showReportDialog: vi.fn(),
|
|
},
|
|
captureException: vi.fn(() => 'mock-event-id-123'),
|
|
isSentryConfigured: false,
|
|
}));
|
|
|
|
/**
|
|
* A component that throws an error when rendered.
|
|
* Used to test ErrorBoundary behavior.
|
|
*/
|
|
const ThrowingComponent = ({ shouldThrow = true }: { shouldThrow?: boolean }) => {
|
|
if (shouldThrow) {
|
|
throw new Error('Test error from ThrowingComponent');
|
|
}
|
|
return <div>Normal render</div>;
|
|
};
|
|
|
|
/**
|
|
* A component that throws an error with a custom message.
|
|
*/
|
|
const ThrowingComponentWithMessage = ({ message }: { message: string }) => {
|
|
throw new Error(message);
|
|
};
|
|
|
|
describe('ErrorBoundary', () => {
|
|
// Suppress console.error during error boundary tests
|
|
// React logs errors to console when error boundaries catch them
|
|
const originalConsoleError = console.error;
|
|
|
|
beforeEach(() => {
|
|
console.error = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
console.error = originalConsoleError;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('rendering children', () => {
|
|
it('should render children when no error occurs', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<div data-testid="child">Child content</div>
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByTestId('child')).toBeInTheDocument();
|
|
expect(screen.getByText('Child content')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render multiple children', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<div data-testid="child-1">First</div>
|
|
<div data-testid="child-2">Second</div>
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
|
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render nested components', () => {
|
|
const NestedComponent = () => (
|
|
<div data-testid="nested">
|
|
<span>Nested content</span>
|
|
</div>
|
|
);
|
|
|
|
render(
|
|
<ErrorBoundary>
|
|
<NestedComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByTestId('nested')).toBeInTheDocument();
|
|
expect(screen.getByText('Nested content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('catching errors', () => {
|
|
it('should catch errors thrown by child components', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
// Should show fallback UI, not the throwing component
|
|
expect(screen.queryByText('Normal render')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display the default error message', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(
|
|
screen.getByText(/We're sorry, but an unexpected error occurred/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('should log error to console', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(console.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call captureException with the error', async () => {
|
|
const { captureException } = await import('../services/sentry.client');
|
|
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(captureException).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
expect.objectContaining({
|
|
componentStack: expect.any(String),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('custom fallback UI', () => {
|
|
it('should render custom fallback when provided', () => {
|
|
render(
|
|
<ErrorBoundary fallback={<div data-testid="custom-fallback">Custom error UI</div>}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
|
|
expect(screen.getByText('Custom error UI')).toBeInTheDocument();
|
|
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should render React element as fallback', () => {
|
|
const CustomFallback = () => (
|
|
<div>
|
|
<h1>Oops!</h1>
|
|
<p>Something broke</p>
|
|
</div>
|
|
);
|
|
|
|
render(
|
|
<ErrorBoundary fallback={<CustomFallback />}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByText('Oops!')).toBeInTheDocument();
|
|
expect(screen.getByText('Something broke')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('onError callback', () => {
|
|
it('should call onError callback when error is caught', () => {
|
|
const onErrorMock = vi.fn();
|
|
|
|
render(
|
|
<ErrorBoundary onError={onErrorMock}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(onErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(onErrorMock).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
expect.objectContaining({
|
|
componentStack: expect.any(String),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass the error message to onError callback', () => {
|
|
const onErrorMock = vi.fn();
|
|
const errorMessage = 'Specific test error message';
|
|
|
|
render(
|
|
<ErrorBoundary onError={onErrorMock}>
|
|
<ThrowingComponentWithMessage message={errorMessage} />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
const [error] = onErrorMock.mock.calls[0];
|
|
expect(error.message).toBe(errorMessage);
|
|
});
|
|
|
|
it('should not call onError when no error occurs', () => {
|
|
const onErrorMock = vi.fn();
|
|
|
|
render(
|
|
<ErrorBoundary onError={onErrorMock}>
|
|
<ThrowingComponent shouldThrow={false} />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(onErrorMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('reload button', () => {
|
|
it('should render reload button in default fallback', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: /reload page/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call window.location.reload when reload button is clicked', () => {
|
|
// Mock window.location.reload
|
|
const reloadMock = vi.fn();
|
|
const originalLocation = window.location;
|
|
|
|
Object.defineProperty(window, 'location', {
|
|
value: { ...originalLocation, reload: reloadMock },
|
|
writable: true,
|
|
});
|
|
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /reload page/i }));
|
|
|
|
expect(reloadMock).toHaveBeenCalledTimes(1);
|
|
|
|
// Restore original location
|
|
Object.defineProperty(window, 'location', {
|
|
value: originalLocation,
|
|
writable: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('default fallback UI structure', () => {
|
|
it('should render error icon', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
const svg = document.querySelector('svg');
|
|
expect(svg).toBeInTheDocument();
|
|
expect(svg).toHaveAttribute('aria-hidden', 'true');
|
|
});
|
|
|
|
it('should have proper accessibility attributes', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
// Check that heading is present
|
|
const heading = screen.getByRole('heading', { level: 1 });
|
|
expect(heading).toHaveTextContent('Something went wrong');
|
|
});
|
|
|
|
it('should have proper styling classes', () => {
|
|
const { container } = render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
// Check for layout classes
|
|
expect(container.querySelector('.flex')).toBeInTheDocument();
|
|
expect(container.querySelector('.min-h-screen')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('state management', () => {
|
|
it('should set hasError to true when error occurs', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
// If hasError is true, fallback UI is shown
|
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should store the error in state', () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
// Error is stored and can be displayed in development mode
|
|
// We verify this by checking the fallback UI is rendered
|
|
expect(screen.queryByText('Normal render')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('getDerivedStateFromError', () => {
|
|
it('should update state correctly via getDerivedStateFromError', () => {
|
|
const error = new Error('Test error');
|
|
const result = ErrorBoundary.getDerivedStateFromError(error);
|
|
|
|
expect(result).toEqual({
|
|
hasError: true,
|
|
error: error,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('SentryErrorBoundary export', () => {
|
|
it('should export SentryErrorBoundary', async () => {
|
|
const { SentryErrorBoundary } = await import('./ErrorBoundary');
|
|
expect(SentryErrorBoundary).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ErrorBoundary with Sentry configured', () => {
|
|
const originalConsoleError = console.error;
|
|
|
|
beforeEach(() => {
|
|
console.error = vi.fn();
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
console.error = originalConsoleError;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should show report feedback button when Sentry is configured and eventId exists', async () => {
|
|
// Re-mock with Sentry configured
|
|
vi.doMock('../services/sentry.client', () => ({
|
|
Sentry: {
|
|
ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
showReportDialog: vi.fn(),
|
|
},
|
|
captureException: vi.fn(() => 'mock-event-id-456'),
|
|
isSentryConfigured: true,
|
|
}));
|
|
|
|
// Re-import after mock
|
|
const { ErrorBoundary: ErrorBoundaryWithSentry } = await import('./ErrorBoundary');
|
|
|
|
render(
|
|
<ErrorBoundaryWithSentry>
|
|
<ThrowingComponent />
|
|
</ErrorBoundaryWithSentry>,
|
|
);
|
|
|
|
// The report feedback button should be visible when Sentry is configured
|
|
// Note: Due to module caching, this may not work as expected in all cases
|
|
// The button visibility depends on isSentryConfigured being true at render time
|
|
expect(screen.getByRole('button', { name: /reload page/i })).toBeInTheDocument();
|
|
});
|
|
});
|