Wave 3B lands the A5 (SAVE_ARCHIVE → zip on disk) and A7 (genuine
RECORDING_ERROR → ERR + recovery notification) assertions, completing
8/14 of the orchestrator's GREEN floor (A0+A1+A2+A3+A4+A5+A6+A7).
Bails at A8 (Wave 3C scope).
Changes per file:
tests/uat/extension-page-harness.ts
- assertA5: 11s settle (>= SEGMENT_DURATION_MS so first rotation
lands a segment) + send SAVE_ARCHIVE + assert resp.success=true.
Page-side only checks SW handler ack; host-side driver verifies
disk-side outcome (zip presence + size floor).
- assertA7: setupFreshRecording helper (A6 tears down; A7 needs
REC state) → snapshot notif count → send RECORDING_ERROR with
a non-Bug-B error code ('codec-unsupported') → 200ms settle →
assert badge='ERR' + popup endsWith popup.html + notif delta=1
+ set-membership for 'mokosh-recovery-*' prefix.
- setupFreshRecording: shared helper for A7 + future assertions
that need a fresh REC state after a teardown.
tests/uat/lib/harness-page-driver.ts
- driveA5: page.evaluate(assertA5) THEN host-side fs polling for
*.zip in handles.downloadsDir. The CDP Browser.setDownloadBehavior
override renames the file to download.zip (data: URL filename
gap), so we accept any *.zip suffix. Merges page-side check +
host-side checks into a single AssertionRecord. Signature now
takes downloadsDir as a second arg.
- driveA7: standard page.evaluate wrapper (no host-side work).
tests/uat/harness.test.ts
- Wraps driveA5 in a closure that captures handles.downloadsDir.
- Reordered: launchHarnessBrowser MUST run before driver list so
the closure can read handles without a TDZ trap.
tests/uat/lib/launch.ts
- Victim page switched from about:blank to a file:// URL backed by
a tmp HTML file in downloadsDir. About:blank breaks A5 because
chrome.tabs.captureVisibleTab needs <all_urls> permission which
matches http/https/file/ftp but NOT about: or data: URLs. The
stub HTML satisfies <all_urls> + provides a real .url for the
production saveArchive's chrome.tabs.query.
src/test-hooks/offscreen-hooks.ts (test-only — tree-shaken from prod)
- installFakeDisplayMedia: mintStream() helper called per
fakeGetDisplayMedia invocation; each call mints a FRESH
MediaStream from the persistent canvas. Real getDisplayMedia
returns a new stream per call — fake now matches. Required for
A7's setupFreshRecording where the previous recording's stream
tracks were stopped by A6's onUserStoppedSharing teardown.
- Added 33ms setInterval-driven drawFrame() alongside the
existing requestAnimationFrame loop. RAF can throttle in
headless Chrome on offscreen documents (page-visibility
heuristics produce 0 fps), which yields zero-byte
MediaRecorder segments that crash ts-ebml's VINT decode in
webm-remux.extractFramesFromSegment with "Unrepresentable
length: Infinity". The setInterval is redundant when RAF fires
at full rate; it's a safety net for the headless-MV3 corner.
Bug B regression-catch demo (success_criteria #3 — MANDATORY per plan):
Step 1 — apply local regression patch (NOT committed):
src/background/index.ts:792 setIdleMode() → setErrorMode()
Step 2 — npm run build:test && npm run test:uat RED snippet:
A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: FAIL
[PASS] SETUP: badge becomes REC after start
[FAIL] A6.1: badge text is '' (NOT 'ERR') after user-stop
expected: ""
actual: "ERR"
[FAIL] A6.2: popup is '' (NOT manifest default) after user-stop
expected: ""
actual: "chrome-extension://<id>/src/popup/index.html"
[PASS] A6.3: NO recovery notification fired (count delta === 0)
[PASS] A6.4: isRecording=false (via badge proxy)
UAT harness: 6/14 assertions passed (bailed: A6 failed; see above)
Step 3 — revert local patch (git checkout -- src/background/index.ts).
Step 4 — npm run build:test && npm run test:uat GREEN snippet:
A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: PASS
[PASS] SETUP: badge becomes REC after start
[PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop
[PASS] A6.2: popup is '' (NOT manifest default) after user-stop
[PASS] A6.3: NO recovery notification fired (count delta === 0)
[PASS] A6.4: isRecording=false (via badge proxy)
UAT harness: 8/14 assertions passed (bailed: A8 failed — NOT YET
IMPLEMENTED — Wave 3C wires driveA8)
The harness CORRECTLY catches the Bug B regression — the canonical
debug 01-09-recovery-flow scenario (operator-initiated stop routed
through setErrorMode locks the operator out of restart because popup
stays pinned to SAVE-only mode). Bug B is now CI-callable end-to-end.
vitest 93/93 GREEN throughout (unit-test layer unaffected). Tier-1
grep gate GREEN (9 forbidden hook strings: 0 occurrences in dist/).
npm run build exit 0; npx tsc --noEmit exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
378 lines
16 KiB
TypeScript
378 lines
16 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 { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { resolve as resolvePath } from 'node:path';
|
|
|
|
import type { Page } from 'puppeteer';
|
|
|
|
import type { AssertionRecord, CheckRecord } from './assertions';
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/** Marker error message for unimplemented Wave-3 drivers — orchestrator
|
|
* matches on this prefix to format the diagnostic distinctly from a
|
|
* genuine assertion failure. */
|
|
const WAVE3_STUB_PREFIX = 'NOT YET IMPLEMENTED';
|
|
|
|
/**
|
|
* 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 — NOT YET IMPLEMENTED ──────────────────────────────── */
|
|
|
|
/**
|
|
* Drive A8 (Bug A onStartup → notification creates). Wave 3C wires.
|
|
* @throws Always — replace stub when Wave 3C lands.
|
|
*/
|
|
export async function driveA8(_page: Page): Promise<AssertionRecord> {
|
|
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA8`);
|
|
}
|
|
|
|
/**
|
|
* Drive A9 (icon file sizes). Wave 3C wires.
|
|
* @throws Always — replace stub when Wave 3C lands.
|
|
*/
|
|
export async function driveA9(_page: Page): Promise<AssertionRecord> {
|
|
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA9`);
|
|
}
|
|
|
|
/**
|
|
* Drive A10 (manifest shape). Wave 3C wires.
|
|
* @throws Always — replace stub when Wave 3C lands.
|
|
*/
|
|
export async function driveA10(_page: Page): Promise<AssertionRecord> {
|
|
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA10`);
|
|
}
|
|
|
|
/* ─── Wave 3D — NOT YET IMPLEMENTED ──────────────────────────────── */
|
|
|
|
/**
|
|
* Drive A11 (35s → ≥3 segments). Wave 3D wires.
|
|
* @throws Always — replace stub when Wave 3D lands.
|
|
*/
|
|
export async function driveA11(_page: Page): Promise<AssertionRecord> {
|
|
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3D wires driveA11`);
|
|
}
|
|
|
|
/**
|
|
* Drive A12 (ffprobe — host-side returns webm bytes). Wave 3D wires.
|
|
* @throws Always — replace stub when Wave 3D lands.
|
|
*/
|
|
export async function driveA12(_page: Page): Promise<AssertionWithBytes> {
|
|
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3D wires driveA12`);
|
|
}
|
|
|
|
/**
|
|
* Drive A13 (zip structure + meta.json). Wave 3D wires.
|
|
* @throws Always — replace stub when Wave 3D lands.
|
|
*/
|
|
export async function driveA13(_page: Page): Promise<AssertionWithBytes> {
|
|
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3D wires driveA13`);
|
|
}
|