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'],