diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 3fde9d1..862f1f0 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -3315,50 +3315,118 @@ async function assertA28(): Promise { }; } -/* ─── Plan 03-01 Task 2 — A29 (rrweb DOM verification; SPEC §10 #4) ─ +/* ─── Plan 04-03 — A29 rewrite (rrweb DOM verification; SPEC §10 #4) + * cs-injection-world pattern (verbatim port of A30 + * per RESEARCH Q3 + Plan 03-02 SUMMARY) ────────────── * - * A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (already - * wired at src/content/index.ts:285) emits Meta + FullSnapshot - * + at least one IncrementalSnapshot when the harness page - * contains the probe HTML (form + table + modal) AND the driver - * injects a DOM mutation before SAVE (RESEARCH Pitfall 1: - * static probe HTML emits Meta + FullSnapshot but not - * IncrementalSnapshot without mutation). + * A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (wired at + * src/content/index.ts:285) MUST capture a DOM mutation that + * WE injected — proving the rrweb pipeline is genuinely live + * on the SAVE-active tab. The captured IncrementalSnapshot + * payload MUST contain the sentinel string the host-side + * driveA29 strict filter searches for. * - * Page side: dispatch the probe-page DOM mutations (input value + - * modal toggle), settle, setupFreshRecording, settle one segment, - * dispatch SAVE_ARCHIVE, push A29.1 ack check. The host-side driveA29 - * does the EventType-enum-shape grep against rrweb/session.json from - * the assembled zip (matches A26's chained-assertion pattern; JSZip - * + @rrweb/types are host-only deps). + * Plan 04-03 rewrite background (RESEARCH Q3 + Plan 03-02/03-03 SUMMARY): + * The pre-04-03 A29 implementation dispatched probe-page DOM mutations + * on the harness page (chrome-extension://) and then relied on a + * loose `EventType.{Meta,FullSnapshot,IncrementalSnapshot}` grep at + * the host-side. Two latent defects fell out of this: * - * FORBIDDEN_HOOK_STRINGS impact: NONE. A29 rides production rrweb - * wiring (record() at src/content/index.ts:285 + GET_RRWEB_EVENTS - * round-trip at src/background/index.ts → src/content/index.ts:318) - * + the existing setupFreshRecording / sendMessageWithTimeout - * helpers. Tier-1 inventory stays at 12 entries. + * 1. The chrome-extension:// harness page never had a content + * script attached at all (`` does NOT cover + * chrome-extension scheme per Chrome match-pattern spec — + * same root cause that drove the A30 cs-injection-world + * adaptation in Plan 03-02). rrweb's record() therefore never + * ran on the harness page. + * 2. The host-side loose grep would still pass because the + * produced zip contained iana.org leftover rrweb events from + * the A27/A28 probe tabs that were still open at SAVE time — + * i.e. A29 "passed" for the wrong reason at ~2/3 success rate + * (documented as a pre-existing flake in Plan 03-02 + 03-03 + * SUMMARYs). A real rrweb regression at src/content/index.ts:284 + * would have been masked because iana.org's home page emits + * plenty of mutations during normal rendering. + * + * Plan 04-03 fix (mechanical port of assertA30 / assertA31 skeleton): + * - Open a fresh https://example.com probe tab via chrome.tabs.create + * (RFC 2606 reserved domain; matches A30 + A31 pattern). + * - Wait the canonical 1.5s for content script attach. + * - Wait the canonical 11s for the first MediaRecorder segment to + * rotate (mirrors A30's SEGMENT_SETTLE_MS). + * - Use chrome.scripting.executeScript with default world: 'ISOLATED' + * (the content-script's realm where rrweb's MutationObserver lives) + * to inject a synthetic DOM mutation that carries a UNIQUE SENTINEL + * STRING ('a29-mutation-sentinel') into a fresh
appendChild'd + * to document.body. + * - Wait 500ms for rrweb's MutationObserver to enqueue the + * IncrementalSnapshot. + * - Dispatch SAVE_ARCHIVE while the probe tab is active (its content + * script is the source of rrweb/session.json). + * - finally-cleanup the probe tab via chrome.tabs.remove with the + * T-02-04-04 silent-ignore precedent. + * + * Host-side driveA29 (rewritten in Plan 04-03 Task 2) JSZip-parses the + * resulting archive, filters rrweb events for + * e.type === EventType.IncrementalSnapshot && + * e.data?.source === IncrementalSource.Mutation, + * descends into the mutation payload's adds[*].node.textContent + * field, and asserts >=1 event contains the sentinel — proving the + * captured mutation came from OUR injection, not from leftover + * iana.org tabs (closes the documented flake). + * + * FORBIDDEN_HOOK_STRINGS impact: NONE. A29's new path rides production + * chrome.tabs.create + chrome.scripting.executeScript + existing + * setupFreshRecording + sendMessageWithTimeout helpers. Tier-1 + * inventory stays at 12 entries. */ -/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27. */ +/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27/A30/A31. */ const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; -/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */ +/** Pre-SAVE segment-settle window (10s rotation + 1s slack). Mirrors + * A30_SEGMENT_SETTLE_MS. */ const A29_SEGMENT_SETTLE_MS = 11_000; -/** Settle window between DOM mutation and SAVE so rrweb's - * IncrementalSnapshot lands in the in-memory buffer before - * GET_RRWEB_EVENTS fires. */ +/** Settle between sentinel-bearing DOM mutation injection and SAVE so + * rrweb's MutationObserver enqueues the IncrementalSnapshot into the + * content-script's in-memory buffer before GET_RRWEB_EVENTS fires. */ const A29_MUTATION_SETTLE_MS = 500; +/** Wait after chrome.tabs.create for the tab navigation to complete so + * the content script attaches + rrweb's record() is wired (mirrors + * A27/A30/A31's canonical 1.5s). */ +const A29_TAB_NAVIGATION_WAIT_MS = 1_500; +/** Probe tab URL — example.com is RFC 2606 reserved + serves stable + * HTML under headless Chrome (Plan 02-04 A27 + Plan 03-02 A30 + + * Plan 03-03 A31 fixture parity). */ +const A29_PROBE_TAB_URL = 'https://example.com/'; +/** Unique sentinel string carried by the injected DOM mutation. The + * host-side driveA29 strict-sentinel filter searches the rrweb + * IncrementalSnapshot payload's `adds[*].node.textContent` field for + * this exact string. Distinctive enough to never collide with + * iana.org / example.com rendering noise. */ +const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel'; +/** DOM id of the synthetic
appendChild'd to document.body inside + * the ISOLATED-world injection. Distinct from A31's probe-password / + * probe-control ids to avoid cross-assertion mixup. */ +const A29_PROBE_DIV_ID = 'a29-probe-mutation'; /** - * A29 — rrweb DOM event recording empirical (SPEC §10 #4). + * A29 — rrweb DOM event recording empirical (SPEC §10 #4 / REQ-rrweb-dom-buffer). * - * Page-side dispatches the probe-page mutation (input.value + modal - * toggle), settles, runs setupFreshRecording + segment-settle, then - * dispatches SAVE_ARCHIVE. Only the SAVE ack lives in the - * page-side checks; the EventType-enum-shape grep is host-side - * because @rrweb/types is host-only. + * Plan 04-03 rewrite (cs-injection-world pattern; verbatim port of + * assertA30/assertA31 skeleton). Creates a fresh https://example.com + * probe tab where the content script attaches normally + rrweb's + * record() is alive, injects a synthetic DOM mutation carrying the + * A29_MUTATION_SENTINEL into the content-script's ISOLATED world via + * chrome.scripting.executeScript, settles a segment, SAVEs while the + * probe tab is active so the SW harvests rrweb/session.json from that + * tab, finally-cleanup the tab. Host-side driveA29 applies the + * strict-sentinel filter (IncrementalSource.Mutation + + * adds[*].node.textContent includes sentinel) to PROVE the captured + * mutation came from our injection (closes the iana.org-leftover- + * flake documented in Plan 03-02 + 03-03 SUMMARYs). * * @returns AssertionResult with 1 page-side check (SAVE ack); host-side - * driveA29 appends A29.0a/A29.2..A29.5 EventType checks. + * driveA29 appends the A29.2 strict-sentinel check + defense- + * in-depth presence checks for Meta + FullSnapshot. */ async function assertA29(): Promise { const result: AssertionResult = { @@ -3368,39 +3436,65 @@ async function assertA29(): Promise { diagnostics: [], }; + let probeTabId: number | undefined; + try { - diag(result, 'Step 1: dispatch probe-page DOM mutation (input value + modal toggle)'); - const textInput = document.querySelector('#probe-text'); - if (textInput !== null) { - textInput.value = 'probe'; - textInput.dispatchEvent(new Event('input', { bubbles: true })); - } - const modalTrigger = document.querySelector('#probe-modal-trigger'); - if (modalTrigger !== null) { - modalTrigger.click(); - } - diag(result, `Step 1 OK — mutations dispatched (#probe-text=${textInput !== null}, #probe-modal-trigger=${modalTrigger !== null})`); - - diag(result, `Step 2: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb IncrementalSnapshot to enqueue`); - await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS)); - - diag(result, 'Step 3: setupFreshRecording'); + diag(result, 'Step 1: setupFreshRecording (A29 owns its recording — clean rrweb buffer window)'); const setupResp = await setupFreshRecording(); if (!setupResp.ok) { throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`); } - diag(result, 'Step 3 OK — REC state established'); + diag(result, 'Step 1 OK — REC state established'); + + diag(result, `Step 2: chrome.tabs.create(${A29_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)`); + const probeTab = await chrome.tabs.create({ url: A29_PROBE_TAB_URL, active: true }); + probeTabId = probeTab.id; + diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? ''}`); + if (probeTabId === undefined) { + throw new Error('chrome.tabs.create returned undefined tab.id'); + } + + diag(result, `Step 3: wait ${A29_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach (rrweb's record() wires in this window)`); + await new Promise((r) => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS)); diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`); await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS)); - diag(result, 'Step 5: dispatch SAVE_ARCHIVE'); + diag(result, 'Step 5: chrome.scripting.executeScript — inject sentinel-bearing
mutation in ISOLATED world (rrweb MutationObserver lives in same world; will capture)'); + const injectionResults = await chrome.scripting.executeScript({ + target: { tabId: probeTabId }, + world: 'ISOLATED', + func: (sentinel: string, divId: string): { appended: boolean; sentinelEquals: boolean } => { + // Synthetic mutation: create a fresh
, set its textContent + // to the sentinel, appendChild to document.body. rrweb's + // MutationObserver wired by record() at src/content/index.ts:285 + // observes document.body and enqueues an IncrementalSnapshot + // whose `data.source === IncrementalSource.Mutation` and whose + // `data.adds[*].node.textContent` carries the sentinel string. + const div = document.createElement('div'); + div.id = divId; + div.textContent = sentinel; + document.body.appendChild(div); + return { + appended: document.getElementById(divId) !== null, + sentinelEquals: div.textContent === sentinel, + }; + }, + args: [A29_MUTATION_SENTINEL, A29_PROBE_DIV_ID], + }); + const injectionSummary = injectionResults[0]?.result ?? null; + diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`); + + diag(result, `Step 6: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb MutationObserver to enqueue the IncrementalSnapshot`); + await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS)); + + diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)'); const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>( { type: 'SAVE_ARCHIVE' }, A29_SAVE_ARCHIVE_TIMEOUT_MS, 'SAVE_ARCHIVE (A29)', ); - diag(result, `Step 5 result: ${JSON.stringify(ack)}`); + diag(result, `Step 7 result: ${JSON.stringify(ack)}`); result.checks.push({ name: 'A29.1: SAVE_ARCHIVE ack received with success=true', @@ -3413,6 +3507,16 @@ async function assertA29(): Promise { } catch (err) { result.error = err instanceof Error ? err.message : String(err); diag(result, `THREW: ${result.error}`); + } finally { + // T-02-04-04 mitigation parity (Plan 02-04 / 03-02 / 03-03 precedent): + // cleanup probe tab with silent-ignore on already-closed. + if (probeTabId !== undefined) { + try { + await chrome.tabs.remove(probeTabId); + } catch (rmErr) { + diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`); + } + } } return result;