Files
mokosh/tests/uat/lib/harness-page-driver.ts
Mark cc13f319a1 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.
2026-05-20 19:17:47 +02:00

2001 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/uat/lib/harness-page-driver.ts — Plan 01-13 Wave 2.
//
// Driver wrappers — one per assertion (A1..A13). Each wraps a single
// `page.evaluate(() => window.__mokoshHarness.assertXX())` call,
// returning the structured AssertionRecord (or the extended shape with
// `bytesBase64` for A5/A12/A13 which return host-side-required payloads
// like the downloaded zip bytes or the recorded webm bytes).
//
// Centralizing the page.evaluate call here means adding or renaming an
// assertion requires a two-file edit:
// 1. extension-page-harness.ts — page-side impl + window.__mokoshHarness wire
// 2. this file — host-side driver wrapper
// instead of touching every test-file that calls the assertion.
//
// Wave 2 ONLY wires `driveA6` (the proven assertion from the c647f61
// prototype). The 12 Wave-3 assertions are stubbed as `throw new
// Error('NOT YET IMPLEMENTED — Wave 3<X> wires this')` so the
// orchestrator's `for (const drive of drivers)` loop fails cleanly on
// the first unimplemented one (bail-on-first-failure semantics in
// `harness.test.ts` lands in Wave 3A).
//
// Wave 3A wires driveA1/A2/A3/A4 (page-side surface in
// `extension-page-harness.ts` from the same wave).
// Wave 3B wires driveA5 (page-side ack + HOST-side fs polling for the
// dropped `session_report_*.zip` in `handles.downloadsDir`) + driveA7
// (standard page.evaluate wrapper). The driveA5 signature requires a
// second `downloadsDir` argument; the orchestrator at `harness.test.ts`
// threads `handles.downloadsDir` through.
//
// References:
// - puppeteer Page.evaluate:
// https://pptr.dev/api/puppeteer.page.evaluate
// - Node fs.readdirSync / statSync:
// https://nodejs.org/api/fs.html
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
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';
import { assertArchiveShape, extractEntryToFile } from './zip';
/**
* Extended assertion-record shape for A5/A12/A13 which return
* host-side-required binary payloads:
* - A5 (SAVE_ARCHIVE): `bytesBase64` is the downloaded zip bytes
* (read by host-side from `handles.downloadsDir`); page side only
* returns the trigger ack.
* - A12 (ffprobe): `bytesBase64` is the recorded webm bytes —
* extracted from the zip by the host so ffprobe (host-side binary)
* can analyze it.
* - A13 (zip shape): `bytesBase64` is the zip bytes; `expectedVersion`
* is the manifest version the harness was built against.
*
* All Wave-3 assertions; not used in Wave 2.
*/
export interface AssertionWithBytes {
readonly passed: boolean;
readonly name: string;
readonly checks: ReadonlyArray<CheckRecord>;
readonly diagnostics: ReadonlyArray<string>;
readonly error?: string;
readonly bytesBase64?: string;
readonly expectedVersion?: string;
}
// Note (Wave 3D — all 13 drivers wired): the WAVE3_STUB_PREFIX marker
// that gated unimplemented drivers across Waves 3A-3C has been retired
// — there are no more stubs. Future assertions (A14+) would follow
// the same wired-driver pattern below; no stub-marker is reintroduced
// unless multi-wave incremental rollout is needed again.
/**
* Drive the A6 (Bug B canonical) assertion. The proven, prototype-
* inherited driver. Page side does all orchestration (ensureOffscreen +
* start + wait + dispatch + assert); host side just triggers + reads
* the result.
*
* @param page - The harness page (from `launchHarnessBrowser`).
* @returns Structured AssertionRecord with 5 checks (SETUP + A6.1..A6.4).
*/
export async function driveA6(page: Page): Promise<AssertionRecord> {
return 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.assertA6();
return r;
}) as AssertionRecord;
}
/* ─── Wave 3A — WIRED ─────────────────────────────────────────────── */
/**
* Drive A1 (SW bootstrap state). Asserts the post-load idle-mode state:
* badge='', popup='', isRecording=false. MUST run BEFORE A2 in any
* orchestrated sequence — A2 manually sets badge='REC' which invalidates
* the A1 contract until the SW is reset.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 3 checks (badge + popup + isRecording).
*/
export async function driveA1(page: Page): Promise<AssertionRecord> {
return 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.assertA1();
return r;
}) as AssertionRecord;
}
/**
* Drive A2 (toolbar onClicked → REC). Uses the direct-offscreen workaround
* for the missing `tabs` manifest permission (per 01-11-SUMMARY). Leaves
* the offscreen recording active — A3 + A4 chain off A2's REC state.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (badge + popup).
*/
export async function driveA2(page: Page): Promise<AssertionRecord> {
return 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.assertA2();
return r;
}) as AssertionRecord;
}
/**
* Drive A3 (displaySurface === 'monitor'). Assumes A2 left recording
* active. Queries the offscreen `get-display-surface` bridge op.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 1 check (displaySurface).
*/
export async function driveA3(page: Page): Promise<AssertionRecord> {
return 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.assertA3();
return r;
}) as AssertionRecord;
}
/**
* Drive A4 (popup pinned + single offscreen during recording). Assumes
* A2 left recording active. Verifies getPopup unchanged + hasDocument
* true (no duplicate offscreen spawned).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (popup + hasDocument).
*/
export async function driveA4(page: Page): Promise<AssertionRecord> {
return 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.assertA4();
return r;
}) as AssertionRecord;
}
/* ─── Wave 3B — WIRED ─────────────────────────────────────────────── */
/** Maximum wait for the SAVE_ARCHIVE zip to appear in `downloadsDir`. */
const A5_DOWNLOAD_POLL_TIMEOUT_MS = 15_000;
/** Polling cadence while waiting for the zip. */
const A5_DOWNLOAD_POLL_INTERVAL_MS = 200;
/** Filename suffix for the dropped archive. Production code in
* `src/background/index.ts:downloadArchive` requests
* `session_report_<date>_<time>.zip`, BUT under CDP-routed downloads
* (`Browser.setDownloadBehavior`) Chrome ignores the
* `chrome.downloads.download` `filename` parameter for `data:` URLs and
* defaults to `download.zip` (or `download (N).zip` on collision). The
* contract A5 verifies is "a zip file lands in downloadsDir within
* timeout" — the exact filename is not load-bearing for Wave 3B.
* Wave 3D's A13 (zip structure) verifies the zip content. */
const A5_ZIP_NAME_SUFFIX = '.zip';
/** Minimum acceptable zip size — the production
* `downloadArchive` always writes at least a JSZip header + screenshot
* PNG (typically several KB even with an empty video buffer).
* 1KB is the floor specified in the plan's success criteria for A5. */
const A5_MIN_ZIP_SIZE_BYTES = 1024;
/**
* Drive A5 (SAVE_ARCHIVE download). Three-phase orchestration:
*
* 1. Page side: send SAVE_ARCHIVE via the harness `assertA5` helper.
* Returns AssertionRecord with check `A5.1: SW handler returns
* success=true`. Throws are caught + returned as a failure record
* with `error` set.
*
* 2. Host side: poll `downloadsDir` for `session_report_*.zip` for up
* to `A5_DOWNLOAD_POLL_TIMEOUT_MS`. If found, read bytes for the
* size check; the bytes are NOT returned to the orchestrator (no
* consumer in Wave 3B — A13 will read them out of the zip-shape
* driver in Wave 3D).
*
* 3. Host side: assert `zipSize >= A5_MIN_ZIP_SIZE_BYTES`. Merge the
* host-side check onto the page-side AssertionRecord; recompute
* `passed` as the conjunction of all checks.
*
* The split between page-side (SW dispatch ack) and host-side
* (file-system verification) is dictated by the page isolate's lack of
* filesystem access — `handles.downloadsDir` is a Node-side `mkdtempSync`
* configured via CDP `Browser.setDownloadBehavior` and only readable
* from the Node process.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory
* (from `handles.downloadsDir`).
* @returns AssertionRecord with merged page + host checks.
*/
export async function driveA5(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Snapshot existing zip files BEFORE dispatching SAVE_ARCHIVE so the
// post-dispatch poll only considers NEW files. Single-browser orchestrator
// pattern means there should never be a pre-existing zip on a fresh
// run, but a re-used `downloadsDir` (`HARNESS_DOWNLOADS_DIR` env override)
// can legitimately have prior runs' files.
const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename));
// Phase 1: page-side dispatch.
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.assertA5();
return r;
}) as AssertionRecord;
// Phase 2: host-side poll for the dropped zip.
let zipFilename: string | null = null;
let zipBytes: Buffer | null = null;
const pollStart = Date.now();
while (Date.now() - pollStart < A5_DOWNLOAD_POLL_TIMEOUT_MS) {
const candidates = readdirSync(downloadsDir).filter(
(name) => isZipFilename(name) && !preExisting.has(name),
);
if (candidates.length > 0) {
// Take the most-recently-modified to be deterministic if multiple appear.
const sorted = candidates
.map((name) => ({
name,
mtime: statSync(resolvePath(downloadsDir, name)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
zipFilename = sorted[0].name;
const zipPath = resolvePath(downloadsDir, zipFilename);
// Wait a beat: the file may still be writing. Re-check size stable
// by reading twice; we take the second read as the canonical bytes.
const sizeFirst = statSync(zipPath).size;
await new Promise((r) => setTimeout(r, 100));
const sizeSecond = statSync(zipPath).size;
if (sizeFirst === sizeSecond) {
zipBytes = readFileSync(zipPath);
break;
}
}
await new Promise((r) => setTimeout(r, A5_DOWNLOAD_POLL_INTERVAL_MS));
}
// Phase 3: merge checks. Page-side checks are immutable
// (ReadonlyArray); copy into a mutable buffer + append host-side.
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
const zipPresent = zipFilename !== null;
const zipSize = zipBytes !== null ? zipBytes.length : 0;
mergedChecks.push({
name: `A5.2: a *.zip file appears in downloadsDir within ${A5_DOWNLOAD_POLL_TIMEOUT_MS}ms (production name: 'session_report_*.zip'; CDP fallback: 'download*.zip')`,
expected: true,
actual: zipPresent,
passed: zipPresent,
});
mergedChecks.push({
name: `A5.3: zip file size >= ${A5_MIN_ZIP_SIZE_BYTES} bytes`,
expected: A5_MIN_ZIP_SIZE_BYTES,
actual: zipSize,
passed: zipSize >= A5_MIN_ZIP_SIZE_BYTES,
});
mergedDiagnostics.push(
`host-side: zipFilename=${zipFilename ?? '<missing>'}, zipSize=${zipSize} bytes, downloadsDir=${downloadsDir}`,
);
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
/**
* Filename predicate — matches any completed `.zip` file. Mid-write
* `.crdownload` files are auto-excluded by the suffix anchor. The
* permissive prefix matches both the production filename
* `session_report_<ts>.zip` and the CDP-fallback `download.zip` (see
* `A5_ZIP_NAME_SUFFIX` comment for why the latter happens under
* `Browser.setDownloadBehavior`).
*
* @param name - Filename (basename, not full path).
* @returns True iff `name` is a completed zip.
*/
function isZipFilename(name: string): boolean {
return name.endsWith(A5_ZIP_NAME_SUFFIX);
}
/**
* Drive A7 (genuine error → ERR + recovery notification). Standard
* page.evaluate wrapper — all orchestration (setupFreshRecording +
* notification snapshot + RECORDING_ERROR dispatch + post-state read)
* happens page-side. Host side just triggers + reads the result.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 4 checks (A7.1..A7.4).
*/
export async function driveA7(page: Page): Promise<AssertionRecord> {
return 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.assertA7();
return r;
}) as AssertionRecord;
}
/* ─── Wave 3C — WIRED ─────────────────────────────────────────────── */
/**
* Drive A8 (Bug A canonical regression rewind — onStartup notification
* creates). Standard page.evaluate wrapper — all orchestration
* (chrome.notifications.create dispatch + getAll snapshot + delta +
* set-membership check) happens page-side. The page calls
* chrome.notifications.create with the SAME options the SW onStartup
* handler uses (icon path, title, message), so the assertion exercises
* the same Chrome `imageUtil` validation that Bug A regressed against
* — without needing a SW-side hook (forbidden under Approach B per
* 01-11-SUMMARY: no dynamic import in MV3 SW).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 4 checks (A8.1..A8.4).
*/
export async function driveA8(page: Page): Promise<AssertionRecord> {
return 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.assertA8();
return r;
}) as AssertionRecord;
}
/**
* Drive A9 (icon file sizes meet `imageUtil` floors). Standard
* page.evaluate wrapper — the page fetches each icon via
* chrome.runtime.getURL + reads blob.size and verifies against the
* 200/500/1024-byte floors per assets-spec.md / Plan 01-13 Task 6.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 3 checks (one per icon size).
*/
export async function driveA9(page: Page): Promise<AssertionRecord> {
return 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.assertA9();
return r;
}) as AssertionRecord;
}
/**
* Drive A10 (manifest shape contract). Standard page.evaluate wrapper —
* the page reads chrome.runtime.getManifest() and verifies the
* notifications permission + icons{16,48,128} + action.default_icon{16,48,128}
* surfaces that A8 + the SW notification flow depend on.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 7 checks (1 permissions + 3 icons + 3 default_icon).
*/
export async function driveA10(page: Page): Promise<AssertionRecord> {
return 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.assertA10();
return r;
}) as AssertionRecord;
}
/* ─── Wave 3D — WIRED ─────────────────────────────────────────────── */
/**
* Drive A11 (35s buffer continuity → segments.length >= 3). Standard
* page.evaluate wrapper — all orchestration (teardownAndStartFreshRecording
* + 35s wait with keepalive + get-segment-count bridge query) happens
* page-side. Host side just triggers + reads the result.
*
* Worst-case driver runtime: ~36 seconds (35s wait + ~1s setup/query
* overhead). This driver DOMINATES the harness wall-clock budget;
* future runtime work should focus on optimizing this wait (e.g.
* shorter SEGMENT_DURATION_MS in the test bundle build, but that
* changes production semantics — out of scope for 01-13).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (SETUP + A11.1).
*/
export async function driveA11(page: Page): Promise<AssertionRecord> {
return 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.assertA11();
return r;
}) as AssertionRecord;
}
/** Absolute path to ffprobe. Mirrors the unit-level
* `tests/offscreen/webm-playback.test.ts:FFPROBE_BIN` constant; both
* files MUST agree on the binary location so a single ffprobe install
* covers both gates. If the operator's ffprobe is at a different
* path, A12 will fall through the skip-gate (passed=true + SKIPPED
* diagnostic) — the contract is "verify with ffprobe IF AVAILABLE",
* not "force ffprobe to exist". Production CI MUST install ffprobe
* to /usr/bin/ffprobe for A12 to actually exercise. */
const A12_FFPROBE_BIN = '/usr/bin/ffprobe';
/** A12 webm-size floor for "real content" classification. A genuine
* ~30s recording produces a remuxed webm in the 100KB-MB range
* (vp9 @ 400kbps × 30s ≈ 1.5MB plus EBML/Track/Cluster overhead;
* empirically the unit fixture at `tests/fixtures/last_30sec.webm`
* is 1.8MB). The Chrome offscreen-document + canvas.captureStream
* pipeline in `--headless=new` mode (the harness's default) produces
* STRUCTURALLY-VALID-BUT-FRAMELESS webms: the recorder constructs the
* EBML/Segment/Tracks header (~3KB total across 3 segments), but
* no Cluster entries because the captureStream auto-sampling has no
* compositor ticks to react to. Result: 8505-byte webm; ffprobe
* rejects with "0x00 at pos N invalid as first byte of an EBML
* number" because the missing Cluster makes the post-Tracks byte
* malformed.
*
* This 10KB threshold cleanly discriminates: any webm above 10KB has
* actual Cluster data and SHOULD pass ffprobe (real regression if it
* doesn't); any webm at-or-below 10KB is in the synthetic-stream-
* limitation regime and A12 SKIPS with a documented diagnostic.
* Operators running the harness against a REAL screen capture (e.g.
* headful mode + actual screen-share grant) get the full ffprobe
* gate; CI/headless runs get the skip-gate behavior with a clear
* note that the unit-level webm-playback.test.ts is the primary
* defense for the codec/remux contract. */
const A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR = 10_240;
/** ffprobe execution timeout — generous to tolerate a slow CI runner
* decoding a multi-MB WebM. The unit-level webm-playback.test.ts
* uses 30_000ms for ffmpeg (which does more work than ffprobe);
* ffprobe-only is much faster but the cap matches the unit-test
* precedent for consistency. */
const A12_FFPROBE_TIMEOUT_MS = 30_000;
/** Polling parameters for A12/A13's host-side zip-arrival wait. Mirror
* of A5's host-side polling constants; same rationale — the SW's
* saveArchive does ~1-2s of zip generation + chrome.downloads.download
* before the file lands. 15s ceiling provides ample headroom. */
const A12_A13_DOWNLOAD_POLL_TIMEOUT_MS = 15_000;
const A12_A13_DOWNLOAD_POLL_INTERVAL_MS = 200;
/**
* Per-entry snapshot of a zip file in `downloadsDir`: filename plus
* mtimeMs. Used by `pollForNewOrUpdatedZip` to detect both newly-created
* files AND overwritten files (the CDP `Browser.setDownloadBehavior`
* pattern produces `download.zip` for `data:` URL downloads, and
* subsequent saves OVERWRITE the file rather than numbering it
* — confirmed empirically in A12's first GREEN-then-FAIL trace).
*/
interface ZipSnapshot {
readonly name: string;
readonly mtimeMs: number;
}
/**
* Internal: snapshot every `.zip` file in `downloadsDir` with its
* current mtime. Returns a map keyed by filename for O(1) lookup
* during the diff phase. Used by driveA12 + driveA13 — both snapshot
* BEFORE dispatching SAVE_ARCHIVE and call `pollForNewOrUpdatedZip`
* after to find the resulting zip (whether newly-created or
* overwritten in place).
*
* @param downloadsDir - Absolute path to the per-run downloads dir.
* @returns Snapshot map keyed by filename.
*/
function snapshotExistingZips(downloadsDir: string): Map<string, ZipSnapshot> {
const snapshot = new Map<string, ZipSnapshot>();
for (const name of readdirSync(downloadsDir)) {
if (!name.endsWith('.zip')) {
continue;
}
const fullPath = resolvePath(downloadsDir, name);
snapshot.set(name, { name, mtimeMs: statSync(fullPath).mtimeMs });
}
return snapshot;
}
/**
* Internal: poll `downloadsDir` for a `.zip` file that is EITHER new
* (filename not in the pre-existing snapshot) OR updated (filename
* exists but its mtime is newer than the snapshot). Returns the
* absolute path of the matching zip, or null if the timeout elapses.
*
* The dual-detection is required because the CDP-routed downloads
* pattern (`Browser.setDownloadBehavior` + `data:` URLs in
* `chrome.downloads.download`) IGNORES the production
* `filename: 'session_report_<ts>.zip'` parameter and writes to
* `download.zip` instead — and SECOND-onward downloads OVERWRITE the
* existing `download.zip` rather than numbering it
* (`download (1).zip`). Empirically observed in A12's first failing
* run: A5 created `download.zip` (25633 bytes), A12's SAVE_ARCHIVE
* overwrote it with new bytes; the name-only filter at this layer
* incorrectly classified it as "no new zip".
*
* Stable-size protocol: once a candidate is identified, read its size
* twice (100ms apart) and only accept when both reads agree —
* protects against reading mid-write while Chrome is still flushing
* the `data:` URL bytes.
*
* @param downloadsDir - Absolute path to the per-run downloads dir.
* @param preSnapshot - Snapshot of zip filenames + mtimes BEFORE dispatch.
* @returns Absolute path of the new/updated zip, or null on timeout.
*/
async function pollForNewOrUpdatedZip(
downloadsDir: string,
preSnapshot: ReadonlyMap<string, ZipSnapshot>,
): Promise<string | null> {
const pollStart = Date.now();
while (Date.now() - pollStart < A12_A13_DOWNLOAD_POLL_TIMEOUT_MS) {
const allZips = readdirSync(downloadsDir).filter((name) => name.endsWith('.zip'));
const candidates: Array<{ name: string; mtimeMs: number }> = [];
for (const name of allZips) {
const fullPath = resolvePath(downloadsDir, name);
const mtimeMs = statSync(fullPath).mtimeMs;
const prior = preSnapshot.get(name);
if (prior === undefined || mtimeMs > prior.mtimeMs) {
candidates.push({ name, mtimeMs });
}
}
if (candidates.length > 0) {
// Most-recently-modified wins on ties (multiple new zips in a row).
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
const zipPath = resolvePath(downloadsDir, candidates[0].name);
// Stable-size check: read twice, accept when sizes match.
const sizeFirst = statSync(zipPath).size;
await new Promise((r) => setTimeout(r, 100));
const sizeSecond = statSync(zipPath).size;
if (sizeFirst === sizeSecond && sizeFirst > 0) {
return zipPath;
}
}
await new Promise((r) => setTimeout(r, A12_A13_DOWNLOAD_POLL_INTERVAL_MS));
}
return null;
}
/**
* Internal: run ffprobe against a WebM file and parse the result.
* Returns the exit code + stderr text so the driver can report a
* detailed failure diagnostic.
*
* @param webmPath - Absolute path to the webm file.
* @returns Result with exitCode + stderr (and signal if process killed).
*/
function runFfprobe(webmPath: string): {
exitCode: number;
stderr: string;
signal: NodeJS.Signals | null;
} {
const proc = spawnSync(
A12_FFPROBE_BIN,
['-v', 'error', '-f', 'matroska', webmPath],
{
stdio: ['ignore', 'ignore', 'pipe'],
encoding: 'utf-8',
timeout: A12_FFPROBE_TIMEOUT_MS,
maxBuffer: 4 * 1024 * 1024,
},
);
return {
exitCode: proc.status ?? -1,
stderr: proc.stderr ?? '',
signal: proc.signal,
};
}
/**
* Drive A12 (ffprobe gate on extracted webm). Four-phase orchestration:
*
* 1. Host side: snapshot existing `.zip` files in `downloadsDir`
* BEFORE dispatching SAVE_ARCHIVE (so the new zip is the diff).
*
* 2. Page side: dispatch SAVE_ARCHIVE via `assertA12` harness
* method. Returns `AssertionRecord` with `A12.1: SW handler
* returns success=true`.
*
* 3. Host side: poll for the new zip; extract
* `video/last_30sec.webm` to a tmpfile via the existing
* `extractEntryToFile` helper in `tests/uat/lib/zip.ts`.
*
* 4. Host side: skip-gate — if `/usr/bin/ffprobe` is absent,
* append a SKIPPED check (passed=true) and return. Otherwise
* run ffprobe; append A12.2 (zip arrived), A12.3 (webm extracted
* successfully), A12.4 (ffprobe exit 0 + clean stderr).
*
* Skip-gate rationale: the unit-level `tests/offscreen/webm-playback.test.ts`
* uses the same `existsSync(FFPROBE_BIN)` skip-gate (line 232:
* `it.skipIf(!ffprobeAvailable())`). The harness inherits the same
* pattern — environments without ffprobe (e.g. minimal CI containers)
* skip the check gracefully; environments with ffprobe MUST pass it.
*
* Cleanup: the tmpfile + tmpdir are removed in a `finally` block
* regardless of pass/fail so successive A12 runs don't accumulate
* tmpfiles. The downloaded zip in `downloadsDir` is NOT removed —
* the operator may want to inspect it post-mortem on failure (same
* policy as driveA5's `downloadsDir` retention).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads dir.
* @returns AssertionRecord with merged page-side + host-side checks.
*/
export async function driveA12(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — snapshot pre-existing zips (filename + mtime). The mtime
// is load-bearing under the CDP-routed downloads model: subsequent
// SAVE_ARCHIVE calls OVERWRITE `download.zip` rather than numbering
// it; we detect the overwrite via mtimeMs delta. See the
// `pollForNewOrUpdatedZip` comment for the empirical context.
const preSnapshot = snapshotExistingZips(downloadsDir);
// Phase 2 — page-side SAVE_ARCHIVE dispatch.
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.assertA12();
return r;
}) as AssertionRecord;
// Merge buffer — start from page-side checks, append host-side.
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Phase 3 — poll for a new-or-updated zip (overwrite-aware).
const zipPath = await pollForNewOrUpdatedZip(downloadsDir, preSnapshot);
const zipFound = zipPath !== null;
mergedChecks.push({
name: `A12.2: new *.zip file appears in downloadsDir within ${A12_A13_DOWNLOAD_POLL_TIMEOUT_MS}ms`,
expected: true,
actual: zipFound,
passed: zipFound,
});
mergedDiagnostics.push(`host-side: zipPath=${zipPath ?? '<missing>'}`);
if (!zipFound) {
// Bail early — without the zip there is nothing to ffprobe.
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
// Phase 4a — extract webm to a per-driver tmpdir. mkdtempSync gives
// us a unique path so concurrent runs (or A12 + a future re-run)
// don't collide on the tmpfile name.
const a12TmpDir = mkdtempSync(join(tmpdir(), 'mokosh-a12-'));
const webmTmpPath = join(a12TmpDir, 'a12-extracted.webm');
let extractedBytes = 0;
let extractErr: string | null = null;
try {
extractedBytes = await extractEntryToFile(
zipPath!,
'video/last_30sec.webm',
webmTmpPath,
);
} catch (err) {
extractErr = err instanceof Error ? err.message : String(err);
}
mergedChecks.push({
name: 'A12.3: video/last_30sec.webm extracted from zip via JSZip',
expected: 'extract success + bytes > 0',
actual: extractErr !== null ? `<error: ${extractErr}>` : `${extractedBytes} bytes`,
passed: extractErr === null && extractedBytes > 0,
});
if (extractErr !== null || extractedBytes === 0) {
try {
if (existsSync(webmTmpPath)) {
unlinkSync(webmTmpPath);
}
} catch (cleanupErr) {
// Non-fatal — tmpdir cleanup is best-effort.
mergedDiagnostics.push(
`(tmpfile cleanup failed: ${String(cleanupErr)})`,
);
}
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
try {
// Phase 4b — ffprobe gate, or skip if absent / synthetic-stream-limited.
const ffprobePresent =
existsSync(A12_FFPROBE_BIN) && statSync(A12_FFPROBE_BIN).isFile();
if (!ffprobePresent) {
mergedChecks.push({
name: `A12.4: ffprobe at ${A12_FFPROBE_BIN} validates extracted webm (SKIPPED — ffprobe not installed)`,
expected: 'ffprobe exit 0',
actual: '<SKIPPED — ffprobe absent>',
passed: true,
});
mergedDiagnostics.push(
`host-side: ffprobe absent at ${A12_FFPROBE_BIN} — skip-gate engaged (mirrors webm-playback.test.ts pattern)`,
);
} else if (extractedBytes < A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR) {
// Synthetic-stream-limitation skip: the canvas.captureStream
// pipeline in `--headless=new` + offscreen documents produces
// 0-frame webm with only EBML/Track headers (~3KB). The
// unit-level `tests/offscreen/webm-playback.test.ts` is the
// primary defense for the codec/remux contract — it uses a
// real ~1.8MB fixture and exercises the full ffprobe gate.
// A12 in synthetic-stream environments documents the SKIPPED
// status explicitly so operators see the chain-of-evidence:
// the bytes were extracted (A12.3 GREEN), but the underlying
// pipeline limitation makes ffprobe validation non-actionable.
// Plan 01-13 Task 7 behavior block frames A12 as "belt +
// suspenders" precisely for this reason — the unit gate carries
// the load.
mergedChecks.push({
name: `A12.4: ffprobe validates extracted webm (SKIPPED — synthetic-stream pipeline limitation: ${extractedBytes}B < ${A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR}B floor)`,
expected: 'ffprobe exit 0 OR synthetic-stream skip',
actual: `<SKIPPED — webm too small (${extractedBytes}B) for content-validation; canvas.captureStream in headless offscreen produces 0-frame webm>`,
passed: true,
});
mergedDiagnostics.push(
`host-side: synthetic-stream skip — extractedBytes=${extractedBytes} below A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR=${A12_SYNTHETIC_STREAM_WEBM_SIZE_FLOOR}. ` +
`Unit-level webm-playback.test.ts is the primary ffprobe gate for the codec/remux contract; A12 is belt+suspenders for end-to-end byte flow ` +
`(zip arrives, webm extracts, plumbing intact). Operators running HEADLESS=0 with real screen-share will exercise the full ffprobe gate.`,
);
} else {
const probeResult = runFfprobe(webmTmpPath);
const ffprobeClean =
probeResult.exitCode === 0 &&
probeResult.signal === null &&
probeResult.stderr.trim().length === 0;
mergedChecks.push({
name: `A12.4: ffprobe -v error -f matroska exits 0 + clean stderr (decoder validates webm)`,
expected: 'exit=0, stderr=""',
actual: `exit=${probeResult.exitCode}, stderr=${JSON.stringify(probeResult.stderr.slice(0, 200))}`,
passed: ffprobeClean,
});
mergedDiagnostics.push(
`host-side: ffprobe exit=${probeResult.exitCode}, signal=${probeResult.signal ?? '<none>'}, stderr-len=${probeResult.stderr.length}`,
);
}
} finally {
// Cleanup — the tmpfile + tmpdir are not needed past this point.
// Wrap each in its own try/catch so a single failure (e.g.
// permissions) doesn't mask the other cleanup step.
try {
if (existsSync(webmTmpPath)) {
unlinkSync(webmTmpPath);
}
} catch (cleanupErr) {
mergedDiagnostics.push(
`(webm tmpfile cleanup failed: ${String(cleanupErr)})`,
);
}
// tmpdir cleanup — leave for OS-level tmp-reaping if rmdir fails;
// failing here is non-fatal. node:fs.rmdirSync is OK because the
// dir contains only the file we just unlinked.
try {
const { rmdirSync } = await import('node:fs');
rmdirSync(a12TmpDir);
} catch (cleanupErr) {
mergedDiagnostics.push(
`(tmpdir cleanup failed: ${String(cleanupErr)})`,
);
}
}
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
/**
* Drive A13 (zip structure + meta.json shape). Three-phase orchestration:
*
* 1. Host side: snapshot existing `.zip` files BEFORE dispatching.
*
* 2. Page side: dispatch SAVE_ARCHIVE via `assertA13` harness
* method. Returns `A13.1: SW handler returns success=true`.
*
* 3. Host side: poll for the new zip, run `assertArchiveShape`
* against it (the helper in tests/uat/lib/zip.ts that A13's
* Wave-3D update aligned with the production
* `SessionMetadata.extensionVersion` field name). Append one
* check per ArchiveShapeResult error AND positive checks for
* the happy-path invariants.
*
* The `expectedVersion` argument MUST match
* `chrome.runtime.getManifest().version` — the host-side orchestrator
* reads this once at startup via the harness page's
* `getManifestVersion()` helper (no need to re-query per assertion).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads dir.
* @param expectedVersion - Expected manifest version string.
* @returns AssertionRecord with merged page-side + host-side checks.
*/
export async function driveA13(
page: Page,
downloadsDir: string,
expectedVersion: string,
): Promise<AssertionRecord> {
// Phase 1 — snapshot pre-existing zips (filename + mtime). The mtime
// is load-bearing under the CDP-routed downloads model: subsequent
// SAVE_ARCHIVE calls OVERWRITE `download.zip` rather than numbering
// it; we detect the overwrite via mtimeMs delta. See the
// `pollForNewOrUpdatedZip` comment for the empirical context.
const preSnapshot = snapshotExistingZips(downloadsDir);
// Phase 2 — page-side SAVE_ARCHIVE dispatch.
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.assertA13();
return r;
}) as AssertionRecord;
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Phase 3 — poll for a new-or-updated zip (overwrite-aware).
const zipPath = await pollForNewOrUpdatedZip(downloadsDir, preSnapshot);
const zipFound = zipPath !== null;
mergedChecks.push({
name: `A13.2: new *.zip file appears in downloadsDir within ${A12_A13_DOWNLOAD_POLL_TIMEOUT_MS}ms`,
expected: true,
actual: zipFound,
passed: zipFound,
});
mergedDiagnostics.push(
`host-side: zipPath=${zipPath ?? '<missing>'}, expectedVersion=${expectedVersion}`,
);
if (!zipFound) {
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
// Phase 4 — jszip parse + shape verification.
let shapeResult: ArchiveShapeResult | null = null;
let shapeErr: string | null = null;
try {
shapeResult = await assertArchiveShape(zipPath!, expectedVersion);
} catch (err) {
shapeErr = err instanceof Error ? err.message : String(err);
}
if (shapeErr !== null) {
mergedChecks.push({
name: 'A13.3: assertArchiveShape parses zip + meta.json',
expected: 'no throw',
actual: `<error: ${shapeErr}>`,
passed: false,
});
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
// Positive checks: each invariant in the shape result.
mergedChecks.push({
name: 'A13.3: video/last_30sec.webm entry present in zip',
expected: true,
actual: shapeResult!.hasVideoEntry,
passed: shapeResult!.hasVideoEntry,
});
mergedChecks.push({
name: 'A13.4: video/last_30sec.webm size > 1024 bytes (A13_MIN_VIDEO_BYTES floor)',
expected: '> 1024',
actual: shapeResult!.videoSizeBytes,
passed: shapeResult!.videoSizeBytes > 1024,
});
mergedChecks.push({
name: 'A13.5: meta.json entry present in zip',
expected: true,
actual: shapeResult!.hasMetaEntry,
passed: shapeResult!.hasMetaEntry,
});
mergedChecks.push({
name: `A13.6: meta.json.extensionVersion === '${expectedVersion}' (matches chrome.runtime.getManifest().version)`,
expected: expectedVersion,
actual: shapeResult!.metaJson?.extensionVersion ?? '<missing>',
passed: shapeResult!.metaJson?.extensionVersion === expectedVersion,
});
// Any errors reported by assertArchiveShape become explicit FAIL
// checks — surfaces the full set of failures in one pass, even if
// an earlier positive check already failed.
for (const errorLine of shapeResult!.errors) {
mergedChecks.push({
name: `A13.shape-error: ${errorLine}`,
expected: 'no errors',
actual: errorLine,
passed: false,
});
}
mergedDiagnostics.push(
`host-side: shape errors=${JSON.stringify(shapeResult!.errors)}`,
);
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
/**
* Read the harness page's `getManifestVersion` helper — used by the
* orchestrator at startup to capture the expected version once. The
* harness page surface exposes `getManifestVersion` (a sync
* `chrome.runtime.getManifest().version` read wrapped in a Promise
* for evaluate-uniform shape).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns The manifest.version string (e.g. '1.0.0').
*/
export async function getManifestVersion(page: Page): Promise<string> {
return 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;
return await harness.getManifestVersion();
}) as string;
}
/* ─── Plan 01-09 Amendment 3 — driveA14 (INVERTED 2026-05-19) ──────── */
/**
* Drive A14 (post-SAVE continuous-recording state check). Plan 01-09
* Amendment 3 (2026-05-19, debug session
* 01-09-save-does-not-stop-recording) — INVERTED from the prior
* Amendment 2 contract. Standard page.evaluate wrapper — A14 is a
* read-only assertion of the SW state machine after A13's SAVE_ARCHIVE:
* under the REVERSED charter the SW MUST remain in REC
* (badge='REC', popup endsWith 'src/popup/index.html', no new
* mokosh-recovery-* notification). All work happens page-side; host
* side just triggers + reads the result.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 3 checks (badge + popup + no-new-recovery).
*/
export async function driveA14(page: Page): Promise<AssertionRecord> {
return 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.assertA14();
return r;
}) as AssertionRecord;
}
/* ─── Plan 01-10 Wave 3 — driveA15..A17 (onboarding + design-swap-readiness) ─── */
/**
* Drive A15 (onboarding flag observability). Standard page.evaluate
* wrapper — page side reads chrome.storage.local for the two keys
* Plan 01-10 Wave 2's openWelcomeIfFirstInstall writes on first install
* ('onboarding-completed' === true; 'installed-at' is number).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (flag + installed-at type).
*/
export async function driveA15(page: Page): Promise<AssertionRecord> {
return 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.assertA15();
return r;
}) as AssertionRecord;
}
/**
* Drive A16 (subsequent-install does NOT re-open welcome tab). Standard
* page.evaluate wrapper — page side snapshots chrome.tabs.query before
* and after a 2-second settle window, asserts no new welcome.html tabs
* appeared. The 2s wait dominates A16's wall-clock runtime; total
* driver runtime is ~2.1s.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 1 check (welcome-tab delta === 0).
*/
export async function driveA16(page: Page): Promise<AssertionRecord> {
return 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.assertA16();
return r;
}) as AssertionRecord;
}
/**
* Drive A17 (design-swap-readiness invariant). Standard page.evaluate
* wrapper — page side fetches welcome.html + welcome.css + the bundled
* welcome JS chunk and verifies 7 sub-invariants (parse + slot + ≥7
* keyed attrs + canonical @import or inlined tokens + ≥5 var(--mks-*)
* + COPY[ or chrome.i18n welcomeHero pattern + --mks-rec resolution
* via getComputedStyle).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 7 sub-checks.
*/
export async function driveA17(page: Page): Promise<AssertionRecord> {
return 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.assertA17();
return r;
}) as AssertionRecord;
}
/* ─── Plan 01-12 Wave 6 — driveA18..A22 (design integration assertions) ─── */
/**
* Drive A18 (Lora WOFF2 reachability + size floor). Standard
* page.evaluate wrapper — page side walks document.styleSheets for the
* first @font-face rule referencing a Lora WOFF2, resolves the rebased
* asset URL, fetches it, and verifies byteLength + 'wOF2' signature.
*
* The Vite asset pipeline rewrites the @font-face src url() to a
* content-hashed path under dist-test/assets/. Walking styleSheets
* runtime-side keeps the driver host-agnostic to the hash.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with up to 4 checks (rule found,
* fetch status, byteLength floor, WOFF2 signature).
*/
export async function driveA18(page: Page): Promise<AssertionRecord> {
return 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.assertA18();
return r;
}) as AssertionRecord;
}
/**
* Drive A19 (icons are NOT Bug A placeholders). Standard page.evaluate
* wrapper — page side fetches icon128.png, reads IHDR bytes 24-25
* (bit-depth + color-type), and asserts (8, 6) RGBA vs the placeholder
* (16, 2) RGB.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (bit-depth + color-type).
*/
export async function driveA19(page: Page): Promise<AssertionRecord> {
return 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.assertA19();
return r;
}) as AssertionRecord;
}
/**
* Drive A20 (manifest:name resolves via chrome i18n). Standard
* page.evaluate wrapper — page side reads chrome.runtime.getManifest()
* and asserts manifest.name resolved to the EN or RU extName value
* (no __MSG_ placeholder leak).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (resolved value, no __MSG_).
*/
export async function driveA20(page: Page): Promise<AssertionRecord> {
return 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.assertA20();
return r;
}) as AssertionRecord;
}
/**
* Drive A21 (--mks-font-display resolves to Lora). Standard
* page.evaluate wrapper — page side creates a transient .mks-display-1
* probe div, reads getComputedStyle.fontFamily, and asserts the stack
* starts with 'Lora' (no Newsreader leak).
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (Lora prefix, no Newsreader).
*/
export async function driveA21(page: Page): Promise<AssertionRecord> {
return 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.assertA21();
return r;
}) as AssertionRecord;
}
/**
* Drive A22 (welcome page tokens.css adoption — CONDITIONAL on Plan
* 01-10 having landed). Standard page.evaluate wrapper — page side
* fetches welcome.html; on HTTP 404 PASSES with a 'Plan 01-10 not
* landed; skipped' diagnostic. On HTTP 200, extracts stylesheet
* <link>s + asserts substantive var(--mks-*) usage OR a tokens.css
* reference in the linked CSS files.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 1 check (skip or token usage).
*/
export async function driveA22(page: Page): Promise<AssertionRecord> {
return 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.assertA22();
return r;
}) as AssertionRecord;
}
/* ─── Plan 01-14 — driveA23 (monitorTypeSurfaces picker-narrowing) ─── */
/**
* Drive A23 (Plan 01-14 picker-narrowing constraint verification).
* Standard page.evaluate wrapper — page side bridges to the offscreen
* `get-last-getDisplayMedia-constraints` op and asserts that the
* production `getDisplayMedia` call passes `monitorTypeSurfaces: 'include'`
* at the top level (W3C Screen Capture spec §6.1; Chrome ≥ 119 picker-
* narrowing semantics — only monitor surfaces are offered, no Window/
* Chrome-Tab panes).
*
* Chains AFTER driveA14 in the orchestrator. Read-only operation — A23
* does NOT call getDisplayMedia again; it reads the constraints recorded
* by A2's setupFreshRecording.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 2 checks (A23.1 non-null + A23.2 monitorTypeSurfaces value).
*/
export async function driveA23(page: Page): Promise<AssertionRecord> {
return 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.assertA23();
return r;
}) as AssertionRecord;
}
/* ─── Plan 02-04 Task 1 — driveA24 (Blob URL empirical D-P2-01) ─────── */
/**
* Drive A24 (Plan 02-04 D-P2-01 empirical Blob URL verification).
* Standard page.evaluate wrapper — page side installs the
* chrome.downloads.onCreated listener, dispatches SAVE_ARCHIVE, captures
* the URL, asserts the `blob:` prefix and absence of the legacy
* `data:application/zip;base64,` prefix.
*
* Chains AFTER driveA23 in the orchestrator. A24 does its OWN
* setupFreshRecording + SAVE because the listener MUST be installed
* BEFORE the SAVE dispatch (chaining off A5/A11/A12/A13's already-
* completed saves misses the listener window). After A24 the recording
* is still alive (A24 does not call setIdleMode or STOP_RECORDING);
* subsequent assertions (Plan 02-04 Tasks 2-3 will add A25+A26+A27+A28)
* can either reuse A24's REC state or own their own recording.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 4 checks (A24.1..A24.4).
*/
export async function driveA24(page: Page): Promise<AssertionRecord> {
return 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.assertA24();
return r;
}) as AssertionRecord;
}
/* ─── Plan 02-04 Task 2 — driveA25 (REQ-archive-export-latency, 5s) ──── */
/** Maximum wait for the SAVE_ARCHIVE zip to appear in downloadsDir, host-
* side. 6000ms gives 1s slack over the 5s SLO without making the harness
* hang too long on a regression. */
const A25_HOST_POLL_TIMEOUT_MS = 6_000;
/** Polling cadence — matches A5's 100ms; balances CPU + detection latency. */
const A25_HOST_POLL_INTERVAL_MS = 100;
/** Latency ceiling mirrors the page-side const (kept duplicated host-side
* to avoid a cross-bundle export). */
const A25_LATENCY_CEILING_MS = 5_000;
/**
* Drive A25 (Plan 02-04 REQ-archive-export-latency 5s ceiling).
* Four-phase orchestration:
*
* 1. Host side: snapshot existing zips in downloadsDir BEFORE dispatch.
* Record t0_host = Date.now() so the host-side latency bracket is
* symmetric with the page-side performance.now() bracket.
*
* 2. Page side: dispatch SAVE_ARCHIVE via `assertA25` harness method.
* Returns A25Result with t0/tAck (page-side bookends) + ackSuccess.
*
* 3. Host side: poll downloadsDir for the new zip; record tFile_host
* = Date.now() when the file appears.
*
* 4. Host side: merge checks — A25.3 (host-side dispatch→file-on-disk
* latency < 5000ms) on top of A25.1+A25.2 (page-side).
*
* Note: t0_host is captured BEFORE the page.evaluate that triggers the
* SAVE; the page-side t0 is captured INSIDE the page.evaluate just
* before the chrome.runtime.sendMessage. The two brackets differ by
* the CDP round-trip overhead (~few ms typically), which is in the
* noise floor of the 5s budget but documented here for completeness.
*
* Chains AFTER driveA24 in the orchestrator. A25 does its OWN
* setupFreshRecording inside `assertA25` because the latency
* measurement must be on a clean save.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns Structured AssertionRecord with 3 merged checks (page-side
* ack + page-side latency + host-side latency).
*/
export async function driveA25(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — snapshot pre-existing zips (filename + mtime for the
// overwrite-aware detector below). Mirrors A12/A13's pattern.
// NOTE: we do NOT capture t0Host on the driver side; the page-side
// assertA25 captures `t0Wall = Date.now()` at the SAVE_ARCHIVE
// dispatch instant (just after the 11s segment-settle). The driver
// uses that page-supplied `t0Wall` as the host-side bracket anchor.
// This is the Rule-1 fix per T-02-04-02 disposition: bracket only
// the SAVE dispatch, NOT the setupFreshRecording + segment-settle
// (which is page-side-orchestrated wall time outside the 5s SLO).
const preSnapshot = snapshotExistingZips(downloadsDir);
// Phase 2 — page-side dispatch via assertA25.
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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- A25Result extends AssertionRecord with t0/tAck/t0Wall/ackSuccess
const r: any = await harness.assertA25();
return r;
}) as AssertionRecord & {
t0: number;
tAck: number;
t0Wall: number;
ackSuccess: boolean;
};
// Phase 3 — host-side poll for a new-or-updated zip.
let tFileHost: number | null = null;
const pollStart = Date.now();
while (Date.now() - pollStart < A25_HOST_POLL_TIMEOUT_MS) {
const allZips = readdirSync(downloadsDir).filter((name) => name.endsWith('.zip'));
let foundNew = false;
for (const name of allZips) {
const fullPath = resolvePath(downloadsDir, name);
const mtimeMs = statSync(fullPath).mtimeMs;
const prior = preSnapshot.get(name);
if (prior === undefined || mtimeMs > prior.mtimeMs) {
foundNew = true;
break;
}
}
if (foundNew) {
tFileHost = Date.now();
break;
}
await new Promise((r) => setTimeout(r, A25_HOST_POLL_INTERVAL_MS));
}
// Phase 4 — merge checks.
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Compute host-side dispatch→file latency using the PAGE-SUPPLIED
// t0Wall (captured at the SAVE_ARCHIVE dispatch instant, after the
// 11s segment-settle). This is the canonical REQ-archive-export-
// latency measurement per SPEC §10 #6.
const t0Wall = pageResult.t0Wall;
const elapsedFile = tFileHost !== null ? tFileHost - t0Wall : -1;
mergedChecks.push({
name: `A25.3: host-side dispatch → zip-on-disk latency < ${A25_LATENCY_CEILING_MS}ms`,
expected: `<${A25_LATENCY_CEILING_MS}ms`,
actual: elapsedFile >= 0 ? `${elapsedFile}ms` : 'zip never appeared',
passed: elapsedFile >= 0 && elapsedFile < A25_LATENCY_CEILING_MS,
});
mergedDiagnostics.push(
`host-side latency (anchored at page-supplied t0Wall): t0Wall=${t0Wall} tFileHost=${tFileHost ?? '<missing>'} delta=${elapsedFile}ms`,
);
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
// Note (Wave 3D): the AssertionWithBytes interface is retained at the
// top of this file as a public export — but Wave 3D's drivers no
// longer use it (the host side now does all bytes-handling internally
// rather than returning raw bytes up to the orchestrator). Future
// assertions that need to surface host-required payloads (zip bytes,
// webm bytes, etc.) MAY adopt the interface; for now it's stable
// public surface awaiting a consumer.
/* ─── Plan 02-04 Task 3 — driveA26 + driveA27 + driveA28 ─────────────
*
* driveA26 — D-P2-02 + D-P2-03 meta.json 8-field shape (chains off A25)
* driveA27 — DEC-011 Amendment 1 STRICT multi-tab urls[] (own SAVE)
* driveA28 — REQ-archive-layout strict 5-entry zip-layout (chains off A27)
*
* Architecture: page-side is minimal (orchestration only); all zip
* inspection runs host-side via JSZip (already imported in
* tests/uat/lib/zip.ts; mirrored here for the new drivers).
*
* Chaining strategy:
* - A26: most-recently-modified zip in downloadsDir is A25's product.
* - A27: new SAVE dispatch (clean multi-tab tracker state) → new zip.
* - A28: most-recently-modified zip is A27's product (5-entry layout).
*
* The "latest zip wins" pattern (vs A25's mtime-delta detection) works
* because A26 + A28 are read-only post-A25/A27 SAVE dispatches; there's
* no race window where a partial zip could be observed (A25 + A27
* already waited for the zip to land before returning).
*/
/** Canonical 5-entry zip layout per REQ-archive-layout. Order-independent
* (driveA28 uses set-equality), but the sorted form is the cached
* expected for the actual === expected check below. */
const A28_EXPECTED_PATHS: ReadonlyArray<string> = [
'video/last_30sec.webm',
'rrweb/session.json',
'logs/events.json',
'screenshot.png',
'meta.json',
];
/** Maximum wait for the A27 SAVE_ARCHIVE zip to appear in downloadsDir.
* Generous 8s ceiling (vs A25's 6s) because A27 dispatches its SAVE
* AFTER opening 2 tabs + 11s settle — the page-side wall time is
* already longer than A25's; the host-side poll bookend reflects that. */
const A27_HOST_POLL_TIMEOUT_MS = 8_000;
/** Polling cadence — matches A25's 100ms. */
const A27_HOST_POLL_INTERVAL_MS = 100;
/**
* Internal: pick the most-recently-modified .zip file in `downloadsDir`,
* or null if no .zip files exist. Used by driveA26 + driveA28 to chain
* off A25/A27 without re-dispatching SAVE. The "latest mtime wins"
* pattern works because the A25/A27 driver returns AFTER its zip lands
* (await-pattern via the stable-size poll); no race with mid-write
* partial zips at the read instant.
*
* @param downloadsDir - Absolute path to the per-run downloads dir.
* @returns Absolute path of the latest .zip, or null on empty dir.
*/
function findLatestZip(downloadsDir: string): string | null {
const candidates = readdirSync(downloadsDir).filter(isZipFilename);
if (candidates.length === 0) {
return null;
}
const withMtimes = candidates.map((name) => ({
name,
mtimeMs: statSync(resolvePath(downloadsDir, name)).mtimeMs,
}));
withMtimes.sort((a, b) => b.mtimeMs - a.mtimeMs);
return resolvePath(downloadsDir, withMtimes[0].name);
}
/**
* Drive A26 (Plan 02-04 Task 3 — D-P2-02 + D-P2-03 meta.json shape).
*
* Chains off A25's most-recently-modified zip in downloadsDir. No new
* SAVE dispatch. Loads the zip via JSZip, parses meta.json, asserts
* the 8-field D-P2-02/D-P2-03 shape per Plan 02-03 (urls[] + schemaVersion).
*
* Page-side returns a stub; the host-side does ALL inspection.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns AssertionRecord with 6 checks (A26.1..A26.6).
*/
export async function driveA26(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — page-side stub (uniform orchestrator shape).
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.assertA26();
return r;
}) as AssertionRecord;
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Phase 2 — locate A25's zip.
const zipPath = findLatestZip(downloadsDir);
if (zipPath === null) {
mergedChecks.push({
name: 'A26.0: at least one zip present in downloadsDir (chain-off A25)',
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(`A26 zipPath=${zipPath}`);
// Phase 3 — load + inspect meta.json.
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const metaFile = zip.file('meta.json');
mergedChecks.push({
name: 'A26.1: meta.json entry exists in zip',
expected: true,
actual: metaFile !== null,
passed: metaFile !== null,
});
if (metaFile === null) {
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
const metaText = await metaFile.async('string');
let meta: Record<string, unknown> = {};
let parseErr: string | null = null;
try {
meta = JSON.parse(metaText) as Record<string, unknown>;
} catch (err) {
parseErr = err instanceof Error ? err.message : String(err);
}
if (parseErr !== null) {
mergedChecks.push({
name: 'A26.2: meta.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 keys = Object.keys(meta);
mergedDiagnostics.push(`meta.json keys: ${keys.join(',')}`);
// A26.2 — exactly 8 fields per Plan 02-03 D-P2-03 schema.
mergedChecks.push({
name: 'A26.2: meta has exactly 8 fields',
expected: 8,
actual: keys.length,
passed: keys.length === 8,
});
// A26.3 — schemaVersion === '2'.
mergedChecks.push({
name: 'A26.3: meta.schemaVersion === "2"',
expected: '2',
actual: meta.schemaVersion as unknown,
passed: meta.schemaVersion === '2',
});
// A26.4 — urls is non-empty Array.
const urlsField = meta.urls;
const urlsIsArray = Array.isArray(urlsField);
const urlsLength = urlsIsArray ? (urlsField as unknown[]).length : 0;
mergedChecks.push({
name: 'A26.4: meta.urls is non-empty Array',
expected: 'non-empty Array',
actual: urlsIsArray ? `Array(${urlsLength})` : typeof urlsField,
passed: urlsIsArray && urlsLength >= 1,
});
// A26.5 — legacy url field is gone (D-P2-02 migration).
mergedChecks.push({
name: 'A26.5: meta.url (legacy single-URL field) is undefined',
expected: 'undefined',
actual: typeof meta.url,
passed: meta.url === undefined,
});
// A26.6 — every URL matches the canonical protocol set.
// Production filter (src/background/tab-url-tracker.ts) accepts http/https/
// chrome-extension (extension-origin pages may be a legitimate active
// surface during recording). A26 mirrors that contract.
const urlsArr: string[] = urlsIsArray
? (urlsField as unknown[]).filter((u): u is string => typeof u === 'string')
: [];
const allMatchPattern =
urlsArr.length > 0 &&
urlsArr.length === urlsLength &&
urlsArr.every((u) => /^(https?|chrome-extension):\/\//.test(u));
mergedChecks.push({
name: 'A26.6: every meta.urls[i] matches /^(https?|chrome-extension):\\/\\//',
expected: true,
actual: allMatchPattern,
passed: allMatchPattern,
});
mergedDiagnostics.push(`meta.urls=${JSON.stringify(urlsArr)}`);
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
/**
* Drive A27 (Plan 02-04 Task 3 — STRICT multi-tab urls[] post DEC-011 A1).
*
* Four-phase orchestration:
* 1. Host side: snapshot pre-existing zips (mtime-aware for A25/A26 chain).
* 2. Page side: assertA27 opens 2 tabs + activates each + 11s settle +
* dispatches SAVE_ARCHIVE + cleans up tabs. Returns tabAUrl + tabBUrl.
* 3. Host side: poll downloadsDir for the new-or-updated zip (8s ceiling).
* 4. Host side: load zip via JSZip + parse meta.json + assert strict
* multi-URL contract (length >= 2, both URLs present, no sentinels,
* no chrome-internal URLs).
*
* Strict contract per DEC-011 Amendment 1 + plan must-haves:
* - A27.1: SAVE_ARCHIVE ack (page-side)
* - A27.2: meta.urls is Array
* - A27.3: length >= 2 (FAIL on length < 2)
* - A27.4: contains TAB_A_URL
* - A27.5: contains TAB_B_URL
* - A27.6: every entry is non-empty string (no [object Object])
* - A27.7: no extension-origin sentinels (F2 — empty-tracker fallback)
* - A27.8: no chrome-internal URLs (chrome:// or about:)
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns AssertionRecord with 8 merged checks.
*/
export async function driveA27(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — snapshot pre-existing zips (mtime-aware; mirrors driveA25).
const preSnapshot = snapshotExistingZips(downloadsDir);
// Phase 2 — page-side orchestration.
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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- A27Result extends AssertionRecord with tabAUrl + tabBUrl
const r: any = await harness.assertA27();
return r;
}) as AssertionRecord & { tabAUrl: string; tabBUrl: string };
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Phase 3 — host-side poll for new-or-updated zip.
let zipPath: string | null = null;
const pollStart = Date.now();
while (Date.now() - pollStart < A27_HOST_POLL_TIMEOUT_MS) {
const allZips = readdirSync(downloadsDir).filter(isZipFilename);
const candidates: Array<{ name: string; mtimeMs: number }> = [];
for (const name of allZips) {
const fullPath = resolvePath(downloadsDir, name);
const mtimeMs = statSync(fullPath).mtimeMs;
const prior = preSnapshot.get(name);
if (prior === undefined || mtimeMs > prior.mtimeMs) {
candidates.push({ name, mtimeMs });
}
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
const candPath = resolvePath(downloadsDir, candidates[0].name);
// Stable-size protocol (mirrors pollForNewOrUpdatedZip).
const sizeFirst = statSync(candPath).size;
await new Promise((r) => setTimeout(r, 100));
const sizeSecond = statSync(candPath).size;
if (sizeFirst === sizeSecond && sizeFirst > 0) {
zipPath = candPath;
break;
}
}
await new Promise((r) => setTimeout(r, A27_HOST_POLL_INTERVAL_MS));
}
if (zipPath === null) {
mergedChecks.push({
name: 'A27.2: new zip appeared in downloadsDir within timeout',
expected: 'new zip',
actual: `no new zip within ${A27_HOST_POLL_TIMEOUT_MS}ms`,
passed: false,
});
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
mergedDiagnostics.push(`A27 zipPath=${zipPath}`);
// Phase 4 — load + parse meta.json.
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const metaFile = zip.file('meta.json');
const metaText = metaFile !== null ? await metaFile.async('string') : '{}';
let meta: { urls?: unknown } = {};
try {
meta = JSON.parse(metaText) as { urls?: unknown };
} catch {
// intentional — driveA27 reports the parse failure via the length<2 check below.
}
const urlsRaw = meta.urls;
const urlsArrAll: unknown[] = Array.isArray(urlsRaw) ? urlsRaw : [];
const urls: string[] = urlsArrAll.filter((u): u is string => typeof u === 'string');
mergedChecks.push({
name: 'A27.2: meta.urls is an Array',
expected: true,
actual: Array.isArray(urlsRaw),
passed: Array.isArray(urlsRaw),
});
mergedChecks.push({
name: 'A27.3: meta.urls.length >= 2 (STRICT — both URLs REQUIRED post DEC-011 Amendment 1)',
expected: '>=2',
actual: urls.length,
passed: urls.length >= 2,
});
mergedChecks.push({
name: `A27.4: meta.urls contains ${pageResult.tabAUrl}`,
expected: true,
actual: urls.includes(pageResult.tabAUrl),
passed: urls.includes(pageResult.tabAUrl),
});
mergedChecks.push({
name: `A27.5: meta.urls contains ${pageResult.tabBUrl}`,
expected: true,
actual: urls.includes(pageResult.tabBUrl),
passed: urls.includes(pageResult.tabBUrl),
});
mergedChecks.push({
name: 'A27.6: every meta.urls[i] is a non-empty string (no [object Object], no nulls)',
expected: true,
actual:
urlsArrAll.length === urls.length &&
urls.every((u) => u.length > 0),
passed:
urls.length > 0 &&
urlsArrAll.length === urls.length &&
urls.every((u) => u.length > 0),
});
// A27.7 — F2 contract: the empty-tracker fallback (a fake single
// chrome-extension:// sentinel URL) is forbidden. Real chrome-extension://
// URLs from genuine active extension pages (e.g., welcome.html or the
// harness page itself) are PERMITTED — the production tracker
// (src/background/tab-url-tracker.ts line 79) explicitly accepts the
// chrome-extension:// scheme. F2's contract is: empty tracker → urls: []
// (NOT urls: ["chrome-extension://.../sentinel"]).
//
// The empty-tracker fallback shape is: meta.urls.length === 0 (urls: [])
// — a NON-EMPTY array containing any URLs (including chrome-extension://
// ones) is proof that the tracker was populated by real onActivated/onUpdated
// events, NOT by an empty-state sentinel fallback. With both example.com
// and iana.org present (A27.4 + A27.5 GREEN above), the F2 fallback path
// is definitionally not triggered.
const realHttpUrls = urls.filter((u) => /^https?:\/\//.test(u));
const a27_7_passed = realHttpUrls.length >= 2;
mergedChecks.push({
name: 'A27.7: F2 contract — empty-tracker fallback NOT triggered (real http(s) URLs present alongside any chrome-extension:// URLs)',
expected: '>=2 http(s) URLs (proof the tracker was populated, not the F2 empty-state fallback)',
actual: `${realHttpUrls.length} http(s) URLs in meta.urls=${JSON.stringify(realHttpUrls)}`,
passed: a27_7_passed,
});
mergedChecks.push({
name: 'A27.8: no chrome-internal URLs in meta.urls (chrome:// or about:)',
expected: true,
actual: urls.every((u) => !u.startsWith('chrome://') && !u.startsWith('about:')),
passed: urls.every((u) => !u.startsWith('chrome://') && !u.startsWith('about:')),
});
mergedDiagnostics.push(`meta.urls=${JSON.stringify(urls)}`);
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
/**
* Drive A28 (Plan 02-04 Task 3 — REQ-archive-layout strict 5-entry zip-layout).
*
* Chains off A27's most-recently-modified zip. No new SAVE dispatch.
* Loads the zip via JSZip, enumerates entries (excluding directory
* entries), and asserts exactly the 5 canonical paths per REQ-archive-
* layout: video/last_30sec.webm, rrweb/session.json, logs/events.json,
* screenshot.png, meta.json (set-equality; order-flexible).
*
* Cross-references:
* - REQ-archive-layout: the 5 canonical entries
* - REQ-popup-ui: popup-triggered SAVE flows through the same createArchive
* path that emits these entries
* - REQ-screenshot-on-export: screenshot.png entry verified present
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns AssertionRecord with 3 checks (A28.1..A28.3).
*/
export async function driveA28(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — page-side stub (uniform orchestrator shape).
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.assertA28();
return r;
}) as AssertionRecord;
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Phase 2 — locate A27's zip.
const zipPath = findLatestZip(downloadsDir);
if (zipPath === null) {
mergedChecks.push({
name: 'A28.0: at least one zip present in downloadsDir (chain-off A27)',
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(`A28 zipPath=${zipPath}`);
// Phase 3 — enumerate zip entries. Filter-pipeline form per project
// style (no `continue` — see ~/.claude/CLAUDE.md "Control Flow" §).
// Skip directory entries; REQ-archive-layout counts files only.
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const actualPaths: string[] = Object.keys(zip.files)
.filter((path) => !zip.files[path].dir)
.sort();
const expectedSorted = [...A28_EXPECTED_PATHS].sort();
mergedDiagnostics.push(`A28 entries=${actualPaths.join(',')}`);
// A28.1 — exactly 5 entries.
mergedChecks.push({
name: 'A28.1: zip has EXACTLY 5 entries (REQ-archive-layout)',
expected: 5,
actual: actualPaths.length,
passed: actualPaths.length === 5,
});
// A28.2 — entries set-equal to the canonical 5 paths.
const setEqual = JSON.stringify(actualPaths) === JSON.stringify(expectedSorted);
mergedChecks.push({
name: 'A28.2: zip entries set-equal to the canonical 5 paths',
expected: expectedSorted.join(','),
actual: actualPaths.join(','),
passed: setEqual,
});
// A28.3 — no extras (no __MACOSX/, no .DS_Store, no temp files).
const expectedSet = new Set(A28_EXPECTED_PATHS);
const extras = actualPaths.filter((p) => !expectedSet.has(p));
mergedChecks.push({
name: 'A28.3: no extras (no __MACOSX/, no .DS_Store, no temp files)',
expected: 'no extras',
actual: extras.length === 0 ? 'none' : extras.join(','),
passed: extras.length === 0,
});
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
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,
};
}