Files
mokosh/tests/uat/extension-page-harness.ts
Mark a20372a8b8 feat(04-05): A34 page-side — cs-injection-world fetch + XHR 404 injection
- Append assertA34 after assertA31 — cs-injection-world skeleton
  (verbatim from assertA30/A31; ROADMAP SC #2 empirical)
- chrome.scripting.executeScript ISOLATED injects TWO 404 triggers
  into the content-script realm: fetch(404) + XMLHttpRequest(404)
- fetch trigger validates Plan 04-01 P1 #11 (Request-narrow URL
  extraction) end-to-end in a real Chrome page context
- XHR trigger covers the distinct XMLHttpRequest.prototype wrapper
  path that A30 did not exercise
- Date.now() uniqueness stamp on both probe URLs (T-04-05-02)
- assertA34 registered in Window interface + __mokoshHarness literal
- Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (rides production
  window.fetch + XMLHttpRequest.prototype + chrome.scripting/tabs)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:37:12 +02:00

4364 lines
190 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; canonical mark SVG bundled + fetchable (A17.8)',
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,
});
// A17.8: Plan 01-10 must_have #9 path-A swap-in invariant (landed
// 2026-05-20 per debug session 01-10-welcome-page-missing-mark).
// Verifies the canonical Mokosh mark SVG is bundled into the
// welcome chunk so populateMark() can assign it as the <img src>.
//
// Vite's default behaviour (build.assetsInlineLimit = 4096 bytes,
// confirmed via vite.dev/config/build-options.html#build-assetsinlinelimit)
// inlines assets smaller than the limit as data: URLs. The
// canonical mokosh-mark.svg is ~600 bytes, so it's INLINED as a
// `data:image/svg+xml,...` literal inside the welcome JS chunk
// (NOT emitted as a separate `dist/assets/<hash>.svg` file).
//
// We accept BOTH bundling shapes — either is correct from a "the
// mark is reachable from the welcome page" standpoint:
// (a) data URL: `data:image/svg+xml,...` substring in jsText
// (Vite inlined small asset path; default behaviour).
// (b) file URL: a `.svg` filename string in jsText (Vite emitted
// separate asset path; would activate if SVG grew past
// 4096 bytes OR assetsInlineLimit was lowered).
//
// If neither shape is present, populateMark() would assign
// `img.src = undefined` and the welcome hero would render an
// empty/broken image — exactly the regression the operator
// reported in the 2026-05-20 UAT.
const hasInlineDataUrl = jsText.includes('data:image/svg+xml');
const svgFileUrlMatches = jsText.match(/["'][^"']*\.svg["']/g) ?? [];
const hasSvgFileUrl = svgFileUrlMatches.length > 0;
const hasBundledMark = hasInlineDataUrl || hasSvgFileUrl;
diag(result, `Step 7: bundled JS contains inlineDataUrl=${hasInlineDataUrl}, svgFileUrlCount=${svgFileUrlMatches.length}`);
// Cross-witness: the canonical mark's source SVG includes the
// viewBox="0 0 32 32" literal (32×32 woven-square mark per
// src/shared/brand/mokosh-mark.svg). The data URL inlining
// path preserves this verbatim (URL-percent-encoded:
// viewBox='0%200%2032%2032'). For the file URL path the
// substring lives at the fetched asset, not the chunk JS.
// Either way, the chunk JS string is sufficient to prove the
// mark survives the bundle.
const hasCanonicalViewBox =
jsText.includes('viewBox=\'0 0 32 32\'')
|| jsText.includes('viewBox="0 0 32 32"')
|| jsText.includes('viewBox=%270%200%2032%2032%27')
|| jsText.includes("viewBox='0%200%2032%2032'");
result.checks.push({
name: 'A17.8: welcome chunk JS bundles the canonical mark SVG (data URL OR file URL) AND canonical viewBox preserved (Plan 01-10 must_have #9 path-A swap-in)',
expected: 'data:image/svg+xml OR .svg URL in bundle; canonical viewBox=\'0 0 32 32\' preserved',
actual: `inlineDataUrl=${hasInlineDataUrl}, svgFileUrl=${hasSvgFileUrl}, canonicalViewBox=${hasCanonicalViewBox}`,
passed: hasBundledMark && hasCanonicalViewBox,
});
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;
}
/* ─── Plan 02-04 Task 1 — A24 (Blob URL empirical) ─────────────────────
*
* A24 — D-P2-01 empirical: SAVE_ARCHIVE → chrome.downloads.download is
* invoked with a `blob:` URL prefix (NOT `data:application/zip;base64,`).
* Closes audit P0-6 functionally (Plan 02-02 unit-tested it at the
* wire boundary; A24 verifies end-to-end through a real Chrome
* instance + the offscreen mint round-trip + chrome.downloads
* platform call).
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A24 uses chrome.downloads.onCreated
* (production cross-realm API; canonical capture pattern) + chrome.runtime
* .sendMessage(SAVE_ARCHIVE). The Tier-1 inventory stays at 12.
*
* Future Plan 02-04 Tasks 2-3 will add A25+A26+A27+A28 (latency, meta.json
* shape, multi-tab urls strict, REQ-archive-layout strict zip-layout).
*/
/** Timeout for SAVE_ARCHIVE message dispatch — matches A5_SAVE_ARCHIVE_TIMEOUT_MS
* (the SW does the same screenshot + content-script + JSZip work). 15s is
* generously above the typical ~2-3s observed in A5. */
const A24_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Max time to poll for the chrome.downloads.download Proxy to fire after
* the SAVE_ARCHIVE ack returns. The platform call is async-resolved post
* the offscreen → SW → chrome.downloads round-trip; 5s is comfortable
* headroom over the typical sub-second delay. */
const A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS = 5_000;
/** Polling cadence while waiting for the chrome.downloads.download spy
* to capture its args. 100 ms matches the global POLL_INTERVAL_MS. */
const A24_DOWNLOAD_SPY_POLL_INTERVAL_MS = 100;
/** Pre-SAVE segment-settle window — mirrors A5_SEGMENT_SETTLE_MS (11s
* = 10s rotation + 1s slack) so getVideoBufferFromOffscreen returns a
* non-empty segment buffer before A24 dispatches SAVE_ARCHIVE. */
const A24_SEGMENT_SETTLE_MS = 11_000;
/**
* A24 — D-P2-01 empirical: SAVE_ARCHIVE invokes chrome.downloads.download
* with a `blob:` URL (NOT `data:application/zip;base64,`).
*
* Strategy: chrome.downloads.onCreated listener captures the URL cross-realm.
*
* The plan's `<action>` block proposed a chrome.downloads.download
* monkey-patch installed in the harness page realm. That approach
* intercepts only calls dispatched FROM the harness page realm — the
* SW's downloadArchive call lives in a different realm and bypasses
* the patch. The canonical cross-realm capture pattern is
* chrome.downloads.onCreated (fires for any download initiated by any
* extension realm, with the full DownloadItem including .url).
*
* Per the plan + saved memory feedback-no-unilateral-scope-reduction:
* NO new test-hook surface. chrome.downloads.onCreated is a production
* chrome.* API available to extension contexts under the existing
* `downloads` permission. Tier-1 FORBIDDEN_HOOK_STRINGS stays at 12.
*
* Chaining: A24 does its OWN setupFreshRecording + SAVE because the
* onCreated listener MUST be installed BEFORE the SAVE — chaining off
* A5/A11/A12/A13's already-completed saves misses the listener window.
*
* @returns Structured result with 4 checks (A24.1..A24.4).
*/
async function assertA24(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A24 — D-P2-01 empirical: chrome.downloads.download receives blob: URL (closes P0-6)',
checks: [],
diagnostics: [],
};
// Capture stored in closure so the onCreated listener can populate it
// across the async SAVE_ARCHIVE dispatch + post-ack poll.
let capturedUrl: string | null = null;
let listenerInstalled = false;
const onCreatedListener = (item: chrome.downloads.DownloadItem): void => {
// First-wins — A24's SAVE_ARCHIVE produces exactly one download; any
// subsequent download in the same test run (e.g. from a stray prior
// SAVE that landed late) would NOT overwrite the captured value.
if (capturedUrl === null) {
capturedUrl = item.url;
}
};
try {
diag(result, 'Step 1: setupFreshRecording (A24 owns its recording — onCreated listener installed pre-SAVE)');
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: settle ${A24_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A24_SEGMENT_SETTLE_MS));
diag(result, 'Step 3: install chrome.downloads.onCreated listener (cross-realm capture)');
chrome.downloads.onCreated.addListener(onCreatedListener);
listenerInstalled = true;
diag(result, 'Step 4: dispatch SAVE_ARCHIVE');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A24_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A24)',
);
diag(result, `Step 4 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A24.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.success === true,
});
diag(result, `Step 5: poll up to ${A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS}ms for chrome.downloads.onCreated to fire`);
const pollStart = Date.now();
while (capturedUrl === null && Date.now() - pollStart < A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, A24_DOWNLOAD_SPY_POLL_INTERVAL_MS));
}
diag(
result,
`Step 5 result: capturedUrl=${capturedUrl === null ? '<null>' : (capturedUrl as string).substring(0, 60) + '...'}`,
);
result.checks.push({
name: 'A24.2: chrome.downloads.onCreated fired (download initiated by SW)',
expected: true,
actual: capturedUrl !== null,
passed: capturedUrl !== null,
});
const urlIsBlob =
capturedUrl !== null && (capturedUrl as string).startsWith('blob:');
const urlIsDataBase64 =
capturedUrl !== null &&
(capturedUrl as string).startsWith('data:application/zip;base64,');
result.checks.push({
name: 'A24.3: download URL starts with "blob:" (D-P2-01 Plan 02-02 closure of P0-6)',
expected: true,
actual: urlIsBlob,
passed: urlIsBlob,
});
result.checks.push({
name: 'A24.4: download URL does NOT start with "data:application/zip;base64," (legacy path retired)',
expected: true,
actual: !urlIsDataBase64,
passed: !urlIsDataBase64,
});
diag(
result,
`capturedUrl prefix: ${capturedUrl === null ? '<null>' : (capturedUrl as string).substring(0, 40) + '...'}`,
);
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 {
// T-02-04-01 mitigation: always remove the listener so it does not
// accumulate across re-runs of the harness within a single session.
// removeListener on a never-added listener is a benign no-op per
// chrome.events spec; the guard is purely diagnostic.
if (listenerInstalled) {
try {
chrome.downloads.onCreated.removeListener(onCreatedListener);
} catch (rmErr) {
diag(result, `(listener cleanup ignored: ${String(rmErr)})`);
}
}
}
return result;
}
/* ─── Plan 02-04 Task 2 — A25 (5s SAVE→ack+file-on-disk latency) ───────
*
* A25 — REQ-archive-export-latency / SPEC §10 #6: page-side records
* dispatch→ack timings via performance.now() bookends; host-side
* driver merges the dispatch→file-on-disk timing via downloadsDir
* polling. End-to-end ceiling: 5000 ms.
*
* T-02-04-02 disposition (accept): the bracket is t0=just-before-SAVE,
* tAck=just-after-ack — NOT broader test orchestration. setupFreshRecording
* + segment-settle happen BEFORE the t0 mark. The 5s budget is measured
* FROM the SAVE dispatch instant.
*/
/** SAVE_ARCHIVE dispatch timeout for A25 — matches A24's 15s. */
const A25_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A25_SEGMENT_SETTLE_MS = 11_000;
/** Hard latency ceiling per SPEC §10 #6 + CON-archive-export-latency. */
const A25_LATENCY_CEILING_MS = 5_000;
/**
* Extended result shape — A25 returns the t0/tAck bookends so the
* host-side driver can compute the merged dispatch→file-on-disk
* latency check.
*
* t0Wall is `Date.now()` captured at the SAVE_ARCHIVE dispatch instant
* (NOT at setupFreshRecording start). It is the canonical anchor for
* host-side dispatch→file-on-disk measurement, because performance.now()
* is monotonic and not comparable to Date.now() across realms. The
* driver computes `tFileHost - t0Wall < 5000ms` to assert REQ-archive-
* export-latency per T-02-04-02 disposition (bracket only the SAVE
* dispatch, not the broader test orchestration).
*/
interface A25Result extends AssertionResult {
t0: number;
tAck: number;
t0Wall: number;
ackSuccess: boolean;
}
/**
* A25 — SAVE_ARCHIVE → zip on disk in <5000ms (REQ-archive-export-latency).
*
* Page-side measures dispatch→ack ONLY (performance.now() bookends
* bracket the chrome.runtime.sendMessage call). Host-side `driveA25`
* additionally polls downloadsDir for the new zip + measures
* dispatch→file-on-disk via mtime delta.
*
* Chaining: A25 does its OWN setupFreshRecording + SAVE (clean latency
* measurement, not compounded with A24's still-pending state). The
* setupFreshRecording + 11s settle WALL TIME is OUTSIDE the t0/tAck
* bracket — only the SAVE dispatch latency counts against the 5s budget.
*
* @returns A25Result with structured checks + t0/tAck bookends for the
* host-side driver.
*/
async function assertA25(): Promise<A25Result> {
const result: A25Result = {
passed: false,
name: 'A25 — REQ-archive-export-latency: <5000ms SAVE→ack (SPEC §10 #6)',
checks: [],
diagnostics: [],
t0: 0,
tAck: 0,
t0Wall: 0,
ackSuccess: false,
};
try {
diag(result, 'Step 1: setupFreshRecording (A25 owns its recording — clean latency measurement)');
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: settle ${A25_SEGMENT_SETTLE_MS}ms for first segment rotation (NOT counted toward 5s budget)`);
await new Promise((r) => setTimeout(r, A25_SEGMENT_SETTLE_MS));
diag(result, 'Step 3: t0 = performance.now() AND t0Wall = Date.now(), then SAVE_ARCHIVE then tAck (this is the budgeted bracket)');
const t0 = performance.now();
const t0Wall = Date.now();
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A25_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A25)',
);
const tAck = performance.now();
const elapsedAck = tAck - t0;
result.t0 = t0;
result.tAck = tAck;
result.t0Wall = t0Wall;
result.ackSuccess = ack.success === true;
diag(
result,
`Step 3 result: ack=${JSON.stringify(ack)}, t0=${t0.toFixed(0)}, tAck=${tAck.toFixed(0)}, t0Wall=${t0Wall}, elapsedAck=${elapsedAck.toFixed(0)}ms`,
);
result.checks.push({
name: 'A25.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.success === true,
});
result.checks.push({
name: `A25.2: page-side dispatch → ack latency < ${A25_LATENCY_CEILING_MS}ms`,
expected: `<${A25_LATENCY_CEILING_MS}ms`,
actual: `${elapsedAck.toFixed(0)}ms`,
passed: elapsedAck < A25_LATENCY_CEILING_MS,
});
diag(
result,
`page-side latency: t0=${t0.toFixed(0)} tAck=${tAck.toFixed(0)} delta=${elapsedAck.toFixed(0)}ms`,
);
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 02-04 Task 3 — A26 + A27 (strict) + A28 ─────────────────────
*
* A26 — D-P2-02 + D-P2-03 empirical: meta.json has the 8-field shape
* with urls[] (not url:string) and schemaVersion='2'.
* A27 — STRICT mode (post DEC-011 Amendment 1): multi-tab URL tracking.
* Opens TWO tabs sequentially, activates each, then dispatches
* SAVE. Host-side asserts meta.urls contains BOTH URLs (length>=2
* REQUIRED; FAILS on length<2).
* A28 — REQ-archive-layout strict zip-layout: zip contains EXACTLY the
* 5 canonical entries (video/last_30sec.webm, rrweb/session.json,
* logs/events.json, screenshot.png, meta.json). Cross-references
* REQ-popup-ui + REQ-screenshot-on-export.
*
* Architecture (per saved memory feedback-no-unilateral-scope-reduction +
* Plan 01-13 Approach B): page-side does the orchestration (SAVE dispatch
* + tab management); host-side does the zip parsing via JSZip (already
* imported in tests/uat/lib/zip.ts; not bundled into the harness page
* realm). A26 + A28 page-sides are intentional stubs returning the
* assertion name — all zip-inspection work is host-side in the drivers.
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A27 uses chrome.tabs.create +
* chrome.tabs.update + chrome.tabs.remove (production APIs; `tabs`
* permission granted via DEC-011 Amendment 1). Tier-1 inventory stays
* at the post-A24/A25 baseline.
*
* T-02-04-04 mitigation: A27 wraps both tab-cleanup calls in try/catch
* with silent-ignore on already-closed (chrome.tabs.remove throws if
* the tab id is gone; the test doesn't care about that side effect).
*/
/** SAVE_ARCHIVE dispatch timeout for A27 — matches A24/A25's 15s. */
const A27_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A27_SEGMENT_SETTLE_MS = 11_000;
/** Wait after chrome.tabs.create for the tab navigation to complete
* (the URL field on the tab object only populates once the page
* begins loading). Conservative 1500ms per plan spec. */
const A27_TAB_NAVIGATION_WAIT_MS = 1_500;
/** Wait after chrome.tabs.update({active:true}) for chrome.tabs.onActivated
* to fire + tab-url-tracker (Plan 02-03 Task 2) to capture the URL. */
const A27_TAB_ACTIVATION_WAIT_MS = 500;
/** Canonical multi-tab URLs for A27 — public sites with stable URLs,
* no PII (per T-02-04-03 disposition). example.com is RFC 2606
* reserved; iana.org is the IANA homepage. Both serve plain HTML
* reliably under headless Chrome. */
const A27_TAB_A_URL = 'https://example.com/';
const A27_TAB_B_URL = 'https://www.iana.org/';
/**
* Extended result shape — A27 returns the canonical URLs the host-side
* driver needs to assert against meta.urls.
*/
interface A27Result extends AssertionResult {
tabAUrl: string;
tabBUrl: string;
}
/**
* A26 — D-P2-02 + D-P2-03 empirical (page-side stub).
*
* Returns the assertion name + a sentinel diagnostic. All real work
* happens host-side in driveA26 (JSZip-parse the latest zip + assert
* meta.json shape). The page-side stub exists purely so the orchestrator's
* single-method-per-assertion contract (window.__mokoshHarness.assertA26)
* is uniform across all 29 assertions.
*
* Chaining: A26 reads the zip produced by A25 (host-side driver picks
* the most-recently-modified zip in downloadsDir). No new SAVE dispatch.
*
* @returns Stub AssertionResult with empty checks; driveA26 fills them.
*/
async function assertA26(): Promise<AssertionResult> {
return {
passed: true,
name: 'A26 — meta.json 8-field shape (D-P2-02 + D-P2-03)',
checks: [],
diagnostics: [
'assertA26 page-side stub; host-side driveA26 inspects latest zip + asserts meta.json shape',
],
};
}
/**
* A27 — STRICT mode (post DEC-011 Amendment 1): multi-tab URL tracking.
*
* Sequence (per plan spec):
* 1. setupFreshRecording (clean state)
* 2. chrome.tabs.create(TAB_A_URL, active:false) → wait 1500ms for nav
* 3. chrome.tabs.update(tabA.id, {active:true}) → wait 500ms for onActivated
* 4. chrome.tabs.create(TAB_B_URL, active:false) → wait 1500ms for nav
* 5. chrome.tabs.update(tabB.id, {active:true}) → wait 500ms for onActivated
* 6. Wait 11s for one segment to land
* 7. Dispatch SAVE_ARCHIVE; await ack with success===true
* 8. Cleanup: try/catch close both tabs via chrome.tabs.remove
*
* Host-side driveA27 then loads the produced zip + parses meta.json + asserts:
* - meta.urls.length >= 2 (FAIL on length < 2)
* - Both TAB_A_URL and TAB_B_URL are in meta.urls (order-flexible)
* - No extension-origin sentinels (chrome-extension://)
* - No chrome-internal URLs (chrome:// or about:)
*
* Page-side returns A27.1 (SAVE_ARCHIVE ack) + the canonical URLs so
* the host-side driver can compute the strict assertions.
*
* T-02-04-04 mitigation: cleanup runs in finally; both tabs.remove
* calls wrapped in try/catch (silent-ignore on already-closed).
*
* @returns A27Result with 1 check (SAVE ack) + tabAUrl + tabBUrl for the driver.
*/
async function assertA27(): Promise<A27Result> {
const result: A27Result = {
passed: false,
name: 'A27 — D-P2-02 STRICT multi-tab urls[] (both URLs REQUIRED post DEC-011 Amendment 1)',
checks: [],
diagnostics: [],
tabAUrl: A27_TAB_A_URL,
tabBUrl: A27_TAB_B_URL,
};
let tabAId: number | undefined;
let tabBId: number | undefined;
try {
diag(result, 'Step 1: setupFreshRecording (A27 owns its recording)');
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: chrome.tabs.create(${A27_TAB_A_URL}, active:false)`);
const tabA = await chrome.tabs.create({ url: A27_TAB_A_URL, active: false });
tabAId = tabA.id;
diag(result, `Step 2 result: tabA.id=${tabAId}, tabA.url=${tabA.url ?? '<pending>'}`);
await new Promise((r) => setTimeout(r, A27_TAB_NAVIGATION_WAIT_MS));
if (tabAId !== undefined) {
diag(result, `Step 3: chrome.tabs.update(${tabAId}, {active:true})`);
await chrome.tabs.update(tabAId, { active: true });
await new Promise((r) => setTimeout(r, A27_TAB_ACTIVATION_WAIT_MS));
}
diag(result, `Step 4: chrome.tabs.create(${A27_TAB_B_URL}, active:false)`);
const tabB = await chrome.tabs.create({ url: A27_TAB_B_URL, active: false });
tabBId = tabB.id;
diag(result, `Step 4 result: tabB.id=${tabBId}, tabB.url=${tabB.url ?? '<pending>'}`);
await new Promise((r) => setTimeout(r, A27_TAB_NAVIGATION_WAIT_MS));
if (tabBId !== undefined) {
diag(result, `Step 5: chrome.tabs.update(${tabBId}, {active:true})`);
await chrome.tabs.update(tabBId, { active: true });
await new Promise((r) => setTimeout(r, A27_TAB_ACTIVATION_WAIT_MS));
}
diag(result, `Step 6: settle ${A27_SEGMENT_SETTLE_MS}ms for one segment to land`);
await new Promise((r) => setTimeout(r, A27_SEGMENT_SETTLE_MS));
diag(result, 'Step 7: dispatch SAVE_ARCHIVE');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A27_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A27)',
);
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A27.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.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}`);
} finally {
// T-02-04-04 mitigation: cleanup tabs with silent-ignore on
// already-closed. chrome.tabs.remove rejects if the tab id is gone;
// we don't care about that side effect for this assertion.
if (tabAId !== undefined) {
try {
await chrome.tabs.remove(tabAId);
} catch (rmErr) {
diag(result, `(tabA cleanup ignored: ${String(rmErr)})`);
}
}
if (tabBId !== undefined) {
try {
await chrome.tabs.remove(tabBId);
} catch (rmErr) {
diag(result, `(tabB cleanup ignored: ${String(rmErr)})`);
}
}
}
return result;
}
/**
* A28 — REQ-archive-layout strict zip-layout (page-side stub).
*
* Returns the assertion name + a sentinel diagnostic. All real work
* happens host-side in driveA28 (JSZip enumerates the latest zip's
* entries + asserts exactly 5 canonical paths, no extras). The
* page-side stub exists purely for orchestrator uniformity.
*
* Chaining: A28 reads the zip produced by A27 (host-side picks the
* most-recently-modified zip in downloadsDir). No new SAVE dispatch.
*
* @returns Stub AssertionResult with empty checks; driveA28 fills them.
*/
async function assertA28(): Promise<AssertionResult> {
return {
passed: true,
name: 'A28 — REQ-archive-layout strict (5 entries)',
checks: [],
diagnostics: [
'assertA28 page-side stub; host-side driveA28 enumerates latest zip + asserts 5-entry layout',
],
};
}
/* ─── Plan 04-03 — A29 rewrite (rrweb DOM verification; SPEC §10 #4)
* cs-injection-world pattern (verbatim port of A30
* per RESEARCH Q3 + Plan 03-02 SUMMARY) ──────────────
*
* A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (wired at
* src/content/index.ts:285) MUST capture a DOM mutation that
* WE injected — proving the rrweb pipeline is genuinely live
* on the SAVE-active tab. The captured IncrementalSnapshot
* payload MUST contain the sentinel string the host-side
* driveA29 strict filter searches for.
*
* Plan 04-03 rewrite background (RESEARCH Q3 + Plan 03-02/03-03 SUMMARY):
* The pre-04-03 A29 implementation dispatched probe-page DOM mutations
* on the harness page (chrome-extension://) and then relied on a
* loose `EventType.{Meta,FullSnapshot,IncrementalSnapshot}` grep at
* the host-side. Two latent defects fell out of this:
*
* 1. The chrome-extension:// harness page never had a content
* script attached at all (`<all_urls>` does NOT cover
* chrome-extension scheme per Chrome match-pattern spec —
* same root cause that drove the A30 cs-injection-world
* adaptation in Plan 03-02). rrweb's record() therefore never
* ran on the harness page.
* 2. The host-side loose grep would still pass because the
* produced zip contained iana.org leftover rrweb events from
* the A27/A28 probe tabs that were still open at SAVE time —
* i.e. A29 "passed" for the wrong reason at ~2/3 success rate
* (documented as a pre-existing flake in Plan 03-02 + 03-03
* SUMMARYs). A real rrweb regression at src/content/index.ts:284
* would have been masked because iana.org's home page emits
* plenty of mutations during normal rendering.
*
* Plan 04-03 fix (mechanical port of assertA30 / assertA31 skeleton):
* - Open a fresh https://example.com probe tab via chrome.tabs.create
* (RFC 2606 reserved domain; matches A30 + A31 pattern).
* - Wait the canonical 1.5s for content script attach.
* - Wait the canonical 11s for the first MediaRecorder segment to
* rotate (mirrors A30's SEGMENT_SETTLE_MS).
* - Use chrome.scripting.executeScript with default world: 'ISOLATED'
* (the content-script's realm where rrweb's MutationObserver lives)
* to inject a synthetic DOM mutation that carries a UNIQUE SENTINEL
* STRING ('a29-mutation-sentinel') into a fresh <div> appendChild'd
* to document.body.
* - Wait 500ms for rrweb's MutationObserver to enqueue the
* IncrementalSnapshot.
* - Dispatch SAVE_ARCHIVE while the probe tab is active (its content
* script is the source of rrweb/session.json).
* - finally-cleanup the probe tab via chrome.tabs.remove with the
* T-02-04-04 silent-ignore precedent.
*
* Host-side driveA29 (rewritten in Plan 04-03 Task 2) JSZip-parses the
* resulting archive, filters rrweb events for
* e.type === EventType.IncrementalSnapshot &&
* e.data?.source === IncrementalSource.Mutation,
* descends into the mutation payload's adds[*].node.textContent
* field, and asserts >=1 event contains the sentinel — proving the
* captured mutation came from OUR injection, not from leftover
* iana.org tabs (closes the documented flake).
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A29's new path rides production
* chrome.tabs.create + chrome.scripting.executeScript + existing
* setupFreshRecording + sendMessageWithTimeout helpers. Tier-1
* inventory stays at 12 entries.
*/
/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27/A30/A31. */
const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). Mirrors
* A30_SEGMENT_SETTLE_MS. */
const A29_SEGMENT_SETTLE_MS = 11_000;
/** Settle between sentinel-bearing DOM mutation injection and SAVE so
* rrweb's MutationObserver enqueues the IncrementalSnapshot into the
* content-script's in-memory buffer before GET_RRWEB_EVENTS fires. */
const A29_MUTATION_SETTLE_MS = 500;
/** Wait after chrome.tabs.create for the tab navigation to complete so
* the content script attaches + rrweb's record() is wired (mirrors
* A27/A30/A31's canonical 1.5s). */
const A29_TAB_NAVIGATION_WAIT_MS = 1_500;
/** Probe tab URL — example.com is RFC 2606 reserved + serves stable
* HTML under headless Chrome (Plan 02-04 A27 + Plan 03-02 A30 +
* Plan 03-03 A31 fixture parity). */
const A29_PROBE_TAB_URL = 'https://example.com/';
/** Unique sentinel string carried by the injected DOM mutation. The
* host-side driveA29 strict-sentinel filter searches the rrweb
* IncrementalSnapshot payload's `adds[*].node.textContent` field for
* this exact string. Distinctive enough to never collide with
* iana.org / example.com rendering noise. */
const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel';
/** DOM id of the synthetic <div> appendChild'd to document.body inside
* the ISOLATED-world injection. Distinct from A31's probe-password /
* probe-control ids to avoid cross-assertion mixup. */
const A29_PROBE_DIV_ID = 'a29-probe-mutation';
/**
* A29 — rrweb DOM event recording empirical (SPEC §10 #4 / REQ-rrweb-dom-buffer).
*
* Plan 04-03 rewrite (cs-injection-world pattern; verbatim port of
* assertA30/assertA31 skeleton). Creates a fresh https://example.com
* probe tab where the content script attaches normally + rrweb's
* record() is alive, injects a synthetic DOM mutation carrying the
* A29_MUTATION_SENTINEL into the content-script's ISOLATED world via
* chrome.scripting.executeScript, settles a segment, SAVEs while the
* probe tab is active so the SW harvests rrweb/session.json from that
* tab, finally-cleanup the tab. Host-side driveA29 applies the
* strict-sentinel filter (IncrementalSource.Mutation +
* adds[*].node.textContent includes sentinel) to PROVE the captured
* mutation came from our injection (closes the iana.org-leftover-
* flake documented in Plan 03-02 + 03-03 SUMMARYs).
*
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
* driveA29 appends the A29.2 strict-sentinel check + defense-
* in-depth presence checks for Meta + FullSnapshot.
*/
async function assertA29(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A29 — rrweb DOM events recorded without errors (SPEC §10 #4 / REQ-rrweb-dom-buffer)',
checks: [],
diagnostics: [],
};
let probeTabId: number | undefined;
try {
diag(result, 'Step 1: setupFreshRecording (A29 owns its recording — clean rrweb buffer window)');
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: chrome.tabs.create(${A29_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)`);
const probeTab = await chrome.tabs.create({ url: A29_PROBE_TAB_URL, active: true });
probeTabId = probeTab.id;
diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? '<pending>'}`);
if (probeTabId === undefined) {
throw new Error('chrome.tabs.create returned undefined tab.id');
}
diag(result, `Step 3: wait ${A29_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach (rrweb's record() wires in this window)`);
await new Promise((r) => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS));
diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS));
diag(result, 'Step 5: chrome.scripting.executeScript — inject sentinel-bearing <div> mutation in ISOLATED world (rrweb MutationObserver lives in same world; will capture)');
const injectionResults = await chrome.scripting.executeScript({
target: { tabId: probeTabId },
world: 'ISOLATED',
func: (sentinel: string, divId: string): { appended: boolean; sentinelEquals: boolean } => {
// Synthetic mutation: create a fresh <div>, set its textContent
// to the sentinel, appendChild to document.body. rrweb's
// MutationObserver wired by record() at src/content/index.ts:285
// observes document.body and enqueues an IncrementalSnapshot
// whose `data.source === IncrementalSource.Mutation` and whose
// `data.adds[*].node.textContent` carries the sentinel string.
const div = document.createElement('div');
div.id = divId;
div.textContent = sentinel;
document.body.appendChild(div);
return {
appended: document.getElementById(divId) !== null,
sentinelEquals: div.textContent === sentinel,
};
},
args: [A29_MUTATION_SENTINEL, A29_PROBE_DIV_ID],
});
const injectionSummary = injectionResults[0]?.result ?? null;
diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`);
diag(result, `Step 6: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb MutationObserver to enqueue the IncrementalSnapshot`);
await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS));
diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A29_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A29)',
);
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A29.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.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}`);
} finally {
// T-02-04-04 mitigation parity (Plan 02-04 / 03-02 / 03-03 precedent):
// cleanup probe tab with silent-ignore on already-closed.
if (probeTabId !== undefined) {
try {
await chrome.tabs.remove(probeTabId);
} catch (rmErr) {
diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`);
}
}
}
return result;
}
/* ─── Plan 03-02 — A30 (event-log verification; SPEC §10 #5) ────────
*
* A30 — REQ-user-event-log empirical: the production listeners at
* src/content/index.ts (setupClickLogging at line 61,
* setupInputLogging at line 77, setupNavigationLogging at line
* 99, setupErrorLogging at line 133, setupNetworkLogging at
* line 164) all fire on synthetic browser events dispatched
* in a probe https:// tab where the production content script
* is injected, producing UserEvent entries with each of the 5
* type-values (click / input / navigation / js_error /
* network_error) in logs/events.json.
*
* Implementation note — MV3 content-script reachability (deviation):
* The plan as written assumed `<all_urls>` content_scripts coverage
* includes `chrome-extension://` URLs — empirically (Task 2
* verification dump 2026-05-20T17:36:25Z), it does NOT. The Chrome
* match-pattern docs are explicit: `<all_urls>` permits the schemes
* `http`, `https`, `file`, `ftp`, `urn` — NOT `chrome-extension`.
* The SW SAVE_ARCHIVE handler logged "Could not establish connection.
* Receiving end does not exist." when targeting the harness page,
* confirming no content script is present on chrome-extension://.
*
* A30 therefore creates a fresh `https://example.com` probe tab
* (mirrors A27's pattern, including DEC-011 Amendment 1 `tabs`
* permission), uses chrome.scripting.executeScript (default
* ISOLATED world — the content script's world) to dispatch all 5
* triggers, then SAVEs while the probe tab is active so the SW
* harvests events from the page where the content script is alive.
*
* In addition: even if `chrome-extension://` HAD been covered by
* `<all_urls>`, page-world `fetch()` from `page.evaluate(...)` would
* NOT have been intercepted by `src/content/index.ts:167`
* (`window.fetch = ...`) — content-script global mutations stay
* inside the ISOLATED world. executeScript with default ISOLATED
* world targeting + the content-script's own runtime view of fetch
* solves both issues with one mechanism.
*
* Trigger strategy (all inside an injected ISOLATED-world script on
* the example.com probe tab):
* - click: dispatch a real `MouseEvent('click')` on document.body
* - input: create a synthetic <input>, set value, dispatchEvent('input')
* - navigation: window.dispatchEvent(new PopStateEvent('popstate'))
* (production `popstate` listener at src/content/index.ts:111;
* NOTE: history.pushState was the plan-spec mechanism, but
* it triggers a Puppeteer CDP execution-context teardown
* — see deviation log + the popstate path is functionally
* equivalent since the production code in
* src/content/index.ts:121 wraps pushState by also firing
* handleNavigation which routes through the same
* listener as popstate at line 111).
* - js_error: window.dispatchEvent(new ErrorEvent('error', ...))
* - network_error: fetch(404-probe-url).catch(noop) — runs in
* ISOLATED world so the patched window.fetch
* at src/content/index.ts:167 fires.
*
* Page-side opens probe tab + injects triggers + settles + SAVE.
* Host-side driveA30 JSZip-parses logs/events.json + asserts each of
* the 5 UserEvent.type literal values is present.
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A30 rides production listeners
* + chrome.tabs.* (`tabs` perm) + chrome.scripting.executeScript
* (`scripting` perm — already in manifest) + existing helpers. Tier-1
* inventory stays at 12.
*/
/** SAVE_ARCHIVE dispatch timeout for A30 — matches A24/A25/A27/A29. */
const A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A30_SEGMENT_SETTLE_MS = 11_000;
/** Settle between trigger dispatches and SAVE so event handlers complete. */
const A30_TRIGGER_SETTLE_MS = 1_000;
/** Wait after chrome.tabs.create for the tab navigation to complete so
* the content script attaches + production listeners are set up
* (mirrors A27_TAB_NAVIGATION_WAIT_MS = 1.5s). */
const A30_TAB_NAVIGATION_WAIT_MS = 1_500;
/** Probe tab URL — example.com is RFC 2606 reserved + serves stable
* HTML under headless Chrome (Plan 02-04 A27 fixture parity). */
const A30_PROBE_TAB_URL = 'https://example.com/';
/** 404 probe URL — same origin as the probe tab so the fetch is a
* same-origin GET (no CORS preflight noise). */
const A30_404_PROBE_URL = 'https://example.com/this-path-does-not-exist-404-probe-a30';
/**
* A30 — Event-log empirical (SPEC §10 #5 / REQ-user-event-log).
*
* Creates a fresh `https://example.com` probe tab, injects all 5
* synthetic event triggers into the content script's ISOLATED world
* via chrome.scripting.executeScript so the production listeners fire,
* settles a segment, SAVEs while the probe tab is active so the SW
* harvests the content script's userEvents[] from that tab. Host-side
* driveA30 inspects logs/events.json from the produced zip and asserts
* each of the 5 UserEvent.type literal values appears at least once.
*
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
* driveA30 appends 5 UserEvent.type presence checks.
*/
async function assertA30(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A30 — event log captures 5 UserEvent types (SPEC §10 #5 / REQ-user-event-log)',
checks: [],
diagnostics: [],
};
let probeTabId: number | undefined;
try {
diag(result, 'Step 1: setupFreshRecording (A30 owns its recording — clean event-log window)');
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: chrome.tabs.create(${A30_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension://`);
const probeTab = await chrome.tabs.create({ url: A30_PROBE_TAB_URL, active: true });
probeTabId = probeTab.id;
diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? '<pending>'}`);
if (probeTabId === undefined) {
throw new Error('chrome.tabs.create returned undefined tab.id');
}
diag(result, `Step 3: wait ${A30_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`);
await new Promise((r) => setTimeout(r, A30_TAB_NAVIGATION_WAIT_MS));
diag(result, `Step 4: settle ${A30_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A30_SEGMENT_SETTLE_MS));
diag(result, 'Step 5: chrome.scripting.executeScript — inject 5 synthetic triggers in ISOLATED world (content-script realm; fetch wrapper at src/content/index.ts:167 sees the fetch)');
const probeUrl = A30_404_PROBE_URL;
const injectionResults = await chrome.scripting.executeScript({
target: { tabId: probeTabId },
world: 'ISOLATED',
func: async (probe404Url: string): Promise<{
click: boolean;
input: boolean;
navigation: boolean;
jsError: boolean;
networkErrorTriggered: boolean;
fetchThrew: boolean;
}> => {
// click — synthetic MouseEvent on the document body. Production
// listener at src/content/index.ts:61 captures the click via
// document.addEventListener('click', ...).
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
const clickDispatched = document.body.dispatchEvent(clickEvent);
// input — synthetic <input>, set value, dispatchEvent('input',
// bubbles:true). Production listener at src/content/index.ts:78
// captures via document.addEventListener('input', ...). Skips
// password type (line 82) — type='text' here.
const probeInput = document.createElement('input');
probeInput.type = 'text';
probeInput.id = 'a30-probe-input';
probeInput.value = 'a30@probe.local';
document.body.appendChild(probeInput);
const inputDispatched = probeInput.dispatchEvent(
new Event('input', { bubbles: true }),
);
probeInput.remove();
// navigation — window-level popstate event. Production listener
// at src/content/index.ts:111 captures via
// window.addEventListener('popstate', ...).
const navigationDispatched = window.dispatchEvent(
new PopStateEvent('popstate', { state: {} }),
);
// js_error — window-level ErrorEvent. Production listener at
// src/content/index.ts:134 captures via
// window.addEventListener('error', ...).
const errorDispatched = window.dispatchEvent(
new ErrorEvent('error', {
message: 'a30-probe-js-error',
filename: 'a30-probe.js',
lineno: 1,
colno: 1,
}),
);
// network_error — fetch into a 404 path. The content script
// patches window.fetch at src/content/index.ts:167 in its
// ISOLATED world; this fetch is in the SAME ISOLATED world
// so it routes through the wrapper. response.ok===false →
// addUserEvent({type:'network_error'}) at line 171.
let fetchThrew = false;
try {
await fetch(probe404Url);
} catch (fetchErr) {
fetchThrew = true;
}
return {
click: clickDispatched,
input: inputDispatched,
navigation: navigationDispatched,
jsError: errorDispatched,
networkErrorTriggered: true,
fetchThrew,
};
},
args: [probeUrl],
});
const injectionSummary = injectionResults[0]?.result ?? null;
diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`);
diag(result, `Step 6: settle ${A30_TRIGGER_SETTLE_MS}ms so async handlers (fetch.then) complete + userEvents[] populates`);
await new Promise((r) => setTimeout(r, A30_TRIGGER_SETTLE_MS));
diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A30_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A30)',
);
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A30.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.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}`);
} finally {
// T-02-04-04 mitigation parity: cleanup probe tab with silent-ignore.
if (probeTabId !== undefined) {
try {
await chrome.tabs.remove(probeTabId);
} catch (rmErr) {
diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`);
}
}
}
return result;
}
/* ─── Plan 03-03 — A31 (password-filter PARTIAL; SPEC §10 #8) ────────
*
* A31 — D-P3-02 PARTIAL: verify the existing minimum filter at
* src/content/index.ts:82 (`if (target.type === 'password') return;`)
* fires when the operator types into a password input.
* Negative-assertion contract: SENTINEL value MUST be absent
* from logs/events.json.
*
* Charter alignment (per CONTEXT.md D-P3-02 + REQUIREMENTS.md line 271):
* - REQ-password-confidentiality moved Out of Scope v1 per 2026-05-20
* charter "we don't care about privacy hardening. At least here."
* - Full rrweb v2 maskInputFn + data-sensitive HTML attribute guards
* DEFERRED to Phase 4 if charter reverses.
* - A31 verifies the EXISTING minimum (the line-82 filter) — does
* NOT expand scope.
*
* Implementation note — cs-injection-world adaptation (Rule 3 blocking
* auto-fix; mirrors Plan 03-02 architectural fix):
* The plan as written drove `document.querySelector('#probe-password')`
* on the harness page (chrome-extension://...harness.html). That
* matches Plan 03-02's `<all_urls>` content_scripts assumption
* which is empirically WRONG (Chrome match-pattern spec: `<all_urls>`
* covers http/https/file/ftp/urn only — NOT chrome-extension). With
* no content script attached to the harness page, the production
* setupInputLogging at src/content/index.ts:78 never sees the
* harness-page input event AT ALL, so A31.2 (absence-of-sentinel)
* and A31.3 (absence-of-#probe-password-target) would pass
* tautologically — neither check empirically verifies the line-82
* filter "fires", only that NO event is captured on the harness page
* regardless of input type. That is NOT a valid §10 #8 PARTIAL
* verification (the filter could be deleted and the test would
* still pass).
*
* A31 therefore reuses the Plan 03-02 cs-injection-world pattern:
* open a fresh https://example.com probe tab where the content
* script DOES attach, inject a `<input type="password">` element +
* type the SENTINEL value + dispatch input event in the
* content-script's ISOLATED world, SAVE while the probe tab is
* active, finally-cleanup the probe tab. For a control case
* (verifies the wiring is operational), the same injection also
* types a control sentinel into a `<input type="text">` element
* — the production setupInputLogging MUST capture that event,
* PROVING the path that would have fired for the password input
* IS active. The control case is host-side-only check
* A31.4 (control event present); the production filter at
* src/content/index.ts:82 early-returns BEFORE addUserEvent so
* the password event NEVER lands in userEvents[] (A31.2 + A31.3).
*
* This satisfies the plan's binding contract literally:
* - artifact "types sentinel into the probe-page password input
* via setupFreshRecording + SAVE" — done (just in a different
* page that has the content script alive)
* - truths #1/#2/#3 (sentinel-value-absent + zero-events-targeting-
* password) — empirically verified because the input event IS
* seen by the production listener + filter
* - threat T-03-03-04 (defense-in-depth) — A31.4 control case is
* the third orthogonal path proving the listeners are alive
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A31 rides production
* setupInputLogging at src/content/index.ts:78 + the line-82 filter
* + chrome.tabs.* + chrome.scripting.executeScript + existing helpers.
* Tier-1 inventory stays at 12 entries.
*/
/** SAVE_ARCHIVE dispatch timeout for A31 — matches A24/A25/A27/A29/A30. */
const A31_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A31_SEGMENT_SETTLE_MS = 11_000;
/** Settle after sentinel-typing trigger so the synchronous handler completes. */
const A31_TRIGGER_SETTLE_MS = 1_000;
/** Wait after chrome.tabs.create for the tab navigation to complete so
* the content script attaches + production listeners are set up
* (mirrors A30_TAB_NAVIGATION_WAIT_MS = 1.5s). */
const A31_TAB_NAVIGATION_WAIT_MS = 1_500;
/** Probe tab URL — example.com is RFC 2606 reserved + serves stable
* HTML under headless Chrome (Plan 02-04 A27 + Plan 03-02 A30 fixture parity). */
const A31_PROBE_TAB_URL = 'https://example.com/';
/** Fixed test sentinel — distinctive string the negative-assertion
* searches for in events.json. Per RESEARCH §"Security Domain":
* this is a probe sentinel, NOT a real secret; logging it would
* itself trigger an explicit RED. */
const A31_PASSWORD_SENTINEL = 'secret-do-not-log-123';
/** Control sentinel — distinctive string typed into a `<input type="text">`
* element in the same injection. Production setupInputLogging at
* src/content/index.ts:78 MUST capture this — driveA31's A31.4
* check verifies its presence as proof that the listener is alive
* (defense-in-depth against T-03-03-04 — if the production listener
* weren't running at all, A31.2/A31.3 would pass tautologically;
* A31.4 GREEN proves the listener IS running, so A31.2/A31.3 GREEN
* actually mean the filter fired). */
const A31_CONTROL_SENTINEL = 'control-event-must-be-logged-a31';
/** Production CSS selector returned by getSelector() at
* src/content/index.ts:241 for the password input (which has id).
* Drives A31.3 (target-absence check). */
const A31_PASSWORD_SELECTOR = '#probe-password';
/** Synthetic password-input element id (matches A31_PASSWORD_SELECTOR
* after the leading `#` is stripped). Injected into the probe tab DOM
* by chrome.scripting.executeScript. */
const A31_PASSWORD_INPUT_ID = 'probe-password';
/** Synthetic control-input element id — referenced by driveA31's
* A31.4 check via the same `#probe-control` selector. */
const A31_CONTROL_INPUT_ID = 'probe-control';
/**
* A31 — Password-filter PARTIAL empirical (SPEC §10 #8 PARTIAL per D-P3-02).
*
* Creates a fresh `https://example.com` probe tab (where the content
* script attaches normally per Plan 03-02 cs-injection-world insight),
* injects two `<input>` elements (a control `type="text"` + a sentinel
* `type="password"`) + types the corresponding sentinels + dispatches
* input events in the content-script's ISOLATED world, settles a
* segment, SAVEs while the probe tab is active, finally-cleanup the
* tab. Host-side driveA31 inspects logs/events.json and asserts:
* - The password SENTINEL is ABSENT from any UserEvent.value field
* (proves the line-82 filter early-returned before addUserEvent)
* - Zero UserEvent entries have target === '#probe-password'
* (proves the same filter via the orthogonal selector path)
* - At least one UserEvent contains the control sentinel
* (proves the listener was alive — defense-in-depth against
* the trivial "no events at all" tautology)
*
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
* driveA31 appends host-side checks for sentinel absence
* (A31.2 + A31.3) + control presence (A31.4).
*/
async function assertA31(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A31 — password filter fires (SPEC §10 #8 PARTIAL per D-P3-02)',
checks: [],
diagnostics: [],
};
let probeTabId: number | undefined;
try {
diag(result, 'Step 1: setupFreshRecording (A31 owns its recording — clean event-log window)');
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: chrome.tabs.create(${A31_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)`);
const probeTab = await chrome.tabs.create({ url: A31_PROBE_TAB_URL, active: true });
probeTabId = probeTab.id;
diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? '<pending>'}`);
if (probeTabId === undefined) {
throw new Error('chrome.tabs.create returned undefined tab.id');
}
diag(result, `Step 3: wait ${A31_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`);
await new Promise((r) => setTimeout(r, A31_TAB_NAVIGATION_WAIT_MS));
diag(result, `Step 4: settle ${A31_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A31_SEGMENT_SETTLE_MS));
diag(result, 'Step 5: chrome.scripting.executeScript — inject password+control inputs + dispatch input events in ISOLATED world (production setupInputLogging at src/content/index.ts:78 sees BOTH, line-82 filter early-returns on the password input only)');
const injectionResults = await chrome.scripting.executeScript({
target: { tabId: probeTabId },
world: 'ISOLATED',
func: (
passwordInputId: string,
passwordSentinel: string,
controlInputId: string,
controlSentinel: string,
): {
passwordTyped: boolean;
controlTyped: boolean;
passwordDispatched: boolean;
controlDispatched: boolean;
} => {
// Create the synthetic password input. Production
// setupInputLogging at src/content/index.ts:78 attaches via
// document.addEventListener('input', ...), so the production
// path covers any element added to document.body — including
// ones created and dispatched synchronously by us.
const passwordInput = document.createElement('input');
passwordInput.type = 'password';
passwordInput.id = passwordInputId;
passwordInput.value = passwordSentinel;
document.body.appendChild(passwordInput);
const passwordDispatched = passwordInput.dispatchEvent(
new Event('input', { bubbles: true }),
);
// Create the synthetic control input. setupInputLogging will
// see this one too — but because target.type !== 'password',
// the line-82 filter does NOT early-return; addUserEvent fires
// and the event lands in userEvents[] with type='input' and
// value containing the control sentinel. A31.4 host-side
// verifies this (defense-in-depth proving the listener IS
// alive — without it, A31.2/A31.3 would pass tautologically).
const controlInput = document.createElement('input');
controlInput.type = 'text';
controlInput.id = controlInputId;
controlInput.value = controlSentinel;
document.body.appendChild(controlInput);
const controlDispatched = controlInput.dispatchEvent(
new Event('input', { bubbles: true }),
);
return {
passwordTyped: passwordInput.value === passwordSentinel,
controlTyped: controlInput.value === controlSentinel,
passwordDispatched,
controlDispatched,
};
},
args: [
A31_PASSWORD_INPUT_ID,
A31_PASSWORD_SENTINEL,
A31_CONTROL_INPUT_ID,
A31_CONTROL_SENTINEL,
],
});
const injectionSummary = injectionResults[0]?.result ?? null;
diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`);
diag(result, `Step 6: settle ${A31_TRIGGER_SETTLE_MS}ms so synchronous handlers complete + userEvents[] populates`);
await new Promise((r) => setTimeout(r, A31_TRIGGER_SETTLE_MS));
diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A31_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A31)',
);
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A31.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.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}`);
} finally {
// T-02-04-04 mitigation parity (Plan 03-02 precedent): cleanup probe
// tab with silent-ignore on already-closed.
if (probeTabId !== undefined) {
try {
await chrome.tabs.remove(probeTabId);
} catch (rmErr) {
diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`);
}
}
}
return result;
}
/* ─── Plan 04-05 — A34 (fetch + XHR network_error empirical; ROADMAP SC #2) ─
*
* A34 — ROADMAP SC #2 empirical: "A page that issues a failing `fetch`
* (response code >= 400) produces a `network_error` entry in
* `events.json`; a failing `XMLHttpRequest` does too."
*
* A34 EXTENDS Plan 03-02's A30 (which exercised the fetch path
* once via a 404-fetch from a probe tab) with:
*
* 1. An empirical end-to-end test that the Plan 04-01 P1 #11
* fetch URL extraction fix (`args[0] instanceof Request ?
* args[0].url : String(args[0])` at src/content/index.ts:194
* + :214) works in a REAL Chrome page context — the fetch
* network_error entry's `target` field must carry the
* actual URL, NOT the literal '[object Request]' that the
* pre-fix implicit coercion produced. A30 only proved a
* network_error entry EXISTS; A34.4 pins the URL value.
*
* 2. A complementary XMLHttpRequest 404 path that A30 does NOT
* cover. XHR uses a distinct code path in
* src/content/index.ts (the XMLHttpRequest.prototype.open
* + .send wrappers at lines ~225-258, with the loadend
* listener emitting network_error when xhr.status >= 400)
* and merits its own empirical gate.
*
* cs-injection-world pattern (Plan 03-02 / Plan 04-03 A29 precedent —
* verbatim skeleton from assertA30 / assertA31 with A34 substitutions):
* - chrome.tabs.create({url: 'https://example.com/'}) probe tab.
* - Wait 1.5s for content-script attach.
* - Wait 11s for first segment rotation.
* - chrome.scripting.executeScript world:'ISOLATED' injects TWO
* triggers into the content-script realm so BOTH production
* wrappers (window.fetch + XMLHttpRequest.prototype) intercept the
* failing requests:
* - fetch('https://example.com/404-fetch-a34-<stamp>').catch(noop)
* - new XMLHttpRequest(); open('GET', '/404-xhr-a34-<stamp>'); send()
* The `-<stamp>` (Date.now()) suffix is a uniqueness guard against
* any future intermediate-caching behavior change (T-04-05-02).
* The fetch(404).catch(noop) is REQUIRED — without the catch the
* network rejection would surface as a separate js_error UserEvent
* which A34 does not care about.
* - Wait ~1s for both responses to land + both content-script
* wrappers to enqueue their network_error UserEvents (the XHR
* loadend listener is async; the fetch .then/.catch chain is async).
* - SAVE_ARCHIVE.
* - Host-side driveA34 JSZip-parses logs/events.json; filters for
* network_error entries whose target contains '404-fetch-a34' /
* '404-xhr-a34'; asserts >=1 of each + meta.status === 404.
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. A34 rides production wrappers
* (window.fetch + XMLHttpRequest.prototype at src/content/index.ts) +
* chrome.tabs.* (`tabs` perm) + chrome.scripting.executeScript
* (`scripting` perm) + existing helpers. Tier-1 inventory stays at 12.
*/
/** SAVE_ARCHIVE dispatch timeout for A34 — matches A24/A25/A27/A29/A30/A31. */
const A34_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A34_SEGMENT_SETTLE_MS = 11_000;
/** Settle after the fetch+XHR triggers so BOTH async network-error
* wrappers in src/content/index.ts complete: the fetch .then/.catch
* chain AND the XHR loadend listener both enqueue their UserEvent. */
const A34_NETWORK_SETTLE_MS = 1_000;
/** Wait after chrome.tabs.create for the tab navigation to complete so
* the content script attaches + production wrappers are installed
* (mirrors A30_TAB_NAVIGATION_WAIT_MS = 1.5s). */
const A34_TAB_NAVIGATION_WAIT_MS = 1_500;
/** Probe tab URL — example.com is RFC 2606 reserved + serves stable
* HTML under headless Chrome (Plan 02-04 A27 / Plan 03-02 A30 parity). */
const A34_PROBE_TAB_URL = 'https://example.com/';
/** 404 fetch probe path — same origin as the probe tab so the fetch is
* a same-origin GET (no CORS preflight noise). example.com serves a
* 404 for arbitrary unknown paths. driveA34 filters network_error
* entries whose target contains this literal. */
const A34_404_FETCH_PATH = '/404-fetch-a34';
/** 404 XHR probe path — distinct from the fetch path so driveA34 can
* tell the two protocol entries apart. Same same-origin rationale. */
const A34_404_XHR_PATH = '/404-xhr-a34';
/**
* A34 — fetch + XHR network_error empirical (ROADMAP SC #2).
*
* Creates a fresh `https://example.com` probe tab (where the content
* script attaches normally per Plan 03-02 cs-injection-world insight),
* injects TWO failing-request triggers — a `fetch(404)` and an
* `XMLHttpRequest` GET against a 404 path — into the content-script's
* ISOLATED world via chrome.scripting.executeScript so BOTH production
* network wrappers in src/content/index.ts intercept them, settles a
* segment, SAVEs while the probe tab is active, finally-cleanup the
* tab. Host-side driveA34 inspects logs/events.json and asserts:
* - >=1 network_error entry whose target contains '404-fetch-a34'
* (proves the fetch wrapper fired AND — per Plan 04-01 P1 #11 —
* the target carries the real URL, not '[object Request]')
* - >=1 network_error entry whose target contains '404-xhr-a34'
* (proves the XHR loadend wrapper fired)
* - the fetch entry's meta.status === 404
* - the XHR entry's meta.status === 404
*
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
* driveA34 appends the fetch/XHR presence + status checks.
*/
async function assertA34(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A34 — fetch + XHR network_error empirical (ROADMAP SC #2)',
checks: [],
diagnostics: [],
};
let probeTabId: number | undefined;
try {
diag(result, 'Step 1: setupFreshRecording (A34 owns its recording — clean event-log window)');
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: chrome.tabs.create(${A34_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)`);
const probeTab = await chrome.tabs.create({ url: A34_PROBE_TAB_URL, active: true });
probeTabId = probeTab.id;
diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? '<pending>'}`);
if (probeTabId === undefined) {
throw new Error('chrome.tabs.create returned undefined tab.id');
}
diag(result, `Step 3: wait ${A34_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`);
await new Promise((r) => setTimeout(r, A34_TAB_NAVIGATION_WAIT_MS));
diag(result, `Step 4: settle ${A34_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A34_SEGMENT_SETTLE_MS));
diag(result, 'Step 5: chrome.scripting.executeScript — inject fetch(404) + XHR(404) triggers in ISOLATED world (production window.fetch + XMLHttpRequest.prototype wrappers at src/content/index.ts both intercept)');
const injectionResults = await chrome.scripting.executeScript({
target: { tabId: probeTabId },
world: 'ISOLATED',
func: async (fetchPath: string, xhrPath: string): Promise<{
stamp: number;
fetchUrl: string;
xhrUrl: string;
fetchSettled: boolean;
xhrLoadended: boolean;
}> => {
// Uniqueness guard against any future intermediate-caching
// behavior change (T-04-05-02). The 404 paths do not exist
// today so the response is always fresh, but the stamp keeps
// A34 robust if example.com's caching semantics ever change.
const stamp = Date.now();
const fetchUrl = `https://example.com${fetchPath}-${stamp}`;
const xhrUrl = `https://example.com${xhrPath}-${stamp}`;
// Trigger 1 — failing fetch. The content script patches
// window.fetch at src/content/index.ts:183 in its ISOLATED
// world; this fetch runs in the SAME ISOLATED world so it
// routes through the wrapper. response.ok===false →
// addUserEvent({type:'network_error'}) at line 187. The
// .catch(noop) is REQUIRED — without it a network-layer
// rejection would surface as a separate js_error UserEvent
// (window 'unhandledrejection' listener) which A34 ignores.
let fetchSettled = false;
try {
await fetch(fetchUrl);
fetchSettled = true;
} catch {
// Expected for a non-2xx / network failure; the production
// wrapper's .catch branch (src/content/index.ts:206) has
// already enqueued the network_error UserEvent by here.
fetchSettled = true;
}
// Trigger 2 — failing XHR. The content script wraps
// XMLHttpRequest.prototype.open + .send at
// src/content/index.ts:232/240; the loadend listener emits a
// network_error UserEvent when xhr.status >= 400. xhr.send()
// is fire-and-forget, so we await the loadend event here so
// the production wrapper has enqueued its UserEvent before
// this injected function returns.
const xhrLoadended = await new Promise<boolean>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', xhrUrl);
xhr.addEventListener('loadend', () => resolve(true));
xhr.addEventListener('error', () => resolve(true));
xhr.send();
});
return { stamp, fetchUrl, xhrUrl, fetchSettled, xhrLoadended };
},
args: [
A34_404_FETCH_PATH,
A34_404_XHR_PATH,
],
});
const injectionSummary = injectionResults[0]?.result ?? null;
diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`);
diag(result, `Step 6: settle ${A34_NETWORK_SETTLE_MS}ms so BOTH async wrappers (fetch .then/.catch + XHR loadend) finish enqueuing their network_error UserEvent`);
await new Promise((r) => setTimeout(r, A34_NETWORK_SETTLE_MS));
diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A34_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A34)',
);
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A34.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.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}`);
} finally {
// T-02-04-04 mitigation parity (Plan 03-02 / 03-03 precedent):
// cleanup probe tab with silent-ignore on already-closed.
if (probeTabId !== undefined) {
try {
await chrome.tabs.remove(probeTabId);
} catch (rmErr) {
diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`);
}
}
}
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>;
// Plan 02-04 Task 1 — Phase 2 closure (A24 D-P2-01 Blob URL)
assertA24: () => Promise<AssertionResult>;
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
assertA25: () => Promise<A25Result>;
// Plan 02-04 Task 3 — D-P2-02 + D-P2-03 + REQ-archive-layout
assertA26: () => Promise<AssertionResult>;
assertA27: () => Promise<A27Result>;
assertA28: () => Promise<AssertionResult>;
// Plan 03-01 — rrweb DOM verification (SPEC §10 #4)
assertA29: () => Promise<AssertionResult>;
// Plan 03-02 — event-log verification (SPEC §10 #5)
assertA30: () => Promise<AssertionResult>;
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
assertA31: () => Promise<AssertionResult>;
// Plan 04-05 — fetch + XHR network_error empirical (ROADMAP SC #2)
assertA34: () => 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,
assertA24,
assertA25,
assertA26,
assertA27,
assertA28,
assertA29,
assertA30,
assertA31,
assertA34,
getManifestVersion,
};
const statusEl = document.getElementById('status');
if (statusEl !== null) {
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A31, assertA34, 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 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + Plan 03-02: A30 + Plan 03-03: A31 + Plan 04-05: A34 + getManifestVersion)');
export {};