Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit b341a712c0 - Show all commits

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 {