Files
mokosh/tests/uat/lib/harness-page-driver.ts
Mark b467123578 feat(01-14): monitorTypeSurfaces:'include' — narrow picker to monitor surfaces only
[per Plan 01-14; closes B-01-14-01 via Step 1b lockstep]

- src/offscreen/recorder.ts: add monitorTypeSurfaces:'include' as top-level
  DisplayMediaStreamOptions sibling of video: (W3C Screen Capture spec §6.1;
  Chrome >= 119; removes tab/window panes from the operator's picker per
  Plan 01-10 RESEARCH §5 + §Pitfall-5 recommendation). Typed widening cast
  extended in lockstep to keep the explicit-typing contract (no `as any`).
  D-15 post-grant validation block at recorder.ts:294 UNCHANGED — belt
  (picker narrowing) + suspenders (post-grant tear-down) chain preserved.

- tests/offscreen/display-surface-constraint.test.ts: lockstep update of
  the strict-deep-equality assertion at lines 223-226 with the same key
  ordering as the source change (video -> monitorTypeSurfaces -> audio).
  toHaveBeenCalledWith contract preserved (NO expect.objectContaining —
  the test author's "catches future drops of ANY field" discipline is
  honored). This edit + the source change land in the SAME commit so the
  98/98 baseline never crosses a commit boundary in RED state.

- src/test-hooks/offscreen-hooks.ts: capture last constraints object in
  module-scoped `lastGetDisplayMediaConstraints` cell (was `_constraints`
  received-but-unused; renamed to `constraints`); add `get-last-getDisplayMedia-constraints`
  bridge op to the __mokoshOffscreenQuery dispatcher between
  get-display-surface and get-segment-count. Defensive try/catch mirrors
  the existing dispatcher pattern; the cell is module-internal so the
  MokoshTestSurface cross-cast in types.ts requires NO change (decision
  documented inline in offscreen-hooks.ts).

- tests/uat/extension-page-harness.ts: add `assertA23` mirroring `assertA3`
  (bridge query → 2-check AssertionResult: non-null constraints + value).
  Extend the `Window.__mokoshHarness` declaration + runtime export + status
  bar text + console.log to reference A23.

- tests/uat/lib/harness-page-driver.ts: export `driveA23(page)` mirroring
  the `driveA14` page.evaluate wrapper shape. Standard read-only driver.

- tests/uat/harness.test.ts: extend FORBIDDEN_HOOK_STRINGS (line 85) with
  `lastGetDisplayMediaConstraints` and `get-last-getDisplayMedia-constraints`.
  Import driveA23. Append `{ name: 'A23', drive: driveA23 }` to the drivers
  array after the A14 entry. Update header comment + orchestrator stdout
  to reflect A14 + A23 chain. The `Total = drivers.length + 1` arithmetic
  adapts automatically: 14 + 1 = 15 → 15 + 1 = 16.

- tests/background/no-test-hooks-in-prod-bundle.test.ts: lockstep
  extension of FORBIDDEN_HOOK_STRINGS (line 105) with the same 2 strings.
  Header comment updated to "Total: 12 surface strings." (was 10).
  Confirms production `dist/` has ZERO occurrences after `npm run build`
  via the `__MOKOSH_UAT__` dead-branch tree-shake (T-01-14-04 mitigation).

D-01 (whole-desktop only via getDisplayMedia; reject window/tab surfaces) is
the design intent that monitorTypeSurfaces:'include' realizes at the picker-
UI level. D-15 post-grant validation (recorder.ts:294-307) remains the
actual enforcement against managed-policy/DevTools/older-Chrome overrides.

Verification chain (per Plan 01-14 §verify; clean post-commit):
- `npx tsc --noEmit` exit 0
- `npm run build` exit 0; dist/ produced, monitorTypeSurfaces ships in
  the offscreen chunk as the operator-facing picker hint
- `npm run build:test` exit 0; dist-test/ produced with the harness
  hooks intact (gated)
- `npm test` 100/100 GREEN (was 98/98; +2 via the 2 new FORBIDDEN_HOOK_STRINGS
  parametrized tests — both PASS, production bundle hook-free)
- `npm run test:uat` 16/16 GREEN (15 → 16 via A23). A23 reads constraints
  `{video: {...}, monitorTypeSurfaces: 'include', audio: false}` from the
  fakeGetDisplayMedia capture cell — round-trips through the full call site.
- Production bundle spot-check:
    `grep -rc 'lastGetDisplayMediaConstraints\|get-last-getDisplayMedia-constraints' dist/ | grep -v ':0$'`
    → empty (all `:0` filtered) → ZERO leakage.

References:
- W3C Screen Capture §6.1 DisplayMediaStreamOptions:
  https://www.w3.org/TR/screen-capture/#dom-displaymediastreamoptions-monitortypesurfaces
- Chrome screen-sharing-controls (Chrome 119+):
  https://developer.chrome.com/docs/web-platform/screen-sharing-controls
- Plan 01-10 RESEARCH §5 + §Pitfall-5 (recommendation provenance):
  .planning/phases/01-stabilize-video-pipeline/01-10-RESEARCH.md
- Architectural-note (replaces retired AMENDMENT-A.md improvisation per
  01-11-SUMMARY): canonical GSD ceremony — plan → checker (B-01-14-01)
  → executor → SUMMARY (this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:37:59 +02:00

1030 lines
44 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 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-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;
}
// 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.