[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>
1030 lines
44 KiB
TypeScript
1030 lines
44 KiB
TypeScript
// 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.
|