Plan 01-13 Task 6 (Wave 3C). Wires the final three Wave-3 assertions
before A11+A12+A13 (Wave 3D — 35s segments / ffprobe / zip shape):
- A8 (Bug A canonical regression rewind) — invokes
chrome.notifications.create from the harness page with the SAME options
the production SW onStartup handler uses (iconUrl resolved via
chrome.runtime.getURL('icons/icon128.png')). Exercises Chrome's
imageUtil icon validation — the exact code path Bug A regressed on
(a881bf0). 4 checks: non-empty assignedId, id-honoring, getAll delta=1,
prefix set-membership. The SW handler invocation itself remains
covered by tests/background/onstartup-notification.test.ts (unit
tier); A8 covers the end-to-end imageUtil-acceptance gate (e2e tier).
Per T-1-13-06 threat-model row: unit + e2e are intentional defense in
depth covering both halves of the Bug A contract.
- A9 (icon file sizes meet imageUtil floors) — fetches icons/icon{16,48,
128}.png via chrome.runtime.getURL and asserts blob.size against the
200/500/1024-byte silent-rejection floors per assets-spec.md. Cheap
pre-check for the Bug A class: a future icon swap that drops below
the floor would silently break the notification flow; A9 catches it
BEFORE the SW even tries to create.
- A10 (manifest shape contract) — chrome.runtime.getManifest() asserts:
permissions includes 'notifications' (without it,
chrome.notifications.create is unreachable), icons['16/48/128']
defined + non-empty, action.default_icon['16/48/128'] same. 7 checks
total. Catches manifest-edit regressions that would silently break A8.
Bug A canonical RED-on-regression demo cycle
============================================
Regression trigger: head -c 50 /tmp/icon128.png.backup > icons/icon128.png
(truncates the 2615-byte PNG to 50 bytes — preserves PNG magic so
manifest loads, but Chrome's imageUtil silent-rejects the create).
RED — A8 standalone driver with truncated icon128.png (50 bytes):
A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): FAIL
Top-level error: notifications.create rejected: Unable to download all specified images.
Diagnostics:
- Step 1: snapshot notif count + ids BEFORE create
- Step 1 result: 0 active; ids=[]
- Step 2: chrome.notifications.create(id='mokosh-startup-1779124969677', iconUrl='chrome-extension://<ext-id>/icons/icon128.png')
- THREW: notifications.create rejected: Unable to download all specified images.
GREEN — A8 standalone driver after restoring icon128.png (2615 bytes):
A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): PASS
Checks:
[PASS] A8.1: create callback resolves with non-empty assignedId (imageUtil acceptance)
expected: "non-empty string"
actual: "mokosh-startup-1779124999809"
[PASS] A8.2: assignedId matches input id (chrome.notifications honors caller-supplied id)
expected: "mokosh-startup-1779124999809"
actual: "mokosh-startup-1779124999809"
[PASS] A8.3: notification count delta === 1 (exactly one new startup notification)
expected: 1
actual: 1
[PASS] A8.4: at least one notification id startsWith 'mokosh-startup-' (set membership)
expected: true
actual: true
The RED→GREEN cycle proves the harness empirically catches Bug A
regression class (imageUtil silent rejection on undersized iconUrl PNG).
The "Unable to download all specified images." rejection is Chrome's
internal error surface for the same imageUtil validation that Bug A
originally regressed on (fix at a881bf0). Note: under the full
orchestrator order, the same truncation surfaces FIRST at A7 (recovery
notification, which shares NOTIFICATION_ICON_PATH) — orchestrator
bail-on-first-failure means A8 isn't reached in the full run. The
isolated A8 demo above (via an ephemeral local driver script, NOT
committed) confirmed A8 catches the same regression independently.
Baseline preserved
==================
- vitest: 93/93 GREEN (SKIP_BUILD=1 to dodge the pre-existing
~5s-default test timeout in no-test-hooks-in-prod-bundle.test.ts;
with a fresh dist/ in place all 9 hook-string sub-tests PASS).
- tsc: clean (no diagnostics).
- npm run build: exit 0; production bundle unchanged
(no SW/offscreen src edits — only tests/ + dist-test/).
- npm run test:uat: 11/14 GREEN (A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10);
bails at A11 (Wave 3D wires that).
Files touched
=============
- tests/uat/extension-page-harness.ts: +assertA8 +assertA9 +assertA10
with 4 + 3 + 7 checks respectively; +createNotificationPromise +
getActiveNotificationIds + STARTUP_NOTIF_PREFIX + A8_GETALL_SETTLE_MS
+ A9_ICON_SPEC helpers. window.__mokoshHarness extends 7 → 10 methods.
- tests/uat/lib/harness-page-driver.ts: replaces driveA8/driveA9/driveA10
NYI stubs with page.evaluate wrappers.
- tests/uat/harness.test.ts: updates Wave-3C-current comment block to
reflect A8+A9+A10 wired (expected diagnostic 11/14, bail at A11).
Approach rationale (per plan resolved-questions §A8)
====================================================
The plan resolved A8's "no SW-side handler-capture hook" challenge with
an explicit SIMPLER WORKAROUND: invoke chrome.notifications.create
DIRECTLY from the harness page with the same production options. This
sidesteps the MV3-SW-dynamic-import block (01-11-SUMMARY) while still
exercising Chrome's imageUtil validation — the exact code path Bug A
broke. Approach considered but rejected per the plan: a SW-side
static eager-import test hook + a __mokoshTriggerStartup message
handler would have required adding a new production code path (even
gated by __MOKOSH_UAT__) and a new FORBIDDEN_HOOK_STRINGS entry. The
page-direct approach adds ZERO production surface and ZERO new
forbidden strings — strictly better.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1406 lines
58 KiB
TypeScript
1406 lines
58 KiB
TypeScript
// tests/uat/extension-page-harness.ts — Plan 01-13 production UAT harness.
|
|
//
|
|
// Inherited from the Plan 01-11 prototype at commit c647f61 per the
|
|
// 01-11-SUMMARY.md architectural pivot (Approach B). The prototype
|
|
// proved A6 (Bug B canonical regression rewind) 5/5 GREEN in ~7s
|
|
// end-to-end, validating the architecture summarized below. Plan 01-13
|
|
// Wave 1 promoted this file from `tests/uat/prototype/` to the
|
|
// production path without behavioral change; Wave 3 will extend the
|
|
// `window.__mokoshHarness` surface with assertA1..A13 methods.
|
|
//
|
|
// Extension-internal harness page entrypoint. Lives at
|
|
// `chrome-extension://<id>/tests/uat/extension-page-harness.html`
|
|
// in the test build (vite.test.config.ts adds it as a Rollup input).
|
|
//
|
|
// ARCHITECTURAL ANCHOR (Approach B): the working architecture for MV3
|
|
// extension UAT is to drive Chrome FROM INSIDE (extension-internal
|
|
// test page + synthetic MediaStream) rather than FROM OUTSIDE (CDP
|
|
// into SW context). Falsification of the Approach-A alternatives
|
|
// (sw.evaluate + popup-bridge + SW-side dynamic-import test hooks)
|
|
// is documented in 01-11-SUMMARY.md.
|
|
//
|
|
// IMPORTANT RESEARCH FINDING (load-bearing — DO NOT REGRESS):
|
|
// Plan 01-11 RESEARCH §6 originally architected `await import(...)`
|
|
// at the top of src/background/index.ts to gate SW-side test hooks.
|
|
// EMPIRICAL FALSIFICATION (01-11 prototype, verified Chrome 148):
|
|
// dynamic import is BLOCKED in MV3 service workers. The SW silently
|
|
// dies — the chunk file is loaded but the await never resolves, so
|
|
// production listeners never register. Production sources:
|
|
// - w3c/webextensions#212 (May 2022, still open as of May 2026)
|
|
// - chromium.googlesource.com es_modules.md: "Dynamic import is
|
|
// currently blocked in Service Workers, but it will change in
|
|
// the future."
|
|
// Approach B WORKS AROUND this by:
|
|
// 1. Removing the SW-side gated dynamic import entirely
|
|
// (src/background/index.ts has comment-only docs at lines 13-30
|
|
// explaining why no hook gate lands here).
|
|
// 2. Using only the OFFSCREEN-side test hook (offscreen IS a DOM
|
|
// document, dynamic import works there — see
|
|
// src/offscreen/recorder.ts top-of-module gate).
|
|
// 3. Driving everything from this harness page using PRODUCTION
|
|
// chrome.* APIs (page has full extension permissions):
|
|
// - chrome.action.getBadgeText / getPopup — read SW state
|
|
// - chrome.offscreen.createDocument — create offscreen FIRST
|
|
// (the page is allowed to call this)
|
|
// - chrome.runtime.sendMessage START_RECORDING — trigger
|
|
// production startRecording path (uses existing offscreen +
|
|
// fake getDisplayMedia from offscreen-hooks.ts)
|
|
// - chrome.notifications.getAll — count active notifications
|
|
// (no SW hook needed)
|
|
// - chrome.runtime.sendMessage __mokoshOffscreenQuery
|
|
// dispatch-ended — trigger Bug B simulation via offscreen
|
|
// bridge (offscreen still uses dynamic import → works)
|
|
//
|
|
// Wave 3A surface — extends `window.__mokoshHarness` from 1 → 5 methods:
|
|
// - `assertA1()` — SW bootstrap state (badge='', popup='', isRecording=false).
|
|
// - `assertA2()` — toolbar onClicked → REC (workaround: send START_RECORDING
|
|
// directly to offscreen + manually set badge/popup;
|
|
// bypasses SW startVideoCapture which needs the
|
|
// `tabs` permission per 01-11-SUMMARY workaround).
|
|
// - `assertA3()` — displaySurface === 'monitor' (via 'get-display-surface'
|
|
// offscreen bridge op; verifies the synthetic stream's
|
|
// monkey-patched getSettings()).
|
|
// - `assertA4()` — popup stays pinned during recording (REC state
|
|
// preserves setRecordingMode's setPopup; offscreen
|
|
// count remains 1 — no second offscreen spawns).
|
|
// - `assertA6()` — canonical Bug B regression assertion (proven).
|
|
//
|
|
// Wave 3B surface — extends `window.__mokoshHarness` from 5 → 7 methods:
|
|
// - `assertA5()` — SAVE_ARCHIVE dispatches; the page-side check confirms
|
|
// the SW handler returns success. The host-side driveA5
|
|
// wrapper handles disk-side verification (zip file
|
|
// presence + size > 1KB) since the page cannot read
|
|
// `handles.downloadsDir`. Page-side method returns
|
|
// `{passed, name, checks: [SW dispatch ack], diagnostics}`;
|
|
// host-side merges its own checks on top.
|
|
// - `assertA7()` — genuine recording error → ERR badge + recovery
|
|
// notification. Sends a synthetic
|
|
// `RECORDING_ERROR{error:'codec-unsupported'}` (NOT
|
|
// 'user-stopped-sharing' — that's Bug B's branch).
|
|
// Asserts: badge='ERR', popup endsWith 'src/popup/index.html',
|
|
// notif count delta===1, and ANY active notification id
|
|
// has the 'mokosh-recovery-' prefix. The set-membership
|
|
// check on the prefix is the plan-resolved choice over a
|
|
// "most recent" check — `chrome.notifications.getAll` does
|
|
// not guarantee key ordering (set membership is reliable;
|
|
// ordering is not).
|
|
//
|
|
// Wave 3C surface — extends `window.__mokoshHarness` from 7 → 10 methods:
|
|
// - `assertA8()` — Bug A canonical regression rewind: invoke
|
|
// `chrome.notifications.create` from the page with the
|
|
// SAME options the production SW `onStartup` handler
|
|
// uses (iconUrl: chrome.runtime.getURL('icons/icon128.png'),
|
|
// title:'Mokosh ready', etc). Bug A was Chrome's
|
|
// `imageUtil` silently rejecting the create when the
|
|
// icon was too small / missing — exercising the same
|
|
// create code-path from the page covers the same
|
|
// icon-acceptance contract WITHOUT needing a SW-side
|
|
// hook (the SW onStartup handler invocation itself is
|
|
// covered by `tests/background/onstartup-notification.test.ts`).
|
|
// Asserts: create resolves with a non-empty id, the id
|
|
// has the 'mokosh-startup-' prefix, and the
|
|
// notification appears in `chrome.notifications.getAll`
|
|
// (count delta === 1, set-membership on the prefix).
|
|
// Plan 01-13 Task 6 §behavior + §implementation pin this
|
|
// approach explicitly (workaround documented at length
|
|
// there); architectural rationale + Bug A history at
|
|
// a881bf0 (the original fix commit).
|
|
// - `assertA9()` — icon files meet Chrome `imageUtil`'s silent-rejection
|
|
// floors per assets-spec.md / Plan 01-13 Task 6
|
|
// behavior: 16→≥200 B, 48→≥500 B, 128→≥1024 B. Fetches
|
|
// `chrome.runtime.getURL('icons/icon{16,48,128}.png')`
|
|
// and reads `blob.size`. Regression target: a future
|
|
// icon swap that drops below the floor would silently
|
|
// break the onStartup / recovery notification flow
|
|
// (Bug A class).
|
|
// - `assertA10()` — manifest shape contract: reads
|
|
// `chrome.runtime.getManifest()` and asserts the three
|
|
// surfaces that A8 + the SW notification flow depend on:
|
|
// `permissions` includes 'notifications', `icons` has
|
|
// keys '16'/'48'/'128', `action.default_icon` has the
|
|
// same three keys. Regression target: a future manifest
|
|
// edit that drops `notifications` (notifications.create
|
|
// silently fails) or removes an icon entry (manifest
|
|
// parser rejects).
|
|
|
|
/**
|
|
* Result shape returned by harness assertions to Puppeteer.
|
|
*/
|
|
interface AssertionResult {
|
|
passed: boolean;
|
|
name: string;
|
|
checks: Array<{
|
|
name: string;
|
|
expected: unknown;
|
|
actual: unknown;
|
|
passed: boolean;
|
|
}>;
|
|
diagnostics: string[];
|
|
error?: string;
|
|
}
|
|
|
|
/** Time in ms to wait for the SW state machine to settle after dispatching 'ended'. */
|
|
const A6_SETTLE_MS = 500;
|
|
/** Poll interval. */
|
|
const POLL_INTERVAL_MS = 100;
|
|
/** Maximum wait for an async state transition. */
|
|
const STATE_WAIT_MS = 8_000;
|
|
|
|
/** Per-step diagnostic logger — also writes to console. */
|
|
function diag(result: AssertionResult, line: string): void {
|
|
result.diagnostics.push(line);
|
|
console.log('[harness-step]', line);
|
|
}
|
|
|
|
/**
|
|
* Poll an async probe until it satisfies the predicate or the timeout
|
|
* elapses.
|
|
*
|
|
* @param probe - Async function returning the current value.
|
|
* @param predicate - Returns true when the value matches the expectation.
|
|
* @param timeoutMs - Maximum wait time before giving up.
|
|
* @param description - Used in the timeout error message.
|
|
* @returns The value that satisfied the predicate, or throws on timeout.
|
|
*/
|
|
async function waitFor<T>(
|
|
probe: () => Promise<T> | T,
|
|
predicate: (value: T) => boolean,
|
|
timeoutMs: number,
|
|
description: string,
|
|
): Promise<T> {
|
|
const start = Date.now();
|
|
let lastValue: T;
|
|
while (Date.now() - start < timeoutMs) {
|
|
lastValue = await probe();
|
|
if (predicate(lastValue)) {
|
|
return lastValue;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
}
|
|
lastValue = await probe();
|
|
throw new Error(
|
|
`waitFor timeout (${timeoutMs}ms) — ${description}; lastValue=${JSON.stringify(lastValue)}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wrap chrome.runtime.sendMessage in a Promise + timeout.
|
|
*
|
|
* @param msg - The message payload.
|
|
* @param timeoutMs - Maximum wait before rejecting.
|
|
* @param label - For diagnostic clarity.
|
|
* @returns The response payload.
|
|
*/
|
|
async function sendMessageWithTimeout<T>(
|
|
msg: unknown,
|
|
timeoutMs: number,
|
|
label: string,
|
|
): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(new Error(`${label}: sendMessage timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
chrome.runtime.sendMessage(msg, (response: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (chrome.runtime.lastError !== undefined) {
|
|
reject(
|
|
new Error(`${label}: ${String(chrome.runtime.lastError.message)}`),
|
|
);
|
|
return;
|
|
}
|
|
resolve(response as T);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Count active notifications via the production chrome.notifications.getAll API.
|
|
* No SW-side hook needed — this returns the live set of notifications.
|
|
*
|
|
* @returns Number of active notifications.
|
|
*/
|
|
async function getActiveNotificationCount(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.notifications.getAll((notifications: Object) => {
|
|
if (chrome.runtime.lastError !== undefined) {
|
|
reject(new Error(String(chrome.runtime.lastError.message)));
|
|
return;
|
|
}
|
|
resolve(Object.keys(notifications ?? {}).length);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create the offscreen document directly from this page. Once the
|
|
* offscreen module loads, the gated offscreen-hooks.ts dynamic import
|
|
* runs and installs the fake getDisplayMedia eagerly. So the next
|
|
* REQUEST_PERMISSIONS → production startRecording → getDisplayMedia
|
|
* call resolves with the synthetic stream.
|
|
*
|
|
* Idempotent: if the offscreen already exists, returns ok=true (we
|
|
* swallow the 'already exists' error).
|
|
*
|
|
* @returns ok status + diagnostic error.
|
|
*/
|
|
async function ensureOffscreen(): Promise<{ ok: boolean; error?: string }> {
|
|
try {
|
|
const url = chrome.runtime.getURL('src/offscreen/index.html');
|
|
const has = await chrome.offscreen.hasDocument();
|
|
if (has) {
|
|
return { ok: true };
|
|
}
|
|
await chrome.offscreen.createDocument({
|
|
url,
|
|
reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],
|
|
justification: 'mokosh UAT harness prototype',
|
|
});
|
|
// Brief wait so the offscreen bootstrap completes (its onMessage
|
|
// listeners register; the test-hook gated import resolves; the
|
|
// installFakeDisplayMedia eager call runs).
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes('already exists')) {
|
|
return { ok: true };
|
|
}
|
|
return { ok: false, error: msg };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger the production REQUEST_PERMISSIONS flow — same path the popup
|
|
* uses. SW responds with `{ granted: true }` after the offscreen
|
|
* recording is live (fake getDisplayMedia returns immediately).
|
|
*
|
|
* @returns SW response.
|
|
*/
|
|
async function startRecording(): Promise<{ granted: boolean }> {
|
|
// PROTOTYPE: bypass the SW's startVideoCapture which requires an active
|
|
// tab with a URL (the extension doesn't have the 'tabs' permission, so
|
|
// chrome.tabs.query never returns url even when a real page is active).
|
|
// Send START_RECORDING directly to the offscreen — the production
|
|
// offscreen recorder handles it identically to the SW-mediated path.
|
|
// The Bug B contract verified by A6 is independent of how recording
|
|
// starts: it only depends on the dispatchEvent('ended') → RECORDING_ERROR
|
|
// → setIdleMode path which is unchanged.
|
|
const offResp = await sendMessageWithTimeout<{ ok: boolean; error?: string }>(
|
|
{ type: 'START_RECORDING' },
|
|
15_000,
|
|
'START_RECORDING',
|
|
);
|
|
if (!offResp.ok) {
|
|
throw new Error(`START_RECORDING failed: ${offResp.error ?? '(no error)'}`);
|
|
}
|
|
// The offscreen's start path does NOT call SW state transitions; we
|
|
// manually trigger setRecordingMode by sending a synthesized message
|
|
// OR — simpler — rely on the offscreen's getCurrentStream as proof of
|
|
// life. But A6 needs the SW's badge to transition to 'REC' so the
|
|
// setIdleMode check post-stop has something to compare against.
|
|
// The cleanest way: after START_RECORDING success, send a fake
|
|
// 'RECORDING_STARTED' or equivalent. But production doesn't have
|
|
// that message. So we use the offscreen's 'has-stream' query to
|
|
// confirm the stream is live, then the SW state is technically
|
|
// 'isRecording=false, badge=""' for the duration — which means A6's
|
|
// pre-condition check (badge==='REC') WILL FAIL.
|
|
//
|
|
// Workaround: explicitly set the badge to 'REC' via chrome.action
|
|
// from the page (mimicking what setRecordingMode would do). This is
|
|
// NOT cheating because the test contract is: when dispatchEvent fires,
|
|
// SW receives RECORDING_ERROR, routes through setIdleMode — that's
|
|
// the actual A6 assertion. The pre-condition is just 'recording is
|
|
// notionally active'. Setting the badge directly suffices to verify
|
|
// the post-stop transition.
|
|
try {
|
|
await chrome.action.setBadgeText({ text: 'REC' });
|
|
await chrome.action.setPopup({ popup: 'src/popup/index.html' });
|
|
} catch (e) {
|
|
console.warn('[harness] failed to set badge/popup manually:', e);
|
|
}
|
|
return { granted: offResp.ok };
|
|
}
|
|
|
|
/**
|
|
* Send a query to the offscreen-side test hook (dynamic-import works
|
|
* in offscreen DOM context, so the hook IS installed there).
|
|
*
|
|
* @param op - One of: 'install-fake-display-media', 'dispatch-ended', 'has-stream'.
|
|
* @returns The bridge response.
|
|
*/
|
|
async function offscreenQuery<T = unknown>(op: string): Promise<T> {
|
|
return sendMessageWithTimeout(
|
|
{ type: '__mokoshOffscreenQuery', op },
|
|
5_000,
|
|
`offscreenQuery(${op})`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The canonical A6 (Bug B regression) assertion. End-to-end flow:
|
|
*
|
|
* 1. ensureOffscreen — create offscreen if missing. Offscreen
|
|
* module load triggers gated dynamic import which installs the
|
|
* fake getDisplayMedia eagerly.
|
|
* 2. startRecording — sends REQUEST_PERMISSIONS to SW. SW production
|
|
* handler ensureOffscreen (no-op) + sendMessage START_RECORDING.
|
|
* Offscreen production recorder.startRecording calls
|
|
* navigator.mediaDevices.getDisplayMedia → fake returns synthetic
|
|
* stream → recording starts.
|
|
* 3. Wait for badge to become 'REC'.
|
|
* 4. Snapshot active notification count BEFORE the simulated stop.
|
|
* 5. dispatchEvent('ended') on the video track via offscreen bridge.
|
|
* This is the Bug B simulation path (RESEARCH §7 BLOCKER).
|
|
* 6. Wait A6_SETTLE_MS for the state machine to propagate.
|
|
* 7. Assert: badge='', popup='', notif count delta=0,
|
|
* isRecording=false (via badge proxy).
|
|
*
|
|
* @returns Structured result with per-check pass/fail + diagnostics.
|
|
*/
|
|
async function assertA6(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
// Step 1 — ensure offscreen exists. This implicitly triggers the
|
|
// offscreen-hooks gated import which installs the fake stream.
|
|
diag(result, 'Step 1: ensureOffscreen');
|
|
const ensureResp = await ensureOffscreen();
|
|
if (!ensureResp.ok) {
|
|
throw new Error(
|
|
`ensureOffscreen failed: ${ensureResp.error ?? '(no error)'}`,
|
|
);
|
|
}
|
|
diag(result, 'Step 1 OK — offscreen ready');
|
|
|
|
// Step 2 — start recording via production path.
|
|
diag(result, 'Step 2: REQUEST_PERMISSIONS (production path)');
|
|
const grantResp = await startRecording();
|
|
if (!grantResp.granted) {
|
|
throw new Error(
|
|
'REQUEST_PERMISSIONS returned granted=false — recording did not start',
|
|
);
|
|
}
|
|
diag(result, 'Step 2 OK — granted=true');
|
|
|
|
// Step 3 — wait for badge to become 'REC' (confirms recording is live).
|
|
diag(result, "Step 3: wait for badge === 'REC'");
|
|
const badgeAfterStart = await waitFor(
|
|
() => chrome.action.getBadgeText({}),
|
|
(v) => v === 'REC',
|
|
STATE_WAIT_MS,
|
|
"badge should transition to 'REC' after REQUEST_PERMISSIONS",
|
|
);
|
|
result.checks.push({
|
|
name: 'SETUP: badge becomes REC after start',
|
|
expected: 'REC',
|
|
actual: badgeAfterStart,
|
|
passed: badgeAfterStart === 'REC',
|
|
});
|
|
diag(result, `Step 3 OK — badge='${badgeAfterStart}'`);
|
|
|
|
// Step 4 — snapshot active notifications BEFORE simulated stop.
|
|
const notifBefore = await getActiveNotificationCount();
|
|
diag(result, `Step 4: notif count BEFORE stop = ${notifBefore}`);
|
|
|
|
// Step 5 — dispatch 'ended' on the video track via offscreen bridge.
|
|
// RESEARCH §7 BLOCKER — dispatchEvent, NOT track.stop().
|
|
diag(result, 'Step 5: dispatch ended on video track');
|
|
const dispatchResp = await offscreenQuery<{ ok: boolean; error?: string }>(
|
|
'dispatch-ended',
|
|
);
|
|
if (!dispatchResp.ok) {
|
|
throw new Error(
|
|
`dispatch-ended returned ok=false: ${dispatchResp.error ?? '(no error)'}`,
|
|
);
|
|
}
|
|
diag(result, 'Step 5 OK — ended dispatched');
|
|
|
|
// Step 6 — wait for state machine to settle.
|
|
diag(result, `Step 6: settle ${A6_SETTLE_MS}ms`);
|
|
await new Promise((r) => setTimeout(r, A6_SETTLE_MS));
|
|
|
|
// Step 7 — assert post-stop state.
|
|
const badgeAfterStop = await chrome.action.getBadgeText({});
|
|
const popupAfterStop = await chrome.action.getPopup({});
|
|
const notifAfter = await getActiveNotificationCount();
|
|
const notifDelta = notifAfter - notifBefore;
|
|
|
|
result.checks.push({
|
|
name: "A6.1: badge text is '' (NOT 'ERR') after user-stop",
|
|
expected: '',
|
|
actual: badgeAfterStop,
|
|
passed: badgeAfterStop === '',
|
|
});
|
|
result.checks.push({
|
|
name: "A6.2: popup is '' (NOT manifest default) after user-stop",
|
|
expected: '',
|
|
actual: popupAfterStop,
|
|
passed: popupAfterStop === '',
|
|
});
|
|
result.checks.push({
|
|
name: 'A6.3: NO recovery notification fired (count delta === 0)',
|
|
expected: 0,
|
|
actual: notifDelta,
|
|
passed: notifDelta === 0,
|
|
});
|
|
result.checks.push({
|
|
name: 'A6.4: isRecording=false (via badge proxy)',
|
|
expected: false,
|
|
actual: badgeAfterStop === 'REC',
|
|
passed: badgeAfterStop !== 'REC',
|
|
});
|
|
|
|
diag(
|
|
result,
|
|
`Step 7 results: badge='${badgeAfterStop}', popup='${popupAfterStop}', notifDelta=${notifDelta}`,
|
|
);
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A1 — SW bootstrap state. Asserts the post-load idle state per
|
|
* src/background/index.ts:setIdleMode (badge='', popup=''). The
|
|
* `isRecording` invariant is verified via the badge-proxy: a non-REC
|
|
* badge implies isRecording=false per the state-machine contract (each
|
|
* setRecordingMode/setIdleMode/setErrorMode transition pairs badge + popup
|
|
* atomically — there is no path that desyncs badge from isRecording).
|
|
*
|
|
* IMPORTANT — A1 MUST run before A2 in any orchestrated sequence. A2
|
|
* manually sets badge='REC' + popup=POPUP_HTML_PATH (workaround for the
|
|
* missing `tabs` permission); once A2 runs the SW is no longer in idle
|
|
* mode and the A1 contract is invalidated until reset.
|
|
*
|
|
* @returns Structured result with 3 checks (badge + popup + isRecording).
|
|
*/
|
|
async function assertA1(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A1 — SW bootstrap state: badge=\'\', popup=\'\', isRecording=false',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
diag(result, 'Step 1: read chrome.action.getBadgeText({})');
|
|
const badge = await chrome.action.getBadgeText({});
|
|
diag(result, `Step 1 result: badge='${badge}'`);
|
|
|
|
diag(result, 'Step 2: read chrome.action.getPopup({})');
|
|
const popup = await chrome.action.getPopup({});
|
|
diag(result, `Step 2 result: popup='${popup}'`);
|
|
|
|
result.checks.push({
|
|
name: 'A1.1: badge text is \'\' (setIdleMode default)',
|
|
expected: '',
|
|
actual: badge,
|
|
passed: badge === '',
|
|
});
|
|
result.checks.push({
|
|
name: 'A1.2: popup is \'\' (setIdleMode default; enables onClicked)',
|
|
expected: '',
|
|
actual: popup,
|
|
passed: popup === '',
|
|
});
|
|
result.checks.push({
|
|
name: 'A1.3: isRecording=false (badge !== \'REC\' proxy)',
|
|
expected: false,
|
|
actual: badge === 'REC',
|
|
passed: badge !== 'REC',
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A2 — toolbar onClicked → REC. Asserts that the recording-start path
|
|
* lands in the REC state machine row (badge='REC', popup=POPUP_HTML_PATH).
|
|
*
|
|
* WORKAROUND (documented per 01-11-SUMMARY + plan resolved-questions
|
|
* row 2): the harness sends START_RECORDING directly to the offscreen
|
|
* recorder, BYPASSING the production chrome.action.onClicked →
|
|
* startVideoCapture path. That path requires `chrome.tabs.query(
|
|
* {active: true})` to return a tab with `.url`, which it does NOT
|
|
* without the `tabs` manifest permission (out of scope for the harness
|
|
* plan — adding it would change production attack surface). The badge
|
|
* + popup transitions normally driven by setRecordingMode are emulated
|
|
* by the page calling chrome.action.setBadgeText + setPopup directly.
|
|
*
|
|
* Coverage of the bypassed SW path is preserved by unit tests:
|
|
* - tests/background/badge-state-machine.test.ts asserts
|
|
* setRecordingMode transitions setBadgeText('REC') + setPopup(...).
|
|
* - tests/background/sw-state-transitions.test.ts (or equivalent)
|
|
* asserts the onClicked → startVideoCapture wiring (no UAT-side
|
|
* re-verification needed).
|
|
*
|
|
* The contract A2 verifies is: when START_RECORDING reaches offscreen,
|
|
* recording starts AND a notional REC state is reachable. A3 + A4 chain
|
|
* off A2's REC state without re-starting recording (single launch +
|
|
* single recording per `npm run test:uat` run per plan single-browser
|
|
* decision).
|
|
*
|
|
* @returns Structured result with badge + popup checks.
|
|
*/
|
|
async function assertA2(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A2 — toolbar onClicked → REC (direct-offscreen workaround for missing tabs permission)',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
diag(result, 'Step 1: ensureOffscreen (creates offscreen if missing)');
|
|
const ensureResp = await ensureOffscreen();
|
|
if (!ensureResp.ok) {
|
|
throw new Error(
|
|
`ensureOffscreen failed: ${ensureResp.error ?? '(no error)'}`,
|
|
);
|
|
}
|
|
diag(result, 'Step 1 OK — offscreen ready');
|
|
|
|
diag(result, 'Step 2: START_RECORDING direct-to-offscreen + manual setBadge/setPopup');
|
|
const grantResp = await startRecording();
|
|
if (!grantResp.granted) {
|
|
throw new Error(
|
|
'startRecording returned granted=false — recording did not start',
|
|
);
|
|
}
|
|
diag(result, 'Step 2 OK — granted=true');
|
|
|
|
diag(result, "Step 3: wait for badge === 'REC'");
|
|
const badgeAfter = await waitFor(
|
|
() => chrome.action.getBadgeText({}),
|
|
(v) => v === 'REC',
|
|
STATE_WAIT_MS,
|
|
'badge should transition to REC after START_RECORDING',
|
|
);
|
|
diag(result, `Step 3 OK — badge='${badgeAfter}'`);
|
|
|
|
diag(result, 'Step 4: read chrome.action.getPopup({})');
|
|
const popupAfter = await chrome.action.getPopup({});
|
|
diag(result, `Step 4 result: popup='${popupAfter}'`);
|
|
|
|
// NOTE — Chrome's chrome.action.getPopup() returns the FULL absolute
|
|
// URL form (e.g. 'chrome-extension://<id>/src/popup/index.html'), NOT
|
|
// the manifest-relative path that was passed to setPopup(). We assert
|
|
// .endsWith('src/popup/index.html') so the check is extension-id
|
|
// independent (the id is randomly assigned at unpacked-load time).
|
|
result.checks.push({
|
|
name: 'A2.1: badge text is \'REC\' after START_RECORDING',
|
|
expected: 'REC',
|
|
actual: badgeAfter,
|
|
passed: badgeAfter === 'REC',
|
|
});
|
|
result.checks.push({
|
|
name: 'A2.2: popup ends with \'src/popup/index.html\' (REC mode SAVE-only popup)',
|
|
expected: '<chrome-extension://<id>/>src/popup/index.html',
|
|
actual: popupAfter,
|
|
passed: popupAfter.endsWith('src/popup/index.html'),
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A3 — displaySurface === 'monitor'. Assumes A2 left a recording active
|
|
* (single-browser orchestrator pattern). Queries the offscreen bridge
|
|
* `get-display-surface` op which reads the active track's
|
|
* `getSettings().displaySurface`. Production code in
|
|
* src/offscreen/recorder.ts:296 enforces this same value (tears down +
|
|
* throws 'wrong-display-surface' otherwise), so if recording is live the
|
|
* value is guaranteed monitor — A3 explicitly verifies the
|
|
* offscreen-hooks `installFakeDisplayMedia` monkey-patched getSettings()
|
|
* correctly reports 'monitor' under the synthetic stream path.
|
|
*
|
|
* @returns Structured result with the displaySurface check.
|
|
*/
|
|
async function assertA3(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A3 — displaySurface === \'monitor\' (monkey-patched synthetic stream)',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
diag(result, "Step 1: bridge query 'get-display-surface'");
|
|
const resp = await offscreenQuery<{
|
|
displaySurface?: string | null;
|
|
ok?: boolean;
|
|
error?: string;
|
|
}>('get-display-surface');
|
|
diag(result, `Step 1 result: ${JSON.stringify(resp)}`);
|
|
|
|
if (resp.ok === false) {
|
|
throw new Error(
|
|
`get-display-surface returned ok=false: ${resp.error ?? '(no error)'}`,
|
|
);
|
|
}
|
|
const displaySurface = resp.displaySurface ?? null;
|
|
|
|
result.checks.push({
|
|
name: 'A3.1: displaySurface === \'monitor\' (offscreen-hooks monkey-patch)',
|
|
expected: 'monitor',
|
|
actual: displaySurface,
|
|
passed: displaySurface === 'monitor',
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A4 — popup pinned during recording + no second offscreen. Assumes A2
|
|
* left a recording active. The contract verified:
|
|
* 1. getPopup still returns 'src/popup/index.html' (REC mode preserved
|
|
* by setRecordingMode; no transition to ERROR / IDLE happened).
|
|
* 2. chrome.offscreen.hasDocument() === true (the recording's offscreen
|
|
* is alive; no duplicate offscreen was created — production code
|
|
* in src/background/index.ts:863-866 makes the toolbar-click-during-
|
|
* recording path a no-op when a recording is already live).
|
|
*
|
|
* Per the plan, A4 is essentially a no-op verification — its purpose is
|
|
* regression protection against a future refactor that might unpin the
|
|
* popup during recording or spawn a second offscreen on stray events.
|
|
*
|
|
* @returns Structured result with popup + hasDocument checks.
|
|
*/
|
|
async function assertA4(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A4 — popup pinned + single offscreen during recording',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
diag(result, 'Step 1: read chrome.action.getPopup({})');
|
|
const popup = await chrome.action.getPopup({});
|
|
diag(result, `Step 1 result: popup='${popup}'`);
|
|
|
|
diag(result, 'Step 2: chrome.offscreen.hasDocument()');
|
|
const hasDoc = await chrome.offscreen.hasDocument();
|
|
diag(result, `Step 2 result: hasDocument=${hasDoc}`);
|
|
|
|
// NOTE — see A2.2 NOTE: chrome.action.getPopup() returns absolute
|
|
// chrome-extension://<id>/... URLs; assert by .endsWith() to stay
|
|
// extension-id independent.
|
|
result.checks.push({
|
|
name: 'A4.1: popup remains \'src/popup/index.html\' during REC',
|
|
expected: '<chrome-extension://<id>/>src/popup/index.html',
|
|
actual: popup,
|
|
passed: popup.endsWith('src/popup/index.html'),
|
|
});
|
|
result.checks.push({
|
|
name: 'A4.2: chrome.offscreen.hasDocument() === true (recording offscreen alive)',
|
|
expected: true,
|
|
actual: hasDoc,
|
|
passed: hasDoc === true,
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Timeout for SAVE_ARCHIVE message dispatch — the SW does screenshot
|
|
* capture + content-script messaging + JSZip generation before responding.
|
|
* The screenshot path can stall briefly under the synthetic-stream pipeline;
|
|
* 15s gives several seconds of headroom over the typical ~2-3s run. */
|
|
const A5_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
|
|
/**
|
|
* Pre-SAVE_ARCHIVE settle window — wait at least one
|
|
* SEGMENT_DURATION_MS (10s, per src/offscreen/recorder.ts) past
|
|
* startRecording so the rotation timer fires, pushes the first segment
|
|
* into the buffer, and `getVideoBufferFromOffscreen` returns a non-empty
|
|
* result. Without this wait, saveArchive throws EmptyVideoBufferError
|
|
* (segments=[] → SW emits RECORDING_ERROR{error:'empty-video-buffer'}
|
|
* and returns success=false). 11s = 10s rotation + 1s slack for
|
|
* MediaRecorder.onstop → onSegmentStopped → segments.push to finish.
|
|
*/
|
|
const A5_SEGMENT_SETTLE_MS = 11_000;
|
|
/** Time in ms to wait for the SW state machine to settle after dispatching
|
|
* RECORDING_ERROR. The handler is synchronous (setErrorMode + create
|
|
* notification) so 200ms is comfortably above the typical few-ms delay. */
|
|
const A7_SETTLE_MS = 200;
|
|
/** Recovery-notification id prefix — must stay in sync with
|
|
* `src/background/index.ts:NOTIFICATION_RECOVERY_PREFIX`. The harness
|
|
* asserts SET MEMBERSHIP against this prefix on the post-dispatch
|
|
* notification map (not "most recent"); `chrome.notifications.getAll`
|
|
* returns an Object whose key ordering is NOT guaranteed by the API
|
|
* contract — set-membership is the reliable check. */
|
|
const RECOVERY_NOTIF_PREFIX = 'mokosh-recovery-';
|
|
|
|
/**
|
|
* Bring the SW state machine to REC + ensure the offscreen recording is
|
|
* live. Idempotent shared helper used by A7 (which runs AFTER A6 tears
|
|
* the recording down) and any future assertion that needs a fresh REC
|
|
* state.
|
|
*
|
|
* Same direct-offscreen workaround as `assertA2` — bypasses the missing
|
|
* `tabs` permission gap by sending START_RECORDING straight to offscreen
|
|
* + manually setting badge/popup. Documented at length in `assertA2`'s
|
|
* comment block.
|
|
*
|
|
* @returns ok status + diagnostic error.
|
|
*/
|
|
async function setupFreshRecording(): Promise<{ ok: boolean; error?: string }> {
|
|
try {
|
|
const ensureResp = await ensureOffscreen();
|
|
if (!ensureResp.ok) {
|
|
return { ok: false, error: `ensureOffscreen failed: ${ensureResp.error ?? '(no error)'}` };
|
|
}
|
|
const grantResp = await startRecording();
|
|
if (!grantResp.granted) {
|
|
return { ok: false, error: 'startRecording returned granted=false' };
|
|
}
|
|
// Wait for badge to become 'REC' before returning — guarantees A2's
|
|
// contract for callers that need REC state immediately.
|
|
await waitFor(
|
|
() => chrome.action.getBadgeText({}),
|
|
(v) => v === 'REC',
|
|
STATE_WAIT_MS,
|
|
"setupFreshRecording: badge should transition to 'REC'",
|
|
);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A5 — SAVE_ARCHIVE dispatch. Asserts the SW's `saveArchive` handler
|
|
* runs to completion and returns `{success: true}`. The actual zip
|
|
* file lands in `handles.downloadsDir` (configured via CDP
|
|
* `Browser.setDownloadBehavior` at launch time) — the host-side
|
|
* `driveA5` wrapper performs file-system verification (presence + size
|
|
* floor) because the page isolate cannot read the per-run downloads
|
|
* directory directly.
|
|
*
|
|
* Pre-condition: recording must be active (A2 + A3 + A4 left it running;
|
|
* A5 runs before A6's user-stop simulation in the orchestrator order).
|
|
* The SW's `saveArchive` does `chrome.tabs.query({active:true,
|
|
* currentWindow:true})` — relies on the launcher's victim about:blank
|
|
* tab being brought-to-front; the `tab.url` field is undefined without
|
|
* the `tabs` permission but `saveArchive` only checks `tab.id` so the
|
|
* permission gap is benign here.
|
|
*
|
|
* The content-script `GET_RRWEB_EVENTS` round-trip in `saveArchive`
|
|
* fails on `about:blank` (no content script injected there) — `saveArchive`
|
|
* catches and continues with `rrwebEvents = []`. The resulting zip is
|
|
* smaller than a production-page zip but well above the 1KB floor
|
|
* because the offscreen video buffer + screenshot dominate.
|
|
*
|
|
* @returns Structured result with 1 page-side check (SW dispatch ack).
|
|
*/
|
|
async function assertA5(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A5 — SAVE_ARCHIVE dispatches; SW handler returns success',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
// Step 1 — settle window. A2 started recording shortly before A5;
|
|
// the offscreen recorder rotates segments every SEGMENT_DURATION_MS
|
|
// (10s). Before the first rotation fires, `segments` is empty and
|
|
// `getVideoBufferFromOffscreen` returns `{segments:[]}` which the SW's
|
|
// `createArchive` treats as `EmptyVideoBufferError`. Wait 11s so at
|
|
// least one segment lands in the buffer before we trigger
|
|
// SAVE_ARCHIVE. The settle dominates A5 wall-clock time (~11s of the
|
|
// ~13s total) — acceptable given the assertion verifies an
|
|
// end-to-end production save flow.
|
|
diag(result, `Step 1: settle ${A5_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
|
await new Promise((r) => setTimeout(r, A5_SEGMENT_SETTLE_MS));
|
|
diag(result, 'Step 1 OK — first rotation should have fired');
|
|
|
|
diag(result, 'Step 2: send SAVE_ARCHIVE to SW');
|
|
// SW handler: saveArchive() → captureScreenshot + getVideoBufferFromOffscreen
|
|
// + chrome.tabs.sendMessage(GET_RRWEB_EVENTS) → createArchive (JSZip)
|
|
// → downloadArchive (chrome.downloads.download with data:application/zip;base64,...).
|
|
// SW responds with { success: true } on the happy path; { success: false, error }
|
|
// otherwise. EmptyVideoBufferError additionally emits a RECORDING_ERROR
|
|
// sendMessage which is filtered out by the orchestrator's per-assertion
|
|
// notification snapshotting (A5 does not assert on notifications).
|
|
const resp = await sendMessageWithTimeout<{
|
|
success: boolean;
|
|
error?: string;
|
|
}>(
|
|
{ type: 'SAVE_ARCHIVE' },
|
|
A5_SAVE_ARCHIVE_TIMEOUT_MS,
|
|
'SAVE_ARCHIVE',
|
|
);
|
|
diag(result, `Step 2 result: ${JSON.stringify(resp)}`);
|
|
|
|
result.checks.push({
|
|
name: 'A5.1: SAVE_ARCHIVE handler returns success=true',
|
|
expected: true,
|
|
actual: resp.success,
|
|
passed: resp.success === true,
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A7 — genuine recording error → ERR + recovery notification. Asserts
|
|
* that a non-Bug-B error code routes through `setErrorMode` (badge='ERR'
|
|
* + popup pinned) AND creates a `mokosh-recovery-*` notification.
|
|
*
|
|
* Sends `RECORDING_ERROR{error:'codec-unsupported'}` — a non-Bug-B path
|
|
* representative of the genuine capture-failure branch (the SW handler
|
|
* at src/background/index.ts:790 routes `'user-stopped-sharing'` through
|
|
* setIdleMode + no notification, everything else through setErrorMode +
|
|
* recovery notification). The exact error code is arbitrary among the
|
|
* non-Bug-B set; `codec-unsupported` is chosen as a clean exemplar.
|
|
*
|
|
* Pre-condition: a fresh recording must be active. A6 (which runs before
|
|
* A7 in the orchestrator order) tears recording down; this assertion
|
|
* calls `setupFreshRecording` to restore REC state before dispatching.
|
|
*
|
|
* Post-conditions verified:
|
|
* 1. badge === 'ERR' (setErrorMode contract per badge-state-machine.test.ts)
|
|
* 2. popup endsWith 'src/popup/index.html' (setErrorMode preserves the
|
|
* SAVE-only popup so the operator can recover the buffer)
|
|
* 3. notif count delta === 1 (exactly one new notification — the
|
|
* recovery one; no other notifications fire on the error path)
|
|
* 4. at least one active notification id has the recovery prefix
|
|
* (set-membership; ordering of `chrome.notifications.getAll` is
|
|
* not contractually stable — see plan resolved-questions §3)
|
|
*
|
|
* @returns Structured result with 4 post-dispatch checks (after SETUP).
|
|
*/
|
|
async function assertA7(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A7 — genuine RECORDING_ERROR → ERR badge + recovery notification',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
diag(result, 'Step 1: setupFreshRecording (A6 may have torn it down)');
|
|
const setupResp = await setupFreshRecording();
|
|
if (!setupResp.ok) {
|
|
throw new Error(
|
|
`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`,
|
|
);
|
|
}
|
|
diag(result, 'Step 1 OK — REC state established');
|
|
|
|
diag(result, 'Step 2: snapshot active notification count + ids');
|
|
const notifBefore = await getActiveNotificationCount();
|
|
diag(result, `Step 2 result: notifBefore=${notifBefore}`);
|
|
|
|
diag(result, "Step 3: send RECORDING_ERROR{error:'codec-unsupported'}");
|
|
// No response expected — the SW handler is fire-and-forget
|
|
// (returns false from onMessage). We swallow lastError via the
|
|
// fire-and-forget pattern: send and continue. sendMessageWithTimeout
|
|
// would reject on `chrome.runtime.lastError === 'no response'`; use
|
|
// the direct API.
|
|
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'codec-unsupported' });
|
|
diag(result, 'Step 3 OK — RECORDING_ERROR dispatched (fire-and-forget)');
|
|
|
|
diag(result, `Step 4: settle ${A7_SETTLE_MS}ms`);
|
|
await new Promise((r) => setTimeout(r, A7_SETTLE_MS));
|
|
|
|
diag(result, 'Step 5: read post-dispatch state (badge + popup + notifications)');
|
|
const badgeAfter = await chrome.action.getBadgeText({});
|
|
const popupAfter = await chrome.action.getPopup({});
|
|
const notifAfter = await getActiveNotificationCount();
|
|
const notifIds = await new Promise<ReadonlyArray<string>>((resolve, reject) => {
|
|
chrome.notifications.getAll((notifications: Object) => {
|
|
if (chrome.runtime.lastError !== undefined) {
|
|
reject(new Error(String(chrome.runtime.lastError.message)));
|
|
return;
|
|
}
|
|
resolve(Object.keys(notifications ?? {}));
|
|
});
|
|
});
|
|
const notifDelta = notifAfter - notifBefore;
|
|
const recoveryIdPresent = notifIds.some(
|
|
(id) => id.startsWith(RECOVERY_NOTIF_PREFIX),
|
|
);
|
|
diag(
|
|
result,
|
|
`Step 5: badge='${badgeAfter}', popup='${popupAfter}', notifAfter=${notifAfter}, delta=${notifDelta}, ids=${JSON.stringify(notifIds)}`,
|
|
);
|
|
|
|
result.checks.push({
|
|
name: "A7.1: badge text is 'ERR' after RECORDING_ERROR (setErrorMode)",
|
|
expected: 'ERR',
|
|
actual: badgeAfter,
|
|
passed: badgeAfter === 'ERR',
|
|
});
|
|
// NOTE — chrome.action.getPopup() returns the absolute extension URL,
|
|
// not the manifest-relative path; .endsWith() keeps the check
|
|
// extension-id independent (see A2.2 NOTE).
|
|
result.checks.push({
|
|
name: "A7.2: popup endsWith 'src/popup/index.html' (SAVE-only popup pinned)",
|
|
expected: '<chrome-extension://<id>/>src/popup/index.html',
|
|
actual: popupAfter,
|
|
passed: popupAfter.endsWith('src/popup/index.html'),
|
|
});
|
|
result.checks.push({
|
|
name: 'A7.3: notification count delta === 1 (exactly one new recovery)',
|
|
expected: 1,
|
|
actual: notifDelta,
|
|
passed: notifDelta === 1,
|
|
});
|
|
result.checks.push({
|
|
name: `A7.4: at least one notification id startsWith '${RECOVERY_NOTIF_PREFIX}' (set membership)`,
|
|
expected: true,
|
|
actual: recoveryIdPresent,
|
|
passed: recoveryIdPresent === true,
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/* ─── Wave 3C — A8 + A9 + A10 ─────────────────────────────────────── */
|
|
|
|
/** Startup-notification id prefix — must stay in sync with
|
|
* `src/background/index.ts:NOTIFICATION_STARTUP_PREFIX`. A8 stamps a
|
|
* fresh id (prefix + Date.now()) when invoking
|
|
* chrome.notifications.create from the page; the assertion then
|
|
* checks that the same id (a) was returned by the create callback,
|
|
* and (b) is present in the post-create getAll() snapshot. */
|
|
const STARTUP_NOTIF_PREFIX = 'mokosh-startup-';
|
|
|
|
/** Time in ms to wait after chrome.notifications.create resolves for
|
|
* the OS-level notification to land in `chrome.notifications.getAll`.
|
|
* The create callback fires AFTER Chrome's imageUtil validates the
|
|
* iconUrl + the OS surface displays the notification; getAll
|
|
* observability is effectively synchronous in practice but a small
|
|
* buffer (200ms) tolerates any future Chrome-internal scheduling. */
|
|
const A8_GETALL_SETTLE_MS = 200;
|
|
|
|
/** Per-icon size floors enforced by A9 — Chrome's `imageUtil`
|
|
* silent-rejection thresholds documented in assets-spec.md and Plan
|
|
* 01-13 Task 6 behavior. Values: 16→≥200 B, 48→≥500 B, 128→≥1024 B.
|
|
* Below these floors, Chrome's image decoder treats the PNG as
|
|
* invalid for chrome.notifications.create's iconUrl param — the
|
|
* create call silently fails (no error to the callback, no
|
|
* notification rendered). Bug A's original failure mode. */
|
|
const A9_ICON_SPEC: ReadonlyArray<{ readonly size: 16 | 48 | 128; readonly floorBytes: number }> = [
|
|
{ size: 16, floorBytes: 200 },
|
|
{ size: 48, floorBytes: 500 },
|
|
{ size: 128, floorBytes: 1024 },
|
|
];
|
|
|
|
/** Wrap chrome.notifications.create in a Promise. The create API uses
|
|
* a callback; we surface chrome.runtime.lastError as a rejection so
|
|
* the harness's try/catch picks it up. A silently-rejected create
|
|
* (Bug A class) resolves with an empty string id and NO lastError —
|
|
* the assertion handles that case by checking id !== '' downstream.
|
|
*
|
|
* @param id - Notification id (caller-supplied; we stamp prefix + Date.now()).
|
|
* @param options - chrome.notifications.create's NotificationOptions.
|
|
* @returns The id Chrome assigned (== input id on the happy path; '' on silent reject).
|
|
*/
|
|
async function createNotificationPromise(
|
|
id: string,
|
|
options: chrome.notifications.NotificationOptions<true>,
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.notifications.create(id, options, (assignedId: string) => {
|
|
if (chrome.runtime.lastError !== undefined) {
|
|
reject(new Error(String(chrome.runtime.lastError.message)));
|
|
return;
|
|
}
|
|
resolve(assignedId);
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Read the set of active notification ids via chrome.notifications.getAll.
|
|
* Returns a sorted array for deterministic diagnostics; callers do
|
|
* set-membership checks (key ordering of getAll is NOT contractually
|
|
* stable per the API docs — set semantics are the reliable check). */
|
|
async function getActiveNotificationIds(): Promise<ReadonlyArray<string>> {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.notifications.getAll((notifications: Object) => {
|
|
if (chrome.runtime.lastError !== undefined) {
|
|
reject(new Error(String(chrome.runtime.lastError.message)));
|
|
return;
|
|
}
|
|
const ids = Object.keys(notifications ?? {});
|
|
ids.sort();
|
|
resolve(ids);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A8 — Bug A canonical regression rewind. Exercises the same
|
|
* `chrome.notifications.create` code path the production SW
|
|
* `onStartup` handler runs (src/background/index.ts:894-912). Bug A
|
|
* was Chrome's `imageUtil` silently rejecting the create when
|
|
* `iconUrl` pointed at an undersized/missing PNG; the same
|
|
* imageUtil validation runs whether the caller is the SW or the
|
|
* harness page — calling from the page avoids the SW-hook problem
|
|
* (no SW-side dynamic import allowed per 01-11-SUMMARY) while
|
|
* covering the regression contract end-to-end.
|
|
*
|
|
* Workaround caveat (documented in Plan 01-13 Task 6 behavior + the
|
|
* threat-model row T-1-13-06): this verifies Chrome's imageUtil
|
|
* accepts the icon, NOT that the SW onStartup handler runs. The
|
|
* SW-handler-invocation gate is `tests/background/onstartup-notification.test.ts`
|
|
* (unit test). Together the two cover both halves of the Bug A
|
|
* regression contract (unit: handler is wired + dispatches; e2e:
|
|
* Chrome's imageUtil accepts the produced iconUrl + notification
|
|
* surfaces in getAll).
|
|
*
|
|
* Post-conditions verified:
|
|
* 1. create callback resolves with a non-empty id (silent rejection
|
|
* produces '' — the Bug A failure signature)
|
|
* 2. the returned id matches the input id (prefix + Date.now() stamp)
|
|
* 3. getAll() count delta === 1 (exactly one new notification)
|
|
* 4. one notification in getAll() has the 'mokosh-startup-' prefix
|
|
* (set-membership — getAll ordering is not contractually stable)
|
|
*
|
|
* @returns Structured result with 4 post-create checks.
|
|
*/
|
|
async function assertA8(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract)',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
diag(result, 'Step 1: snapshot notif count + ids BEFORE create');
|
|
const idsBefore = await getActiveNotificationIds();
|
|
diag(result, `Step 1 result: ${idsBefore.length} active; ids=${JSON.stringify(idsBefore)}`);
|
|
|
|
// Mirror the production SW onStartup handler's options shape
|
|
// (src/background/index.ts:898-905). iconUrl resolved via
|
|
// chrome.runtime.getURL so the extension-scoped path becomes a
|
|
// chrome-extension://<id>/icons/icon128.png URL that imageUtil
|
|
// can fetch + decode. The `priority: 1` matches production —
|
|
// not load-bearing for the imageUtil contract but kept for
|
|
// fidelity (so any future imageUtil refactor that varies behaviour
|
|
// by priority still covers the production call shape).
|
|
const inputId = STARTUP_NOTIF_PREFIX + Date.now();
|
|
const options: chrome.notifications.NotificationOptions<true> = {
|
|
type: 'basic',
|
|
iconUrl: chrome.runtime.getURL('icons/icon128.png'),
|
|
title: 'Mokosh ready',
|
|
message: 'Click here to start recording your session.',
|
|
priority: 1,
|
|
};
|
|
diag(result, `Step 2: chrome.notifications.create(id='${inputId}', iconUrl='${options.iconUrl}')`);
|
|
let assignedId: string;
|
|
try {
|
|
assignedId = await createNotificationPromise(inputId, options);
|
|
} catch (createErr) {
|
|
const errMsg = createErr instanceof Error ? createErr.message : String(createErr);
|
|
throw new Error(`notifications.create rejected: ${errMsg}`);
|
|
}
|
|
diag(result, `Step 2 result: assignedId='${assignedId}'`);
|
|
|
|
diag(result, `Step 3: settle ${A8_GETALL_SETTLE_MS}ms before getAll`);
|
|
await new Promise((r) => setTimeout(r, A8_GETALL_SETTLE_MS));
|
|
|
|
diag(result, 'Step 4: snapshot notif count + ids AFTER create');
|
|
const idsAfter = await getActiveNotificationIds();
|
|
diag(result, `Step 4 result: ${idsAfter.length} active; ids=${JSON.stringify(idsAfter)}`);
|
|
const delta = idsAfter.length - idsBefore.length;
|
|
const startupIdPresent = idsAfter.some((id) => id.startsWith(STARTUP_NOTIF_PREFIX));
|
|
|
|
result.checks.push({
|
|
// The decisive imageUtil-acceptance check: a silent rejection (Bug A
|
|
// regression class) produces `assignedId === ''` per the Chrome
|
|
// notifications API contract — the callback still fires, but with
|
|
// an empty id and no lastError. Asserting non-empty AND matching
|
|
// the input id catches both classes (silent reject AND any future
|
|
// id-mangling regression).
|
|
name: 'A8.1: create callback resolves with non-empty assignedId (imageUtil acceptance)',
|
|
expected: 'non-empty string',
|
|
actual: assignedId === '' ? '<empty (Bug A signature: imageUtil silent reject)>' : assignedId,
|
|
passed: assignedId !== '',
|
|
});
|
|
result.checks.push({
|
|
name: 'A8.2: assignedId matches input id (chrome.notifications honors caller-supplied id)',
|
|
expected: inputId,
|
|
actual: assignedId,
|
|
passed: assignedId === inputId,
|
|
});
|
|
result.checks.push({
|
|
name: 'A8.3: notification count delta === 1 (exactly one new startup notification)',
|
|
expected: 1,
|
|
actual: delta,
|
|
passed: delta === 1,
|
|
});
|
|
result.checks.push({
|
|
name: `A8.4: at least one notification id startsWith '${STARTUP_NOTIF_PREFIX}' (set membership)`,
|
|
expected: true,
|
|
actual: startupIdPresent,
|
|
passed: startupIdPresent === true,
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A9 — icon file sizes meet Chrome `imageUtil` silent-rejection floors.
|
|
* Fetches each icon via `chrome.runtime.getURL` (resolves to
|
|
* `chrome-extension://<id>/icons/icon{N}.png`) and reads `blob.size`.
|
|
* Floors per Plan 01-13 Task 6 behavior (and the project-wide
|
|
* assets-spec): 16→≥200 B, 48→≥500 B, 128→≥1024 B.
|
|
*
|
|
* Regression target: a future icon swap (e.g. an over-aggressive
|
|
* SVG-to-PNG export that produces a 50-byte placeholder) would silently
|
|
* break the onStartup / recovery notification flow. A9 catches that
|
|
* BEFORE the SW even tries to create — same Bug A class, different
|
|
* tier of defense.
|
|
*
|
|
* @returns Structured result with one check per icon (3 checks total).
|
|
*/
|
|
async function assertA9(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A9 — icon files meet imageUtil size floors (16≥200B, 48≥500B, 128≥1024B)',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
for (const { size, floorBytes } of A9_ICON_SPEC) {
|
|
const url = chrome.runtime.getURL(`icons/icon${size}.png`);
|
|
diag(result, `Step: fetch ${url}`);
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`fetch ${url} returned HTTP ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
const blob = await response.blob();
|
|
const actualBytes = blob.size;
|
|
diag(result, `Step result: icon${size}.png size=${actualBytes}B (floor=${floorBytes}B)`);
|
|
result.checks.push({
|
|
name: `A9.${size}: icons/icon${size}.png size >= ${floorBytes} bytes (imageUtil floor)`,
|
|
expected: `>= ${floorBytes}`,
|
|
actual: actualBytes,
|
|
passed: actualBytes >= floorBytes,
|
|
});
|
|
}
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* A10 — manifest shape. Reads `chrome.runtime.getManifest()` and
|
|
* asserts the three surfaces A8 + the SW notification flow depend on:
|
|
* 1. `permissions` array includes 'notifications' (without this
|
|
* permission, chrome.notifications.create is undefined or throws
|
|
* 'no permission' silently — Bug A precondition).
|
|
* 2. `icons` map has keys '16' / '48' / '128' (manifest parser
|
|
* requires at least one but the production flow uses all three).
|
|
* 3. `action.default_icon` map has keys '16' / '48' / '128' (toolbar
|
|
* icon rendering at all three densities).
|
|
*
|
|
* Regression target: a future manifest edit that drops `notifications`
|
|
* (would make A8 fail at create time) or removes an icon entry (would
|
|
* make A9 fail at fetch time + break manifest parsing on some Chrome
|
|
* channels). A10 is the cheap, deterministic guard for those classes.
|
|
*
|
|
* @returns Structured result with manifest-shape checks.
|
|
*/
|
|
async function assertA10(): Promise<AssertionResult> {
|
|
const result: AssertionResult = {
|
|
passed: false,
|
|
name: 'A10 — manifest shape: notifications permission + 16/48/128 icons + action.default_icon',
|
|
checks: [],
|
|
diagnostics: [],
|
|
};
|
|
|
|
try {
|
|
diag(result, 'Step 1: chrome.runtime.getManifest()');
|
|
const manifest = chrome.runtime.getManifest();
|
|
diag(
|
|
result,
|
|
`Step 1 result: manifest_version=${manifest.manifest_version} version=${manifest.version}`,
|
|
);
|
|
|
|
const permissions = manifest.permissions ?? [];
|
|
const hasNotifications = permissions.includes('notifications');
|
|
diag(result, `Step 2: permissions=${JSON.stringify(permissions)}`);
|
|
|
|
// The chrome.* typings model `icons` as `Record<string, string>` keyed
|
|
// by stringified pixel sizes ('16', '48', etc.). Use bracket access +
|
|
// truthy check rather than .hasOwnProperty so a defined-but-empty-string
|
|
// value (a different regression class — manifest parser would normally
|
|
// reject it but defense in depth) also fails the contract.
|
|
const icons = (manifest.icons ?? {}) as Record<string, string | undefined>;
|
|
const icon16Present = typeof icons['16'] === 'string' && icons['16']!.length > 0;
|
|
const icon48Present = typeof icons['48'] === 'string' && icons['48']!.length > 0;
|
|
const icon128Present = typeof icons['128'] === 'string' && icons['128']!.length > 0;
|
|
diag(result, `Step 3: icons=${JSON.stringify(icons)}`);
|
|
|
|
const action = manifest.action ?? {};
|
|
const defaultIcon =
|
|
typeof action.default_icon === 'object' && action.default_icon !== null
|
|
? (action.default_icon as Record<string, string | undefined>)
|
|
: {};
|
|
const di16Present = typeof defaultIcon['16'] === 'string' && defaultIcon['16']!.length > 0;
|
|
const di48Present = typeof defaultIcon['48'] === 'string' && defaultIcon['48']!.length > 0;
|
|
const di128Present = typeof defaultIcon['128'] === 'string' && defaultIcon['128']!.length > 0;
|
|
diag(result, `Step 4: action.default_icon=${JSON.stringify(defaultIcon)}`);
|
|
|
|
result.checks.push({
|
|
name: "A10.1: permissions includes 'notifications' (chrome.notifications.create reachable)",
|
|
expected: true,
|
|
actual: hasNotifications,
|
|
passed: hasNotifications === true,
|
|
});
|
|
result.checks.push({
|
|
name: "A10.2a: icons['16'] defined + non-empty",
|
|
expected: true,
|
|
actual: icon16Present,
|
|
passed: icon16Present === true,
|
|
});
|
|
result.checks.push({
|
|
name: "A10.2b: icons['48'] defined + non-empty",
|
|
expected: true,
|
|
actual: icon48Present,
|
|
passed: icon48Present === true,
|
|
});
|
|
result.checks.push({
|
|
name: "A10.2c: icons['128'] defined + non-empty",
|
|
expected: true,
|
|
actual: icon128Present,
|
|
passed: icon128Present === true,
|
|
});
|
|
result.checks.push({
|
|
name: "A10.3a: action.default_icon['16'] defined + non-empty",
|
|
expected: true,
|
|
actual: di16Present,
|
|
passed: di16Present === true,
|
|
});
|
|
result.checks.push({
|
|
name: "A10.3b: action.default_icon['48'] defined + non-empty",
|
|
expected: true,
|
|
actual: di48Present,
|
|
passed: di48Present === true,
|
|
});
|
|
result.checks.push({
|
|
name: "A10.3c: action.default_icon['128'] defined + non-empty",
|
|
expected: true,
|
|
actual: di128Present,
|
|
passed: di128Present === true,
|
|
});
|
|
|
|
result.passed = result.checks.every((c) => c.passed);
|
|
} catch (err) {
|
|
result.error = err instanceof Error ? err.message : String(err);
|
|
diag(result, `THREW: ${result.error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Install the global harness surface.
|
|
declare global {
|
|
interface Window {
|
|
__mokoshHarness: {
|
|
assertA1: () => Promise<AssertionResult>;
|
|
assertA2: () => Promise<AssertionResult>;
|
|
assertA3: () => Promise<AssertionResult>;
|
|
assertA4: () => Promise<AssertionResult>;
|
|
assertA5: () => Promise<AssertionResult>;
|
|
assertA6: () => Promise<AssertionResult>;
|
|
assertA7: () => Promise<AssertionResult>;
|
|
assertA8: () => Promise<AssertionResult>;
|
|
assertA9: () => Promise<AssertionResult>;
|
|
assertA10: () => Promise<AssertionResult>;
|
|
};
|
|
}
|
|
}
|
|
|
|
window.__mokoshHarness = {
|
|
assertA1,
|
|
assertA2,
|
|
assertA3,
|
|
assertA4,
|
|
assertA5,
|
|
assertA6,
|
|
assertA7,
|
|
assertA8,
|
|
assertA9,
|
|
assertA10,
|
|
};
|
|
|
|
const statusEl = document.getElementById('status');
|
|
if (statusEl !== null) {
|
|
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA5, assertA6, assertA7, assertA8, assertA9, assertA10} available.';
|
|
}
|
|
|
|
console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3C: A1+A2+A3+A4+A5+A6+A7+A8+A9+A10)');
|
|
|
|
export {};
|