From b341a712c07beabf3ec64358e2f112fc5b1245e4 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 21 May 2026 16:27:00 +0200 Subject: [PATCH] feat(04-03): A29 host-side strict-sentinel filter + 5/5 PASS stress test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the loose-EventType grep with a strict-sentinel filter pipeline per RESEARCH Q3 Code Example Pattern 3: - Import IncrementalSource from @rrweb/types (new binding alongside the existing EventType import) - Filter events for (e.type === EventType.IncrementalSnapshot && e.data?.source === IncrementalSource.Mutation) - Descend into each filtered event's data.adds[*].node.textContent and search for the page-side-injected 'a29-mutation-sentinel' string - A29.2: assert sentinelEvents.length >= 1 — proves the captured mutation came from OUR injection, not from iana.org leftovers Defense-in-depth preserved: - A29.3: rrweb emitted at least one Meta event (renumbered) - A29.4: rrweb emitted at least one FullSnapshot (renumbered) The previous A29.5 (loose IncrementalSnapshot >=1) is subsumed by the A29.2 strict-sentinel check (which requires IncrementalSnapshot AND Mutation source AND injected sentinel — strictly stronger). Empirical verification (all 33/33 GREEN preserved, A29 flake closed): - npx tsc --noEmit → 0 - npm test → 183/183 GREEN preserved (Plan 04-02 baseline) - npm run test:uat → 33/33 GREEN × 5 consecutive runs - A29 mutationEvents=1 + sentinelEvents=1 in ALL 5 runs (no flake) A29 historical flake rate of ~2/3 (documented Plan 03-02 + 03-03 SUMMARYs) is closed end-to-end: the iana.org leftover DOM mutations no longer satisfy A29 because the strict-sentinel filter requires the EXACT string 'a29-mutation-sentinel' that only the page-side chrome.scripting.executeScript injection produces. Pre-checkpoint bundle gates verified (per feedback-pre-checkpoint- bundle-gates.md): - Gate 1: Tier-1 FORBIDDEN_HOOK_STRINGS — 13/13 sub-tests PASS, count unchanged at 12 - Gate 2: SW CSP-safety — new Function=0, eval=0 (Plan 04-02 baseline) - Gate 3+4: Buffer / window / document counts unchanged from Plan 04-02 (Plan 04-03 modifies tests/ only) - Gate 5: manifest validates clean against locked DEC-011 Amendment 1 --- tests/uat/lib/harness-page-driver.ts | 130 ++++++++++++++++++++------- 1 file changed, 100 insertions(+), 30 deletions(-) diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index 2f208bf..997b7b4 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -39,7 +39,7 @@ import { tmpdir } from 'node:os'; import { join, resolve as resolvePath } from 'node:path'; import JSZip from 'jszip'; -import { EventType } from '@rrweb/types'; +import { EventType, IncrementalSource } from '@rrweb/types'; import type { Page } from 'puppeteer'; import type { AssertionRecord, CheckRecord } from './assertions'; @@ -1850,36 +1850,79 @@ export async function driveA28( }; } -/* ─── Plan 03-01 — driveA29 (rrweb DOM verification host-side) ─────── */ +/* ─── Plan 04-03 — driveA29 (rrweb DOM verification host-side; + * strict-sentinel filter rewrite per RESEARCH Q3) ─── */ + +/** Sentinel string the host-side strict filter searches for inside + * the rrweb IncrementalSnapshot mutation payload. MUST stay in sync + * with the page-side A29_MUTATION_SENTINEL at + * tests/uat/extension-page-harness.ts. The page-side + * chrome.scripting.executeScript injection appends a
whose + * textContent is exactly this string; rrweb's MutationObserver + * inside the content-script's ISOLATED world captures the mutation + * with `data.adds[*].node.textContent` containing the sentinel. + * Distinctive enough to never collide with iana.org / example.com + * rendering noise. */ +const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel'; /** - * Drive A29 (Plan 03-01 — SPEC §10 #4 / REQ-rrweb-dom-buffer). + * Drive A29 (Plan 04-03 — SPEC §10 #4 / REQ-rrweb-dom-buffer). * * Three-phase orchestration: - * 1. Page-side assertA29 dispatches the probe DOM mutations, settles, - * runs setupFreshRecording, settles a segment, dispatches SAVE. + * 1. Page-side assertA29 (Plan 04-03 rewrite) opens a fresh + * https://example.com probe tab, injects a sentinel-bearing DOM + * mutation in the content-script's ISOLATED world via + * chrome.scripting.executeScript, settles a segment, dispatches + * SAVE while the probe tab is active, finally-cleanup the tab. * Returns AssertionRecord with A29.1 (SAVE ack) only. * 2. Host-side findLatestZip picks the just-produced zip (mtime-sort * wins; race-free per Plan 02-04 precedent — assertA29 awaits the * SAVE ack before returning so the zip has landed by here). - * 3. Host-side JSZip-parse rrweb/session.json + EventType-enum grep. + * 3. Host-side JSZip-parse rrweb/session.json + STRICT-SENTINEL filter + * pipeline (RESEARCH Q3 Code Example Pattern 3). + * + * Plan 04-03 strict-sentinel rewrite (closes documented A29 flake): + * The pre-04-03 driveA29 ran a loose-EventType grep that counted + * EventType.{Meta,FullSnapshot,IncrementalSnapshot} occurrences >=1. + * That trivially passed because the produced zip contained iana.org + * leftover rrweb events from A27/A28's still-open probe tabs at SAVE + * time — A29 was "passing" by reading those leftover events 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). + * + * The strict-sentinel rewrite filters for events where: + * e.type === EventType.IncrementalSnapshot && + * e.data?.source === IncrementalSource.Mutation + * then descends into the mutation payload's `data.adds[*].node.textContent` + * field, asserting >=1 event contains the page-side-injected + * A29_MUTATION_SENTINEL. That string can ONLY have come from our + * chrome.scripting.executeScript injection — no iana.org or + * example.com path produces it. Net result: A29 actually verifies + * the rrweb pipeline at src/content/index.ts:284 end-to-end. + * + * Defense-in-depth presence checks for Meta + FullSnapshot are + * PRESERVED (they remain valuable as "rrweb wired up at all" + * indicators) but renumbered to leave room for the canonical + * A29.2 strict-sentinel check at the front. + * + * Filter-pipeline form (no for/continue) per CLAUDE.md Control Flow §. * * Checks appended host-side: * - A29.0: at least one zip present in downloadsDir * - A29.0a: rrweb/session.json entry exists in zip + * - A29.0b: rrweb/session.json parses as JSON * - A29.1 (already from page side): SAVE_ARCHIVE ack success - * - A29.2: events.length > 0 - * - A29.3: events.some(e => e.type === EventType.Meta) - * - A29.4: events.some(e => e.type === EventType.FullSnapshot) - * - A29.5: events.some(e => e.type === EventType.IncrementalSnapshot) - * - * RESEARCH Pitfall 1 mitigation: the page-side dispatches a real DOM - * mutation (input value + modal toggle) BEFORE SAVE so the - * IncrementalSnapshot check (A29.5) has actual content to find. + * - A29.2: STRICT-SENTINEL — at least one IncrementalSnapshot whose + * data.source === IncrementalSource.Mutation AND whose + * data.adds[*].node.textContent contains + * 'a29-mutation-sentinel' (closes iana.org-leftover-flake) + * - A29.3: rrweb emitted at least one Meta event (defense-in-depth) + * - A29.4: rrweb emitted at least one FullSnapshot (defense-in-depth) * * @param page - The harness page from `launchHarnessBrowser`. * @param downloadsDir - Absolute path to the per-run downloads directory. - * @returns AssertionRecord with 6 merged checks (A29.0a + A29.1..A29.5). + * @returns AssertionRecord with the merged checks. */ export async function driveA29( page: Page, @@ -1937,10 +1980,13 @@ export async function driveA29( } const rrwebText = await rrwebFile.async('string'); - let events: Array<{ type: number; timestamp: number }> = []; + // Loose event shape — we descend into data.source + data.adds below. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- rrweb v2 emits a polymorphic structural type; we only need a few fields. + let events: Array<{ type: number; timestamp: number; data?: any }> = []; let parseErr: string | null = null; try { - events = JSON.parse(rrwebText) as Array<{ type: number; timestamp: number }>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above. + events = JSON.parse(rrwebText) as Array<{ type: number; timestamp: number; data?: any }>; } catch (err) { parseErr = err instanceof Error ? err.message : String(err); } @@ -1965,30 +2011,54 @@ export async function driveA29( mergedDiagnostics.push(`A29 events.length=${events.length}`); mergedDiagnostics.push(`A29 event types: ${distinctTypes.join(',')}`); - mergedChecks.push({ - name: 'A29.2: rrweb/session.json contains > 0 events', - expected: '>0', - actual: events.length, - passed: events.length > 0, + // Strict-sentinel filter pipeline (RESEARCH Q3 Code Example Pattern 3): + // 1. Filter for events whose type === EventType.IncrementalSnapshot + // AND whose data.source === IncrementalSource.Mutation + // (rrweb v2 enum value 0; verified via node_modules/@rrweb/types/dist/index.d.ts). + // 2. Filter mutation events whose data.adds[*].node.textContent + // contains the page-side-injected A29_MUTATION_SENTINEL. + // The sentinel can only have come from our chrome.scripting.executeScript + // injection in assertA29 — no iana.org / example.com leftover path + // produces this exact string. This closes the documented A29 flake. + const mutationEvents = events.filter( + (e) => + e.type === EventType.IncrementalSnapshot && + e.data?.source === IncrementalSource.Mutation, + ); + const sentinelEvents = mutationEvents.filter((e) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- rrweb mutation payload adds[*] entries are polymorphic by node type; we only need textContent on the node leaf. + const adds = (e.data?.adds ?? []) as Array; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above. + return adds.some( + (a: any) => + typeof a?.node?.textContent === 'string' && + a.node.textContent.includes(A29_MUTATION_SENTINEL), + ); }); + mergedDiagnostics.push( + `A29 mutationEvents=${mutationEvents.length}, sentinelEvents=${sentinelEvents.length}`, + ); + mergedChecks.push({ - name: `A29.3: rrweb emitted at least one Meta event (EventType.Meta=${EventType.Meta})`, + name: `A29.2: rrweb captured the injected mutation containing '${A29_MUTATION_SENTINEL}' (closes iana.org-leftover-flake from Plan 03-02/03-03)`, + expected: '>=1 sentinel-bearing IncrementalSnapshot.Mutation event', + actual: `${sentinelEvents.length} sentinel-bearing mutation events of ${mutationEvents.length} total mutation events of ${events.length} total events`, + passed: sentinelEvents.length >= 1, + }); + // Defense-in-depth presence checks — preserved because Meta + + // FullSnapshot prove rrweb wired up at all (not just our injection). + mergedChecks.push({ + name: `A29.3: rrweb emitted at least one Meta event (EventType.Meta=${EventType.Meta}; defense-in-depth)`, expected: 'has Meta', actual: events.some((e) => e.type === EventType.Meta), passed: events.some((e) => e.type === EventType.Meta), }); mergedChecks.push({ - name: `A29.4: rrweb emitted at least one FullSnapshot (EventType.FullSnapshot=${EventType.FullSnapshot})`, + name: `A29.4: rrweb emitted at least one FullSnapshot (EventType.FullSnapshot=${EventType.FullSnapshot}; defense-in-depth)`, expected: 'has FullSnapshot', actual: events.some((e) => e.type === EventType.FullSnapshot), passed: events.some((e) => e.type === EventType.FullSnapshot), }); - mergedChecks.push({ - name: `A29.5: rrweb emitted at least one IncrementalSnapshot (EventType.IncrementalSnapshot=${EventType.IncrementalSnapshot})`, - expected: 'has IncrementalSnapshot', - actual: events.some((e) => e.type === EventType.IncrementalSnapshot), - passed: events.some((e) => e.type === EventType.IncrementalSnapshot), - }); const a29MergedPassed = mergedChecks.every((c) => c.passed); return {