From 485313060b557272c13c391998f0e321f404a26b Mon Sep 17 00:00:00 2001 From: Samuel Ojetunde Date: Tue, 28 Jul 2026 12:55:44 +0100 Subject: [PATCH] test: add unit tests for window.onerror handler not re-logging errors caught by React error boundary --- .../common/SectionErrorBoundary.tsx | 2 + src/main.tsx | 4 + .../globalErrorHandler.utils.test.ts | 223 ++++++++++++++++++ src/utils/globalErrorHandler.utils.ts | 126 ++++++++++ 4 files changed, 355 insertions(+) create mode 100644 src/utils/__tests__/globalErrorHandler.utils.test.ts create mode 100644 src/utils/globalErrorHandler.utils.ts diff --git a/src/components/common/SectionErrorBoundary.tsx b/src/components/common/SectionErrorBoundary.tsx index 97494039..07f1a5a0 100644 --- a/src/components/common/SectionErrorBoundary.tsx +++ b/src/components/common/SectionErrorBoundary.tsx @@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; import { AlertCircle, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { markErrorAsCaught } from '@/utils/globalErrorHandler.utils'; interface Props { children: ReactNode; @@ -26,6 +27,7 @@ class SectionErrorBoundary extends Component { } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + markErrorAsCaught(error); console.error(`Uncaught error in section ${this.props.sectionName || 'Unknown'}:`, error, errorInfo); } diff --git a/src/main.tsx b/src/main.tsx index 98c2e6cb..bbf55803 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { initGlobalErrorHandler } from '@/utils/globalErrorHandler.utils'; + +// Initialize global error handler to prevent duplicate logs with React error boundaries +initGlobalErrorHandler(); createRoot(document.getElementById('root')!).render( diff --git a/src/utils/__tests__/globalErrorHandler.utils.test.ts b/src/utils/__tests__/globalErrorHandler.utils.test.ts new file mode 100644 index 00000000..3f54cec2 --- /dev/null +++ b/src/utils/__tests__/globalErrorHandler.utils.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { + initGlobalErrorHandler, + handleGlobalError, + markErrorAsCaught, + wasErrorAlreadyCaught, + emitStructuredLog, + resetErrorTracking, +} from '@/utils/globalErrorHandler.utils'; + +describe('Global Error Handler', () => { + beforeEach(() => { + resetErrorTracking(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initGlobalErrorHandler', () => { + it('sets window.onerror to handleGlobalError', () => { + initGlobalErrorHandler(); + expect(window.onerror).toBe(handleGlobalError); + }); + + it('does not throw when called multiple times', () => { + initGlobalErrorHandler(); + expect(() => initGlobalErrorHandler()).not.toThrow(); + expect(window.onerror).toBe(handleGlobalError); + }); + }); + + describe('handleGlobalError', () => { + it('does not emit a structured log for errors already caught by error boundary', () => { + const error = new Error('Render error in creator list'); + markErrorAsCaught(error); + + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = handleGlobalError('Render error in creator list', 'LandingPage.tsx', 852, 10, error); + + expect(logSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); // Prevent default browser handling + }); + + it('emits a structured log for unhandled errors outside React', () => { + const error = new Error('Timeout error in setTimeout callback'); + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = handleGlobalError('Timeout error in setTimeout callback', 'app.js', 15, 3, error); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'Timeout error in setTimeout callback', + source: 'app.js', + line: 15, + column: 3, + error: { + name: 'Error', + message: 'Timeout error in setTimeout callback', + stack: expect.any(String), + }, + timestamp: expect.any(Number), + }); + expect(result).toBe(false); // Allow default browser handling + }); + + it('does not emit duplicate structured logs for the same error identity', () => { + const error = new Error('Duplicate error'); + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleGlobalError('Duplicate error', 'app.js', 10, 5, error); + handleGlobalError('Duplicate error', 'app.js', 10, 5, error); + + expect(logSpy).toHaveBeenCalledTimes(1); + }); + + it('emits structured logs for Error events without an Error object', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleGlobalError('Script error', 'unknown.js'); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'Script error', + source: 'unknown.js', + line: undefined, + column: undefined, + error: null, + timestamp: expect.any(Number), + }); + }); + + it('allows both unhandled and boundary-caught errors to work simultaneously without conflict', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Boundary catches an error + const caughtError = new Error('Boundary caught error'); + markErrorAsCaught(caughtError); + + // An unrelated unhandled error occurs elsewhere + const unhandledError = new Error('Unhandled timeout error'); + + handleGlobalError('Unhandled timeout error', 'app.js', 15, 3, unhandledError); + handleGlobalError('Boundary caught error', 'LandingPage.tsx', 852, 10, caughtError); + + // Only the unhandled error should be logged, once + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + '[Global Error Handler]', + expect.objectContaining({ + error: expect.objectContaining({ + message: 'Unhandled timeout error', + }), + }) + ); + }); + + it('handles string message errors without an Error object', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleGlobalError('String error message'); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + '[Global Error Handler]', + expect.objectContaining({ + message: 'String error message', + error: null, + }) + ); + }); + }); + + describe('emitStructuredLog', () => { + it('outputs a structured error log with all fields', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('detailed error'); + + emitStructuredLog({ + message: 'detailed error', + source: 'test.js', + lineno: 42, + colno: 10, + error, + timestamp: 1234567890, + }); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'detailed error', + source: 'test.js', + line: 42, + column: 10, + error: { + name: 'Error', + message: 'detailed error', + stack: error.stack, + }, + timestamp: 1234567890, + }); + }); + + it('handles null error gracefully', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + emitStructuredLog({ + message: 'Script error', + source: 'unknown.js', + lineno: 0, + colno: 0, + error: undefined, + timestamp: 1234567890, + }); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'Script error', + source: 'unknown.js', + line: 0, + column: 0, + error: null, + timestamp: 1234567890, + }); + }); + }); + + describe('markErrorAsCaught and wasErrorAlreadyCaught', () => { + it('correctly marks and identifies caught errors', () => { + const error = new Error('test error'); + + expect(wasErrorAlreadyCaught(error)).toBe(false); + + markErrorAsCaught(error); + + expect(wasErrorAlreadyCaught(error)).toBe(true); + }); + + it('identifies errors with same message and stack as caught', () => { + const error1 = new Error('same error'); + const error2 = new Error('same error'); + + // They should have the same stack (same line in test) + error2.stack = error1.stack; + + markErrorAsCaught(error1); + + expect(wasErrorAlreadyCaught(error2)).toBe(true); + }); + + it('distinguishes different errors', () => { + const error1 = new Error('error one'); + const error2 = new Error('error two'); + + markErrorAsCaught(error1); + + expect(wasErrorAlreadyCaught(error2)).toBe(false); + }); + + it('marks errors as caught via the mock error boundary integration', () => { + const error = new Error('Error caught by SectionErrorBoundary'); + markErrorAsCaught(error); + + expect(wasErrorAlreadyCaught(error)).toBe(true); + }); + }); +}); diff --git a/src/utils/globalErrorHandler.utils.ts b/src/utils/globalErrorHandler.utils.ts new file mode 100644 index 00000000..fd8f902c --- /dev/null +++ b/src/utils/globalErrorHandler.utils.ts @@ -0,0 +1,126 @@ +/** + * Global error handler utilities to prevent duplicate error logging + * between React error boundaries and window.onerror. + * + * Architecture: + * - React error boundaries call `markErrorAsCaught(error)` in componentDidCatch + * - The global `window.onerror` handler checks if the error was already caught + * - If caught, the handler silently returns true (preventing default browser handling) + * - If not caught, it emits a structured log for the genuine unhandled error + * - Duplicate identity detection prevents the same error from being logged more than once + */ + +interface StructuredErrorLog { + message: string | Event; + source?: string; + lineno?: number; + colno?: number; + error?: Error; + timestamp: number; +} + +/** Generate a unique identity for an error based on its message and stack. */ +function getErrorIdentity(error: Error): string { + return `${error.message}::${error.stack ?? ''}`; +} + +/** Track error identities already caught by React error boundaries. */ +const caughtErrorIds = new Set(); + +/** Track error identities already processed by the global handler (prevents duplicates). */ +const processedErrorIds = new Set(); + +/** + * Register an error as already caught by a React error boundary. + * The global error handler will skip this error to avoid duplicate logs. + */ +export function markErrorAsCaught(error: Error): void { + caughtErrorIds.add(getErrorIdentity(error)); +} + +/** + * Check if an error was already caught by a React error boundary. + */ +export function wasErrorAlreadyCaught(error: Error): boolean { + return caughtErrorIds.has(getErrorIdentity(error)); +} + +/** + * Emit a structured error log with all relevant metadata. + * Testable in isolation by spying on console.error. + */ +export function emitStructuredLog(log: StructuredErrorLog): void { + console.error('[Global Error Handler]', { + message: log.message, + source: log.source, + line: log.lineno, + column: log.colno, + error: log.error + ? { + name: log.error.name, + message: log.error.message, + stack: log.error.stack, + } + : null, + timestamp: log.timestamp, + }); +} + +/** + * Handle a global error event. + * - If the error was already caught by a React error boundary, skip it and return true + * - If the error is a duplicate (same identity already processed), skip it and return true + * - Otherwise, emit a structured log for the genuine unhandled error + * + * @returns true to prevent default browser error handling, false otherwise. + */ +export function handleGlobalError( + message: string | Event, + source?: string, + lineno?: number, + colno?: number, + error?: Error +): boolean { + // Skip errors already caught by React error boundaries + if (error && wasErrorAlreadyCaught(error)) { + return true; + } + + // Skip duplicate errors (same identity already processed) + if (error) { + const identity = getErrorIdentity(error); + if (processedErrorIds.has(identity)) { + return true; + } + processedErrorIds.add(identity); + } + + // Emit structured log for genuine unhandled errors + emitStructuredLog({ + message, + source, + lineno, + colno, + error, + timestamp: Date.now(), + }); + + return false; +} + +/** + * Initialize the global error handler on window.onerror. + * Should be called once at application startup (e.g., in main.tsx). + */ +export function initGlobalErrorHandler(): void { + window.onerror = handleGlobalError; +} + +/** + * Clear all tracked error identities. + * Useful in tests to reset state between test cases. + */ +export function resetErrorTracking(): void { + caughtErrorIds.clear(); + processedErrorIds.clear(); +}