[per Plan 01-14; closes B-01-14-01 via Step 1b lockstep]
- src/offscreen/recorder.ts: add monitorTypeSurfaces:'include' as top-level
DisplayMediaStreamOptions sibling of video: (W3C Screen Capture spec §6.1;
Chrome >= 119; removes tab/window panes from the operator's picker per
Plan 01-10 RESEARCH §5 + §Pitfall-5 recommendation). Typed widening cast
extended in lockstep to keep the explicit-typing contract (no `as any`).
D-15 post-grant validation block at recorder.ts:294 UNCHANGED — belt
(picker narrowing) + suspenders (post-grant tear-down) chain preserved.
- tests/offscreen/display-surface-constraint.test.ts: lockstep update of
the strict-deep-equality assertion at lines 223-226 with the same key
ordering as the source change (video -> monitorTypeSurfaces -> audio).
toHaveBeenCalledWith contract preserved (NO expect.objectContaining —
the test author's "catches future drops of ANY field" discipline is
honored). This edit + the source change land in the SAME commit so the
98/98 baseline never crosses a commit boundary in RED state.
- src/test-hooks/offscreen-hooks.ts: capture last constraints object in
module-scoped `lastGetDisplayMediaConstraints` cell (was `_constraints`
received-but-unused; renamed to `constraints`); add `get-last-getDisplayMedia-constraints`
bridge op to the __mokoshOffscreenQuery dispatcher between
get-display-surface and get-segment-count. Defensive try/catch mirrors
the existing dispatcher pattern; the cell is module-internal so the
MokoshTestSurface cross-cast in types.ts requires NO change (decision
documented inline in offscreen-hooks.ts).
- tests/uat/extension-page-harness.ts: add `assertA23` mirroring `assertA3`
(bridge query → 2-check AssertionResult: non-null constraints + value).
Extend the `Window.__mokoshHarness` declaration + runtime export + status
bar text + console.log to reference A23.
- tests/uat/lib/harness-page-driver.ts: export `driveA23(page)` mirroring
the `driveA14` page.evaluate wrapper shape. Standard read-only driver.
- tests/uat/harness.test.ts: extend FORBIDDEN_HOOK_STRINGS (line 85) with
`lastGetDisplayMediaConstraints` and `get-last-getDisplayMedia-constraints`.
Import driveA23. Append `{ name: 'A23', drive: driveA23 }` to the drivers
array after the A14 entry. Update header comment + orchestrator stdout
to reflect A14 + A23 chain. The `Total = drivers.length + 1` arithmetic
adapts automatically: 14 + 1 = 15 → 15 + 1 = 16.
- tests/background/no-test-hooks-in-prod-bundle.test.ts: lockstep
extension of FORBIDDEN_HOOK_STRINGS (line 105) with the same 2 strings.
Header comment updated to "Total: 12 surface strings." (was 10).
Confirms production `dist/` has ZERO occurrences after `npm run build`
via the `__MOKOSH_UAT__` dead-branch tree-shake (T-01-14-04 mitigation).
D-01 (whole-desktop only via getDisplayMedia; reject window/tab surfaces) is
the design intent that monitorTypeSurfaces:'include' realizes at the picker-
UI level. D-15 post-grant validation (recorder.ts:294-307) remains the
actual enforcement against managed-policy/DevTools/older-Chrome overrides.
Verification chain (per Plan 01-14 §verify; clean post-commit):
- `npx tsc --noEmit` exit 0
- `npm run build` exit 0; dist/ produced, monitorTypeSurfaces ships in
the offscreen chunk as the operator-facing picker hint
- `npm run build:test` exit 0; dist-test/ produced with the harness
hooks intact (gated)
- `npm test` 100/100 GREEN (was 98/98; +2 via the 2 new FORBIDDEN_HOOK_STRINGS
parametrized tests — both PASS, production bundle hook-free)
- `npm run test:uat` 16/16 GREEN (15 → 16 via A23). A23 reads constraints
`{video: {...}, monitorTypeSurfaces: 'include', audio: false}` from the
fakeGetDisplayMedia capture cell — round-trips through the full call site.
- Production bundle spot-check:
`grep -rc 'lastGetDisplayMediaConstraints\|get-last-getDisplayMedia-constraints' dist/ | grep -v ':0$'`
→ empty (all `:0` filtered) → ZERO leakage.
References:
- W3C Screen Capture §6.1 DisplayMediaStreamOptions:
https://www.w3.org/TR/screen-capture/#dom-displaymediastreamoptions-monitortypesurfaces
- Chrome screen-sharing-controls (Chrome 119+):
https://developer.chrome.com/docs/web-platform/screen-sharing-controls
- Plan 01-10 RESEARCH §5 + §Pitfall-5 (recommendation provenance):
.planning/phases/01-stabilize-video-pipeline/01-10-RESEARCH.md
- Architectural-note (replaces retired AMENDMENT-A.md improvisation per
01-11-SUMMARY): canonical GSD ceremony — plan → checker (B-01-14-01)
→ executor → SUMMARY (this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2045 lines
86 KiB
TypeScript
2045 lines
86 KiB
TypeScript
// tests/uat/extension-page-harness.ts — Plan 01-13 production UAT harness.
|
||
//
|
||
// Inherited from the Plan 01-11 prototype at commit c647f61 per the
|
||
// 01-11-SUMMARY.md architectural pivot (Approach B). The prototype
|
||
// proved A6 (Bug B canonical regression rewind) 5/5 GREEN in ~7s
|
||
// end-to-end, validating the architecture summarized below. Plan 01-13
|
||
// Wave 1 promoted this file from `tests/uat/prototype/` to the
|
||
// production path without behavioral change; Wave 3 will extend the
|
||
// `window.__mokoshHarness` surface with assertA1..A13 methods.
|
||
//
|
||
// Extension-internal harness page entrypoint. Lives at
|
||
// `chrome-extension://<id>/tests/uat/extension-page-harness.html`
|
||
// in the test build (vite.test.config.ts adds it as a Rollup input).
|
||
//
|
||
// ARCHITECTURAL ANCHOR (Approach B): the working architecture for MV3
|
||
// extension UAT is to drive Chrome FROM INSIDE (extension-internal
|
||
// test page + synthetic MediaStream) rather than FROM OUTSIDE (CDP
|
||
// into SW context). Falsification of the Approach-A alternatives
|
||
// (sw.evaluate + popup-bridge + SW-side dynamic-import test hooks)
|
||
// is documented in 01-11-SUMMARY.md.
|
||
//
|
||
// IMPORTANT RESEARCH FINDING (load-bearing — DO NOT REGRESS):
|
||
// Plan 01-11 RESEARCH §6 originally architected `await import(...)`
|
||
// at the top of src/background/index.ts to gate SW-side test hooks.
|
||
// EMPIRICAL FALSIFICATION (01-11 prototype, verified Chrome 148):
|
||
// dynamic import is BLOCKED in MV3 service workers. The SW silently
|
||
// dies — the chunk file is loaded but the await never resolves, so
|
||
// production listeners never register. Production sources:
|
||
// - w3c/webextensions#212 (May 2022, still open as of May 2026)
|
||
// - chromium.googlesource.com es_modules.md: "Dynamic import is
|
||
// currently blocked in Service Workers, but it will change in
|
||
// the future."
|
||
// Approach B WORKS AROUND this by:
|
||
// 1. Removing the SW-side gated dynamic import entirely
|
||
// (src/background/index.ts has comment-only docs at lines 13-30
|
||
// explaining why no hook gate lands here).
|
||
// 2. Using only the OFFSCREEN-side test hook (offscreen IS a DOM
|
||
// document, dynamic import works there — see
|
||
// src/offscreen/recorder.ts top-of-module gate).
|
||
// 3. Driving everything from this harness page using PRODUCTION
|
||
// chrome.* APIs (page has full extension permissions):
|
||
// - chrome.action.getBadgeText / getPopup — read SW state
|
||
// - chrome.offscreen.createDocument — create offscreen FIRST
|
||
// (the page is allowed to call this)
|
||
// - chrome.runtime.sendMessage START_RECORDING — trigger
|
||
// production startRecording path (uses existing offscreen +
|
||
// fake getDisplayMedia from offscreen-hooks.ts)
|
||
// - chrome.notifications.getAll — count active notifications
|
||
// (no SW hook needed)
|
||
// - chrome.runtime.sendMessage __mokoshOffscreenQuery
|
||
// dispatch-ended — trigger Bug B simulation via offscreen
|
||
// bridge (offscreen still uses dynamic import → works)
|
||
//
|
||
// Wave 3A surface — extends `window.__mokoshHarness` from 1 → 5 methods:
|
||
// - `assertA1()` — SW bootstrap state (badge='', popup='', isRecording=false).
|
||
// - `assertA2()` — toolbar onClicked → REC (workaround: send START_RECORDING
|
||
// directly to offscreen + manually set badge/popup;
|
||
// bypasses SW startVideoCapture which needs the
|
||
// `tabs` permission per 01-11-SUMMARY workaround).
|
||
// - `assertA3()` — displaySurface === 'monitor' (via 'get-display-surface'
|
||
// offscreen bridge op; verifies the synthetic stream's
|
||
// monkey-patched getSettings()).
|
||
// - `assertA4()` — popup stays pinned during recording (REC state
|
||
// preserves setRecordingMode's setPopup; offscreen
|
||
// count remains 1 — no second offscreen spawns).
|
||
// - `assertA6()` — canonical Bug B regression assertion (proven).
|
||
//
|
||
// Wave 3B surface — extends `window.__mokoshHarness` from 5 → 7 methods:
|
||
// - `assertA5()` — SAVE_ARCHIVE dispatches; the page-side check confirms
|
||
// the SW handler returns success. The host-side driveA5
|
||
// wrapper handles disk-side verification (zip file
|
||
// presence + size > 1KB) since the page cannot read
|
||
// `handles.downloadsDir`. Page-side method returns
|
||
// `{passed, name, checks: [SW dispatch ack], diagnostics}`;
|
||
// host-side merges its own checks on top.
|
||
// - `assertA7()` — genuine recording error → ERR badge + recovery
|
||
// notification. Sends a synthetic
|
||
// `RECORDING_ERROR{error:'codec-unsupported'}` (NOT
|
||
// 'user-stopped-sharing' — that's Bug B's branch).
|
||
// Asserts: badge='ERR', popup endsWith 'src/popup/index.html',
|
||
// notif count delta===1, and ANY active notification id
|
||
// has the 'mokosh-recovery-' prefix. The set-membership
|
||
// check on the prefix is the plan-resolved choice over a
|
||
// "most recent" check — `chrome.notifications.getAll` does
|
||
// not guarantee key ordering (set membership is reliable;
|
||
// ordering is not).
|
||
//
|
||
// Wave 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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 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>;
|
||
assertA23: () => Promise<AssertionResult>;
|
||
getManifestVersion: () => Promise<string>;
|
||
};
|
||
}
|
||
}
|
||
|
||
window.__mokoshHarness = {
|
||
assertA1,
|
||
assertA2,
|
||
assertA3,
|
||
assertA4,
|
||
assertA5,
|
||
assertA6,
|
||
assertA7,
|
||
assertA8,
|
||
assertA9,
|
||
assertA10,
|
||
assertA11,
|
||
assertA12,
|
||
assertA13,
|
||
assertA14,
|
||
assertA23,
|
||
getManifestVersion,
|
||
};
|
||
|
||
const statusEl = document.getElementById('status');
|
||
if (statusEl !== null) {
|
||
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA14, assertA23, getManifestVersion} available.';
|
||
}
|
||
|
||
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-14: A23 + getManifestVersion)');
|
||
|
||
export {};
|