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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user