From 9be71e8fd18e03340fbf9f139a20b0c0f0a23f80 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:37:32 -0700 Subject: [PATCH] feat(site-builder): capture recent console errors for issue reports Ring buffer of the most recent console.error/window-error messages (20 max, 500 chars each, message text only) for Task 19's report payload. installConsoleErrorBuffer() is idempotent via a marker stamped on the patched console.error itself (not just a module-scoped flag), so React 18 StrictMode double-invocation or HMR re-running this module's top level can't wrap an already-patched console.error and build a growing chain. The patch always chains to the original. Co-Authored-By: Claude Opus 5 (1M context) --- craft/src/main.tsx | 5 ++ craft/src/utils/console-buffer.test.ts | 70 ++++++++++++++++ craft/src/utils/console-buffer.ts | 106 +++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 craft/src/utils/console-buffer.test.ts create mode 100644 craft/src/utils/console-buffer.ts diff --git a/craft/src/main.tsx b/craft/src/main.tsx index 5e77a5c..695fa33 100644 --- a/craft/src/main.tsx +++ b/craft/src/main.tsx @@ -2,8 +2,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './App'; import { WhpConfig } from './types'; +import { installConsoleErrorBuffer } from './utils/console-buffer'; import './styles/editor.css'; +// Installed before React mounts so errors thrown during the first render are +// captured too. +installConsoleErrorBuffer(); + // Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev) const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null; diff --git a/craft/src/utils/console-buffer.test.ts b/craft/src/utils/console-buffer.test.ts new file mode 100644 index 0000000..77bd089 --- /dev/null +++ b/craft/src/utils/console-buffer.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect, vi, afterEach } from 'vitest'; +import { + installConsoleErrorBuffer, + getRecentConsoleErrors, + __resetConsoleErrorBuffer, +} from './console-buffer'; + +afterEach(() => { + __resetConsoleErrorBuffer(); + vi.restoreAllMocks(); +}); + +describe('console error buffer', () => { + test('captures console.error calls', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + installConsoleErrorBuffer(); + console.error('boom', 42); + const entries = getRecentConsoleErrors(); + expect(entries).toHaveLength(1); + expect(entries[0].message).toBe('boom 42'); + expect(typeof entries[0].ts).toBe('number'); + }); + + test('always chains to the original console.error', () => { + const original = vi.spyOn(console, 'error').mockImplementation(() => {}); + installConsoleErrorBuffer(); + console.error('passed through'); + expect(original).toHaveBeenCalledWith('passed through'); + }); + + test('retains only the last 20 entries, oldest first', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + installConsoleErrorBuffer(); + for (let i = 0; i < 25; i++) console.error(`e${i}`); + const entries = getRecentConsoleErrors(); + expect(entries).toHaveLength(20); + expect(entries[0].message).toBe('e5'); + expect(entries[19].message).toBe('e24'); + }); + + test('truncates a long message to 500 characters', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + installConsoleErrorBuffer(); + console.error('x'.repeat(900)); + expect(getRecentConsoleErrors()[0].message).toHaveLength(500); + }); + + test('installing twice does not double-capture', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + installConsoleErrorBuffer(); + installConsoleErrorBuffer(); + console.error('once'); + expect(getRecentConsoleErrors()).toHaveLength(1); + }); + + test('captures window error events', () => { + installConsoleErrorBuffer(); + window.dispatchEvent(new ErrorEvent('error', { message: 'window blew up' })); + expect(getRecentConsoleErrors().some((e) => e.message.includes('window blew up'))).toBe(true); + }); + + test('getRecentConsoleErrors returns a copy, not the live array', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + installConsoleErrorBuffer(); + console.error('a'); + const first = getRecentConsoleErrors(); + first.push({ ts: 0, message: 'injected' }); + expect(getRecentConsoleErrors()).toHaveLength(1); + }); +}); diff --git a/craft/src/utils/console-buffer.ts b/craft/src/utils/console-buffer.ts new file mode 100644 index 0000000..4720530 --- /dev/null +++ b/craft/src/utils/console-buffer.ts @@ -0,0 +1,106 @@ +/** + * A tiny ring buffer of the most recent errors, attached to the issue + * reports users file from inside the builder. Without it a report says + * "it broke" and nothing else. + * + * Message text only -- no stack traces. Production stacks are minified into + * uselessness and leak bundle paths for no diagnostic gain. + * + * The console.error patch ALWAYS chains to the original. A monitor that + * swallows diagnostics is worse than no monitor. + * + * Idempotency note: installConsoleErrorBuffer() is guarded by a marker + * stamped directly on the patched console.error function itself, not just a + * module-scoped boolean. React 18 StrictMode (which double-invokes effects) + * and hot module reload can re-run this module's top-level code, resetting + * any local `let installed = false` while the *global* console.error is + * still the already-patched function from the previous install. A + * boolean-only guard would then treat that already-patched function as "the + * original" and wrap it again, building a chain that grows on every reload. + * Checking the marker on the live console.error avoids that regardless of + * how many times this module's top level re-executes. + */ + +export interface ConsoleErrorEntry { + ts: number; + message: string; +} + +const MAX_ENTRIES = 20; +const MAX_MESSAGE = 500; +const MARKER = '__whpConsoleErrorBufferPatched'; + +type MarkedConsoleError = typeof console.error & { + [MARKER]?: true; + __original?: typeof console.error; +}; + +function isPatched(fn: typeof console.error): fn is MarkedConsoleError { + return typeof fn === 'function' && (fn as MarkedConsoleError)[MARKER] === true; +} + +let buffer: ConsoleErrorEntry[] = []; +let errorListener: ((e: ErrorEvent) => void) | null = null; +let rejectionListener: ((e: PromiseRejectionEvent) => void) | null = null; + +function record(message: string): void { + const text = message.length > MAX_MESSAGE ? message.slice(0, MAX_MESSAGE) : message; + buffer.push({ ts: Date.now(), message: text }); + if (buffer.length > MAX_ENTRIES) buffer = buffer.slice(buffer.length - MAX_ENTRIES); +} + +function stringifyArg(arg: unknown): string { + if (typeof arg === 'string') return arg; + if (arg instanceof Error) return `${arg.name}: ${arg.message}`; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } +} + +/** Idempotent. Patches console.error and adds window error listeners. */ +export function installConsoleErrorBuffer(): void { + if (isPatched(console.error)) return; + + const original = console.error.bind(console); + const patched: MarkedConsoleError = (...args: unknown[]): void => { + try { + record(args.map(stringifyArg).join(' ')); + } catch { + // Recording must never break logging. + } + original(...args); + }; + patched[MARKER] = true; + patched.__original = original; + console.error = patched; + + if (typeof window !== 'undefined') { + errorListener = (e: ErrorEvent) => record(`window.onerror: ${e.message}`); + rejectionListener = (e: PromiseRejectionEvent) => + record(`unhandledrejection: ${stringifyArg(e.reason)}`); + window.addEventListener('error', errorListener); + window.addEventListener('unhandledrejection', rejectionListener); + } +} + +/** Oldest-first copy of the retained entries (at most 20). */ +export function getRecentConsoleErrors(): ConsoleErrorEntry[] { + return buffer.slice(); +} + +/** Test-only: clear the buffer and un-patch. */ +export function __resetConsoleErrorBuffer(): void { + buffer = []; + const current = console.error; + if (isPatched(current) && current.__original) { + console.error = current.__original; + } + if (typeof window !== 'undefined') { + if (errorListener) window.removeEventListener('error', errorListener); + if (rejectionListener) window.removeEventListener('unhandledrejection', rejectionListener); + } + errorListener = null; + rejectionListener = null; +}