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