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:
2026-08-09 10:37:32 -07:00
co-authored by Claude Opus 5
parent aba0d187d7
commit 9be71e8fd1
3 changed files with 181 additions and 0 deletions
+70
View File
@@ -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);
});
});