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>
120 lines
4.4 KiB
TypeScript
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();
|
|
});
|
|
});
|