diff --git a/.planning/config.json b/.planning/config.json index a27c29c..9219ec0 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -6,6 +6,7 @@ "workflow": { "code_review": true, "code_review_depth": "deep", - "tdd_mode": true + "tdd_mode": true, + "use_worktrees": false } } diff --git a/tests/content/fetch-interception.test.ts b/tests/content/fetch-interception.test.ts new file mode 100644 index 0000000..543c50a --- /dev/null +++ b/tests/content/fetch-interception.test.ts @@ -0,0 +1,384 @@ +// tests/content/fetch-interception.test.ts +// +// Phase 4 Plan 04-01 Wave 0 RED — Audit P1 #11 contract pins. +// +// Sites under test (src/content/index.ts): +// * line ~174 (ok-branch of setupNetworkLogging): args[0]?.toString() ← BUG +// * line ~190 (catch-branch of setupNetworkLogging): args[0]?.toString() ← BUG +// +// Bug shape: when the page does `fetch(new Request(url))`, `args[0]?.toString()` +// resolves to the literal string '[object Request]' (Request.prototype lacks a +// custom toString), so the captured network_error UserEvent.target loses the +// real URL and downstream archive consumers see '[object Request]' instead. +// +// Phase 4 Plan 04-01 fix (RESEARCH.md §"Specifics" + 04-PATTERNS.md): +// target: args[0] instanceof Request ? args[0].url : String(args[0]) +// +// 3-test contract per 04-01-PLAN.md acceptance criteria: +// * Test A: fetch(stringUrl) + response.ok=false → target === stringUrl +// * Test B: fetch(new Request(url)) + response.ok=false → target === request.url +// (RED today: emits '[object Request]'; GREEN after Plan 04-01) +// * Test C: fetch(stringUrl) + .catch path → target === stringUrl AND +// type === 'network_error' (regression guard for the catch-branch +// twin fix at line ~190) +// +// Scaffold: pure-function-with-vi.fn-stub mirroring +// tests/background/start-video-capture-no-tab.test.ts (Plan 01-09). The +// content script's only chrome.* surface is chrome.runtime.onMessage + +// chrome.runtime.sendMessage; the test stubs both as no-ops. window / +// document / history are stubbed at module-scope before +// `await import('../../src/content/index')` to satisfy the content script's +// init() addEventListener / window.fetch / window.location.href calls. +// +// We capture the userEvents array by sending a GET_RRWEB_EVENTS message +// through the registered onMessage handler — that handler reads the +// module-internal userEvents + rrwebEvents arrays and feeds them back via +// sendResponse, which is exactly the production export shape we want to pin. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { UserEvent } from '../../src/shared/types'; + +// Stub rrweb so the content-script's `import { record } from 'rrweb'` doesn't +// pull rrweb's ESM bundle through vitest's CJS transform pipeline (rrweb's +// lib/*.js is plain ESM and crashes with "exports is not defined" under the +// vitest worker default transform). The stubbed record() is a no-op for the +// fetch tests — they only exercise setupNetworkLogging, not rrweb. +vi.mock('rrweb', () => ({ + record: (_opts: unknown) => () => {}, +})); + +// ──────────────────────────────────────────────────────────────────────────── +// Stub builder for the content-script realm +// ──────────────────────────────────────────────────────────────────────────── + +interface OnMessageCallback { + (msg: unknown, sender: unknown, sendResponse: (r: unknown) => void): boolean | void; +} + +interface ContentChromeStub { + runtime: { + sendMessage: ReturnType; + onMessage: { + addListener: ReturnType; + _callbacks: OnMessageCallback[]; + }; + }; +} + +interface CapturedFetchResult { + ok: boolean; + status: number; + statusText: string; + url: string; +} + +interface GlobalWithContentRealm { + chrome?: ContentChromeStub; + window?: unknown; + document?: unknown; + history?: unknown; + Request?: unknown; + setInterval?: unknown; + XMLHttpRequest?: unknown; +} + +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; +} + +/** + * Synthesize a minimal DOM-ish window + document + history so the content + * script can wire its setup* handlers without throwing. The content script's + * production realm carries a real DOM; here we provide just enough surface + * for the addEventListener + fetch wrap + readyState gate paths. + */ +function installFakeDom(initialHref: string): { + fetchSlot: { current: typeof globalThis.fetch | undefined }; + navigationListeners: Record void>>; +} { + const navigationListeners: Record void>> = {}; + const documentListeners: Record void>> = {}; + const fetchSlot: { current: typeof globalThis.fetch | undefined } = { current: undefined }; + + const fakeLocation = { href: initialHref }; + + const fakeWindow = { + location: fakeLocation, + get fetch() { + return fetchSlot.current; + }, + set fetch(fn: typeof globalThis.fetch | undefined) { + fetchSlot.current = fn; + }, + 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 {}, + }; + + // Minimal Request polyfill so `new Request(url)` works in Node env. + 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 = ''; + } + } + toString(): string { + // Match the native Request behavior: no overridden toString → defaults + // to '[object Request]'. We INTENTIONALLY do NOT override here so the + // bug under test reproduces faithfully. + return Object.prototype.toString.call(this); + } + } + + const g = globalThis as unknown as GlobalWithContentRealm; + g.window = fakeWindow; + g.document = fakeDocument; + g.history = fakeHistory; + g.Request = FakeRequest; + // Pin setInterval to a no-op so the content script's cleanupOldEvents + // scheduler never fires during tests. + g.setInterval = (() => 0) as unknown as typeof setInterval; + // XMLHttpRequest wrapper is irrelevant for fetch tests; provide a stub + // class so prototype assignment in setupNetworkLogging does not throw. + g.XMLHttpRequest = class FakeXHR { + open(): void {} + send(): void {} + addEventListener(): void {} + } as unknown as typeof XMLHttpRequest; + return { fetchSlot, navigationListeners }; +} + +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; +} + +/** + * Read userEvents through the production GET_RRWEB_EVENTS path: the content + * script's chrome.runtime.onMessage handler returns {events, userEvents} for + * this message type. This is the canonical export surface for the archive + * pipeline so it doubles as a contract pin. + */ +function captureUserEvents(stub: ContentChromeStub): UserEvent[] { + let captured: UserEvent[] = []; + const handler = stub.runtime.onMessage._callbacks[0]; + handler( + { type: 'GET_RRWEB_EVENTS' }, + {}, + (response: unknown) => { + const r = response as { userEvents?: UserEvent[] }; + captured = r.userEvents ?? []; + }, + ); + return captured; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Test suite +// ──────────────────────────────────────────────────────────────────────────── + +describe('Audit P1 #11 — fetch URL extraction handles Request + string args', () => { + beforeEach(() => { + vi.resetModules(); + vi.useRealTimers(); + uninstallFakeDom(); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test A — RED-LOOKING-PASS today, GREEN after fix: + // fetch(stringUrl) where response.ok=false → captured target === stringUrl. + // Today's String() coercion happens to work for string args (String(s)===s); + // GREEN after fix also works. The test pins the contract. + // ────────────────────────────────────────────────────────────────────── + it('A: fetch(stringUrl) with response.ok=false captures the string URL', async () => { + const stub = buildContentChromeStub(); + (globalThis as unknown as GlobalWithContentRealm).chrome = stub; + const { fetchSlot } = installFakeDom('https://example.com/page'); + + // Install an originalFetch that resolves with a non-ok Response shape. + fetchSlot.current = ((_input: unknown, _init?: unknown) => { + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Server Error', + url: 'https://example.com/api/fail', + } as CapturedFetchResult); + }) as unknown as typeof globalThis.fetch; + + await import('../../src/content/index'); + // Drain microtasks so start() runs (it's synchronous but defensive). + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const stringUrl = 'https://example.com/api/explicit-string'; + // After the content script ran, window.fetch is the wrapped version. + const w = (globalThis as unknown as GlobalWithContentRealm).window as { + fetch: typeof globalThis.fetch; + }; + await w.fetch(stringUrl).catch(() => undefined); + // Allow the .then chain to settle. + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const events = captureUserEvents(stub); + const netErr = events.find((e) => e.type === 'network_error'); + expect(netErr).toBeDefined(); + expect(netErr?.target).toBe(stringUrl); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test B — RED today, GREEN after Plan 04-01 fix: + // fetch(new Request(url)) where response.ok=false → captured target === + // request.url (NOT '[object Request]'). + // + // Canonical post-fix expression (per RESEARCH §"Specifics"): + // target: args[0] instanceof Request ? args[0].url : String(args[0]) + // ────────────────────────────────────────────────────────────────────── + it('B: fetch(new Request(url)) captures Request.url via instanceof Request narrow', async () => { + const stub = buildContentChromeStub(); + (globalThis as unknown as GlobalWithContentRealm).chrome = stub; + const { fetchSlot } = installFakeDom('https://example.com/page'); + + fetchSlot.current = ((_input: unknown, _init?: unknown) => { + return Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + url: 'https://api.example.com/missing', + } as CapturedFetchResult); + }) as unknown as typeof globalThis.fetch; + + await import('../../src/content/index'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const requestUrl = 'https://api.example.com/v1/widgets/42'; + const RequestCtor = (globalThis as unknown as GlobalWithContentRealm) + .Request as new (input: string) => { url: string }; + const requestInstance = new RequestCtor(requestUrl); + + const w = (globalThis as unknown as GlobalWithContentRealm).window as { + fetch: typeof globalThis.fetch; + }; + await w.fetch(requestInstance as unknown as RequestInfo).catch(() => undefined); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const events = captureUserEvents(stub); + const netErr = events.find((e) => e.type === 'network_error'); + expect(netErr).toBeDefined(); + // The canonical Plan 04-01 invariant: the real URL surfaces, not the + // literal Object.prototype.toString tag. + expect(netErr?.target).toBe(requestUrl); + expect(netErr?.target).not.toBe('[object Request]'); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test C — REGRESSION pin: catch-branch (line ~190) carries the same fix. + // fetch(stringUrl) that throws → captured target === stringUrl AND + // type === 'network_error'. + // ────────────────────────────────────────────────────────────────────── + it('C: fetch(stringUrl) .catch path records network_error with the correct URL', async () => { + const stub = buildContentChromeStub(); + (globalThis as unknown as GlobalWithContentRealm).chrome = stub; + const { fetchSlot } = installFakeDom('https://example.com/page'); + + fetchSlot.current = ((_input: unknown, _init?: unknown) => { + return Promise.reject(new Error('Network unreachable')); + }) as unknown as typeof globalThis.fetch; + + await import('../../src/content/index'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const stringUrl = 'https://example.com/api/throws'; + const w = (globalThis as unknown as GlobalWithContentRealm).window as { + fetch: typeof globalThis.fetch; + }; + // The wrapped fetch re-throws after recording; catch the throw to keep + // the test promise green. + await w.fetch(stringUrl).catch(() => undefined); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const events = captureUserEvents(stub); + const netErr = events.find((e) => e.type === 'network_error'); + expect(netErr).toBeDefined(); + expect(netErr?.type).toBe('network_error'); + expect(netErr?.target).toBe(stringUrl); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test D — REGRESSION pin: catch-branch (line ~190) carries the same + // `instanceof Request` type-narrow as the ok-branch (line ~174). Mirrors + // Test B for the throw path. + // ────────────────────────────────────────────────────────────────────── + it('D: fetch(new Request(url)) .catch path captures Request.url via instanceof Request narrow', async () => { + const stub = buildContentChromeStub(); + (globalThis as unknown as GlobalWithContentRealm).chrome = stub; + const { fetchSlot } = installFakeDom('https://example.com/page'); + + fetchSlot.current = ((_input: unknown, _init?: unknown) => { + return Promise.reject(new Error('aborted')); + }) as unknown as typeof globalThis.fetch; + + await import('../../src/content/index'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const requestUrl = 'https://api.example.com/v1/abort-me'; + const RequestCtor = (globalThis as unknown as GlobalWithContentRealm) + .Request as new (input: string) => { url: string }; + const requestInstance = new RequestCtor(requestUrl); + const w = (globalThis as unknown as GlobalWithContentRealm).window as { + fetch: typeof globalThis.fetch; + }; + await w.fetch(requestInstance as unknown as RequestInfo).catch(() => undefined); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const events = captureUserEvents(stub); + const netErr = events.find((e) => e.type === 'network_error'); + expect(netErr).toBeDefined(); + expect(netErr?.target).toBe(requestUrl); + expect(netErr?.target).not.toBe('[object Request]'); + }); +}); diff --git a/tests/content/navigation-tracking.test.ts b/tests/content/navigation-tracking.test.ts new file mode 100644 index 0000000..409a01e --- /dev/null +++ b/tests/content/navigation-tracking.test.ts @@ -0,0 +1,298 @@ +// tests/content/navigation-tracking.test.ts +// +// Phase 4 Plan 04-01 Wave 0 RED — Audit P1 #14 contract pins. +// +// Site under test (src/content/index.ts:99-109): `handleNavigation` closure +// inside setupNavigationLogging emits navigation UserEvent with +// `meta: { previousUrl: history.state?.url || 'unknown' }`. In practice no +// modern SPA populates history.state.url, so the field is permanently +// 'unknown' — useless for support reproducing where the operator came from. +// +// Phase 4 Plan 04-01 fix (RESEARCH.md §"Specifics" + 04-PATTERNS.md): +// - ADD module-scope `let previousUrl = window.location.href;` +// - In handleNavigation, swap-then-emit: +// const fromUrl = previousUrl; +// const toUrl = window.location.href; +// previousUrl = toUrl; +// addUserEvent({ ..., meta: { previousUrl: fromUrl } }); +// +// 3-test contract per 04-01-PLAN.md acceptance criteria: +// * Test A: popstate after location mutation → UserEvent.meta.previousUrl +// === the prior href (NOT 'unknown'). +// * Test B: hashchange → same semantics. +// * Test C: history.pushState wrap continues to fire navigation handler +// AND meta.previousUrl carries the prior URL (regression guard +// for the pushState/replaceState patch path). +// +// Scaffold: same Node-env stub pattern as fetch-interception.test.ts. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { UserEvent } from '../../src/shared/types'; + +// Stub rrweb so the content-script's `import { record } from 'rrweb'` doesn't +// pull rrweb's ESM bundle through vitest's CJS transform pipeline (rrweb's +// lib/*.js is plain ESM and crashes with "exports is not defined" under the +// vitest worker default transform). The stubbed record() is a no-op for the +// navigation tests — they only exercise setupNavigationLogging. +vi.mock('rrweb', () => ({ + record: (_opts: unknown) => () => {}, +})); + +// ──────────────────────────────────────────────────────────────────────────── + +interface OnMessageCallback { + (msg: unknown, sender: unknown, sendResponse: (r: unknown) => void): boolean | void; +} + +interface ContentChromeStub { + runtime: { + sendMessage: ReturnType; + onMessage: { + addListener: ReturnType; + _callbacks: OnMessageCallback[]; + }; + }; +} + +interface FakeWindowHandle { + setHref(next: string): void; + dispatch(type: string): void; +} + +interface FakeHistoryHandle { + callPushState(url?: string): void; + callReplaceState(url?: string): void; +} + +interface GlobalWithContentRealm { + chrome?: ContentChromeStub; + window?: unknown; + document?: unknown; + history?: unknown; + Request?: unknown; + setInterval?: unknown; + XMLHttpRequest?: unknown; +} + +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): { + windowHandle: FakeWindowHandle; + historyHandle: FakeHistoryHandle; +} { + const navigationListeners: Record void>> = {}; + const documentListeners: Record void>> = {}; + + const fakeLocation = { href: initialHref }; + + 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 { + if (url !== undefined) fakeLocation.href = url; + }, + replaceState(_state: unknown, _title: string, url?: string): void { + if (url !== undefined) fakeLocation.href = url; + }, + }; + + 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; + g.setInterval = (() => 0) as unknown as typeof setInterval; + g.XMLHttpRequest = class FakeXHR { + open(): void {} + send(): void {} + addEventListener(): void {} + } as unknown as typeof XMLHttpRequest; + + return { + windowHandle: { + setHref(next: string): void { + fakeLocation.href = next; + }, + dispatch(type: string): void { + fakeWindow.dispatchEvent({ type }); + }, + }, + historyHandle: { + callPushState(url?: string): void { + // Use the (potentially wrapped) history.pushState assigned by the + // content script's setupNavigationLogging override. + (globalThis as unknown as GlobalWithContentRealm).history; // ensure read + const h = (globalThis as unknown as { history: { pushState: (s: unknown, t: string, u?: string) => void } }).history; + h.pushState({}, '', url); + }, + callReplaceState(url?: string): void { + const h = (globalThis as unknown as { history: { replaceState: (s: unknown, t: string, u?: string) => void } }).history; + h.replaceState({}, '', url); + }, + }, + }; +} + +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 captureUserEvents(stub: ContentChromeStub): UserEvent[] { + let captured: UserEvent[] = []; + const handler = stub.runtime.onMessage._callbacks[0]; + handler( + { type: 'GET_RRWEB_EVENTS' }, + {}, + (response: unknown) => { + const r = response as { userEvents?: UserEvent[] }; + captured = r.userEvents ?? []; + }, + ); + return captured; +} + +// ──────────────────────────────────────────────────────────────────────────── + +describe('Audit P1 #14 — navigation URL tracking uses module-level previousUrl', () => { + beforeEach(() => { + vi.resetModules(); + uninstallFakeDom(); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test A — RED today, GREEN after fix: + // popstate dispatch after a location mutation → UserEvent.meta.previousUrl + // === prior href, NOT 'unknown'. + // ────────────────────────────────────────────────────────────────────── + it('A: popstate captures the previous URL (NOT "unknown")', async () => { + const stub = buildContentChromeStub(); + (globalThis as unknown as GlobalWithContentRealm).chrome = stub; + const startHref = 'https://example.com/start'; + const { windowHandle } = installFakeDom(startHref); + + await import('../../src/content/index'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + // Operator navigates to /next; popstate fires. + windowHandle.setHref('https://example.com/next'); + windowHandle.dispatch('popstate'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const events = captureUserEvents(stub); + const nav = events.find((e) => e.type === 'navigation'); + expect(nav).toBeDefined(); + const previousUrl = (nav?.meta as { previousUrl?: unknown } | undefined)?.previousUrl; + expect(previousUrl).toBe(startHref); + expect(previousUrl).not.toBe('unknown'); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test B — RED today, GREEN after fix: + // hashchange via same module-level previousUrl mechanism. + // ────────────────────────────────────────────────────────────────────── + it('B: hashchange captures the previous URL', async () => { + const stub = buildContentChromeStub(); + (globalThis as unknown as GlobalWithContentRealm).chrome = stub; + const startHref = 'https://example.com/page'; + const { windowHandle } = installFakeDom(startHref); + + await import('../../src/content/index'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + // Operator hash-jumps. + windowHandle.setHref('https://example.com/page#section-2'); + windowHandle.dispatch('hashchange'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const events = captureUserEvents(stub); + const nav = events.find((e) => e.type === 'navigation'); + expect(nav).toBeDefined(); + const previousUrl = (nav?.meta as { previousUrl?: unknown } | undefined)?.previousUrl; + expect(previousUrl).toBe(startHref); + expect(previousUrl).not.toBe('unknown'); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test C — REGRESSION + module-state guard: + // history.pushState wraps still fire navigation events AND meta.previousUrl + // honors the prior module state (NOT the freshly-pushed URL — that would + // mean previousUrl was mutated before reading fromUrl). + // ────────────────────────────────────────────────────────────────────── + it('C: history.pushState wrap emits navigation with prior URL as previousUrl', async () => { + const stub = buildContentChromeStub(); + (globalThis as unknown as GlobalWithContentRealm).chrome = stub; + const startHref = 'https://example.com/initial'; + const { historyHandle } = installFakeDom(startHref); + + await import('../../src/content/index'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + historyHandle.callPushState('https://example.com/after-push'); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + + const events = captureUserEvents(stub); + const navs = events.filter((e) => e.type === 'navigation'); + expect(navs.length).toBeGreaterThanOrEqual(1); + const lastNav = navs[navs.length - 1]; + const previousUrl = (lastNav.meta as { previousUrl?: unknown } | undefined)?.previousUrl; + // The prior URL (startHref) must be what meta.previousUrl carries — NOT + // the freshly-pushed href (that's `value` / `url`). + expect(previousUrl).toBe(startHref); + expect(previousUrl).not.toBe('https://example.com/after-push'); + expect(previousUrl).not.toBe('unknown'); + }); +}); diff --git a/tests/content/rrweb-timestamps.test.ts b/tests/content/rrweb-timestamps.test.ts new file mode 100644 index 0000000..2e74719 --- /dev/null +++ b/tests/content/rrweb-timestamps.test.ts @@ -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; + 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()); + }); +});