// 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]'); }); });