diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index c9feef6..f56b8fa 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -3315,6 +3315,109 @@ async function assertA28(): Promise { }; } +/* ─── Plan 03-01 Task 2 — A29 (rrweb DOM verification; SPEC §10 #4) ─ + * + * A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (already + * wired at src/content/index.ts:285) emits Meta + FullSnapshot + * + at least one IncrementalSnapshot when the harness page + * contains the probe HTML (form + table + modal) AND the driver + * injects a DOM mutation before SAVE (RESEARCH Pitfall 1: + * static probe HTML emits Meta + FullSnapshot but not + * IncrementalSnapshot without mutation). + * + * Page side: dispatch the probe-page DOM mutations (input value + + * modal toggle), settle, setupFreshRecording, settle one segment, + * dispatch SAVE_ARCHIVE, push A29.1 ack check. The host-side driveA29 + * does the EventType-enum-shape grep against rrweb/session.json from + * the assembled zip (matches A26's chained-assertion pattern; JSZip + * + @rrweb/types are host-only deps). + * + * FORBIDDEN_HOOK_STRINGS impact: NONE. A29 rides production rrweb + * wiring (record() at src/content/index.ts:285 + GET_RRWEB_EVENTS + * round-trip at src/background/index.ts → src/content/index.ts:318) + * + the existing setupFreshRecording / sendMessageWithTimeout + * helpers. Tier-1 inventory stays at 12 entries. + */ + +/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27. */ +const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; +/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */ +const A29_SEGMENT_SETTLE_MS = 11_000; +/** Settle window between DOM mutation and SAVE so rrweb's + * IncrementalSnapshot lands in the in-memory buffer before + * GET_RRWEB_EVENTS fires. */ +const A29_MUTATION_SETTLE_MS = 500; + +/** + * A29 — rrweb DOM event recording empirical (SPEC §10 #4). + * + * Page-side dispatches the probe-page mutation (input.value + modal + * toggle), settles, runs setupFreshRecording + segment-settle, then + * dispatches SAVE_ARCHIVE. Only the SAVE ack lives in the + * page-side checks; the EventType-enum-shape grep is host-side + * because @rrweb/types is host-only. + * + * @returns AssertionResult with 1 page-side check (SAVE ack); host-side + * driveA29 appends A29.0a/A29.2..A29.5 EventType checks. + */ +async function assertA29(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A29 — rrweb DOM events recorded without errors (SPEC §10 #4 / REQ-rrweb-dom-buffer)', + checks: [], + diagnostics: [], + }; + + try { + diag(result, 'Step 1: dispatch probe-page DOM mutation (input value + modal toggle)'); + const textInput = document.querySelector('#probe-text'); + if (textInput !== null) { + textInput.value = 'probe'; + textInput.dispatchEvent(new Event('input', { bubbles: true })); + } + const modalTrigger = document.querySelector('#probe-modal-trigger'); + if (modalTrigger !== null) { + modalTrigger.click(); + } + diag(result, `Step 1 OK — mutations dispatched (#probe-text=${textInput !== null}, #probe-modal-trigger=${modalTrigger !== null})`); + + diag(result, `Step 2: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb IncrementalSnapshot to enqueue`); + await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS)); + + diag(result, 'Step 3: setupFreshRecording'); + const setupResp = await setupFreshRecording(); + if (!setupResp.ok) { + throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`); + } + diag(result, 'Step 3 OK — REC state established'); + + diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`); + await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS)); + + diag(result, 'Step 5: dispatch SAVE_ARCHIVE'); + const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>( + { type: 'SAVE_ARCHIVE' }, + A29_SAVE_ARCHIVE_TIMEOUT_MS, + 'SAVE_ARCHIVE (A29)', + ); + diag(result, `Step 5 result: ${JSON.stringify(ack)}`); + + result.checks.push({ + name: 'A29.1: SAVE_ARCHIVE ack received with success=true', + expected: true, + actual: ack.success, + passed: ack.success === true, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + /** * Read `chrome.runtime.getManifest().version`. Used by the host-side * orchestrator at startup to capture the expected version for A13's @@ -3366,6 +3469,8 @@ declare global { assertA26: () => Promise; assertA27: () => Promise; assertA28: () => Promise; + // Plan 03-01 — rrweb DOM verification (SPEC §10 #4) + assertA29: () => Promise; getManifestVersion: () => Promise; }; } @@ -3400,14 +3505,15 @@ window.__mokoshHarness = { assertA26, assertA27, assertA28, + assertA29, getManifestVersion, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A28, getManifestVersion} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A29, getManifestVersion} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + getManifestVersion)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + getManifestVersion)'); export {}; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index eb3d315..f9ec2e6 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -97,6 +97,8 @@ import { driveA26, driveA27, driveA28, + // Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer) + driveA29, getManifestVersion, } from './lib/harness-page-driver'; import { @@ -265,7 +267,7 @@ async function assertA0_GrepGate(): Promise<{ */ async function main(): Promise { process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n'); - process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28)\n'); + process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29)\n'); process.stdout.write('='.repeat(72) + '\n'); // A0 pre-flight (no Chrome launch needed; runs against built dist/). @@ -333,6 +335,10 @@ async function main(): Promise { (page) => driveA27(page, handles.downloadsDir); const driveA28Wrapped: (page: import('puppeteer').Page) => Promise = (page) => driveA28(page, handles.downloadsDir); + // Plan 03-01 — driveA29 needs downloadsDir for host-side JSZip parse + // of rrweb/session.json from the just-produced zip. + const driveA29Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA29(page, handles.downloadsDir); const drivers: ReadonlyArray<{ readonly name: string; @@ -428,6 +434,13 @@ async function main(): Promise { // and asserts EXACTLY 5 paths: video/last_30sec.webm, rrweb/session.json, // logs/events.json, screenshot.png, meta.json (set-equality; no extras). { name: 'A28', drive: driveA28Wrapped }, + // Plan 03-01 A29: rrweb DOM verification (SPEC §10 #4). + // A29 owns its SAVE because the probe-page DOM mutation must + // happen between page load and SAVE so rrweb's IncrementalSnapshot + // fires (RESEARCH Pitfall 1). Host-side driveA29 JSZip-parses + // rrweb/session.json and asserts the EventType enum surfaces + // (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present. + { name: 'A29', drive: driveA29Wrapped }, ]; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index b1c572f..8ca1afe 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -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 { + // 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: ``, + 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, + }; +}