Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -39,7 +39,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve as resolvePath } from 'node:path';
|
import { join, resolve as resolvePath } from 'node:path';
|
||||||
|
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { EventType } from '@rrweb/types';
|
import { EventType, IncrementalSource } from '@rrweb/types';
|
||||||
import type { Page } from 'puppeteer';
|
import type { Page } from 'puppeteer';
|
||||||
|
|
||||||
import type { AssertionRecord, CheckRecord } from './assertions';
|
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:
|
* Three-phase orchestration:
|
||||||
* 1. Page-side assertA29 dispatches the probe DOM mutations, settles,
|
* 1. Page-side assertA29 (Plan 04-03 rewrite) opens a fresh
|
||||||
* runs setupFreshRecording, settles a segment, dispatches SAVE.
|
* 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.
|
* Returns AssertionRecord with A29.1 (SAVE ack) only.
|
||||||
* 2. Host-side findLatestZip picks the just-produced zip (mtime-sort
|
* 2. Host-side findLatestZip picks the just-produced zip (mtime-sort
|
||||||
* wins; race-free per Plan 02-04 precedent — assertA29 awaits the
|
* wins; race-free per Plan 02-04 precedent — assertA29 awaits the
|
||||||
* SAVE ack before returning so the zip has landed by here).
|
* 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:
|
* Checks appended host-side:
|
||||||
* - A29.0: at least one zip present in downloadsDir
|
* - A29.0: at least one zip present in downloadsDir
|
||||||
* - A29.0a: rrweb/session.json entry exists in zip
|
* - 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.1 (already from page side): SAVE_ARCHIVE ack success
|
||||||
* - A29.2: events.length > 0
|
* - A29.2: STRICT-SENTINEL — at least one IncrementalSnapshot whose
|
||||||
* - A29.3: events.some(e => e.type === EventType.Meta)
|
* data.source === IncrementalSource.Mutation AND whose
|
||||||
* - A29.4: events.some(e => e.type === EventType.FullSnapshot)
|
* data.adds[*].node.textContent contains
|
||||||
* - A29.5: events.some(e => e.type === EventType.IncrementalSnapshot)
|
* 'a29-mutation-sentinel' (closes iana.org-leftover-flake)
|
||||||
*
|
* - A29.3: rrweb emitted at least one Meta event (defense-in-depth)
|
||||||
* RESEARCH Pitfall 1 mitigation: the page-side dispatches a real DOM
|
* - A29.4: rrweb emitted at least one FullSnapshot (defense-in-depth)
|
||||||
* mutation (input value + modal toggle) BEFORE SAVE so the
|
|
||||||
* IncrementalSnapshot check (A29.5) has actual content to find.
|
|
||||||
*
|
*
|
||||||
* @param page - The harness page from `launchHarnessBrowser`.
|
* @param page - The harness page from `launchHarnessBrowser`.
|
||||||
* @param downloadsDir - Absolute path to the per-run downloads directory.
|
* @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(
|
export async function driveA29(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -1937,10 +1980,13 @@ export async function driveA29(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rrwebText = await rrwebFile.async('string');
|
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;
|
let parseErr: string | null = null;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
parseErr = err instanceof Error ? err.message : String(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 events.length=${events.length}`);
|
||||||
mergedDiagnostics.push(`A29 event types: ${distinctTypes.join(',')}`);
|
mergedDiagnostics.push(`A29 event types: ${distinctTypes.join(',')}`);
|
||||||
|
|
||||||
mergedChecks.push({
|
// Strict-sentinel filter pipeline (RESEARCH Q3 Code Example Pattern 3):
|
||||||
name: 'A29.2: rrweb/session.json contains > 0 events',
|
// 1. Filter for events whose type === EventType.IncrementalSnapshot
|
||||||
expected: '>0',
|
// AND whose data.source === IncrementalSource.Mutation
|
||||||
actual: events.length,
|
// (rrweb v2 enum value 0; verified via node_modules/@rrweb/types/dist/index.d.ts).
|
||||||
passed: events.length > 0,
|
// 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({
|
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',
|
expected: 'has Meta',
|
||||||
actual: events.some((e) => e.type === EventType.Meta),
|
actual: events.some((e) => e.type === EventType.Meta),
|
||||||
passed: events.some((e) => e.type === EventType.Meta),
|
passed: events.some((e) => e.type === EventType.Meta),
|
||||||
});
|
});
|
||||||
mergedChecks.push({
|
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',
|
expected: 'has FullSnapshot',
|
||||||
actual: events.some((e) => e.type === EventType.FullSnapshot),
|
actual: events.some((e) => e.type === EventType.FullSnapshot),
|
||||||
passed: 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);
|
const a29MergedPassed = mergedChecks.every((c) => c.passed);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user