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