feat(04-03): A29 page-side rewrite — cs-injection-world + sentinel

Replace harness-page-mutation approach with verbatim port of the
canonical cs-injection-world pattern from Plan 03-02 (assertA30) +
Plan 03-03 (assertA31):

- chrome.tabs.create(https://example.com/, active:true) opens probe
  tab where content script + rrweb's record() attach normally
  (chrome-extension:// is NOT covered by <all_urls> per Chrome
  match-pattern spec; was the root flake cause)
- 1.5s tab-attach + 11s segment-settle waits (canonical A27/A30/A31)
- chrome.scripting.executeScript world: 'ISOLATED' injects a sentinel-
  bearing <div> (textContent='a29-mutation-sentinel') into document.body
  — rrweb's MutationObserver lives in the same ISOLATED world so the
  IncrementalSnapshot's data.adds[*].node.textContent will carry the
  sentinel
- 500ms MutationObserver-enqueue settle
- SAVE_ARCHIVE while probe tab is active (SW harvests rrweb/session.json
  from there)
- try/finally chrome.tabs.remove with silent-ignore (T-02-04-04 parity)

A29 constants block extended: A29_TAB_NAVIGATION_WAIT_MS,
A29_PROBE_TAB_URL, A29_MUTATION_SENTINEL, A29_PROBE_DIV_ID.

This closes the documented ~2/3 success-rate flake from Plans 03-02 +
03-03 where A29 "passed" by reading iana.org leftover DOM mutations
from A27/A28's probe tabs — 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.

Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12; assertA30 + assertA31
untouched; __mokoshHarness wiring unchanged. Host-side driveA29
strict-sentinel filter lands in Task 2.

Verify:
- npx tsc --noEmit → 0
- npm run build:test → 0
- grep -c 'A29_MUTATION_SENTINEL' tests/uat/extension-page-harness.ts → 3
- grep -nE "world: 'ISOLATED'" tests/uat/extension-page-harness.ts → 3
  call sites (A29 + A30 + A31) — ISOLATED parity per RESEARCH Pitfall 5
This commit is contained in:
2026-05-21 15:48:18 +02:00
parent 6a1fc32826
commit 73eb9b654c

View File

@@ -3315,50 +3315,118 @@ async function assertA28(): Promise<AssertionResult> {
};
}
/* ─── 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 (`<all_urls>` 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 <div> 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 <div> 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<AssertionResult> {
const result: AssertionResult = {
@@ -3368,39 +3436,65 @@ async function assertA29(): Promise<AssertionResult> {
diagnostics: [],
};
let probeTabId: number | undefined;
try {
diag(result, 'Step 1: dispatch probe-page DOM mutation (input value + modal toggle)');
const textInput = document.querySelector<HTMLInputElement>('#probe-text');
if (textInput !== null) {
textInput.value = 'probe';
textInput.dispatchEvent(new Event('input', { bubbles: true }));
}
const modalTrigger = document.querySelector<HTMLButtonElement>('#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 ?? '<pending>'}`);
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 <div> 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 <div>, 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<AssertionResult> {
} 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;