// 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; onMessage: { addListener: ReturnType; _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 void>> = {}; const documentListeners: Record 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()); }); });