Files
mokosh/tests/content/navigation-tracking.test.ts
Mark 3dbc51cdcd 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>
2026-05-21 14:19:39 +02:00

299 lines
12 KiB
TypeScript

// 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<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
_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<string, Array<(ev: unknown) => void>> = {};
const documentListeners: Record<string, Array<(ev: unknown) => 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');
});
});