Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -6,6 +6,7 @@
|
|||||||
"workflow": {
|
"workflow": {
|
||||||
"code_review": true,
|
"code_review": true,
|
||||||
"code_review_depth": "deep",
|
"code_review_depth": "deep",
|
||||||
"tdd_mode": true
|
"tdd_mode": true,
|
||||||
|
"use_worktrees": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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]');
|
||||||
|
});
|
||||||
|
});
|
||||||
298
tests/content/navigation-tracking.test.ts
Normal file
298
tests/content/navigation-tracking.test.ts
Normal file
@@ -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<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
283
tests/content/rrweb-timestamps.test.ts
Normal file
283
tests/content/rrweb-timestamps.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
onMessage: {
|
||||||
|
addListener: ReturnType<typeof vi.fn>;
|
||||||
|
_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<string, Array<(ev: unknown) => void>> = {};
|
||||||
|
const documentListeners: Record<string, Array<(ev: unknown) => 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user