test(04-01): Wave 0 RED — audit P1 #11/#14/#15 content-script test scaffolds
Three new test files at tests/content/ (NEW directory mirroring src/content/)
pin the canonical Plan 04-01 contracts; 7 of 9 tests are RED today and flip
GREEN once src/content/index.ts gains the three surgical edits in Task 2.
* tests/content/fetch-interception.test.ts (4 tests; A+C pass today via the
identity String(string)===string coincidence, B+D RED — they fetch a
`new Request(url)` and assert target === request.url under the canonical
`args[0] instanceof Request ? args[0].url : String(args[0])` narrow).
* tests/content/navigation-tracking.test.ts (3 tests; all 3 RED — popstate
+ hashchange + history.pushState wrap all read meta.previousUrl which is
permanently 'unknown' under today's `history.state?.url || 'unknown'`
emit; GREEN after module-level `let previousUrl` lands).
* tests/content/rrweb-timestamps.test.ts (2 tests; both RED — Test A asserts
rrweb-emit normalizes timestamps to Date.now()-class >1e12 instead of the
rrweb-internal page-load-relative small int; Test B regresses
cleanupOldEvents arithmetic correctness when both sides are Unix-epoch).
Scaffold mirrors tests/background/start-video-capture-no-tab.test.ts (Plan
01-09): vi.resetModules() in beforeEach, minimal chrome.* + window/document/
history/Request stubs installed on globalThis before
`await import('../../src/content/index')`. rrweb is mocked via vi.mock so the
content-script's `import { record } from 'rrweb'` short-circuits to a no-op
factory (avoids the rrweb-lib ESM-in-CJS transform crash). userEvents and
rrwebEvents are read back through the canonical GET_RRWEB_EVENTS chrome.
runtime.onMessage path the production archive pipeline uses.
Also folds in the .planning/config.json `use_worktrees: false` flip the
orchestrator staged before respawning this executor in foreground mode.
Plan: 04-01 Wave 0
Files:
- tests/content/fetch-interception.test.ts
- tests/content/navigation-tracking.test.ts
- tests/content/rrweb-timestamps.test.ts
- .planning/config.json (worktree mode disabled)
Verification (RED gate):
- npm test -- tests/content/ --run → 7 failed | 2 passed (9)
- grep -c "instanceof Request" tests/content/fetch-interception.test.ts → 5
- grep -c "previousUrl" tests/content/navigation-tracking.test.ts → 24
- grep -cE "Date\.now\(\)" tests/content/rrweb-timestamps.test.ts → 9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
283
tests/content/rrweb-timestamps.test.ts
Normal file
283
tests/content/rrweb-timestamps.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
// tests/content/rrweb-timestamps.test.ts
|
||||
//
|
||||
// Phase 4 Plan 04-01 Wave 0 RED — Audit P1 #15 contract pins.
|
||||
//
|
||||
// Site under test (src/content/index.ts:286-289): inside `initRrweb`, the
|
||||
// rrweb `record({ emit(event) { rrwebEvents.push(event); } })` callback
|
||||
// pushes events whose `event.timestamp` is rrweb-internal (page-load-relative
|
||||
// small integer). Meanwhile `cleanupOldEvents` at lines 27-50 does
|
||||
// `(now - event.timestamp) < RRWEB_RETENTION_MS` with `now = Date.now()`
|
||||
// (Unix epoch ms). The arithmetic is a category error: `Date.now() - 42` is
|
||||
// always >> 10 min retention, so every rrweb event flunks the retention
|
||||
// check and gets evicted (or, more subtly, the comparison is wrong-by-orders-
|
||||
// of-magnitude — depends on rrweb's internal clock origin).
|
||||
//
|
||||
// Phase 4 Plan 04-01 fix (RESEARCH.md §"Specifics" + 04-PATTERNS.md):
|
||||
// - Prepend `event.timestamp = Date.now();` in the emit callback so all
|
||||
// events carry Unix-epoch ms (Date.now()-class, > 1e12 in 2026).
|
||||
//
|
||||
// 2-test contract per 04-01-PLAN.md acceptance criteria:
|
||||
// * Test A: stub rrweb.record; invoke its emit callback with a synthetic
|
||||
// event carrying timestamp=42 (page-load-relative); after the
|
||||
// content-script emit, rrwebEvents[0].timestamp > 1e12 (Unix-epoch
|
||||
// class). RED today (current code preserves the 42).
|
||||
// * Test B: regression — cleanupOldEvents at lines 27-50 correctly
|
||||
// evicts events older than retention WHEN both sides of the arithmetic
|
||||
// are Unix-epoch. Build a synthetic rrwebEvents containing one event
|
||||
// timestamped Date.now() - RRWEB_RETENTION_MS - 1000 (older) and one
|
||||
// fresh; after cleanup, only the fresh event remains.
|
||||
//
|
||||
// Scaffold: vi.mock('rrweb') captures the emit callback; the test then
|
||||
// invokes it directly to simulate rrweb's natural emit-loop. The Plan 04-01
|
||||
// fix lands at the wrapper layer, so the test asserts the wrapper's
|
||||
// behavior, not rrweb's internals.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OnMessageCallback {
|
||||
(msg: unknown, sender: unknown, sendResponse: (r: unknown) => void): boolean | void;
|
||||
}
|
||||
|
||||
interface ContentChromeStub {
|
||||
runtime: {
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
onMessage: {
|
||||
addListener: ReturnType<typeof vi.fn>;
|
||||
_callbacks: OnMessageCallback[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface RrwebEmitCallback {
|
||||
(event: { timestamp: number; [k: string]: unknown }): void;
|
||||
}
|
||||
|
||||
interface RrwebCapture {
|
||||
emit: RrwebEmitCallback | null;
|
||||
}
|
||||
|
||||
interface GlobalWithContentRealm {
|
||||
chrome?: ContentChromeStub;
|
||||
window?: unknown;
|
||||
document?: unknown;
|
||||
history?: unknown;
|
||||
Request?: unknown;
|
||||
setInterval?: unknown;
|
||||
XMLHttpRequest?: unknown;
|
||||
}
|
||||
|
||||
// Module-scope rrweb capture shared across the vi.mock factory closure.
|
||||
// vi.mock is hoisted, but the factory itself is invoked per import — we
|
||||
// expose a getter so the test can reach the emit callback after import.
|
||||
const rrwebCapture: RrwebCapture = { emit: null };
|
||||
|
||||
vi.mock('rrweb', () => ({
|
||||
record: (opts: { emit: RrwebEmitCallback }) => {
|
||||
rrwebCapture.emit = opts.emit;
|
||||
// record() returns a stopFn in real rrweb; for the wrapper test we don't
|
||||
// need it.
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
function buildContentChromeStub(): ContentChromeStub {
|
||||
const stub: ContentChromeStub = {
|
||||
runtime: {
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
onMessage: { addListener: vi.fn(), _callbacks: [] },
|
||||
},
|
||||
};
|
||||
stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => {
|
||||
stub.runtime.onMessage._callbacks.push(cb);
|
||||
});
|
||||
return stub;
|
||||
}
|
||||
|
||||
function installFakeDom(initialHref: string): { intervalSlot: { fn: (() => void) | null } } {
|
||||
const navigationListeners: Record<string, Array<(ev: unknown) => void>> = {};
|
||||
const documentListeners: Record<string, Array<(ev: unknown) => void>> = {};
|
||||
const fakeLocation = { href: initialHref };
|
||||
const intervalSlot: { fn: (() => void) | null } = { fn: null };
|
||||
|
||||
const fakeWindow = {
|
||||
location: fakeLocation,
|
||||
fetch: undefined as unknown,
|
||||
addEventListener(type: string, listener: (ev: unknown) => void): void {
|
||||
const bucket = navigationListeners[type] ?? [];
|
||||
bucket.push(listener);
|
||||
navigationListeners[type] = bucket;
|
||||
},
|
||||
dispatchEvent(ev: { type: string }): boolean {
|
||||
const bucket = navigationListeners[ev.type] ?? [];
|
||||
for (const fn of bucket) fn(ev);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const fakeDocument = {
|
||||
readyState: 'complete',
|
||||
addEventListener(type: string, listener: (ev: unknown) => void): void {
|
||||
const bucket = documentListeners[type] ?? [];
|
||||
bucket.push(listener);
|
||||
documentListeners[type] = bucket;
|
||||
},
|
||||
dispatchEvent(ev: { type: string }): boolean {
|
||||
const bucket = documentListeners[ev.type] ?? [];
|
||||
for (const fn of bucket) fn(ev);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const fakeHistory = {
|
||||
state: null,
|
||||
pushState(_state: unknown, _title: string, _url?: string): void {},
|
||||
replaceState(_state: unknown, _title: string, _url?: string): void {},
|
||||
};
|
||||
|
||||
class FakeRequest {
|
||||
public url: string;
|
||||
constructor(input: string | { url?: string }, _init?: unknown) {
|
||||
if (typeof input === 'string') this.url = input;
|
||||
else if (input && typeof input.url === 'string') this.url = input.url;
|
||||
else this.url = '';
|
||||
}
|
||||
}
|
||||
|
||||
const g = globalThis as unknown as GlobalWithContentRealm;
|
||||
g.window = fakeWindow;
|
||||
g.document = fakeDocument;
|
||||
g.history = fakeHistory;
|
||||
g.Request = FakeRequest;
|
||||
// Capture the cleanup fn registered by setInterval so Test B can invoke it.
|
||||
g.setInterval = ((fn: () => void, _ms: number) => {
|
||||
intervalSlot.fn = fn;
|
||||
return 0;
|
||||
}) as unknown as typeof setInterval;
|
||||
g.XMLHttpRequest = class FakeXHR {
|
||||
open(): void {}
|
||||
send(): void {}
|
||||
addEventListener(): void {}
|
||||
} as unknown as typeof XMLHttpRequest;
|
||||
|
||||
return { intervalSlot };
|
||||
}
|
||||
|
||||
function uninstallFakeDom(): void {
|
||||
const g = globalThis as unknown as GlobalWithContentRealm;
|
||||
delete g.window;
|
||||
delete g.document;
|
||||
delete g.history;
|
||||
delete g.Request;
|
||||
delete g.setInterval;
|
||||
delete g.XMLHttpRequest;
|
||||
}
|
||||
|
||||
function captureRrwebEvents(stub: ContentChromeStub): Array<{ timestamp: number }> {
|
||||
let captured: Array<{ timestamp: number }> = [];
|
||||
const handler = stub.runtime.onMessage._callbacks[0];
|
||||
handler(
|
||||
{ type: 'GET_RRWEB_EVENTS' },
|
||||
{},
|
||||
(response: unknown) => {
|
||||
const r = response as { events?: Array<{ timestamp: number }> };
|
||||
captured = r.events ?? [];
|
||||
},
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Audit P1 #15 — rrweb buffer cleanup uses Unix-epoch timestamps', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
rrwebCapture.emit = null;
|
||||
uninstallFakeDom();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Test A — RED today, GREEN after fix:
|
||||
// rrweb emit normalized to Date.now() class (> 1e12 in 2026), NOT the
|
||||
// synthetic page-load-relative small int.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
it('A: rrweb emit normalizes timestamp to Date.now()-class (>1e12), not page-load-relative', async () => {
|
||||
const stub = buildContentChromeStub();
|
||||
(globalThis as unknown as GlobalWithContentRealm).chrome = stub;
|
||||
installFakeDom('https://example.com/probe');
|
||||
|
||||
await import('../../src/content/index');
|
||||
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
||||
|
||||
expect(rrwebCapture.emit).not.toBeNull();
|
||||
const beforeEmit = Date.now();
|
||||
// Synthesize rrweb's natural emit shape: small page-load-relative
|
||||
// timestamp (rrweb's internal RAF origin).
|
||||
const synthetic = { timestamp: 42, type: 4, data: { source: 1 } };
|
||||
rrwebCapture.emit?.(synthetic);
|
||||
const afterEmit = Date.now();
|
||||
|
||||
const events = captureRrwebEvents(stub);
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
// The wrapper MUST normalize: the captured timestamp is Unix-epoch ms
|
||||
// (~1.7e12 in 2026), NOT the 42 we passed in.
|
||||
expect(events[0].timestamp).toBeGreaterThan(1e12);
|
||||
expect(events[0].timestamp).not.toBe(42);
|
||||
// Sanity: the normalized timestamp lies inside our emit-call window.
|
||||
expect(events[0].timestamp).toBeGreaterThanOrEqual(beforeEmit);
|
||||
expect(events[0].timestamp).toBeLessThanOrEqual(afterEmit);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Test B — Regression pin: cleanupOldEvents at lines 27-50 correctly
|
||||
// evicts events older than retention WHEN timestamps are Unix-epoch. We
|
||||
// verify the post-fix arithmetic is meaningful end-to-end: emit an old
|
||||
// event (via direct emit with stubbed Date.now), emit a fresh event, then
|
||||
// synthesize the cleanup interval callback and assert only the fresh
|
||||
// event remains.
|
||||
//
|
||||
// Strategy: monkey-patch Date.now to return an old time, emit, restore
|
||||
// Date.now, emit fresh, run the captured cleanup fn. The fix ensures BOTH
|
||||
// sides of (now - event.timestamp) are in the same epoch class.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
it('B: cleanupOldEvents evicts old rrweb events under Unix-epoch arithmetic (regression pin)', async () => {
|
||||
const stub = buildContentChromeStub();
|
||||
(globalThis as unknown as GlobalWithContentRealm).chrome = stub;
|
||||
const { intervalSlot } = installFakeDom('https://example.com/probe');
|
||||
|
||||
await import('../../src/content/index');
|
||||
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
||||
|
||||
expect(rrwebCapture.emit).not.toBeNull();
|
||||
expect(intervalSlot.fn).not.toBeNull();
|
||||
|
||||
// Constants mirrored from src/content/index.ts:
|
||||
const RRWEB_RETENTION_MS = 10 * 60 * 1000;
|
||||
|
||||
// ── Emit "old" event: monkey-patch Date.now to a past time so the
|
||||
// wrapper's normalization writes the OLD epoch into the buffer.
|
||||
const realNow = Date.now;
|
||||
const pastNow = realNow() - RRWEB_RETENTION_MS - 5_000; // 5 s older than retention
|
||||
Date.now = () => pastNow;
|
||||
rrwebCapture.emit?.({ timestamp: 1, type: 4, data: {} });
|
||||
Date.now = realNow;
|
||||
|
||||
// ── Emit "fresh" event under real Date.now.
|
||||
rrwebCapture.emit?.({ timestamp: 2, type: 4, data: {} });
|
||||
|
||||
// Before cleanup, both events should be buffered.
|
||||
const before = captureRrwebEvents(stub);
|
||||
expect(before.length).toBe(2);
|
||||
|
||||
// Run the interval-registered cleanup fn.
|
||||
intervalSlot.fn?.();
|
||||
|
||||
const after = captureRrwebEvents(stub);
|
||||
// The old event was evicted by the (now - event.timestamp) > retention
|
||||
// check; the fresh event survives.
|
||||
expect(after.length).toBe(1);
|
||||
expect(after[0].timestamp).toBeGreaterThan(realNow() - 1_000);
|
||||
expect(after[0].timestamp).toBeLessThanOrEqual(realNow());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user