import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index' const mockConfig = vi.hoisted(() => ({ isDev: false, })) vi.mock('@/config', () => ({ get IS_DEV() { return mockConfig.isDev }, })) type ThrowOnRenderProps = { message?: string shouldThrow: boolean } const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => { if (shouldThrow) throw new Error(message) return
Child content rendered
} let consoleErrorSpy: ReturnType describe('ErrorBoundary', () => { beforeEach(() => { vi.clearAllMocks() mockConfig.isDev = false consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) }) afterEach(() => { consoleErrorSpy.mockRestore() }) // Verify default render and default fallback behavior. describe('Rendering', () => { it('should render children when no error occurs', () => { render( , ) expect(screen.getByText('Child content rendered')).toBeInTheDocument() }) it('should render default fallback with title and message when child throws', async () => { render( , ) expect(await screen.findByText('Something went wrong')).toBeInTheDocument() expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument() }) it('should render custom title, message, and className in fallback', async () => { render( , ) expect(await screen.findByText('Custom crash title')).toBeInTheDocument() expect(screen.getByText('Custom recovery message')).toBeInTheDocument() const fallbackRoot = document.querySelector('.custom-boundary') expect(fallbackRoot).toBeInTheDocument() expect(fallbackRoot).not.toHaveClass('min-h-[200px]') }) }) // Validate explicit fallback prop variants. describe('Fallback props', () => { it('should render node fallback when fallback prop is a React node', async () => { render( Node fallback content}> , ) expect(await screen.findByText('Node fallback content')).toBeInTheDocument() }) it('should render function fallback with error message when fallback prop is a function', async () => { render( (
Function fallback: {' '} {error.message}
)} >
, ) expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument() }) }) // Validate error reporting and details panel behavior. describe('Error reporting', () => { it('should call onError with error and errorInfo when child throws', async () => { const onError = vi.fn() render( , ) await screen.findByText('Something went wrong') expect(onError).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledWith( expect.objectContaining({ message: 'render boom' }), expect.objectContaining({ componentStack: expect.any(String) }), ) }) it('should render details block when showDetails is true', async () => { render( , ) expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument() expect(screen.getByText('Error:')).toBeInTheDocument() expect(screen.getByText(/details boom/i)).toBeInTheDocument() }) it('should log boundary errors in development mode', async () => { mockConfig.isDev = true render( , ) await screen.findByText('Something went wrong') expect(consoleErrorSpy).toHaveBeenCalledWith( 'ErrorBoundary caught an error:', expect.objectContaining({ message: 'dev boom' }), ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error Info:', expect.objectContaining({ componentStack: expect.any(String) }), ) }) }) // Validate recovery controls and automatic reset triggers. describe('Recovery', () => { it('should hide recovery actions when enableRecovery is false', async () => { render( , ) await screen.findByText('Something went wrong') expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument() }) it('should reset and render children when Try Again is clicked', async () => { const onReset = vi.fn() const RecoveryHarness = () => { const [shouldThrow, setShouldThrow] = React.useState(true) return ( { onReset() setShouldThrow(false) }} > ) } render() fireEvent.click(await screen.findByRole('button', { name: 'Try Again' })) await screen.findByText('Child content rendered') expect(onReset).toHaveBeenCalledTimes(1) }) it('should reset after resetKeys change when boundary is in error state', async () => { const ResetKeysHarness = () => { const [shouldThrow, setShouldThrow] = React.useState(true) const [boundaryKey, setBoundaryKey] = React.useState(0) return ( <> ) } render() await screen.findByText('Something went wrong') fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' })) await waitFor(() => { expect(screen.getByText('Child content rendered')).toBeInTheDocument() }) }) it('should reset after children change when resetOnPropsChange is true', async () => { const ResetOnPropsHarness = () => { const [shouldThrow, setShouldThrow] = React.useState(true) const [childLabel, setChildLabel] = React.useState('first child') return ( <> {shouldThrow ? :
{childLabel}
}
) } render() await screen.findByText('Something went wrong') fireEvent.click(screen.getByRole('button', { name: 'Replace children' })) await waitFor(() => { expect(screen.getByText('second child')).toBeInTheDocument() }) }) }) }) describe('ErrorBoundary utility exports', () => { beforeEach(() => { vi.clearAllMocks() consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) }) afterEach(() => { consoleErrorSpy.mockRestore() }) // Validate imperative error hook behavior. describe('useErrorHandler', () => { it('should trigger error boundary fallback when setError is called', async () => { const HookConsumer = () => { const setError = useErrorHandler() return ( ) } render( Hook fallback shown}> , ) fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' })) expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument() }) }) // Validate async error bridge hook behavior. describe('useAsyncError', () => { it('should trigger error boundary fallback when async error callback is called', async () => { const AsyncHookConsumer = () => { const throwAsyncError = useAsyncError() return ( ) } render( Async fallback shown}> , ) fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' })) expect(await screen.findByText('Async fallback shown')).toBeInTheDocument() }) }) // Validate HOC wrapper behavior and metadata. describe('withErrorBoundary', () => { it('should wrap component and render custom title when wrapped component throws', async () => { type WrappedProps = { shouldThrow: boolean } const WrappedTarget = ({ shouldThrow }: WrappedProps) => { if (shouldThrow) throw new Error('wrapped boom') return
Wrapped content
} const Wrapped = withErrorBoundary(WrappedTarget, { customTitle: 'Wrapped boundary title', }) render() expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument() }) it('should set displayName using wrapped component name', () => { const NamedComponent = () =>
named content
const Wrapped = withErrorBoundary(NamedComponent) expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)') }) }) // Validate simple fallback helper component. describe('ErrorFallback', () => { it('should render message and call reset action when button is clicked', () => { const resetErrorBoundaryAction = vi.fn() render( , ) expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument() expect(screen.getByText('fallback helper message')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Try again' })) expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1) }) }) })