diff --git a/frontend/tests/components/common/ErrorBoundary.test.tsx b/frontend/tests/components/common/ErrorBoundary.test.tsx new file mode 100644 index 0000000..7530aa0 --- /dev/null +++ b/frontend/tests/components/common/ErrorBoundary.test.tsx @@ -0,0 +1,401 @@ +/** + * ErrorBoundary Component Tests + * + * Tests for the error boundary component that catches JavaScript errors + * in child components and displays fallback UI. + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; + +// Component that throws an error for testing +function ThrowingComponent({ shouldThrow = true }: { shouldThrow?: boolean }) { + if (shouldThrow) { + throw new Error('Test error message'); + } + return
Child content
; +} + +describe('ErrorBoundary', () => { + // Suppress console.error during tests since we're testing error handling + const originalError = console.error; + + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when no error occurs', () => { + it('renders children normally', () => { + render( + +
Test content
+
+ ); + + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + + it('does not show error UI', () => { + render( + +
Test content
+
+ ); + + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('renders multiple children', () => { + render( + +
First child
+
Second child
+
+ ); + + expect(screen.getByText('First child')).toBeInTheDocument(); + expect(screen.getByText('Second child')).toBeInTheDocument(); + }); + }); + + describe('when an error occurs', () => { + it('catches the error and shows default fallback UI', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText(/An unexpected error occurred/)).toBeInTheDocument(); + }); + + it('displays the error message', () => { + render( + + + + ); + + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('shows the reset button by default', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('logs error to console', () => { + render( + + + + ); + + expect(console.error).toHaveBeenCalledWith( + 'ErrorBoundary caught an error:', + expect.any(Error), + expect.objectContaining({ componentStack: expect.any(String) }) + ); + }); + + it('hides the throwing component', () => { + render( + + + + ); + + expect(screen.queryByText('Child content')).not.toBeInTheDocument(); + }); + }); + + describe('custom fallback', () => { + it('renders custom fallback when provided', () => { + render( + Custom error UI}> + + + ); + + expect(screen.getByText('Custom error UI')).toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('does not show default fallback when custom fallback is provided', () => { + render( + Fallback}> + + + ); + + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + }); + + describe('onError callback', () => { + it('calls onError when an error occurs', () => { + const onError = jest.fn(); + + render( + + + + ); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Test error message' }), + expect.objectContaining({ componentStack: expect.any(String) }) + ); + }); + + it('does not call onError when no error occurs', () => { + const onError = jest.fn(); + + render( + +
Normal content
+
+ ); + + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('reset functionality', () => { + it('resets error state when reset button is clicked', async () => { + const user = userEvent.setup(); + + // Track whether we should throw + let shouldThrow = true; + + function ResettableThrowingComponent() { + if (shouldThrow) { + throw new Error('Resettable error'); + } + return
Recovered content
; + } + + render( + + + + ); + + // Should show error UI after first render throws + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('Resettable error')).toBeInTheDocument(); + + // Stop throwing before clicking reset + shouldThrow = false; + + // Click reset + await user.click(screen.getByRole('button', { name: /try again/i })); + + // After reset, the component re-renders without throwing + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + expect(screen.getByText('Recovered content')).toBeInTheDocument(); + }); + }); + + describe('showReset prop', () => { + it('hides reset button when showReset is false', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + + it('shows reset button when showReset is true', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('shows reset button by default (showReset undefined)', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + }); + + describe('error without message', () => { + it('handles error with null message gracefully', () => { + function ThrowNullError() { + const error = new Error(); + error.message = ''; + throw error; + } + + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + // Empty message should still render the error container but be empty + }); + }); + + describe('accessibility', () => { + it('has descriptive error title', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('has descriptive help text', () => { + render( + + + + ); + + expect( + screen.getByText(/An unexpected error occurred. Please try again or contact support/) + ).toBeInTheDocument(); + }); + + it('icons have aria-hidden', () => { + render( + + + + ); + + const icons = document.querySelectorAll('[aria-hidden="true"]'); + expect(icons.length).toBeGreaterThan(0); + }); + + it('reset button is keyboard accessible', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + // First render without error to set up the component + expect(screen.getByText('Child content')).toBeInTheDocument(); + }); + }); + + describe('DefaultFallback rendering', () => { + it('shows error in monospace font', () => { + render( + + + + ); + + const errorMessage = screen.getByText('Test error message'); + expect(errorMessage).toHaveClass('font-mono'); + }); + + it('renders within a Card component', () => { + render( + + + + ); + + // The card should have destructive styling + const card = document.querySelector('.border-destructive\\/50'); + expect(card).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('handles deeply nested errors', () => { + function DeepChild() { + throw new Error('Deep error'); + } + + function MiddleComponent() { + return ( +
+ +
+ ); + } + + render( + +
+ +
+
+ ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('Deep error')).toBeInTheDocument(); + }); + + it('isolates errors to its own boundary', () => { + render( +
+ + + +
Sibling content
+
+ ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('Sibling content')).toBeInTheDocument(); + }); + + it('allows nested error boundaries', () => { + function InnerThrowing() { + throw new Error('Inner error'); + } + + render( + Outer fallback}> +
+ Inner fallback
}> + +
+ + + ); + + // Inner boundary should catch the error + expect(screen.getByText('Inner fallback')).toBeInTheDocument(); + expect(screen.queryByText('Outer fallback')).not.toBeInTheDocument(); + }); + }); +});