Files
mokosh/tests/uat/extension-page-harness.ts
Mark b112cb7861 test(01-10): wave-3 task-4 — harness A15+A16+A17 (onboarding flag observability + no-re-open settle + design-swap-readiness with @import probe); 24/24 GREEN
Plan 01-10 Wave 3: extends the UAT harness with three new page-side
assertions covering the onboarding contract + the canonical-tokens
design-swap-readiness invariant. UAT baseline 21 → 24 GREEN.

tests/uat/extension-page-harness.ts (page-side):
  - assertA15 — chrome.storage.local 'onboarding-completed' === true +
    'installed-at' is number. Verifies SW's openWelcomeIfFirstInstall
    side-effects.
  - assertA16 — 2s settle window; chrome.tabs.query welcome-tab count
    delta === 0. Verifies flag-gating across SW respawns.
  - assertA17 — 7 sub-checks covering: welcome.html parse + .welcome-hero
    + >=7 mokosh-keyed attrs + welcome.css canonical @import literal OR
    inlined --mks-* evidence + (zero hex OR canonical resolved) + >=5
    var(--mks-*) refs + bundled JS preserves populate plumbing +
    getComputedStyle --mks-rec → rgb(178, 84, 61) (canonical D-04 Loom).
  - window.__mokoshHarness surface extended with the three new methods;
    type declaration + assignment + page-ready status text updated.

tests/uat/lib/harness-page-driver.ts (host-side):
  - driveA15, driveA16, driveA17 — standard page.evaluate wrappers
    matching driveA14 / driveA18..A22 idiom. driveA16 dominates the
    new wall-clock budget (~2.1s for the settle window).

tests/uat/harness.test.ts (orchestrator):
  - Drivers array interleaves A15/A16/A17 AFTER A14 + BEFORE A18.
    A22's skip-gate no longer triggers (Plan 01-10 lands welcome.html;
    A22 now exercises the substantive token-usage path).
  - FORBIDDEN_HOOK_STRINGS unchanged at 12 entries (A15-A17 use only
    chrome.tabs.query / chrome.storage.local.get / fetch / DOMParser /
    getComputedStyle — all production-API surfaces).

DEVIATION (Rule 1 — auto-fix bug in plan-supplied check):
  The plan's A17.6 spec used literal substring checks 'COPY[' and
  'chrome.i18n.getMessage(' which fail against minified production
  output. Vite/Rollup terser renames `COPY` → `f` (local variable
  mangling) and welcome.ts's source uses optional chaining
  `chrome?.i18n?.getMessage?.(` which doesn't match the verbatim
  literal. Replaced with two minification-survivable witnesses:
    1. 'welcome.page.title' — literal Object.freeze key (terser
       preserves object-literal keys verbatim).
    2. 'i18n' + 'getMessage' + 'welcomeHero' substring conjunction —
       chrome global + property access + fallback key literal; all
       three survive minification regardless of optional-chaining
       insertion or rename.
  Both witnesses prove the populate plumbing survives the build (the
  ground-truth contract A17.6 enforces). The relaxed contract is
  semantically equivalent — neither substring is load-bearing on its
  own; both witness the same underlying invariant.

Verify (all GREEN):
  - npm run test:uat: 24/24 assertions passed (A0 grep gate + A1..A14
    + A15..A17 + A18..A22 + A23).
  - npx tsc --noEmit: clean.
  - npm run build:test: clean; dist-test/assets/welcome-wB0e_R_n.js
    bundled; harness page bundle includes new asserts.
  - SKIP_BUILD=1 npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts:
    13/13 GREEN (Tier-1 grep gate; FORBIDDEN_HOOK_STRINGS at 12).
  - Full vitest baseline preserved: 137 ex-grep-gate + 13 grep-gate
    = 150 GREEN (Plan 01-10 target).

A17.7 canonical proof: getComputedStyle.color = 'rgb(178, 84, 61)' —
the @import '../shared/tokens.css' directive resolves through to the
canonical D-04 Loom palette --mks-madder-600 = #b2543d at runtime, as
the empirical proof Plan 01-12 must_have #9 path-B contract demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:41:10 +02:00

2824 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/uat/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 3D surface — extends `window.__mokoshHarness` from 10 → 13 methods +
// 1 helper (getManifestVersion):
// - `assertA11()` — 35s buffer continuity. Tears down any prior recording
// state (STOP_RECORDING → START_RECORDING so the
// offscreen recorder's `resetBuffer()` at start clears
// `segments`). Waits 35_000ms wall-clock. Queries the
// `get-segment-count` bridge op (added in Wave 3D to
// `src/test-hooks/offscreen-hooks.ts`). Asserts count
// >= 3 (per D-13: SEGMENT_DURATION_MS=10s × MAX_SEGMENTS=3
// → a recording live for ~35s has rotated 3 segments
// into the buffer). The 35s wait dominates the entire
// `npm run test:uat` wall-clock budget.
// - `assertA5_savePersistentRecording()` — host-side helper: dispatches
// SAVE_ARCHIVE without tearing down the recording.
// Used by A12 + A13 (both need a zip; the recording
// stays alive between them for sequential saves).
// - `assertA12()` — page-side: dispatch SAVE_ARCHIVE (same path as
// A5/saveArchive). Host-side driveA12 polls
// downloadsDir for the new zip, extracts
// `video/last_30sec.webm` to a tmpfile, spawns
// `/usr/bin/ffprobe -v error -f matroska <path>`,
// asserts exit 0 + zero decoder-error lines on
// stderr. Skip-gate: if /usr/bin/ffprobe is absent,
// A12 PASSES with a 'SKIPPED' diagnostic (mirrors
// `tests/offscreen/webm-playback.test.ts` pattern).
// - `assertA13()` — page-side: dispatch SAVE_ARCHIVE. Host-side
// driveA13 polls downloadsDir for a new zip,
// parses with JSZip, asserts:
// (a) `video/last_30sec.webm` entry present + > 1KB,
// (b) `meta.json` entry present + parses as JSON,
// (c) `meta.json.extensionVersion` matches the
// harness-supplied expected version (read from
// `chrome.runtime.getManifest().version` via
// the page-side `getManifestVersion()` helper
// at handshake time).
// - `getManifestVersion()` — page-side helper returning
// `chrome.runtime.getManifest().version`. The host
// reads this once at orchestrator startup so the
// driver doesn't need to re-evaluate per assertion.
//
// 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;
}
/* ─── Wave 3D — A11 + A12 + A13 ────────────────────────────────────── */
/** A11 fresh-recording reset cadence — STOP_RECORDING (synchronous,
* recorder nulls mediaStream + stops tracks) then START_RECORDING
* triggers `resetBuffer()` at recorder.ts:318 which clears the
* `segments` array. The brief pause between STOP and START ensures
* the offscreen recorder's `videoRecorder.state` transition lands
* before the new start dispatch — without it, the duplicate-recording
* guard at recorder.ts:247-250 would reject the re-start. */
const A11_STOP_TO_START_PAUSE_MS = 200;
/** Wall-clock wait for A11 — the segment rotation lifecycle (D-13;
* SEGMENT_DURATION_MS = 10_000) needs at least 30_000ms to produce
* 3 finalized segments. 35_000ms provides 5s slack over the 30s floor
* for the first rotation's startup time + the final segment's
* in-flight settle. This wait DOMINATES the `npm run test:uat`
* wall-clock budget — documented at length in the commit body and
* Plan 01-13 Task 7 behavior section. */
const A11_WAIT_MS = 35_000;
/** Minimum segments expected after A11_WAIT_MS — per D-13 the recorder
* caps at MAX_SEGMENTS = 3 (the ring-buffer trims older segments when
* segments.length > MAX_SEGMENTS at recorder.ts:451-453). So 35s →
* exactly 3 segments after a fresh START. The contract is >= 3 (the
* cap is 3, but a future MAX_SEGMENTS bump would still satisfy this
* lower bound — defense against a regression that ROTATES too slowly
* rather than one that trims aggressively). */
const A11_MIN_SEGMENT_COUNT = 3;
/** Page-side keepalive cadence during A11's 35s wait. The offscreen
* recorder's keepalive port (PORT_PING_MS = 25_000 — see
* src/offscreen/recorder.ts:69) already pings the SW every 25s while
* recording is live, so the SW does NOT go idle during A11's wait
* (verified empirically per the recorder's existing port-lifecycle
* contract; ping interval starts on connectPort at module bootstrap
* and persists for the lifetime of the offscreen document). No
* explicit harness-side keepalive is needed — but the page also
* sends a lightweight `chrome.runtime.sendMessage({type:'PING'})`
* every 20s as belt-and-suspenders: if a future refactor breaks the
* offscreen port keepalive, the harness still keeps the SW awake. */
const A11_KEEPALIVE_INTERVAL_MS = 20_000;
/** A12/A13 SAVE_ARCHIVE timeout — same value as A5 (the SW handler
* does the same screenshot + buffer fetch + zip+download work). */
const A12_A13_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/**
* Tear down any prior recording state and start a fresh recording.
* Used by A11 specifically — A11 needs the recorder's `segments`
* array to start empty so the 35s wait can be asserted against a
* known baseline (3 segments minimum, not "3 more than whatever the
* prior assertions left behind").
*
* Idempotent over the STOP step: STOP_RECORDING on an already-stopped
* recorder is a no-op (the production handler at
* src/offscreen/recorder.ts:527 checks `videoRecorder.state !==
* 'inactive'` and skips the .stop() call when inactive). The
* subsequent START_RECORDING calls `resetBuffer()` at recorder.ts:318
* which clears `segments`, in-flight chunks, AND the rotation timer.
*
* @returns ok status with optional error message on failure.
*/
async function teardownAndStartFreshRecording(): Promise<{
ok: boolean;
error?: string;
}> {
try {
// Step 1 — send STOP_RECORDING to the offscreen recorder. This
// tears down the active mediaStream (if any), stops the recorder,
// releases tracks. Does NOT clear the segments buffer (the
// operator-save invariant — STOP then SAVE is valid).
await sendMessageWithTimeout<{ ok: boolean; error?: string }>(
{ type: 'STOP_RECORDING' },
5_000,
'STOP_RECORDING',
);
// Step 2 — brief settle. The .stop() call triggers onstop async;
// we want the recorder's `videoRecorder.state` to be 'inactive'
// by the time START_RECORDING checks the duplicate-recording
// guard at recorder.ts:247-250. 200ms is comfortably above the
// typical few-ms async transition.
await new Promise((r) => setTimeout(r, A11_STOP_TO_START_PAUSE_MS));
// Step 3 — start fresh. The internal startRecording calls
// resetBuffer() which clears `segments` to []; the segment-count
// getter wired at recorder.ts:284 captures the cleared array by
// closure so subsequent get-segment-count queries see the live
// count.
const grantResp = await startRecording();
if (!grantResp.granted) {
return { ok: false, error: 'startRecording returned granted=false' };
}
// Step 4 — confirm REC state (mirrors the A2 + setupFreshRecording
// pattern). Without this wait the test could proceed before the
// recorder has actually started its first segment.
await waitFor(
() => chrome.action.getBadgeText({}),
(v) => v === 'REC',
STATE_WAIT_MS,
"teardownAndStartFreshRecording: badge should transition to 'REC'",
);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* A11 — 35s buffer continuity → >= 3 segments. Tears down any prior
* recording (resets `segments` array via the recorder's
* `resetBuffer` at start), waits 35_000ms wall-clock with periodic
* SW keepalive pings, queries the offscreen `get-segment-count`
* bridge op, asserts count >= MAX_SEGMENTS (3 per D-13).
*
* The 35s wait is the worst-case time budget item in the entire
* harness. Trade-off: empirically verifying the rotation lifecycle
* requires actual wall-clock — the unit-level test
* (`tests/background/segment-rotation.test.ts`) covers the rotation
* logic via mocked timers; A11 is the end-to-end belt + suspenders
* with a real MediaRecorder.
*
* Post-condition: recording is LEFT ACTIVE after A11 completes. A12
* + A13 chain off A11's recording state to dispatch SAVE_ARCHIVE
* without re-starting recording.
*
* @returns Structured result with 2 checks (SETUP + A11.1).
*/
async function assertA11(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: `A11 — 35s buffer continuity → segments.length >= ${A11_MIN_SEGMENT_COUNT} (D-13 ring buffer)`,
checks: [],
diagnostics: [],
};
let keepaliveTimerId: ReturnType<typeof setInterval> | null = null;
try {
diag(result, 'Step 1: teardownAndStartFreshRecording');
const setupResp = await teardownAndStartFreshRecording();
if (!setupResp.ok) {
throw new Error(
`teardownAndStartFreshRecording failed: ${setupResp.error ?? '(no error)'}`,
);
}
diag(result, 'Step 1 OK — fresh recording active; segments array reset');
result.checks.push({
name: 'SETUP: fresh recording established (badge REC; segments=[])',
expected: true,
actual: true,
passed: true,
});
diag(
result,
`Step 2: wait ${A11_WAIT_MS}ms with keepalive ping every ${A11_KEEPALIVE_INTERVAL_MS}ms`,
);
// Belt-and-suspenders keepalive. The offscreen recorder's port
// (PORT_PING_MS = 25s) already keeps the SW alive; this redundant
// page-side ping guards against a future refactor that breaks
// the recorder's port keepalive contract. Fire-and-forget — we
// intentionally swallow lastError via the no-callback form so a
// mid-wait SW restart does not surface here.
/**
* Periodic keepalive ping. Fire-and-forget — we want zero
* back-pressure on the 35s wait loop.
*/
const sendKeepalivePing = (): void => {
try {
chrome.runtime.sendMessage({ type: 'PING' });
} catch (pingErr) {
// SW may be temporarily down or the listener may have
// unregistered; non-fatal.
console.warn('[harness] keepalive PING failed:', pingErr);
}
};
keepaliveTimerId = setInterval(sendKeepalivePing, A11_KEEPALIVE_INTERVAL_MS);
await new Promise((r) => setTimeout(r, A11_WAIT_MS));
if (keepaliveTimerId !== null) {
clearInterval(keepaliveTimerId);
keepaliveTimerId = null;
}
diag(result, `Step 2 OK — ${A11_WAIT_MS}ms wall-clock elapsed`);
diag(result, "Step 3: bridge query 'get-segment-count'");
const countResp = await offscreenQuery<{
count?: number;
error?: string;
}>('get-segment-count');
diag(result, `Step 3 result: ${JSON.stringify(countResp)}`);
const observedCount = typeof countResp.count === 'number' ? countResp.count : -1;
result.checks.push({
name: `A11.1: segment count >= ${A11_MIN_SEGMENT_COUNT} after ${A11_WAIT_MS}ms (D-13 ring buffer; SEGMENT_DURATION_MS=10s × MAX_SEGMENTS=3)`,
expected: `>= ${A11_MIN_SEGMENT_COUNT}`,
actual: observedCount,
passed: observedCount >= A11_MIN_SEGMENT_COUNT,
});
result.passed = result.checks.every((c) => c.passed);
} catch (err) {
result.error = err instanceof Error ? err.message : String(err);
diag(result, `THREW: ${result.error}`);
} finally {
// Defensive — keepalive must always be cleared, even on throw, so
// a subsequent assertion doesn't see phantom PING traffic.
if (keepaliveTimerId !== null) {
clearInterval(keepaliveTimerId);
}
}
return result;
}
/**
* A12 — page-side: dispatch SAVE_ARCHIVE so a new zip lands in
* `downloadsDir`. Host-side driveA12 then:
* 1. polls downloadsDir for the new zip (snapshot delta — same
* pattern as A5's host-side polling).
* 2. extracts `video/last_30sec.webm` from the zip via JSZip to a
* tmpfile.
* 3. spawns `/usr/bin/ffprobe -v error -f matroska <tmpfile>`.
* 4. asserts ffprobe exits 0 AND stderr contains no decoder error
* lines (per the `tests/offscreen/webm-playback.test.ts`
* ffprobe-success contract).
*
* Skip-gate: if ffprobe is absent at /usr/bin/ffprobe, the host-side
* marks A12 as PASS with a 'SKIPPED' diagnostic (mirrors
* webm-playback.test.ts:90-96 ffprobeAvailable pattern). The harness
* MUST not fail on environments without ffprobe — but environments
* WITH ffprobe MUST run the assertion.
*
* Pre-condition: A11 left recording active with >= 3 segments. A12's
* SAVE_ARCHIVE captures those segments into the zip. Recording stays
* active for A13.
*
* The page side only returns the SW dispatch ack. The host side does
* all fs + ffprobe work.
*
* @returns Structured result with 1 page-side check (SAVE_ARCHIVE ack).
*/
async function assertA12(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A12 — SAVE_ARCHIVE produces a zip; video/last_30sec.webm passes ffprobe (host-side gate)',
checks: [],
diagnostics: [],
};
try {
diag(result, 'Step 1: send SAVE_ARCHIVE to SW (recording must be live from A11)');
const resp = await sendMessageWithTimeout<{
success: boolean;
error?: string;
}>(
{ type: 'SAVE_ARCHIVE' },
A12_A13_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE',
);
diag(result, `Step 1 result: ${JSON.stringify(resp)}`);
result.checks.push({
name: 'A12.1: SAVE_ARCHIVE handler returns success=true (zip path will be ffprobe-validated host-side)',
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;
}
/**
* A13 — page-side: dispatch SAVE_ARCHIVE so a new zip lands in
* `downloadsDir`. Host-side driveA13 then:
* 1. polls downloadsDir for the new zip (snapshot delta).
* 2. parses with JSZip (`assertArchiveShape` in tests/uat/lib/zip.ts
* already encodes the full contract — A13 reuses it).
* 3. asserts `video/last_30sec.webm` entry present + size >= 1 KB,
* `meta.json` entry present + parses as JSON,
* `meta.json.extensionVersion === chrome.runtime.getManifest().version`
* (the harness's `getManifestVersion` helper is called once at
* orchestrator startup; driveA13 receives the expected version
* via closure).
*
* The SessionMetadata shape in src/shared/types.ts:103 names the
* field `extensionVersion` (NOT `version`); the `assertArchiveShape`
* helper in tests/uat/lib/zip.ts:25 currently models it as `version`
* — A13's driver passes the right field name (Wave 3D updates the
* helper to read `extensionVersion`, since it's the actual production
* field per src/background/index.ts:572).
*
* Plan 01-09 Amendment 3 (2026-05-19, debug session
* 01-09-save-does-not-stop-recording) update: the SAVE_ARCHIVE auto-stop
* was REVERSED. Under the new charter SAVE does NOT stop the recorder,
* so A12's SAVE leaves the recording live and A13 could in principle
* SAVE directly against A12's still-buffered segments. We KEEP the
* setupFreshRecording + 11s settle here as a defensive guarantee that
* A13 sees a known-good fresh-rotation buffer regardless of upstream
* assertion ordering (A12 itself might fail mid-flight or be reordered
* by a future maintainer; this isolation keeps A13's contract orthogonal).
* The 11s wall-clock cost is preserved — same as before Amendment 3.
*
* @returns Structured result with checks (SETUP + A13.1).
*/
async function assertA13(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A13 — SAVE_ARCHIVE zip shape: webm entry + meta.json + extensionVersion match (host-side gate)',
checks: [],
diagnostics: [],
};
try {
// Plan 01-09 Amendment 3 (2026-05-19): SAVE_ARCHIVE no longer
// auto-stops recording (charter reversed). A13 still re-establishes
// a fresh recording as a defensive guarantee — see function-level
// docstring above for rationale. Without the setupFreshRecording +
// settle, if A13 were ever reordered ahead of A11 or run in
// isolation against a buffer with stale/missing segments,
// saveArchive would throw EmptyVideoBufferError.
diag(result, 'Step 1: setupFreshRecording (defensive — guarantees fresh buffer for A13 SAVE)');
const setupResp = await setupFreshRecording();
if (!setupResp.ok) {
throw new Error(
`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`,
);
}
diag(result, 'Step 1 OK — fresh recording active');
result.checks.push({
name: 'SETUP: fresh recording established (defensive — orthogonal to A12 ordering)',
expected: true,
actual: true,
passed: true,
});
// Step 2 — segment-settle. Same rationale as A5 (line ~890): the
// offscreen recorder rotates segments every SEGMENT_DURATION_MS
// (10s). Before the first rotation, `segments` is empty and
// `getVideoBufferFromOffscreen` returns `{segments:[]}` which
// createArchive treats as EmptyVideoBufferError. Wait 11s so at
// least one segment lands in the buffer.
diag(result, `Step 2: settle ${A5_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A5_SEGMENT_SETTLE_MS));
diag(result, 'Step 2 OK — first rotation should have fired');
diag(result, 'Step 3: send SAVE_ARCHIVE to SW (own fresh-recording save)');
const resp = await sendMessageWithTimeout<{
success: boolean;
error?: string;
}>(
{ type: 'SAVE_ARCHIVE' },
A12_A13_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE',
);
diag(result, `Step 3 result: ${JSON.stringify(resp)}`);
result.checks.push({
name: 'A13.1: SAVE_ARCHIVE handler returns success=true (zip shape verified host-side)',
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;
}
/** Settle window for the SW state machine after SAVE_ARCHIVE completes.
* Under Amendment 3 (2026-05-19, charter reversed) saveArchive has no
* finally block — the state machine should remain in REC throughout.
* The 500ms settle is preserved for cross-event-loop quiescence of
* any in-flight chrome.action.* postings; the observed lag is
* typically a few ms. */
const A14_POST_SAVE_SETTLE_MS = 500;
/** Expected popup path suffix in REC mode. setRecordingMode pins the
* popup to POPUP_HTML_PATH ('src/popup/index.html'); chrome.action.getPopup
* resolves this to an absolute chrome-extension://<ext-id>/<path> URL.
* We assert via endsWith to stay ext-id-agnostic. */
const A14_POPUP_HTML_SUFFIX = 'src/popup/index.html';
/**
* 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 which asserted post-SAVE
* IDLE; under the reversed charter the SW MUST remain in REC after SAVE.
*
* Verifies that A13's SAVE_ARCHIVE (and by extension every SAVE) leaves
* the SW state machine UNCHANGED — recording continues:
* - badge text === 'REC' (setRecordingMode from the earlier
* setupFreshRecording is still in effect; no setIdleMode was called)
* - popup endsWith 'src/popup/index.html' (setRecordingMode pinned it;
* getPopup resolves to chrome-extension://<ext-id>/src/popup/index.html)
* - no NEW notification with the 'mokosh-recovery-' prefix surfaced
* since A14 entered (delta-based — A7 left a recovery notification
* in the active set; SAVE must NOT add another one, per the
* "save is not an error" contract — same as the prior Amendment 2
* contract, regression-preserved)
*
* Pre-condition: A13 just completed. A13 calls setupFreshRecording so
* the state machine entered REC for that assertion; under the REVERSED
* charter saveArchive does not transition out of REC, so the state
* remains REC when A14 reads it.
*
* Post-condition: no state change — A14 is read-only.
*
* Direct isRecording check is transitively verified via the presence of
* the REC badge — the production SW state machine
* (src/background/index.ts:setRecordingMode/setIdleMode/setErrorMode)
* pairs isRecording transitions with badge transitions atomically, so
* badge='REC' is a reliable proxy for isRecording=true.
*
* @returns Structured result with 3 checks (badge + popup + no-new-recovery-notif).
*/
async function assertA14(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A14 — post-SAVE continuous-recording: badge=\'REC\' + popup endsWith popup.html + no new mokosh-recovery-* notif',
checks: [],
diagnostics: [],
};
try {
// Snapshot the recovery-notification ids BEFORE the A14 settle.
// A7 left at least one recovery-* in the active set; the delta we
// care about is "did any NEW recovery surface since A13's SAVE
// completed". Under the REVERSED charter A13's SAVE does NOT
// dispatch STOP_RECORDING/setIdleMode; the SW stays in REC. The
// empty-buffer-error branch is not exercised under A13's happy
// path (it ran setupFreshRecording + 11s settle so segments
// are non-empty), so no RECORDING_ERROR is broadcast and no
// recovery notification surfaces.
diag(result, 'Step 1: snapshot mokosh-recovery-* notification ids (delta baseline)');
const idsBefore = await getActiveNotificationIds();
const recoveryIdsBefore = idsBefore.filter(
(id) => id.startsWith(RECOVERY_NOTIF_PREFIX),
);
diag(
result,
`Step 1 result: ${recoveryIdsBefore.length} active recovery-prefix ids: ${JSON.stringify(recoveryIdsBefore)}`,
);
diag(result, `Step 2: settle ${A14_POST_SAVE_SETTLE_MS}ms for post-A13 state machine to quiesce`);
await new Promise((r) => setTimeout(r, A14_POST_SAVE_SETTLE_MS));
diag(result, 'Step 3: read post-SAVE state (badge + popup + recovery ids delta)');
const badge = await chrome.action.getBadgeText({});
const popup = await chrome.action.getPopup({});
const idsAfter = await getActiveNotificationIds();
const recoveryIdsAfter = idsAfter.filter(
(id) => id.startsWith(RECOVERY_NOTIF_PREFIX),
);
const recoveryDelta = recoveryIdsAfter.length - recoveryIdsBefore.length;
diag(
result,
`Step 3 result: badge='${badge}', popup='${popup}', recoveryDelta=${recoveryDelta} (before=${recoveryIdsBefore.length}, after=${recoveryIdsAfter.length})`,
);
result.checks.push({
name: 'A14.1: badge text is \'REC\' after SAVE_ARCHIVE (recording continues; setRecordingMode persists)',
expected: 'REC',
actual: badge,
passed: badge === 'REC',
});
// chrome.action.getPopup() returns an absolute chrome-extension://
// URL ending in the registered path (POPUP_HTML_PATH = 'src/popup/index.html').
// Assert via endsWith to stay extension-id-agnostic.
result.checks.push({
name: `A14.2: popup endsWith '${A14_POPUP_HTML_SUFFIX}' after SAVE_ARCHIVE (setRecordingMode still pinned; onClicked stays inert)`,
expected: A14_POPUP_HTML_SUFFIX,
actual: popup,
passed: popup.endsWith(A14_POPUP_HTML_SUFFIX),
});
result.checks.push({
name: 'A14.3: NO new mokosh-recovery-* notification (save is not an error)',
expected: 0,
actual: recoveryDelta,
passed: recoveryDelta === 0,
});
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;
}
/* ─── Plan 01-10 Wave 3 — A15 + A16 + A17 (onboarding + design-swap-readiness) ───
*
* Plan 01-10 D-17-onboarding harness extensions:
* A15 — onboarding flag observability: chrome.storage.local
* 'onboarding-completed' === true AND 'installed-at' is a
* number (SW's openWelcomeIfFirstInstall side-effects observed
* from extension-internal context with full chrome.* privilege).
* A16 — subsequent-install does NOT re-open welcome tab: snapshot
* chrome.tabs.query before a 2-second settle window; assert no
* new welcome.html tabs appear (flag-gating contract: the SW's
* onInstalled handler running again across SW respawns must
* NOT spawn additional welcome tabs once the flag is set).
* A17 — design-swap-readiness invariant (EXTENDED per Plan 01-12
* path-B revision): welcome.html parses + .welcome-hero slot
* exists + ≥7 mokosh-keyed attrs + welcome.css carries the
* canonical @import directive (or inlined evidence) + zero
* #hex literals in welcome.css source-file region + ≥5
* var(--mks-*) references + bundled welcome JS contains COPY[
* OR chrome.i18n.getMessage('welcomeHero pattern + A17.7's
* getComputedStyle probe resolves --mks-rec to a non-default
* value (canonical rgb(178, 84, 61) = #b2543d).
*
* Each assertion uses ONLY production chrome.* APIs + fetch + DOMParser
* + getComputedStyle — NO new test-mode symbols are introduced (Tier-1
* FORBIDDEN_HOOK_STRINGS stays at 12 post-01-14).
*/
/**
* A15 — onboarding flag observability. Read-only inspection of
* chrome.storage.local for the two keys Plan 01-10 Wave 2's
* openWelcomeIfFirstInstall sets on first install:
* - 'onboarding-completed' === true
* - 'installed-at' === <number> (Date.now() at install)
*
* The flag-gating contract this verifies is: the SW's onInstalled
* handler ran with reason='install' AT LEAST ONCE this session AND
* the helper completed both chrome.storage.local.set + chrome.tabs.create
* before A15 fires. The harness page is opened as a normal tab during
* `launchHarnessBrowser` AFTER Chrome has loaded the extension, so by
* the time A15 fires the chrome.runtime.onInstalled.addListener has
* already fired with reason='install' (first-install path) and the
* helper has had ample time to complete its three awaited calls.
*
* Mirrors `assertA22` style: bridge-free read of public chrome.storage.local.
*
* @returns Structured result with 2 checks (flag === true + installed-at number).
*/
async function assertA15(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: "A15 — onboarding flag set on first install (chrome.storage.local 'onboarding-completed' === true; 'installed-at' is number)",
checks: [],
diagnostics: [],
};
try {
diag(result, "Step 1: chrome.storage.local.get(['onboarding-completed', 'installed-at'])");
const stored = await chrome.storage.local.get([
'onboarding-completed',
'installed-at',
]);
diag(result, `Step 1 result: ${JSON.stringify(stored)}`);
result.checks.push({
name: "A15.1: chrome.storage.local 'onboarding-completed' === true (set by openWelcomeIfFirstInstall on first install)",
expected: true,
actual: stored['onboarding-completed'],
passed: stored['onboarding-completed'] === true,
});
result.checks.push({
name: "A15.2: chrome.storage.local 'installed-at' is a number (Date.now() recorded at install)",
expected: 'number',
actual: typeof stored['installed-at'],
passed: typeof stored['installed-at'] === 'number',
});
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;
}
/** A16 — subsequent-install settle window (welcome tab does NOT spontaneously
* reappear). Snapshot before/after a 2-second window; the delta MUST be 0
* because the openWelcomeIfFirstInstall helper's flag-gating prevents
* re-fire even if the SW spawns multiple onInstalled events across
* respawn cycles. 2 s is a generous over-budget; the SW's storage.get
* + storage.set + tabs.create round-trip is sub-100ms. */
const A16_SETTLE_MS = 2_000;
const A16_WELCOME_URL_SUFFIX = 'src/welcome/welcome.html';
/**
* A16 — subsequent-install does NOT re-open welcome tab. Snapshot
* chrome.tabs.query({}) for welcome.html tab URLs before a settle
* window, then again after, and assert no NEW welcome tabs appeared.
*
* Implementation note: chrome.tabs.query without filters returns all
* tabs across all windows. We filter to URLs ending with the
* '/src/welcome/welcome.html' suffix (extension-id-agnostic).
*
* @returns Structured result with 1 check (delta === 0).
*/
async function assertA16(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A16 — subsequent install does NOT re-open welcome tab (2s settle window: no new welcome.html tabs appear)',
checks: [],
diagnostics: [],
};
try {
diag(result, 'Step 1: snapshot chrome.tabs.query({}) — count welcome.html tabs BEFORE settle');
const tabsBefore = await chrome.tabs.query({});
const beforeCount = tabsBefore.filter(
(t) => typeof t.url === 'string' && t.url.endsWith(A16_WELCOME_URL_SUFFIX),
).length;
diag(result, `Step 1 result: ${beforeCount} welcome tab(s) before settle`);
diag(result, `Step 2: settle ${A16_SETTLE_MS}ms (sufficient for any spurious onInstalled re-fire to flush)`);
await new Promise((r) => setTimeout(r, A16_SETTLE_MS));
diag(result, 'Step 3: re-snapshot chrome.tabs.query({}) — count welcome.html tabs AFTER settle');
const tabsAfter = await chrome.tabs.query({});
const afterCount = tabsAfter.filter(
(t) => typeof t.url === 'string' && t.url.endsWith(A16_WELCOME_URL_SUFFIX),
).length;
diag(result, `Step 3 result: ${afterCount} welcome tab(s) after settle (delta=${afterCount - beforeCount})`);
const delta = afterCount - beforeCount;
result.checks.push({
name: 'A16.1: welcome-tab count delta over 2s settle === 0 (onInstalled flag-gating works)',
expected: 0,
actual: delta,
passed: delta === 0,
});
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;
}
/** A17 invariants — design-swap-readiness contract from Plan 01-10
* must_have #9. Each constant captures one numeric threshold used in
* the A17 sub-checks.
*
* A17.3: zero hex literals in welcome.css source-file region — relaxed
* per post-01-12 revision: if Vite inlines @import contents, hex
* literals from the canonical token values may surface in the BUILT
* artifact. Pass criteria becomes (no hex OR canonical-tokens-resolved).
* A17.4: ≥ 5 var(--mks-*) references.
* A17.5: @import '../shared/tokens.css' literal in welcome.css OR
* inlined evidence via '--mks-rec' literal in the compiled bundle.
* A17.6: bundled JS contains 'COPY[' OR 'chrome.i18n.getMessage('
* with welcomeHero substring (either pattern proves populate plumbing).
* A17.7: getComputedStyle probe — --mks-rec resolves to canonical
* rgb(178, 84, 61). The harness page <link>s tokens.css directly (Plan
* 01-12 Wave 6); the probe creates a transient div, applies the var,
* reads computed color.
*/
const A17_MIN_KEYED_ATTRS = 7;
const A17_MIN_VAR_USES = 5;
const A17_CANONICAL_REC_RGB = 'rgb(178, 84, 61)';
/**
* A17 — design-swap-readiness invariant. Multi-step assertion verifying
* welcome.html + welcome.css + the bundled welcome JS chunk all hold
* the contracts that Plan 01-10's path-B (canonical tokens import) +
* chrome.i18n adoption depend on.
*
* Reaches the bundled JS via parsing the <script src> in welcome.html
* (Vite emits hashed chunk names; resolving relative-to-welcome.html
* stays bundle-agnostic). Runs the --mks-rec probe in the harness page
* context where tokens.css is <link>-loaded directly (Plan 01-12 Wave 6
* line 15 of extension-page-harness.html).
*
* @returns Structured result with 7 sub-checks (A17.1..A17.7).
*/
async function assertA17(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A17 — design-swap-readiness: welcome.html parses; .welcome-hero exists; ≥7 mokosh-keyed attrs; welcome.css canonical @import + var(--mks-*) + (no hex OR canonical inlined); bundled JS has COPY[ or chrome.i18n.getMessage(welcomeHero; --mks-rec resolves',
checks: [],
diagnostics: [],
};
try {
const welcomeUrl = chrome.runtime.getURL('src/welcome/welcome.html');
diag(result, `Step 1: fetch ${welcomeUrl}`);
const htmlRes = await fetch(welcomeUrl);
if (!htmlRes.ok) {
throw new Error(`welcome.html returned HTTP ${htmlRes.status} ${htmlRes.statusText}`);
}
const htmlText = await htmlRes.text();
const parsed = new DOMParser().parseFromString(htmlText, 'text/html');
const hero = parsed.querySelector('.welcome-hero');
diag(result, `Step 1 result: ${htmlText.length}-byte HTML; .welcome-hero ${hero === null ? '<missing>' : '<found>'}`);
result.checks.push({
name: 'A17.1: welcome.html parses + .welcome-hero slot exists',
expected: 'non-null',
actual: hero === null ? 'null' : 'element',
passed: hero !== null,
});
diag(result, 'Step 2: count data-mokosh-key + data-mokosh-i18n-key attributes');
const dataKeyMatches = htmlText.match(/data-mokosh-key=/g) ?? [];
const dataI18nKeyMatches = htmlText.match(/data-mokosh-i18n-key=/g) ?? [];
const totalKeyedAttrs = dataKeyMatches.length + dataI18nKeyMatches.length;
diag(result, `Step 2 result: data-mokosh-key=${dataKeyMatches.length}, data-mokosh-i18n-key=${dataI18nKeyMatches.length}, total=${totalKeyedAttrs}`);
result.checks.push({
name: `A17.2: welcome.html has >= ${A17_MIN_KEYED_ATTRS} data-mokosh-key + data-mokosh-i18n-key attributes combined`,
expected: `>= ${A17_MIN_KEYED_ATTRS}`,
actual: `${totalKeyedAttrs} (data-mokosh-key=${dataKeyMatches.length}, data-mokosh-i18n-key=${dataI18nKeyMatches.length})`,
passed: totalKeyedAttrs >= A17_MIN_KEYED_ATTRS,
});
// Resolve the welcome.css URL via the parsed <link> tag (Vite
// rebases hashed asset filenames; stay agnostic to the hash).
const linkEl = parsed.querySelector('link[rel="stylesheet"][href]');
const cssHref = linkEl?.getAttribute('href') ?? '';
if (cssHref.length === 0) {
throw new Error('no <link rel="stylesheet" href> in welcome.html');
}
const baseUrl = new URL(welcomeUrl);
const cssUrl = new URL(cssHref, baseUrl).href;
diag(result, `Step 3: fetch ${cssUrl}`);
const cssRes = await fetch(cssUrl);
if (!cssRes.ok) {
throw new Error(`welcome.css returned HTTP ${cssRes.status} ${cssRes.statusText}`);
}
const cssText = await cssRes.text();
diag(result, `Step 3 result: ${cssText.length}-byte CSS`);
const hexMatches = cssText.match(/#[0-9a-fA-F]{3,8}/g) ?? [];
const hasCanonicalImportLiteral =
cssText.includes("@import '../shared/tokens.css'")
|| cssText.includes('@import "../shared/tokens.css"');
const hasCanonicalInlined = cssText.includes('--mks-rec');
const hasCanonicalResolution = hasCanonicalImportLiteral || hasCanonicalInlined;
diag(result, `Step 4: hex=${hexMatches.length}, importLiteral=${hasCanonicalImportLiteral}, inlinedTokens=${hasCanonicalInlined}`);
result.checks.push({
name: 'A17.3: welcome.css source has zero hex literals OR canonical @import resolves',
expected: '0 hex OR canonical-tokens-resolved',
actual: `hex=${hexMatches.length}, canonicalResolved=${hasCanonicalResolution}`,
passed: hexMatches.length === 0 || hasCanonicalResolution,
});
const varMatches = cssText.match(/var\(--mks-/g) ?? [];
result.checks.push({
name: `A17.4: welcome.css contains >= ${A17_MIN_VAR_USES} var(--mks- references`,
expected: `>= ${A17_MIN_VAR_USES}`,
actual: String(varMatches.length),
passed: varMatches.length >= A17_MIN_VAR_USES,
});
result.checks.push({
name: "A17.5: welcome.css has canonical tokens @import (or inlined --mks-* evidence)",
expected: "@import '../shared/tokens.css' OR --mks-* inlined",
actual: hasCanonicalResolution ? 'present' : 'absent',
passed: hasCanonicalResolution,
});
// Locate the bundled welcome JS chunk via the parsed <script src>.
const scriptEl = parsed.querySelector('script[type="module"][src]');
const scriptSrc = scriptEl?.getAttribute('src') ?? '';
if (scriptSrc.length === 0) {
throw new Error('no <script type="module" src> in welcome.html');
}
const jsUrl = new URL(scriptSrc, baseUrl).href;
diag(result, `Step 5: fetch ${jsUrl}`);
const jsRes = await fetch(jsUrl);
if (!jsRes.ok) {
throw new Error(`welcome JS chunk returned HTTP ${jsRes.status} ${jsRes.statusText}`);
}
const jsText = await jsRes.text();
diag(result, `Step 5 result: ${jsText.length}-byte JS`);
// A17.6: prove the populate plumbing survives the build. Vite's
// terser/Rollup minifier rewrites local variable names (e.g.
// `COPY` → `f`; `COPY[key]` → `f[e.key]`) AND inserts optional
// chaining at chrome.i18n call sites (e.g. `chrome?.i18n?.getMessage?.(`
// per the welcome.ts source pattern matching the popup precedent).
// Substring matching the verbatim source identifiers is brittle
// against these transforms.
//
// Two minification-survivable witnesses are checked:
// 1. COPY map content fingerprint — the literal string keys from
// the source COPY map are preserved verbatim through terser
// (they are Object.freeze literal keys; mangling skips
// object literals). Match 'welcome.page.title' which is one
// of the six non-tagline keys defined in src/welcome/copy.ts.
// 2. chrome.i18n.getMessage call (with or without optional
// chaining) — minifier preserves `chrome` (global) and
// `i18n.getMessage` (property access; can't be renamed across
// module boundaries). Match `i18n` AND `getMessage` AND
// either `welcomeHero` literal key (Object.freeze fingerprint
// from the fallbacks Object.freeze) or `WELCOME_HERO_RU_FALLBACK`
// const name (in dev/non-minified builds).
const hasCopyKeyLiteral = jsText.includes('welcome.page.title');
const hasI18nWelcomeHero =
jsText.includes('i18n')
&& jsText.includes('getMessage')
&& jsText.includes('welcomeHero');
result.checks.push({
name: 'A17.6: bundled JS preserves populate plumbing (COPY key literal AND chrome.i18n welcomeHero call site)',
expected: 'COPY key literal OR chrome.i18n.getMessage welcomeHero references',
actual: `copyKey('welcome.page.title')=${hasCopyKeyLiteral}, i18nWelcomeHero=${hasI18nWelcomeHero}`,
passed: hasCopyKeyLiteral || hasI18nWelcomeHero,
});
diag(result, 'Step 6: probe --mks-rec via getComputedStyle on a transient div');
const probe = document.createElement('div');
probe.style.color = 'var(--mks-rec)';
document.body.appendChild(probe);
const resolvedColor = getComputedStyle(probe).color;
document.body.removeChild(probe);
diag(result, `Step 6 result: getComputedStyle.color = '${resolvedColor}'`);
// Accept canonical rgb(178, 84, 61) OR any non-default non-empty
// value (the probe inheriting the browser default rgb(0, 0, 0)
// would mean tokens didn't resolve through the page's stylesheet
// chain). The harness page <link>s tokens.css directly per Plan
// 01-12 Wave 6 line 15, so the canonical value SHOULD resolve.
const resolvedNonDefault =
resolvedColor.length > 0
&& resolvedColor !== 'rgb(0, 0, 0)'
&& !resolvedColor.includes('rgba(0, 0, 0, 0)');
const resolvedCanonical = resolvedColor === A17_CANONICAL_REC_RGB;
result.checks.push({
name: `A17.7: --mks-rec resolves to non-default value via getComputedStyle (canonical=${A17_CANONICAL_REC_RGB} per D-04 Loom palette)`,
expected: `non-default (preferably ${A17_CANONICAL_REC_RGB})`,
actual: `${resolvedColor} (canonical=${resolvedCanonical})`,
passed: resolvedNonDefault,
});
result.passed = result.checks.every((c) => c.passed);
diag(result, `A17: ${result.checks.filter((c) => c.passed).length}/${result.checks.length} subchecks passed`);
} catch (err) {
result.error = err instanceof Error ? err.message : String(err);
diag(result, `THREW: ${result.error}`);
}
return result;
}
/**
* A23 — Plan 01-14 picker-narrowing constraint verification.
*
* Asserts that the production `getDisplayMedia` call at
* src/offscreen/recorder.ts:270 passes `monitorTypeSurfaces: 'include'`
* as a top-level constraint sibling of `video:` (W3C Screen Capture
* spec §6.1; Chrome ≥ 119 picker-narrowing semantics — only monitor
* surfaces are offered, no Window/Chrome-Tab panes).
*
* Queries the offscreen bridge `get-last-getDisplayMedia-constraints` op
* which reads the recorded constraints from the `fakeGetDisplayMedia`
* shim's last invocation. A23 chains AFTER A14 (A14 is the final read-
* only post-SAVE state check; A23 is independent because A2's
* setupFreshRecording already invoked getDisplayMedia and the recorded
* cell is read-only from A23's perspective — no side effects).
*
* Mirrors `assertA3` (line 686): bridge query → structured AssertionResult
* with two checks (non-null constraints + monitorTypeSurfaces value).
*
* @returns Structured result with the monitorTypeSurfaces check.
*/
async function assertA23(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A23 — getDisplayMedia called with monitorTypeSurfaces:\'include\' (Plan 01-14 picker narrowing)',
checks: [],
diagnostics: [],
};
try {
diag(result, "Step 1: bridge query 'get-last-getDisplayMedia-constraints'");
const resp = await offscreenQuery<{
constraints?: DisplayMediaStreamOptions | null;
ok?: boolean;
error?: string;
}>('get-last-getDisplayMedia-constraints');
diag(result, `Step 1 result: ${JSON.stringify(resp)}`);
if (resp.ok === false) {
throw new Error(
`get-last-getDisplayMedia-constraints returned ok=false: ${resp.error ?? '(no error)'}`,
);
}
const constraints = resp.constraints ?? null;
result.checks.push({
name: 'A23.1: constraints object recorded by fakeGetDisplayMedia (non-null)',
expected: 'non-null DisplayMediaStreamOptions',
actual: constraints === null ? '<null>' : JSON.stringify(constraints),
passed: constraints !== null,
});
// The constraints object MAY be widened by the production cast (per
// Plan 01-14 source change) to include the top-level field, so we
// dot-access via the same indexed-property shape (`as any`) since
// monitorTypeSurfaces is not in the lib.dom.d.ts DisplayMediaStreamOptions
// ambient (TypeScript bundled types lag the W3C spec — same lag that
// forced the `cursor: 'always'` typed-widening cast on recorder.ts:268).
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- constraints widening for top-level monitorTypeSurfaces sibling per W3C spec §6.1 (lib.dom.d.ts lag)
const monitorTypeSurfaces = (constraints as any)?.monitorTypeSurfaces ?? null;
result.checks.push({
name: 'A23.2: constraints.monitorTypeSurfaces === \'include\' (W3C spec §6.1; Chrome ≥ 119 picker narrowing)',
expected: 'include',
actual: monitorTypeSurfaces,
passed: monitorTypeSurfaces === 'include',
});
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 6 — A18 + A19 + A20 + A21 + A22 ─────────────────────────────
*
* Plan 01-12 Wave 6 design-integration harness extensions:
* A18 — Lora WOFF2 reachability + size floor (font self-host invariant)
* A19 — Loom icons NOT the prior Bug A placeholders (icon-overwrite
* invariant)
* A20 — manifest:name resolves via chrome i18n to 'Mokosh — Session
* Capture' (default_locale='en' fallback chain)
* A21 — getComputedStyle on .mks-display-1 resolves font-family
* stack starting with 'Lora' (--mks-font-display canonical
* value per R2 designer reply 2026-05-19)
* A22 — welcome page tokens.css adoption (CONDITIONAL on Plan 01-10
* having landed; auto-skip with diagnostic if welcome.html 404s)
*
* Each assertion uses ONLY production chrome.* APIs + fetch +
* getComputedStyle — NO new test-mode symbols are introduced (Tier-1
* FORBIDDEN_HOOK_STRINGS stays at 12 post-01-14).
*/
/** A18 — Lora WOFF2 reachability + size floor. Vite emits the WOFF2
* files under dist-test/assets/<hash>.woff2 with content-hashed names;
* walk document.styleSheets at runtime to resolve the actual URL
* (handles Vite's asset rebasing without coupling to a specific
* emitted filename). Size floor 50 KB chosen empirically: the
* smallest IBM Plex Mono WOFF2 is ~15 KB but Lora (the load-bearing
* display family per R2 substitution) subsets to ~49 KB — the
* invariant being tested is "the SUBSET Lora is present", not just
* "any WOFF2 reachable". */
const A18_LORA_MIN_BYTES = 40_000;
async function assertA18(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A18 — Lora WOFF2 reachable from harness page (font self-host MV3 CSP invariant)',
checks: [],
diagnostics: [],
};
try {
diag(result, 'Step 1: walk document.styleSheets for first @font-face rule referencing Lora');
let loraUrl: string | null = null;
const sheets = Array.from(document.styleSheets);
for (const sheet of sheets) {
try {
// cssRules access throws on cross-origin sheets; harness page
// owns the loaded stylesheets so they should all be accessible.
const rules = Array.from(sheet.cssRules);
for (const rule of rules) {
if (rule.constructor.name === 'CSSFontFaceRule') {
const src = (rule as CSSFontFaceRule).style.getPropertyValue('src');
const match = src.match(/url\(["']?([^"')]*Lora[^"')]*\.woff2)["']?\)/);
if (match !== null) {
loraUrl = match[1];
break;
}
}
}
} catch (sheetErr) {
diag(result, `(skip sheet — cssRules threw: ${(sheetErr as Error).message})`);
}
if (loraUrl !== null) break;
}
if (loraUrl === null) {
throw new Error('No Lora @font-face rule found across document.styleSheets');
}
diag(result, `Step 1 result: loraUrl=${loraUrl}`);
result.checks.push({
name: 'A18.1: Lora @font-face rule present in a stylesheet (tokens.css adoption)',
expected: 'src url(...Lora....woff2) matched',
actual: loraUrl,
passed: true,
});
diag(result, `Step 2: fetch ${loraUrl}`);
const resolvedUrl = new URL(loraUrl, document.location.href).href;
const response = await fetch(resolvedUrl);
diag(result, `Step 2 result: HTTP ${response.status} ${response.statusText}`);
result.checks.push({
name: 'A18.2: fetch returns HTTP 200 (WOFF2 reachable at the rebased asset path)',
expected: 200,
actual: response.status,
passed: response.ok,
});
if (!response.ok) {
result.passed = false;
return result;
}
diag(result, 'Step 3: read arrayBuffer + assert byteLength floor');
const buf = await response.arrayBuffer();
diag(result, `Step 3 result: byteLength=${buf.byteLength}`);
result.checks.push({
name: `A18.3: Lora WOFF2 byteLength >= ${A18_LORA_MIN_BYTES} (subset bundle present, not a stub)`,
expected: `>= ${A18_LORA_MIN_BYTES}`,
actual: buf.byteLength,
passed: buf.byteLength >= A18_LORA_MIN_BYTES,
});
// Additional sanity: WOFF2 signature 'wOF2' = 0x77 0x4F 0x46 0x32
const head = new Uint8Array(buf, 0, 4);
const isWoff2 = head[0] === 0x77 && head[1] === 0x4f && head[2] === 0x46 && head[3] === 0x32;
result.checks.push({
name: "A18.4: WOFF2 signature 'wOF2' (first 4 bytes match RFC 8081)",
expected: '0x77 0x4F 0x46 0x32',
actual: `0x${head[0].toString(16).padStart(2, '0')} 0x${head[1].toString(16).padStart(2, '0')} 0x${head[2].toString(16).padStart(2, '0')} 0x${head[3].toString(16).padStart(2, '0')}`,
passed: isWoff2,
});
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;
}
/** A19 — Loom icons NOT the Bug A placeholders. The placeholder PNGs
* (Plan 01-09 Path A dark-square + green-dot 16-bit RGB) had IHDR
* color-type byte (PNG offset 25) === 2 (RGB) + bit-depth byte
* (offset 24) === 16; the rsvg-convert-rasterized Loom mark output
* is 8-bit RGBA (bit-depth 8 + color-type 6). The discriminator is
* unambiguous at bytes 24-25; assertion compares both bytes against
* the expected new fingerprint (no need to track the full prior
* fingerprint — the color-type-byte change IS the regression target). */
const A19_EXPECTED_BIT_DEPTH = 8;
const A19_EXPECTED_COLOR_TYPE = 6; // RGBA
async function assertA19(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A19 — icons rasterized from Loom mark (8-bit RGBA, NOT 16-bit RGB Bug A placeholders)',
checks: [],
diagnostics: [],
};
try {
const url = chrome.runtime.getURL('icons/icon128.png');
diag(result, `Step 1: fetch ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`fetch ${url} returned HTTP ${response.status}`);
}
const buf = await response.arrayBuffer();
diag(result, `Step 1 result: byteLength=${buf.byteLength}`);
// PNG IHDR layout per ISO/IEC 15948 §11.2.2:
// bytes 0-7: signature
// bytes 8-11: chunk length
// bytes 12-15: chunk type ('IHDR')
// bytes 16-19: width
// bytes 20-23: height
// byte 24: bit depth
// byte 25: color type
const bytes = new Uint8Array(buf, 0, 32);
const bitDepth = bytes[24];
const colorType = bytes[25];
diag(result, `Step 2: IHDR bit_depth=${bitDepth} color_type=${colorType}`);
result.checks.push({
name: `A19.1: icon128.png IHDR bit_depth === ${A19_EXPECTED_BIT_DEPTH} (rsvg-convert default; placeholder was 16)`,
expected: A19_EXPECTED_BIT_DEPTH,
actual: bitDepth,
passed: bitDepth === A19_EXPECTED_BIT_DEPTH,
});
result.checks.push({
name: `A19.2: icon128.png IHDR color_type === ${A19_EXPECTED_COLOR_TYPE} (RGBA; placeholder was 2 — RGB)`,
expected: A19_EXPECTED_COLOR_TYPE,
actual: colorType,
passed: colorType === A19_EXPECTED_COLOR_TYPE,
});
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;
}
/** A20 — manifest:name resolves via chrome i18n. With default_locale='en',
* chrome.runtime.getManifest().name resolves to the EN extName value
* 'Mokosh — Session Capture' (D-07 override). Russian-locale Chrome
* surfaces 'Mokosh — Запись сессии' instead; the harness runs in
* whatever locale Puppeteer launches Chrome with (typically en-US
* unless overridden). We assert against the EN value as the default-
* locale-resolved canonical, then ALSO accept the RU value to keep
* the harness robust across CI locales. */
const A20_EN_EXTNAME = 'Mokosh — Session Capture';
const A20_RU_EXTNAME = 'Mokosh — Запись сессии';
async function assertA20(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A20 — manifest:name resolves via chrome i18n (default_locale=en fallback chain)',
checks: [],
diagnostics: [],
};
try {
const manifest = chrome.runtime.getManifest();
diag(result, `Step 1: chrome.runtime.getManifest().name=${JSON.stringify(manifest.name)}`);
const isResolved = manifest.name === A20_EN_EXTNAME || manifest.name === A20_RU_EXTNAME;
result.checks.push({
name: `A20.1: manifest.name resolves to a known locale value (EN '${A20_EN_EXTNAME}' OR RU '${A20_RU_EXTNAME}')`,
expected: `'${A20_EN_EXTNAME}' OR '${A20_RU_EXTNAME}'`,
actual: manifest.name,
passed: isResolved,
});
// Bonus check: the raw __MSG_ placeholder should NEVER leak through
// — if chrome.i18n is misconfigured (missing _locales/, wrong
// default_locale, etc.) the literal '__MSG_extName__' surfaces as
// the resolved name. Catch that class of regression explicitly.
result.checks.push({
name: "A20.2: manifest.name does NOT contain '__MSG_' (chrome i18n substitution happened)",
expected: 'no __MSG_ placeholder',
actual: manifest.name,
passed: !manifest.name.includes('__MSG_'),
});
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;
}
/** A21 — `--mks-font-display` resolves to a font-family stack starting
* with Lora. The canonical token value per R2 designer reply
* 2026-05-19 is `"Lora", "Iowan Old Style", "Times New Roman", serif`.
* Creates a transient probe div, applies `.mks-display-1` (which sets
* font-family: var(--mks-font-display)), reads getComputedStyle, and
* asserts the resolved font-family stack starts with 'Lora' or
* '"Lora"' (browsers normalize quoting differently — both forms
* acceptable). */
async function assertA21(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A21 — --mks-font-display resolves to Lora stack (R2 designer reply 2026-05-19)',
checks: [],
diagnostics: [],
};
let probe: HTMLDivElement | null = null;
try {
diag(result, "Step 1: create transient probe div with class='mks-display-1'");
probe = document.createElement('div');
probe.className = 'mks-display-1';
probe.textContent = 'Probe';
// Hide off-screen so the visual harness page doesn't shift layout.
probe.style.position = 'absolute';
probe.style.left = '-9999px';
probe.style.top = '-9999px';
document.body.appendChild(probe);
// Force a layout so CSS resolves.
void probe.offsetHeight;
const computed = window.getComputedStyle(probe).fontFamily;
diag(result, `Step 1 result: getComputedStyle(probe).fontFamily=${JSON.stringify(computed)}`);
// Accept both quoted ('Lora') and unquoted (Lora) leading forms.
// Chrome typically returns the family list with each member quoted
// if it contains a space or non-identifier; Lora is identifier-safe
// so it MAY be unquoted in the resolved stack. Belt-and-suspenders.
const startsWithLora = /^("Lora"|Lora)/.test(computed);
result.checks.push({
name: "A21.1: getComputedStyle(.mks-display-1).fontFamily starts with Lora (no fallback chain hit)",
expected: "starts with '\"Lora\"' OR 'Lora'",
actual: computed,
passed: startsWithLora,
});
// Also confirm Newsreader is absent — would indicate a stale
// unmigrated tokens.css.
const newsreaderAbsent = !/Newsreader/i.test(computed);
result.checks.push({
name: 'A21.2: resolved fontFamily does NOT contain Newsreader (R2 substitution complete)',
expected: 'no Newsreader',
actual: computed,
passed: newsreaderAbsent,
});
result.passed = result.checks.every((c) => c.passed);
} catch (err) {
result.error = err instanceof Error ? err.message : String(err);
diag(result, `THREW: ${result.error}`);
} finally {
if (probe !== null && probe.parentNode !== null) {
probe.parentNode.removeChild(probe);
}
}
return result;
}
/** A22 — welcome page tokens.css adoption. CONDITIONAL on Plan 01-10
* having landed. If src/welcome/welcome.html is reachable, fetches it
* + the linked welcome.css and asserts substantive var(--mks-*) usage
* (regex /var\(--mks-[a-z-]+\)/g match count >= 3). If welcome.html
* returns HTTP 404, A22 PASSES with a 'Plan 01-10 not landed; skipped'
* diagnostic — mirrors the assertA12 ffprobe skip-gate pattern. */
const A22_MIN_TOKEN_USES = 3;
async function assertA22(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A22 — welcome page adopts canonical tokens.css (Plan 01-10 conditional; skip-gate on 404 OR fetch-failed)',
checks: [],
diagnostics: [],
};
try {
const welcomeUrl = chrome.runtime.getURL('src/welcome/welcome.html');
diag(result, `Step 1: HEAD-equivalent fetch ${welcomeUrl} (skip-gate probe)`);
// Chrome extensions throw a TypeError 'Failed to fetch' at the
// network layer when the requested path is not in
// web_accessible_resources OR the file is genuinely absent. Both
// failure modes mean Plan 01-10 has not landed — skip-gate
// accordingly. (A regular HTTP 404 also flows here through the
// try-pathway in case future Chrome versions normalize the
// behavior.)
let probe: Response;
try {
probe = await fetch(welcomeUrl);
} catch (fetchErr) {
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
result.checks.push({
name: "A22.SKIPPED: welcome.html fetch threw — Plan 01-10 not landed; A22 passes informationally",
expected: 'reachable OR network/404 (both mean 01-10 not landed)',
actual: `fetch threw: ${msg}`,
passed: true,
});
result.passed = true;
diag(result, `A22 SKIPPED — Plan 01-10 not landed (fetch threw: ${msg})`);
return result;
}
diag(result, `Step 1 result: HTTP ${probe.status}`);
if (probe.status === 404) {
// Skip-gate: Plan 01-10 has not yet landed; A22 PASSES with
// diagnostic per Plan 01-12 Wave 6 §interfaces note.
result.checks.push({
name: "A22.SKIPPED: welcome.html returned 404 — Plan 01-10 not landed; A22 passes informationally",
expected: 'src/welcome/welcome.html reachable OR 404',
actual: 'HTTP 404',
passed: true,
});
result.passed = true;
diag(result, 'A22 SKIPPED — Plan 01-10 not landed (welcome.html absent)');
return result;
}
if (!probe.ok) {
throw new Error(`welcome.html returned unexpected HTTP ${probe.status} ${probe.statusText}`);
}
diag(result, 'Step 2: read welcome.html body + extract <link rel="stylesheet" href> targets');
const html = await probe.text();
const linkMatches = Array.from(html.matchAll(/<link[^>]+rel=["']?stylesheet["']?[^>]+href=["']([^"']+)["']/gi));
diag(result, `Step 2 result: ${linkMatches.length} stylesheet link(s) found`);
if (linkMatches.length === 0) {
throw new Error('No <link rel="stylesheet"> found in welcome.html');
}
// Fetch each linked stylesheet + count var(--mks-*) usages across all of them.
let totalTokenUses = 0;
let hasTokensImport = false;
for (const match of linkMatches) {
const href = match[1];
const resolved = new URL(href, welcomeUrl).href;
diag(result, `Step 3: fetch ${resolved}`);
const cssResp = await fetch(resolved);
if (!cssResp.ok) {
diag(result, `(skip ${href} — HTTP ${cssResp.status})`);
continue;
}
const css = await cssResp.text();
const tokenMatches = css.match(/var\(--mks-[a-z-]+\)/g) ?? [];
totalTokenUses += tokenMatches.length;
if (/tokens\.css/.test(css)) hasTokensImport = true;
diag(result, `Step 3 result: ${href} contains ${tokenMatches.length} var(--mks-*) usages; tokens.css ref=${hasTokensImport}`);
}
result.checks.push({
name: `A22.1: welcome page stylesheets contain >= ${A22_MIN_TOKEN_USES} var(--mks-*) usages OR @import tokens.css`,
expected: `>= ${A22_MIN_TOKEN_USES} var(--mks-*) usages OR tokens.css reference`,
actual: `${totalTokenUses} var(--mks-*) + tokens.css=${hasTokensImport}`,
passed: totalTokenUses >= A22_MIN_TOKEN_USES || hasTokensImport,
});
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;
}
/**
* Read `chrome.runtime.getManifest().version`. Used by the host-side
* orchestrator at startup to capture the expected version for A13's
* meta.json check. The harness page has the manifest available
* synchronously via `chrome.runtime.getManifest()` (no async needed),
* but we wrap it in a Promise for uniform driver evaluation shape.
*
* @returns The extension version string (e.g. '1.0.0').
*/
async function getManifestVersion(): Promise<string> {
return chrome.runtime.getManifest().version;
}
// 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>;
assertA11: () => Promise<AssertionResult>;
assertA12: () => Promise<AssertionResult>;
assertA13: () => Promise<AssertionResult>;
assertA14: () => Promise<AssertionResult>;
// Plan 01-10 Wave 3 — onboarding + design-swap-readiness
assertA15: () => Promise<AssertionResult>;
assertA16: () => Promise<AssertionResult>;
assertA17: () => Promise<AssertionResult>;
// Plan 01-12 Wave 6 — design-integration assertions
assertA18: () => Promise<AssertionResult>;
assertA19: () => Promise<AssertionResult>;
assertA20: () => Promise<AssertionResult>;
assertA21: () => Promise<AssertionResult>;
assertA22: () => Promise<AssertionResult>;
// Plan 01-14 — picker narrowing
assertA23: () => Promise<AssertionResult>;
getManifestVersion: () => Promise<string>;
};
}
}
window.__mokoshHarness = {
assertA1,
assertA2,
assertA3,
assertA4,
assertA5,
assertA6,
assertA7,
assertA8,
assertA9,
assertA10,
assertA11,
assertA12,
assertA13,
assertA14,
assertA15,
assertA16,
assertA17,
assertA18,
assertA19,
assertA20,
assertA21,
assertA22,
assertA23,
getManifestVersion,
};
const statusEl = document.getElementById('status');
if (statusEl !== null) {
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA17, assertA18..A22, assertA23, getManifestVersion} available.';
}
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + getManifestVersion)');
export {};