feat(04-03): A29 host-side strict-sentinel filter + 5/5 PASS stress test

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
This commit is contained in:
2026-05-21 16:27:00 +02:00
parent 73eb9b654c
commit b341a712c0

View File

@@ -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 <div> 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<any>;
// 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 {