Wave 3A landed. `npm run test:uat` now exercises 5/14 assertions
end-to-end (A0 + A1 + A2 + A3 + A4); bails at A5 NOT YET IMPLEMENTED
(Wave 3B scope). A6 still PASSES 5/5 through the standalone
`npx tsx tests/uat/a6.test.ts` entry — the orchestrator-level A6 won't
reach in Wave 3A because the sequential loop bails at A5; once Wave 3B
wires driveA5 the loop will fall through to A6 (which uses the proven
Wave-2 driveA6 driver — no rework needed there).
Files changed:
- `tests/uat/extension-page-harness.ts` — extends `window.__mokoshHarness`
from `{ assertA6 }` to `{ assertA1, assertA2, assertA3, assertA4,
assertA6 }`. Per-assertion contracts:
• A1 — chrome.action.getBadgeText({}) === '' + getPopup({}) === ''
+ isRecording=false (badge !== 'REC' proxy per state-machine atomic
pairing). 3 CheckRecords.
• A2 — ensureOffscreen + START_RECORDING direct-to-offscreen
(workaround for the `tabs` manifest permission gap per
01-11-SUMMARY + plan resolved-questions row 2) + manual
setBadgeText('REC') + setPopup(POPUP_HTML_PATH) + waitFor
badge==='REC'. The bypassed chrome.action.onClicked →
startVideoCapture path is unit-tested in
tests/background/badge-state-machine.test.ts; A2 verifies the
contract that matters (recording reaches the REC state-machine
row). 2 CheckRecords.
• A3 — offscreen bridge query 'get-display-surface' (new in this
plan via the prior commit's offscreen-hooks extension) → asserts
=== 'monitor'. 1 CheckRecord.
• A4 — getPopup remains 'src/popup/index.html' + hasDocument()===true
(no duplicate offscreen). Essentially a no-op verification —
regression protection against future refactors that might unpin
the popup during recording or spawn extra offscreens on stray
events. 2 CheckRecords.
• IMPORTANT: chrome.action.getPopup() returns the FULL absolute
chrome-extension://<id>/... URL (not the manifest-relative path).
A2.2 + A4.1 assert via .endsWith('src/popup/index.html') to stay
extension-id independent. Empirical finding from first orchestrator
run; documented inline.
- `tests/uat/lib/harness-page-driver.ts` — wires `driveA1/A2/A3/A4`
(replaces the 4 NOT YET IMPLEMENTED Wave-3A stubs from
eb64521). Each wraps a single page.evaluate(() =>
window.__mokoshHarness.assertXX()) call per the contract laid down
by driveA6. A5+A7..A13 remain stubbed for Waves 3B+3C+3D.
- `tests/uat/harness.test.ts` (NEW) — top-level UAT orchestrator
driving all 14 assertions sequentially against a single Chrome +
single harness page. A0 (Tier-1 grep gate) runs pre-flight before
any Chrome launch — mirrors
tests/background/no-test-hooks-in-prod-bundle.test.ts forbidden-
string inventory (9 entries; belt-and-suspenders per
feedback-pre-checkpoint-bundle-gates.md memory). Bail-on-first-
failure with [SKIP] markers for unreached assertions + structured
diagnostic dump (full SW + offscreen console tail) on each failure.
SKIP_PROD_REBUILD=1 escape hatch skips the A0-side `npm run build`
for developer iteration.
Verification (all GREEN):
- npx tsc --noEmit: clean (root)
- npx tsc --noEmit -p tests/uat: clean (UAT subtree)
- npm run build: clean; production bundle hook-free
(9-string grep gate in vitest unit gate)
- npm run build:test: clean; dist-test/assets/extension_page_harness-*.js
grew from 3.87kB → 7.67kB (A1+A2+A3+A4 added)
- SKIP_BUILD=1 npx vitest run: 93/93 GREEN
(Wave 0+1+2 baseline 92 + 1 from the 9th grep-gate string from
the prior commit; this commit adds zero new vitest tests — the
A1-A4 contracts are verified at UAT-harness time only)
- npx tsx tests/uat/a6.test.ts (standalone): 5/5 GREEN; exit 0
(Wave-2 A6 baseline preserved through orchestrator-adjacent
harness page surface extension)
- npm run test:uat (full operator entry): 5/14 GREEN
(A0 + A1 + A2 + A3 + A4); bails at A5 NOT YET IMPLEMENTED
(Wave 3B scope, expected). Total wall clock ~25s (~5s build +
~5s prod-rebuild for A0 + ~15s assertion sequence).
Operator empirical-verification deferred to orchestrator (per
feedback-pre-checkpoint-bundle-gates.md — the orchestrator runs SW
CSP-safety + Node-globals + DOM-globals grep on the built bundle
before surfacing any checkpoint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
709 lines
27 KiB
TypeScript
709 lines
27 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).
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Install the global harness surface.
|
|
declare global {
|
|
interface Window {
|
|
__mokoshHarness: {
|
|
assertA1: () => Promise<AssertionResult>;
|
|
assertA2: () => Promise<AssertionResult>;
|
|
assertA3: () => Promise<AssertionResult>;
|
|
assertA4: () => Promise<AssertionResult>;
|
|
assertA6: () => Promise<AssertionResult>;
|
|
};
|
|
}
|
|
}
|
|
|
|
window.__mokoshHarness = { assertA1, assertA2, assertA3, assertA4, assertA6 };
|
|
|
|
const statusEl = document.getElementById('status');
|
|
if (statusEl !== null) {
|
|
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA6} available.';
|
|
}
|
|
|
|
console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3A: A1+A2+A3+A4+A6)');
|
|
|
|
export {};
|