feat(03-01): Task 2 — assertA29 + driveA29 + orchestrator wiring (A29 30/30 GREEN)

Page-side (tests/uat/extension-page-harness.ts):
- assertA29 dispatches probe-page DOM mutation (input value + modal
  toggle), settles 500ms for rrweb IncrementalSnapshot to enqueue,
  setupFreshRecording, 11s segment-settle, SAVE_ARCHIVE; pushes
  A29.1 SAVE ack check. Module-local constants:
  A29_SAVE_ARCHIVE_TIMEOUT_MS=15s, A29_SEGMENT_SETTLE_MS=11s,
  A29_MUTATION_SETTLE_MS=500ms.
- declare global interface + window.__mokoshHarness object literal
  extended with assertA29 (single-method-per-assertion contract).
- statusEl + console banner updated A28 → A29 + cite Plan 03-01.

Host-side (tests/uat/lib/harness-page-driver.ts):
- Add `import { EventType } from '@rrweb/types';`.
- driveA29 — 3-phase orchestration mirroring driveA26:
  Phase 1 page.evaluate harness.assertA29(); Phase 2 findLatestZip;
  Phase 3 JSZip.loadAsync rrweb/session.json + EventType grep.
  Appends A29.0a (rrweb/session.json present) + A29.2..A29.5
  (events.length>0 + Meta + FullSnapshot + IncrementalSnapshot).

Orchestrator (tests/uat/harness.test.ts):
- driveA29 imported after driveA28.
- driveA29Wrapped const captures handles.downloadsDir.
- drivers array push A29 entry with banner citing Plan 03-01 + Pitfall 1.
- Architecture banner string updated A28 → A29.

Empirical verification (HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat):
- UAT harness: 30/30 GREEN (29 prior + A29 NEW).
- A29 events.length=4; event types observed: 2, 3, 4 (FullSnapshot,
  IncrementalSnapshot, Meta — all three required types present).
- Pitfall 1 mitigation empirically verified — the pre-SAVE DOM
  mutation produced the IncrementalSnapshot.
- vitest 171/171 GREEN preserved (full suite).
- Tier-1 FORBIDDEN_HOOK_STRINGS unit gate 13/13 GREEN (12 strings × 0
  hits each) — A29 rides production rrweb wiring + GET_RRWEB_EVENTS
  bridge + sendMessageWithTimeout helper; NO new __MOKOSH_UAT__
  symbols.
- npx tsc --noEmit exit 0.
This commit is contained in:
2026-05-20 19:17:47 +02:00
parent c02914df86
commit cc13f319a1
3 changed files with 273 additions and 3 deletions

View File

@@ -39,6 +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 type { Page } from 'puppeteer';
import type { AssertionRecord, CheckRecord } from './assertions';
@@ -1847,3 +1848,153 @@ export async function driveA28(
error: pageResult.error,
};
}
/* ─── Plan 03-01 — driveA29 (rrweb DOM verification host-side) ─────── */
/**
* Drive A29 (Plan 03-01 — 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.
* 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.
*
* Checks appended host-side:
* - A29.0: at least one zip present in downloadsDir
* - A29.0a: rrweb/session.json entry exists in zip
* - 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.
*
* @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).
*/
export async function driveA29(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — page-side orchestration + SAVE.
const pageResult = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
const harness = (window as any).__mokoshHarness;
const r: AssertionRecord = await harness.assertA29();
return r;
}) as AssertionRecord;
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Phase 2 — locate the produced zip.
const zipPath = findLatestZip(downloadsDir);
if (zipPath === null) {
mergedChecks.push({
name: 'A29.0: at least one zip present in downloadsDir',
expected: '>=1 zip',
actual: 'no zip in downloadsDir',
passed: false,
});
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
mergedDiagnostics.push(`A29 zipPath=${zipPath}`);
// Phase 3 — load + inspect rrweb/session.json.
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const rrwebFile = zip.file('rrweb/session.json');
mergedChecks.push({
name: 'A29.0a: rrweb/session.json entry exists in zip',
expected: true,
actual: rrwebFile !== null,
passed: rrwebFile !== null,
});
if (rrwebFile === null) {
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
const rrwebText = await rrwebFile.async('string');
let events: Array<{ type: number; timestamp: number }> = [];
let parseErr: string | null = null;
try {
events = JSON.parse(rrwebText) as Array<{ type: number; timestamp: number }>;
} catch (err) {
parseErr = err instanceof Error ? err.message : String(err);
}
if (parseErr !== null) {
mergedChecks.push({
name: 'A29.0b: rrweb/session.json parses as JSON',
expected: 'JSON.parse success',
actual: `<error: ${parseErr}>`,
passed: false,
});
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
const distinctTypes = [...new Set(events.map((e) => e.type))].sort((a, b) => a - b);
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,
});
mergedChecks.push({
name: `A29.3: rrweb emitted at least one Meta event (EventType.Meta=${EventType.Meta})`,
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})`,
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 {
passed: a29MergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}