Files
mokosh/tests/uat/extension-page-harness.ts
Mark eb2258a880 feat(01-13): wave-1 — promote c647f61 prototype to production paths; A6 GREEN
Move the three load-bearing prototype files from `tests/uat/prototype/`
to their production paths under `tests/uat/`, leaving the architectural
narrative (research findings, BLOCKER citations, falsification table
references) intact. No behavioral changes — A6 still PASSES 5/5 in ~7s
end-to-end from the new paths.

File moves (git mv preserves history):
  - tests/uat/prototype/extension-page-harness.html
      → tests/uat/extension-page-harness.html
  - tests/uat/prototype/extension-page-harness.ts
      → tests/uat/extension-page-harness.ts
  - tests/uat/prototype/a6.test.ts
      → tests/uat/a6.test.ts

The `tests/uat/prototype/` directory is now empty (git does not track
empty directories; will not appear in subsequent `git status`).

Path-reference updates inside the moved files:
  - tests/uat/extension-page-harness.html: `<p>` line referencing the
    chrome-extension:// URL updated to drop `/prototype/`.
  - tests/uat/extension-page-harness.ts: file-header docstring rewritten
    to cite Plan 01-13 / Approach B / inheritance from c647f61. The
    load-bearing architectural-finding comment block (MV3 SW dynamic-
    import block falsification, Approach-B chrome.* surface summary)
    is REWORDED but its semantic content + research citations are
    PRESERVED — every load-bearing fact survives the rename.
  - tests/uat/a6.test.ts:
      * File-header rewritten to position the file as Plan 01-13's
        standalone single-assertion entry point (preserves the future-
        proof rationale: this entry stays around forever for fast TDD
        iteration on A6 even after Wave 3 folds A6 into the orchestrator
        harness.test.ts).
      * REPO_ROOT resolvePath chain corrected from `..,..,..` to `..,..`
        — the file is now two directory levels above the repo root
        instead of three. Without this fix DIST_TEST_DIR would resolve
        to a path one level above the actual repo root and
        assertBundlePresent would throw. **VERIFIED by running the
        driver: build path resolves correctly.**
      * harnessUrl constant updated to drop `/prototype/` from the
        chrome-extension://<id>/tests/uat/extension-page-harness.html
        URL — must match the rollup emission path in dist-test/.
      * Stdout labels updated: 'PROTOTYPE A6 result' → 'A6 result',
        'Plan 01-11 PROTOTYPE — A6 ... feasibility test' → 'Plan 01-13
        — A6 (Bug B canonical) standalone driver'. Inside the docstrings
        the historical 'originally landed as 01-11 prototype' provenance
        is preserved per the plan's contract.

vite.test.config.ts:
  - `rollupOptions.input` renamed `prototype_harness` → `extension_page_harness`
    pointing at the new production path. crxjs emits the harness HTML
    to `dist-test/tests/uat/extension-page-harness.html` (verified by
    `ls dist-test/tests/uat/`).
  - The `modulePreload: { polyfill: false }` line is PRESERVED — this
    is the CRITICAL SW FIX per 01-11-SUMMARY (disabling the polyfill
    is what makes the test bundle's offscreen-side dynamic import work
    without crashing in non-DOM contexts that incorrectly try to call
    document.querySelector).
  - File-header comment §4 and the inline `define.__MOKOSH_UAT__` comment
    are PRESERVED — load-bearing rationale for the dedicated build-time
    token (vs `import.meta.env.MODE === 'test'` which collides with
    vitest).

Verification (all GREEN):
  - `npm run build:test` — exit 0; dist-test/ emits
    `tests/uat/extension-page-harness.html` and `assets/extension_page_harness-*.js`.
  - `npx tsx tests/uat/a6.test.ts` — exits 0 with "A6 result: PASS";
    5/5 checks GREEN (SETUP: badge becomes REC; A6.1 badge==''; A6.2
    popup==''; A6.3 notif delta==0; A6.4 isRecording=false). End-to-end
    runtime ~7s headless on this workstation.
  - `npx tsc --noEmit` — exit 0 (root tsconfig + tests/uat/tsconfig.json).
  - `npx vitest run` — 92/92 GREEN; the moves do not touch any vitest-
    discovered files.
  - `npm run build` — exit 0; Tier-1 grep gate stays GREEN
    (the moves do not touch production code).

Wave 2 (next): build out `tests/uat/lib/{launch,assertions,harness-page-
driver}.ts` around the extension-page architecture; rewrite
`tests/uat/a6.test.ts` to use the shared lib (still PASSES 5/5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:01:58 +02:00

428 lines
16 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 1 surface — the page exposes `window.__mokoshHarness` with one
// method (assertA6); Wave 3 extends to all 13 assertions:
// - `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;
}
// Install the global harness surface.
declare global {
interface Window {
__mokoshHarness: {
assertA6: () => Promise<AssertionResult>;
};
}
}
window.__mokoshHarness = { assertA6 };
const statusEl = document.getElementById('status');
if (statusEl !== null) {
statusEl.textContent = 'Harness ready. window.__mokoshHarness.assertA6() available.';
}
console.log('[harness-page] ready — window.__mokoshHarness installed');
export {};