diff --git a/src/shared/components/ErrorBoundary.test.tsx b/src/shared/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..4e35aab --- /dev/null +++ b/src/shared/components/ErrorBoundary.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import { useState } from 'react' +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ErrorBoundary } from './ErrorBoundary' +import { logger } from '../utils/logger' +import { renderWithTheme } from '../../test/renderWithTheme' + +vi.mock('../utils/logger', () => ({ + logger: { + error: vi.fn(), + }, +})) + +const loggerError = vi.mocked(logger.error) + +const preventUnhandledErrorLog = (event: ErrorEvent) => { + event.preventDefault() +} + +/** Throws during render so tests exercise React's error boundary path. */ +function FailingChild({ message = 'Boundary boom' }: { message?: string }) { + throw new Error(message) +} + +function MaybeFailingChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) { + throw new Error('Recoverable render failure') + } + + return

Recovered child

+} + +/** Test harness that clears the child error before invoking the boundary reset. */ +function RecoveryHarness() { + const [shouldThrow, setShouldThrow] = useState(true) + + return ( + <> + + + + + + ) +} + +describe('ErrorBoundary', () => { + let consoleErrorSpy: ReturnType + + beforeAll(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + window.addEventListener('error', preventUnhandledErrorLog) + }) + + beforeEach(() => { + vi.stubEnv('PROD', false) + loggerError.mockClear() + consoleErrorSpy.mockClear() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + afterAll(() => { + window.removeEventListener('error', preventUnhandledErrorLog) + consoleErrorSpy.mockRestore() + }) + + it('renders the fallback and reports render errors through the guarded logger', () => { + renderWithTheme( + + + + ) + + expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument() + expect(screen.getByText(/An unexpected error occurred/i)).toBeInTheDocument() + expect(loggerError).toHaveBeenCalledWith( + 'ErrorBoundary caught an error:', + expect.objectContaining({ message: 'Boundary boom' }), + expect.objectContaining({ componentStack: expect.any(String) }) + ) + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + 'ErrorBoundary caught an error:', + expect.anything(), + expect.anything() + ) + }) + + it.each([ + ['light', 'from-[#e8dfd0]'], + ['dark', 'from-[#1a1512]'], + ] as const)('applies the %s fallback theme classes', (theme, expectedClass) => { + const { container } = renderWithTheme( + + + , + { theme } + ) + + const fallbackRoot = container.firstElementChild + expect(fallbackRoot).toHaveClass('min-h-screen') + expect(fallbackRoot?.className).toContain(expectedClass) + }) + + it('recovers and re-renders children when the reset action is clicked', async () => { + const user = userEvent.setup() + renderWithTheme() + + expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Resolve failure' })) + await user.click(screen.getByRole('button', { name: 'Try Again' })) + + await waitFor(() => expect(screen.getByText('Recovered child')).toBeInTheDocument()) + expect(screen.queryByRole('heading', { name: 'Something went wrong' })).not.toBeInTheDocument() + }) + + it('keeps the home navigation action available from the fallback', () => { + renderWithTheme( + + + + ) + + expect(() => fireEvent.click(screen.getByRole('button', { name: 'Go to Home' }))).not.toThrow() + }) + + it('does not expose error details in production fallback UI', () => { + vi.stubEnv('PROD', true) + + renderWithTheme( + + + + ) + + expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument() + expect(screen.queryByText('Error Details')).not.toBeInTheDocument() + expect(screen.queryByText(/secret stack context/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/shared/components/ErrorBoundary.tsx b/src/shared/components/ErrorBoundary.tsx index cddab4c..d2b4ac0 100644 --- a/src/shared/components/ErrorBoundary.tsx +++ b/src/shared/components/ErrorBoundary.tsx @@ -1,62 +1,64 @@ -import { Component, ReactNode } from 'react'; -import { useTheme } from '../contexts/ThemeContext'; +import { Component, ErrorInfo, ReactNode } from 'react' +import { useTheme } from '../contexts/ThemeContext' +import { logger } from '../utils/logger' interface Props { - children: ReactNode; + children: ReactNode } interface State { - hasError: boolean; - error: Error | null; + hasError: boolean + error: Error | null } export class ErrorBoundary extends Component { constructor(props: Props) { - super(props); - this.state = { hasError: false, error: null }; + super(props) + this.state = { hasError: false, error: null } } static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; + return { hasError: true, error } } - componentDidCatch(error: Error, errorInfo: any) { - console.error('ErrorBoundary caught an error:', error, errorInfo); + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + logger.error('ErrorBoundary caught an error:', error, errorInfo) } handleReset = () => { - this.setState({ hasError: false, error: null }); - window.location.reload(); - }; + this.setState({ hasError: false, error: null }) + } render() { if (this.state.hasError) { - return ; + return } - return this.props.children; + return this.props.children } } function ErrorFallback({ error, onReset }: { error: Error | null; onReset: () => void }) { - const { theme } = useTheme(); + const { theme } = useTheme() return ( -
+
{/* Background Effects */}
{/* Error Card */} -
+
{/* Error Icon */}
@@ -78,31 +80,43 @@ function ErrorFallback({ error, onReset }: { error: Error | null; onReset: () => {/* Error Message */}
-

+

Something went wrong

-

+

An unexpected error occurred. We've been notified and are working to fix it.

{/* Error Details (collapsible) */} - {error && ( -
- + {!import.meta.env.PROD && error && ( +
+ Error Details -
+            
               {error.toString()}
             
@@ -114,10 +128,10 @@ function ErrorFallback({ error, onReset }: { error: Error | null; onReset: () => onClick={onReset} className="w-full py-3 rounded-[12px] bg-[#c9983a] hover:bg-[#d4af37] text-white font-medium transition-all shadow-[0_4px_12px_rgba(201,152,58,0.3)]" > - Reload Page + Try Again
{/* Support Info */} -

+

If this problem persists, please contact support or try refreshing the page.

- ); + ) } diff --git a/vite.config.ts b/vite.config.ts index ae0c82b..fa11391 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ globals: true, setupFiles: ['./src/test-setup.ts', './src/test/setup.ts'], css: false, - exclude: ['e2e/**'], + exclude: ['e2e/**', 'node_modules/**'], coverage: { provider: 'v8', reporter: ['text', 'text-summary', 'html'],