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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user