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:
@@ -3315,6 +3315,109 @@ async function assertA28(): Promise<AssertionResult> {
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── 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<AssertionResult> {
|
||||
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<HTMLInputElement>('#probe-text');
|
||||
if (textInput !== null) {
|
||||
textInput.value = 'probe';
|
||||
textInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
const modalTrigger = document.querySelector<HTMLButtonElement>('#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<AssertionResult>;
|
||||
assertA27: () => Promise<A27Result>;
|
||||
assertA28: () => Promise<AssertionResult>;
|
||||
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4)
|
||||
assertA29: () => Promise<AssertionResult>;
|
||||
getManifestVersion: () => Promise<string>;
|
||||
};
|
||||
}
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
(page) => driveA27(page, handles.downloadsDir);
|
||||
const driveA28Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||
(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<AssertionRecord> =
|
||||
(page) => driveA29(page, handles.downloadsDir);
|
||||
|
||||
const drivers: ReadonlyArray<{
|
||||
readonly name: string;
|
||||
@@ -428,6 +434,13 @@ async function main(): Promise<number> {
|
||||
// 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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user