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 {