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>
This commit is contained in:
384
tests/content/fetch-interception.test.ts
Normal file
384
tests/content/fetch-interception.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
onMessage: {
|
||||
addListener: ReturnType<typeof vi.fn>;
|
||||
_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<string, Array<(ev: unknown) => void>>;
|
||||
} {
|
||||
const navigationListeners: Record<string, Array<(ev: unknown) => void>> = {};
|
||||
const documentListeners: Record<string, Array<(ev: unknown) => 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]');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user