Files
site-builder/craft/src/utils/console-buffer.test.ts
T
shadowdaoandClaude Opus 5 fc8918c1f9 fix(site-builder): stop the console-error marker from re-deriving "original" from a live wrapper
Review found a gap in the idempotency marker added for Task 16: if
external code wraps our patched console.error between two of our own
installs, the marker sees an unmarked function and treats it as
virgin, capturing the external wrapper itself as "the original".
That both double-records (the old patch is still reachable inside the
wrapper's closure) and makes __resetConsoleErrorBuffer() restore to
the wrapper instead of the real original.

Fix: stash the true original exactly once, directly on the `console`
object (not module scope, so it survives HMR too), and always
re-wrap that stashed reference rather than whatever console.error
currently is. Reinstalling after an external wrap now discards that
wrapper instead of guessing whether it still chains to us -- a
deliberate, documented trade-off, since there is no safe way to tell
those two cases apart from the outside.

Also: window error/rejection listeners now catch exceptions from a
hostile e.reason the same way the console.error patch already did,
and the module doc comment now notes the known HMR buffer-orphan
wrinkle. Adds two tests covering the external-wrapper and
module-re-execution scenarios; both were mutation-verified to fail
against the prior implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:51:08 -07:00

120 lines
4.4 KiB
TypeScript

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);
});
test('reinstalling after external code wraps console.error does not double-record, and reset restores the true original', () => {
const trueOriginal = vi.spyOn(console, 'error').mockImplementation(() => {});
installConsoleErrorBuffer();
// External code wraps our patch without knowing anything about our
// marker convention -- this is exactly what a third-party script or
// another monitor might do.
const ourPatch = console.error;
const external = vi.fn((...args: unknown[]) => {
ourPatch(...args);
});
console.error = external;
installConsoleErrorBuffer();
console.error('dup-check');
expect(getRecentConsoleErrors().filter((e) => e.message === 'dup-check')).toHaveLength(1);
__resetConsoleErrorBuffer();
expect(console.error).toBe(trueOriginal);
});
test('surviving module re-execution: reinstalling after the module reloads does not re-wrap an already-patched console.error', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const mod1 = await import('./console-buffer');
mod1.installConsoleErrorBuffer();
const patchedAfterFirstInstall = console.error;
// Simulate HMR: the module graph re-evaluates, producing a fresh module
// instance with its own reset top-level state, while the *global*
// console.error is still whatever the previous instance patched it to.
vi.resetModules();
const mod2 = await import('./console-buffer');
mod2.installConsoleErrorBuffer();
// The marker on the live console.error -- not module-local state -- is
// what installConsoleErrorBuffer() consults, so the reloaded instance
// must recognize the existing patch and leave it alone rather than
// wrapping it a second time.
expect(console.error).toBe(patchedAfterFirstInstall);
console.error('once-across-reload');
expect(
mod1.getRecentConsoleErrors().filter((e) => e.message === 'once-across-reload')
).toHaveLength(1);
mod1.__resetConsoleErrorBuffer();
});
});