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