Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/shared/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <p>Recovered child</p>
}

/** Test harness that clears the child error before invoking the boundary reset. */
function RecoveryHarness() {
const [shouldThrow, setShouldThrow] = useState(true)

return (
<>
<button onClick={() => setShouldThrow(false)}>Resolve failure</button>
<ErrorBoundary>
<MaybeFailingChild shouldThrow={shouldThrow} />
</ErrorBoundary>
</>
)
}

describe('ErrorBoundary', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>

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(
<ErrorBoundary>
<FailingChild />
</ErrorBoundary>
)

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(
<ErrorBoundary>
<FailingChild />
</ErrorBoundary>,
{ 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(<RecoveryHarness />)

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(
<ErrorBoundary>
<FailingChild />
</ErrorBoundary>
)

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(
<ErrorBoundary>
<FailingChild message="secret stack context" />
</ErrorBoundary>
)

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()
})
})
112 changes: 64 additions & 48 deletions src/shared/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 <ErrorFallback error={this.state.error} onReset={this.handleReset} />;
return <ErrorFallback error={this.state.error} onReset={this.handleReset} />
}

return this.props.children;
return this.props.children
}
}

function ErrorFallback({ error, onReset }: { error: Error | null; onReset: () => void }) {
const { theme } = useTheme();
const { theme } = useTheme()

return (
<div className={`min-h-screen flex items-center justify-center px-6 transition-colors ${
theme === 'dark'
? 'bg-gradient-to-br from-[#1a1512] via-[#231c17] to-[#2d241d]'
: 'bg-gradient-to-br from-[#e8dfd0] via-[#d4c5b0] to-[#c9b89a]'
}`}>
<div
className={`min-h-screen flex items-center justify-center px-6 transition-colors ${
theme === 'dark'
? 'bg-gradient-to-br from-[#1a1512] via-[#231c17] to-[#2d241d]'
: 'bg-gradient-to-br from-[#e8dfd0] via-[#d4c5b0] to-[#c9b89a]'
}`}
>
{/* Background Effects */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-red-500/20 blur-3xl animate-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 rounded-full bg-red-600/10 blur-3xl animate-pulse" />

{/* Error Card */}
<div className={`relative z-10 w-full max-w-md backdrop-blur-[40px] border rounded-[28px] p-8 shadow-[0_8px_32px_rgba(0,0,0,0.08)] transition-colors ${
theme === 'dark'
? 'bg-white/[0.08] border-white/15'
: 'bg-white/[0.15] border-white/25'
}`}>
<div
className={`relative z-10 w-full max-w-md backdrop-blur-[40px] border rounded-[28px] p-8 shadow-[0_8px_32px_rgba(0,0,0,0.08)] transition-colors ${
theme === 'dark' ? 'bg-white/[0.08] border-white/15' : 'bg-white/[0.15] border-white/25'
}`}
>
{/* Error Icon */}
<div className="flex justify-center mb-6">
<div className="w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center">
Expand All @@ -78,31 +80,43 @@ function ErrorFallback({ error, onReset }: { error: Error | null; onReset: () =>

{/* Error Message */}
<div className="text-center mb-6">
<h2 className={`text-2xl font-bold mb-2 transition-colors ${
theme === 'dark' ? 'text-[#f5efe5]' : 'text-[#2d2820]'
}`}>
<h2
className={`text-2xl font-bold mb-2 transition-colors ${
theme === 'dark' ? 'text-[#f5efe5]' : 'text-[#2d2820]'
}`}
>
Something went wrong
</h2>
<p className={`text-sm transition-colors ${
theme === 'dark' ? 'text-[#d4c5b0]' : 'text-[#7a6b5a]'
}`}>
<p
className={`text-sm transition-colors ${
theme === 'dark' ? 'text-[#d4c5b0]' : 'text-[#7a6b5a]'
}`}
>
An unexpected error occurred. We've been notified and are working to fix it.
</p>
</div>

{/* Error Details (collapsible) */}
{error && (
<details className={`mb-6 rounded-lg p-4 transition-colors ${
theme === 'dark' ? 'bg-white/[0.06]' : 'bg-white/[0.12]'
}`}>
<summary className={`cursor-pointer text-sm font-medium transition-colors ${
theme === 'dark' ? 'text-[#d4c5b0] hover:text-[#f5efe5]' : 'text-[#7a6b5a] hover:text-[#2d2820]'
}`}>
{!import.meta.env.PROD && error && (
<details
className={`mb-6 rounded-lg p-4 transition-colors ${
theme === 'dark' ? 'bg-white/[0.06]' : 'bg-white/[0.12]'
}`}
>
<summary
className={`cursor-pointer text-sm font-medium transition-colors ${
theme === 'dark'
? 'text-[#d4c5b0] hover:text-[#f5efe5]'
: 'text-[#7a6b5a] hover:text-[#2d2820]'
}`}
>
Error Details
</summary>
<pre className={`mt-3 text-xs overflow-auto p-3 rounded transition-colors ${
theme === 'dark' ? 'bg-black/[0.3] text-red-400' : 'bg-black/[0.05] text-red-600'
}`}>
<pre
className={`mt-3 text-xs overflow-auto p-3 rounded transition-colors ${
theme === 'dark' ? 'bg-black/[0.3] text-red-400' : 'bg-black/[0.05] text-red-600'
}`}
>
{error.toString()}
</pre>
</details>
Expand All @@ -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
</button>
<button
onClick={() => window.location.href = '/'}
onClick={() => (window.location.href = '/')}
className={`w-full py-3 rounded-[12px] font-medium transition-all ${
theme === 'dark'
? 'bg-white/[0.08] hover:bg-white/[0.12] text-[#f5efe5] border border-white/15'
Expand All @@ -129,12 +143,14 @@ function ErrorFallback({ error, onReset }: { error: Error | null; onReset: () =>
</div>

{/* Support Info */}
<p className={`text-xs text-center mt-6 transition-colors ${
theme === 'dark' ? 'text-[#a3a3a3]' : 'text-[#8a7d6f]'
}`}>
<p
className={`text-xs text-center mt-6 transition-colors ${
theme === 'dark' ? 'text-[#a3a3a3]' : 'text-[#8a7d6f]'
}`}
>
If this problem persists, please contact support or try refreshing the page.
</p>
</div>
</div>
);
)
}
2 changes: 1 addition & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down