Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
4 changed files with 871 additions and 283 deletions
Showing only changes of commit eb64521321 - Show all commits

View File

@@ -1,311 +1,61 @@
// tests/uat/a6.test.ts — Plan 01-13 standalone A6 entry point.
//
// Puppeteer-driven single-assertion driver for A6 (Bug B canonical).
// Originally landed as the Plan 01-11 prototype at commit c647f61;
// Plan 01-13 Wave 1 promoted this file from `tests/uat/prototype/` to
// the production path without behavioral change. Wave 2 will refactor
// the launch + console-capture + result-print plumbing into reusable
// lib helpers (`tests/uat/lib/{launch,assertions,harness-page-driver}
// .ts`) and rewrite this driver against them; Wave 3 folds A6 into
// `tests/uat/harness.test.ts` as the assertion of record for `npm run
// test:uat`. This standalone entry is RETAINED throughout for fast
// TDD iteration on the A6 contract (`npx tsx tests/uat/a6.test.ts` —
// ~7s end-to-end vs the orchestrator's ~60-90s for all 14).
// Refactored in Wave 2 to use the shared `tests/uat/lib/` scaffolding
// (`launchHarnessBrowser`, `driveA6`, `runAssertion`, `printAssertionResult`).
// Behavior-preserving: A6 still PASSES 5/5 in ~7s end-to-end. The ~80
// LoC of Chrome-launch + console-attach + result-print plumbing
// previously inlined here now lives in `tests/uat/lib/{launch,assertions,
// harness-page-driver}.ts` — single source of truth for Wave 3's 13
// additional assertions.
//
// Assertion contract — A6 (Bug B canonical): when the offscreen
// recorder fires RECORDING_ERROR{error: 'user-stopped-sharing'}
// (simulated via dispatchEvent('ended') on the active video track per
// 01-11 RESEARCH §7 BLOCKER — track.stop() does NOT fire 'ended' per
// W3C spec), the SW state machine routes through setIdleMode (NOT
// setErrorMode): badge becomes empty, popup empties, isRecording=false,
// NO recovery notification fires. The prototype verified this PASSES
// 5/5 today AND FAILS on local revert of the Bug B fix at
// src/background/index.ts:776 — both halves of the RED-on-regression
// demo land in the Wave 3B commit body as the canonical TDD canon.
// This standalone entry is RETAINED throughout the rest of Plan 01-13
// for fast TDD iteration on the A6 contract:
// `npx tsx tests/uat/a6.test.ts` # headless, ~7s
// `HEADLESS=0 npx tsx tests/uat/a6.test.ts` # interactive debug view
//
// Usage:
// tsx tests/uat/a6.test.ts
// HEADLESS=0 tsx tests/uat/a6.test.ts # debug view
// The orchestrator-level entry `npm run test:uat` (lands in Wave 3A)
// runs all 14 assertions (~60-90s); this single-A6 entry is for the
// inner loop when iterating on Bug B fix verification or harness-page
// surface changes.
//
// Pre-flight: requires `dist-test/` from `npm run build:test`. The test
// will fail loudly if the bundle is missing.
//
// References:
// - eyeo's MV3 testing journey (uses extension-internal test page +
// bidirectional messaging):
// https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension
// - Chrome MV3 E2E testing official guide:
// https://developer.chrome.com/docs/extensions/mv3/end-to-end-testing/
// Pre-flight: requires `dist-test/` from `npm run build:test`. The
// `assertBundlePresent` call inside `launchHarnessBrowser` fails
// loudly if the bundle is missing.
import { existsSync, statSync } from 'node:fs';
import { dirname, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type Page } from 'puppeteer';
// Plan 01-13 Wave 1: this file lives at `tests/uat/a6.test.ts` (was
// `tests/uat/prototype/a6.test.ts` pre-Wave-1). Repo root is two
// directory levels up — was three pre-Wave-1. The resolvePath chain
// MUST stay in sync with the on-disk location or `DIST_TEST_DIR` will
// resolve to the wrong path and `assertBundlePresent` will throw.
const HARNESS_FILE_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolvePath(HARNESS_FILE_DIR, '..', '..');
const DIST_TEST_DIR = resolvePath(REPO_ROOT, 'dist-test');
/** Per-check record returned by the harness page. */
interface CheckRecord {
name: string;
expected: unknown;
actual: unknown;
passed: boolean;
}
/** Result returned by `window.__mokoshHarness.assertA6()`. */
interface HarnessAssertionResult {
passed: boolean;
name: string;
checks: CheckRecord[];
diagnostics: string[];
error?: string;
}
import { launchHarnessBrowser } from './lib/launch';
import { driveA6 } from './lib/harness-page-driver';
import { runAssertion, printAssertionResult } from './lib/assertions';
/**
* Verify the test bundle is present; fail loudly if missing.
* Standalone A6 driver entry point.
*
* @throws If dist-test/ is missing or not a directory.
*/
function assertBundlePresent(): void {
if (!existsSync(DIST_TEST_DIR)) {
throw new Error(
`dist-test/ missing at ${DIST_TEST_DIR} — run \`npm run build:test\` first.`,
);
}
if (!statSync(DIST_TEST_DIR).isDirectory()) {
throw new Error(`dist-test/ at ${DIST_TEST_DIR} is not a directory.`);
}
}
/**
* Launch Chrome with the test bundle loaded as an unpacked MV3
* extension. Returns the browser handle + resolved extension id.
*
* Bumps `protocolTimeout` from the default 30s to 90s so the
* end-to-end assertion (which does several sendMessage round-trips
* + waits for badge transitions) has enough headroom on slow CI
* runners without the assertion call itself timing out at the CDP layer.
*
* @returns Browser handle + extension id.
*/
async function launchChrome(): Promise<{
browser: Browser;
extensionId: string;
}> {
const headless = process.env.HEADLESS !== '0';
const browser = await puppeteer.launch({
enableExtensions: [DIST_TEST_DIR],
headless,
pipe: true,
protocolTimeout: 90_000,
args: [
'--no-sandbox',
// We do NOT need --auto-select-desktop-capture-source for the
// prototype because the fake getDisplayMedia bypasses the picker
// entirely. Including it would be a no-op.
],
});
// Resolve extension id. browser.extensions() returns a Map<id, Extension>
// populated asynchronously after the extension's manifest loads. Poll
// for up to 5s with a clear diagnostic on timeout.
const POLL_TIMEOUT_MS = 5_000;
const POLL_INTERVAL_MS = 100;
const pollStart = Date.now();
let extensionsMap = await browser.extensions();
while (extensionsMap.size === 0 && Date.now() - pollStart < POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
extensionsMap = await browser.extensions();
}
const entries = [...extensionsMap];
if (entries.length === 0) {
await browser.close();
throw new Error(
`No extensions loaded after ${POLL_TIMEOUT_MS}ms — dist-test/ malformed?`,
);
}
const [extensionId] = entries[0];
return { browser, extensionId };
}
/**
* Pretty-print the harness assertion result for stdout.
*
* @param result - The structured result from assertA6().
*/
function printResult(result: HarnessAssertionResult): void {
process.stdout.write('\n');
process.stdout.write('='.repeat(72) + '\n');
process.stdout.write(`A6 result: ${result.passed ? 'PASS' : 'FAIL'}\n`);
process.stdout.write(`Assertion: ${result.name}\n`);
if (result.error !== undefined) {
process.stdout.write(`Top-level error: ${result.error}\n`);
}
process.stdout.write('\nChecks:\n');
for (const check of result.checks) {
const mark = check.passed ? '[PASS]' : '[FAIL]';
process.stdout.write(` ${mark} ${check.name}\n`);
process.stdout.write(` expected: ${JSON.stringify(check.expected)}\n`);
process.stdout.write(` actual: ${JSON.stringify(check.actual)}\n`);
}
process.stdout.write('\nDiagnostics:\n');
for (const diag of result.diagnostics) {
process.stdout.write(` - ${diag}\n`);
}
process.stdout.write('='.repeat(72) + '\n');
}
/**
* Main prototype entry point. Returns the process exit code.
*
* @returns 0 on PASS, 1 on FAIL.
* @returns Process exit code: 0 on PASS, 1 on FAIL.
*/
async function main(): Promise<number> {
process.stdout.write('\nMokosh Plan 01-13 — A6 (Bug B canonical) standalone driver\n');
process.stdout.write('Architecture: extension-internal page + bridge + synthetic stream\n');
process.stdout.write('='.repeat(72) + '\n');
assertBundlePresent();
process.stdout.write(`Bundle: ${DIST_TEST_DIR}\n`);
process.stdout.write('Launching Chrome...\n');
const { browser, extensionId } = await launchChrome();
process.stdout.write(`Extension id: ${extensionId}\n`);
// Diagnostic capture buffers — flushed on result print.
const consoleLines: string[] = [];
const handles = await launchHarnessBrowser();
process.stdout.write(`Extension id: ${handles.extensionId}\n`);
process.stdout.write(`Downloads dir: ${handles.downloadsDir}\n`);
process.stdout.write('Harness page ready; invoking assertA6()...\n\n');
let exitCode = 1;
try {
// Open the prototype harness page. The page lives at the test-build
// path (vite.test.config.ts adds it as a rollup input).
const harnessUrl = `chrome-extension://${extensionId}/tests/uat/extension-page-harness.html`;
process.stdout.write(`Opening: ${harnessUrl}\n`);
// Open a 'victim' page first — production code calls
// chrome.tabs.query({active:true}) and demands a tab with .url
// (the operator's recording-target page). The harness page itself
// is a chrome-extension:// URL which has no .url surfaced (without
// 'tabs' permission). We open a real http URL in a separate tab
// and bring it to front before REQUEST_PERMISSIONS fires.
const victimPage = await browser.newPage();
await victimPage.goto('about:blank');
// about:blank has tab.url === 'about:blank' (truthy), so production
// tab.id + tab.url check passes.
const page: Page = await browser.newPage();
page.on('console', (msg) => {
const line = `[page:${msg.type()}] ${msg.text()}`;
consoleLines.push(line);
process.stderr.write(line + '\n');
});
page.on('pageerror', (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
const line = `[page:ERROR] ${msg}`;
consoleLines.push(line);
process.stderr.write(line + '\n');
});
// Also capture SW console logs (where production logger.* writes).
// The SW target appears when the extension loads — wait briefly,
// then attach a worker handle and forward console events.
try {
const swTarget = await browser.waitForTarget(
(t) => t.type() === 'service_worker' && t.url().includes(extensionId),
{ timeout: 10_000 },
);
const sw = await swTarget.worker();
if (sw !== null) {
sw.on('console', (msg) => {
const line = `[sw:${msg.type()}] ${msg.text()}`;
consoleLines.push(line);
process.stderr.write(line + '\n');
});
}
} catch (swAttachErr) {
process.stderr.write(
`(note: SW console attach skipped — ${String(swAttachErr)})\n`,
);
}
await page.goto(harnessUrl, {
waitUntil: 'domcontentloaded',
timeout: 10_000,
});
process.stdout.write('Page loaded; waiting for window.__mokoshHarness...\n');
// The harness page's bundled script installs window.__mokoshHarness
// on module-load. Wait for the bootstrap to land.
await page.waitForFunction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where window types are loose.
() => (window as any).__mokoshHarness !== undefined,
{ timeout: 5_000 },
const result = await runAssertion(
'A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode',
() => driveA6(handles.harnessPage),
{ swConsole: handles.swConsole, offConsole: handles.offConsole },
);
process.stdout.write('Harness page ready; invoking assertA6()...\n\n');
// Try also attaching to offscreen target console logs once it appears.
let offscreenAttached = false;
browser.on('targetcreated', async (target) => {
if (offscreenAttached) return;
const url = target.url();
if (
target.type() === 'background_page' &&
url.includes(extensionId) &&
url.includes('offscreen')
) {
offscreenAttached = true;
try {
const offPage = await target.asPage();
offPage.on('console', (msg) => {
const line = `[off:${msg.type()}] ${msg.text()}`;
consoleLines.push(line);
process.stderr.write(line + '\n');
});
} catch (offAttachErr) {
process.stderr.write(
`(note: offscreen console attach skipped — ${String(offAttachErr)})\n`,
);
}
}
});
// Bring the victim page to front so chrome.tabs.query({active:true})
// returns it (not the harness page) when production startVideoCapture
// runs. The harness page can still be evaluated against — Puppeteer's
// page handle doesn't care about active-tab state.
await victimPage.bringToFront();
// Run the end-to-end A6 assertion. The page-side code does all the
// orchestration — Puppeteer is just the trigger + result reader.
const result = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context.
const harness = (window as any).__mokoshHarness;
const r = await harness.assertA6();
return r;
}) as HarnessAssertionResult;
printResult(result);
printAssertionResult(result);
exitCode = result.passed ? 0 : 1;
} catch (err) {
process.stderr.write(`\n*** Top-level harness error: ${String(err)}\n`);
if (consoleLines.length > 0) {
process.stderr.write('\nCaptured console (last 50 lines):\n');
for (const line of consoleLines.slice(-50)) {
process.stderr.write(` ${line}\n`);
}
}
exitCode = 1;
} finally {
try {
await browser.close();
await handles.browser.close();
} catch (closeErr) {
process.stderr.write(`(non-fatal: browser close threw: ${String(closeErr)})\n`);
}

263
tests/uat/lib/assertions.ts Normal file
View File

@@ -0,0 +1,263 @@
// tests/uat/lib/assertions.ts — Plan 01-13 Wave 2.
//
// Host-side assertion primitives. Re-exports of node:assert/strict with
// structured failure messages + diagnostic-dump wrappers that capture
// SW + offscreen console buffers on failure.
//
// IMPORTANT — NO chrome.* helpers here. All chrome.* work happens
// inside the extension-internal harness page (see
// tests/uat/extension-page-harness.ts and its `window.__mokoshHarness`
// surface). This module is host-side ONLY — it runs in the Node
// process that drives Puppeteer. Calling chrome.* from here would
// fail (no chrome global in Node) — by-construction, not by convention.
//
// References:
// - node:assert/strict (deep strict equality):
// https://nodejs.org/api/assert.html#strict-assertion-mode
import * as assert from 'node:assert/strict';
/**
* One assertion-internal check record — populated by the harness page's
* `assertA*` methods. Each AssertionRecord carries 1..N CheckRecords
* which collectively determine whether the AssertionRecord PASSES.
*/
export interface CheckRecord {
readonly name: string;
readonly expected: unknown;
readonly actual: unknown;
readonly passed: boolean;
}
/**
* Structured result returned by every page-side `assertA*` method.
* Mirrors the shape used by the proven prototype (c647f61) so the
* host-side `runAssertion` + `printAssertionResult` can consume any
* assertion uniformly.
*/
export interface AssertionRecord {
readonly passed: boolean;
readonly name: string;
readonly checks: ReadonlyArray<CheckRecord>;
readonly diagnostics: ReadonlyArray<string>;
readonly error?: string;
}
/**
* Accumulating console buffers from `launchHarnessBrowser`. Passed
* into `runAssertion` so a failing assertion can dump the SW + offscreen
* logs to stderr alongside the structured CheckRecords. The buffers
* are MUTABLE arrays owned by `launch.ts`; readers MUST NOT mutate.
*/
export interface ConsoleBuffers {
readonly swConsole: ReadonlyArray<string>;
readonly offConsole: ReadonlyArray<string>;
}
/**
* How many trailing lines of each console buffer to dump on a failure.
* Bounded so a long-running test with thousands of lines does not
* overwhelm stderr; the cap is generous enough to capture the relevant
* preamble + the actual failure trigger.
*/
const CONSOLE_DUMP_TAIL_LINES = 100;
/**
* Wrap a single assertion attempt with try/catch + diagnostic dump on
* failure. The `fn` is the page-side call (typically a `driveA*`
* wrapper from `harness-page-driver.ts`); a thrown error becomes an
* AssertionRecord with `passed: false` + the error message in `.error`.
*
* On failure, dumps the last `CONSOLE_DUMP_TAIL_LINES` of each console
* buffer to stderr — sized to fit the typical assertion timeline
* (several seconds of SW + offscreen logs) without spamming.
*
* @param name - Assertion name (used only for the failure preamble).
* @param fn - Async function returning the page-side AssertionRecord.
* @param buffers - Console buffers from `launchHarnessBrowser`.
* @returns The page-side AssertionRecord (with passed=false on throw).
*/
export async function runAssertion(
name: string,
fn: () => Promise<AssertionRecord>,
buffers: ConsoleBuffers,
): Promise<AssertionRecord> {
try {
const result = await fn();
if (!result.passed) {
dumpConsoleTail(name, buffers);
}
return result;
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
dumpConsoleTail(name, buffers);
return {
passed: false,
name,
checks: [],
diagnostics: [`runAssertion caught: ${errMsg}`],
error: errMsg,
};
}
}
/**
* Dump the tail of each console buffer to stderr — used by
* `runAssertion` on any failure path. Each line is already pre-tagged
* (`[sw:log] ...` / `[off:log] ...`) by the listeners in `launch.ts`.
*
* @param assertionName - Name to prefix the dump header.
* @param buffers - Console buffers to dump.
*/
function dumpConsoleTail(
assertionName: string,
buffers: ConsoleBuffers,
): void {
process.stderr.write(
`\n--- console dump for assertion '${assertionName}' (tail ${CONSOLE_DUMP_TAIL_LINES} lines per buffer) ---\n`,
);
const swTail = buffers.swConsole.slice(-CONSOLE_DUMP_TAIL_LINES);
const offTail = buffers.offConsole.slice(-CONSOLE_DUMP_TAIL_LINES);
for (const line of swTail) {
process.stderr.write(line + '\n');
}
for (const line of offTail) {
process.stderr.write(line + '\n');
}
process.stderr.write(
`--- end console dump for '${assertionName}' ---\n\n`,
);
}
/**
* Pretty-print an AssertionRecord to stdout. Used by both the
* orchestrator (`harness.test.ts` in Wave 3A) and the standalone A6
* entry (`a6.test.ts`). Single source of formatting truth.
*
* @param result - The structured result from a page-side assertion.
*/
export function printAssertionResult(result: AssertionRecord): void {
process.stdout.write('\n');
process.stdout.write('='.repeat(72) + '\n');
process.stdout.write(`${result.name}: ${result.passed ? 'PASS' : 'FAIL'}\n`);
if (result.error !== undefined) {
process.stdout.write(`Top-level error: ${result.error}\n`);
}
process.stdout.write('\nChecks:\n');
for (const check of result.checks) {
const mark = check.passed ? '[PASS]' : '[FAIL]';
process.stdout.write(` ${mark} ${check.name}\n`);
process.stdout.write(` expected: ${JSON.stringify(check.expected)}\n`);
process.stdout.write(` actual: ${JSON.stringify(check.actual)}\n`);
}
process.stdout.write('\nDiagnostics:\n');
for (const diag of result.diagnostics) {
process.stdout.write(` - ${diag}\n`);
}
process.stdout.write('='.repeat(72) + '\n');
}
/**
* Wrapper around `assert.deepStrictEqual` with a structured message
* preamble. Throws AssertionError on mismatch (caller catches in
* `runAssertion`).
*
* @param actual - Observed value.
* @param expected - Reference value.
* @param message - Human-readable context (e.g. assertion name).
*/
export function assertEqual(actual: unknown, expected: unknown, message: string): void {
assert.deepStrictEqual(actual, expected, message);
}
/**
* Assert `actual >= expected`. Throws AssertionError on failure with
* a structured message including both values.
*
* @param actual - Observed numeric value.
* @param expected - Lower bound (inclusive).
* @param message - Human-readable context.
*/
export function assertGte(actual: number, expected: number, message: string): void {
if (actual < expected) {
throw new assert.AssertionError({
message: `${message} — expected ${actual} >= ${expected}`,
actual,
expected,
operator: '>=',
});
}
}
/**
* Assert `actual` matches the regex. Throws AssertionError on failure.
*
* @param actual - Observed string.
* @param regex - Pattern to test.
* @param message - Human-readable context.
*/
export function assertMatch(actual: string, regex: RegExp, message: string): void {
if (!regex.test(actual)) {
throw new assert.AssertionError({
message: `${message} — expected ${JSON.stringify(actual)} to match ${regex}`,
actual,
expected: regex,
operator: 'match',
});
}
}
/**
* Assert `cond` is exactly `true`. Throws AssertionError otherwise.
*
* @param cond - Boolean to assert true.
* @param message - Human-readable context.
*/
export function assertTrue(cond: boolean, message: string): void {
assert.strictEqual(cond, true, message);
}
/**
* Default polling interval for `waitFor` — matches the prototype's
* 100ms cadence (good tradeoff between CPU and detection latency).
*/
const WAIT_FOR_POLL_INTERVAL_MS = 100;
/**
* Poll an async probe until it satisfies the predicate or the timeout
* elapses. Mirrors the prototype's host-side polling primitive
* (verbatim semantics, host-side scope).
*
* IMPORTANT: this is the HOST-SIDE waitFor. The HARNESS-PAGE-SIDE
* waitFor (inside `tests/uat/extension-page-harness.ts`) is a separate
* implementation with identical semantics — the page-side runs in the
* browser isolate; the host-side runs in Node. They cannot share a
* module because one is bundled into the HTML harness and the other
* runs natively.
*
* @param probe - Async function returning the current value.
* @param predicate - Returns true when the value matches the expectation.
* @param timeoutMs - Maximum wait time before throwing.
* @param description - Used in the timeout error message.
* @returns The value that satisfied the predicate.
* @throws If the timeout elapses; the error includes the last observed value.
*/
export 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 = await probe();
while (Date.now() - start < timeoutMs) {
if (predicate(lastValue)) {
return lastValue;
}
await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_POLL_INTERVAL_MS));
lastValue = await probe();
}
throw new Error(
`waitFor timeout (${timeoutMs}ms) — ${description}; lastValue=${JSON.stringify(lastValue)}`,
);
}

View File

@@ -0,0 +1,181 @@
// tests/uat/lib/harness-page-driver.ts — Plan 01-13 Wave 2.
//
// Driver wrappers — one per assertion (A1..A13). Each wraps a single
// `page.evaluate(() => window.__mokoshHarness.assertXX())` call,
// returning the structured AssertionRecord (or the extended shape with
// `bytesBase64` for A5/A12/A13 which return host-side-required payloads
// like the downloaded zip bytes or the recorded webm bytes).
//
// Centralizing the page.evaluate call here means adding or renaming an
// assertion requires a two-file edit:
// 1. extension-page-harness.ts — page-side impl + window.__mokoshHarness wire
// 2. this file — host-side driver wrapper
// instead of touching every test-file that calls the assertion.
//
// Wave 2 ONLY wires `driveA6` (the proven assertion from the c647f61
// prototype). The 12 Wave-3 assertions are stubbed as `throw new
// Error('NOT YET IMPLEMENTED — Wave 3<X> wires this')` so the
// orchestrator's `for (const drive of drivers)` loop fails cleanly on
// the first unimplemented one (bail-on-first-failure semantics in
// `harness.test.ts` lands in Wave 3A).
//
// References:
// - puppeteer Page.evaluate:
// https://pptr.dev/api/puppeteer.page.evaluate
import type { Page } from 'puppeteer';
import type { AssertionRecord, CheckRecord } from './assertions';
/**
* Extended assertion-record shape for A5/A12/A13 which return
* host-side-required binary payloads:
* - A5 (SAVE_ARCHIVE): `bytesBase64` is the downloaded zip bytes
* (read by host-side from `handles.downloadsDir`); page side only
* returns the trigger ack.
* - A12 (ffprobe): `bytesBase64` is the recorded webm bytes —
* extracted from the zip by the host so ffprobe (host-side binary)
* can analyze it.
* - A13 (zip shape): `bytesBase64` is the zip bytes; `expectedVersion`
* is the manifest version the harness was built against.
*
* All Wave-3 assertions; not used in Wave 2.
*/
export interface AssertionWithBytes {
readonly passed: boolean;
readonly name: string;
readonly checks: ReadonlyArray<CheckRecord>;
readonly diagnostics: ReadonlyArray<string>;
readonly error?: string;
readonly bytesBase64?: string;
readonly expectedVersion?: string;
}
/** Marker error message for unimplemented Wave-3 drivers — orchestrator
* matches on this prefix to format the diagnostic distinctly from a
* genuine assertion failure. */
const WAVE3_STUB_PREFIX = 'NOT YET IMPLEMENTED';
/**
* Drive the A6 (Bug B canonical) assertion. The proven, prototype-
* inherited driver. Page side does all orchestration (ensureOffscreen +
* start + wait + dispatch + assert); host side just triggers + reads
* the result.
*
* @param page - The harness page (from `launchHarnessBrowser`).
* @returns Structured AssertionRecord with 5 checks (SETUP + A6.1..A6.4).
*/
export async function driveA6(page: Page): Promise<AssertionRecord> {
return await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
const harness = (window as any).__mokoshHarness;
const r: AssertionRecord = await harness.assertA6();
return r;
}) as AssertionRecord;
}
/* ─── Wave 3A — NOT YET IMPLEMENTED ──────────────────────────────── */
/**
* Drive A1 (SW bootstrap state). Wave 3A wires this.
* @throws Always — replace stub when Wave 3A lands.
*/
export async function driveA1(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3A wires driveA1`);
}
/**
* Drive A2 (toolbar onClicked → REC). Wave 3A wires this.
* @throws Always — replace stub when Wave 3A lands.
*/
export async function driveA2(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3A wires driveA2`);
}
/**
* Drive A3 (displaySurface monitor). Wave 3A wires this.
* @throws Always — replace stub when Wave 3A lands.
*/
export async function driveA3(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3A wires driveA3`);
}
/**
* Drive A4 (popup during recording). Wave 3A wires this.
* @throws Always — replace stub when Wave 3A lands.
*/
export async function driveA4(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3A wires driveA4`);
}
/* ─── Wave 3B — NOT YET IMPLEMENTED ──────────────────────────────── */
/**
* Drive A5 (SAVE_ARCHIVE download). Wave 3B wires this; signature will
* take a second `downloadsDir` parameter so the host side can poll
* for the dropped zip file.
*
* @throws Always — replace stub when Wave 3B lands.
*/
export async function driveA5(_page: Page): Promise<AssertionWithBytes> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3B wires driveA5`);
}
/**
* Drive A7 (genuine error → ERR + recovery notification). Wave 3B wires.
* @throws Always — replace stub when Wave 3B lands.
*/
export async function driveA7(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3B wires driveA7`);
}
/* ─── Wave 3C — NOT YET IMPLEMENTED ──────────────────────────────── */
/**
* Drive A8 (Bug A onStartup → notification creates). Wave 3C wires.
* @throws Always — replace stub when Wave 3C lands.
*/
export async function driveA8(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA8`);
}
/**
* Drive A9 (icon file sizes). Wave 3C wires.
* @throws Always — replace stub when Wave 3C lands.
*/
export async function driveA9(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA9`);
}
/**
* Drive A10 (manifest shape). Wave 3C wires.
* @throws Always — replace stub when Wave 3C lands.
*/
export async function driveA10(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA10`);
}
/* ─── Wave 3D — NOT YET IMPLEMENTED ──────────────────────────────── */
/**
* Drive A11 (35s → ≥3 segments). Wave 3D wires.
* @throws Always — replace stub when Wave 3D lands.
*/
export async function driveA11(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3D wires driveA11`);
}
/**
* Drive A12 (ffprobe — host-side returns webm bytes). Wave 3D wires.
* @throws Always — replace stub when Wave 3D lands.
*/
export async function driveA12(_page: Page): Promise<AssertionWithBytes> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3D wires driveA12`);
}
/**
* Drive A13 (zip structure + meta.json). Wave 3D wires.
* @throws Always — replace stub when Wave 3D lands.
*/
export async function driveA13(_page: Page): Promise<AssertionWithBytes> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3D wires driveA13`);
}

394
tests/uat/lib/launch.ts Normal file
View File

@@ -0,0 +1,394 @@
// tests/uat/lib/launch.ts — Plan 01-13 Wave 2.
//
// Approach-B harness launch helper. Inherits the Puppeteer launch +
// victim-page-bringToFront + harness-page-open pattern from the proven
// `tests/uat/a6.test.ts` driver (originally landed as Plan 01-11
// prototype at commit c647f61; promoted to production paths by 01-13
// Wave 1). Refactored into a reusable helper so Wave 3's 13 assertion
// drivers share the same setup overhead — one Chrome launch + one
// harness page + one victim page per `npm run test:uat` run.
//
// Architectural commitments (per 01-11-SUMMARY.md, DO NOT REGRESS):
// - Drive Chrome FROM INSIDE: `harnessPage` runs at
// `chrome-extension://<id>/tests/uat/extension-page-harness.html`
// with full chrome.* API access (Approach B; sw.evaluate fallback
// was falsified per SUMMARY §2 — only chrome.{loadTimes,csi}
// surfaced through CDP).
// - `victimPage` is a brought-to-front about:blank tab so the
// production `chrome.tabs.query({active:true})` sees a real tab
// with a `.url` (Plan 01-13 retains the `tabs` permission gap as
// out-of-scope; A2 + similar tests send `START_RECORDING` directly
// to offscreen, bypassing the SW's `startVideoCapture` which needs
// the tabs permission to read `tab.url`). Workaround documented in
// the plan's resolved-open-questions table row 2.
// - Downloads land in a per-run tmp dir (`mkdtempSync`) so A5 polling
// does not collide with operator downloads. Configured via CDP
// `Browser.setDownloadBehavior` on the harness page's CDP session.
// - SW + offscreen consoles forwarded to `swConsole` / `offConsole`
// accumulating string buffers. Offscreen attach via
// `browser.on('targetcreated')` is OPPORTUNISTIC per the prototype
// pattern — offscreen targets appear asynchronously when
// `chrome.offscreen.createDocument` runs from inside the harness
// page; the harness must not block waiting for them.
// - NO `--auto-select-desktop-capture-source` flag: unreliable in
// `--headless=new` per 01-11-SUMMARY falsification 4. The synthetic
// `installFakeDisplayMedia` (offscreen-hooks.ts eager install)
// bypasses Chrome's picker entirely.
//
// References:
// - puppeteer.launch options:
// https://pptr.dev/api/puppeteer.launchoptions
// - puppeteer.Browser.extensions():
// https://pptr.dev/api/puppeteer.browser.extensions
// - CDP Browser.setDownloadBehavior (per-context download path):
// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior
// - puppeteer CDP session helper:
// https://pptr.dev/api/puppeteer.cdpsession
// - Node fs.mkdtempSync:
// https://nodejs.org/api/fs.html#fsmkdtempsyncprefix-options
import { existsSync, mkdtempSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type Page } from 'puppeteer';
/** Repo root resolved from this file's location (tests/uat/lib/launch.ts). */
const HARNESS_FILE_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolvePath(HARNESS_FILE_DIR, '..', '..', '..');
const DIST_TEST_DIR = resolvePath(REPO_ROOT, 'dist-test');
/** Time bounds for the various polling/attach steps. Keep in sync with
* the comments below — each value has a rationale, not a guess. */
const EXTENSION_ID_POLL_TIMEOUT_MS = 5_000;
const EXTENSION_ID_POLL_INTERVAL_MS = 100;
const HARNESS_BOOTSTRAP_TIMEOUT_MS = 5_000;
const HARNESS_GOTO_TIMEOUT_MS = 10_000;
const SW_TARGET_ATTACH_TIMEOUT_MS = 10_000;
/** Bumped from the puppeteer default 30s to give the assertions
* several sendMessage round-trips of CDP headroom on slow CI runners. */
const PROTOCOL_TIMEOUT_MS = 90_000;
/**
* Handles returned by `launchHarnessBrowser`. The caller owns the
* `browser` and is responsible for calling `browser.close()` in a
* `finally` block. `downloadsDir` is created by this function and is
* deliberately NOT cleaned up automatically — failing tests benefit
* from the operator inspecting the downloads dir post-mortem.
*/
export interface HarnessHandles {
readonly browser: Browser;
readonly extensionId: string;
readonly harnessPage: Page;
readonly victimPage: Page;
readonly downloadsDir: string;
/** Accumulating SW console log lines, format `[sw:<type>] <text>`. */
readonly swConsole: string[];
/** Accumulating offscreen console log lines, format `[off:<type>] <text>`. */
readonly offConsole: string[];
}
/**
* Options for `launchHarnessBrowser`. All fields optional; defaults
* apply (`headless: process.env.HEADLESS !== '0'`; `downloadsDir` ←
* fresh mkdtempSync).
*/
export interface LaunchOptions {
/** Override `--headless=new`; useful for visual debugging. */
readonly headless?: boolean;
/** Override the auto-created downloads dir; useful for cross-run debugging. */
readonly downloadsDir?: string;
}
/**
* Verify the test bundle is present at `dist-test/`; fail loudly with
* an actionable error if missing. The harness cannot launch without
* the bundle so failing early avoids confusing puppeteer errors.
*
* @throws If `dist-test/` is missing or not a directory.
*/
function assertBundlePresent(): void {
if (!existsSync(DIST_TEST_DIR)) {
throw new Error(
`dist-test/ missing at ${DIST_TEST_DIR} — run \`npm run build:test\` first.`,
);
}
if (!statSync(DIST_TEST_DIR).isDirectory()) {
throw new Error(`dist-test/ at ${DIST_TEST_DIR} is not a directory.`);
}
}
/**
* Poll `browser.extensions()` until at least one extension is loaded
* or the timeout elapses. Returns the first extension's id.
*
* @param browser - Puppeteer browser handle.
* @returns The resolved extension id string.
* @throws If no extension loads within `EXTENSION_ID_POLL_TIMEOUT_MS`.
*/
async function resolveExtensionIdWithPolling(browser: Browser): Promise<string> {
const pollStart = Date.now();
let extensionsMap = await browser.extensions();
while (
extensionsMap.size === 0 &&
Date.now() - pollStart < EXTENSION_ID_POLL_TIMEOUT_MS
) {
await new Promise((resolve) => setTimeout(resolve, EXTENSION_ID_POLL_INTERVAL_MS));
extensionsMap = await browser.extensions();
}
const entries = [...extensionsMap];
if (entries.length === 0) {
throw new Error(
`No extensions loaded after ${EXTENSION_ID_POLL_TIMEOUT_MS}ms — dist-test/ malformed?`,
);
}
const [extensionId] = entries[0];
return extensionId;
}
/**
* Attach a SW console listener that forwards every console event to
* the provided buffer (both for in-memory diagnostic capture AND for
* stderr streaming so the operator sees live logs during a hung
* assertion). Best-effort: if the SW target cannot be found inside
* `SW_TARGET_ATTACH_TIMEOUT_MS`, the failure is logged to stderr but
* the harness continues (the assertion may still pass — many
* assertions do not need SW console data).
*
* @param browser - Puppeteer browser handle.
* @param extensionId - The resolved extension id.
* @param swConsole - Accumulating string buffer to push log lines into.
*/
async function attachSwConsoleBestEffort(
browser: Browser,
extensionId: string,
swConsole: string[],
): Promise<void> {
try {
const swTarget = await browser.waitForTarget(
(t) => t.type() === 'service_worker' && t.url().includes(extensionId),
{ timeout: SW_TARGET_ATTACH_TIMEOUT_MS },
);
const sw = await swTarget.worker();
if (sw !== null) {
/**
* Named callback per project style — every chrome.* console event
* formatted with a leading `[sw:<type>]` tag for grep-ability.
*/
const onSwConsole = (msg: { type: () => string; text: () => string }): void => {
const line = `[sw:${msg.type()}] ${msg.text()}`;
swConsole.push(line);
process.stderr.write(line + '\n');
};
sw.on('console', onSwConsole);
}
} catch (swAttachErr) {
process.stderr.write(
`(launch: SW console attach skipped — ${String(swAttachErr)})\n`,
);
}
}
/**
* Register a `targetcreated` listener that lazily attaches the
* offscreen console once it appears. The offscreen target is created
* later (when the harness page calls `chrome.offscreen.createDocument`),
* so we cannot wait for it eagerly; instead we register the listener
* upfront and let it fire when the offscreen target spawns.
*
* Idempotent — only the first matching offscreen target is attached.
*
* @param browser - Puppeteer browser handle.
* @param extensionId - The resolved extension id.
* @param offConsole - Accumulating string buffer for offscreen log lines.
*/
function registerOffscreenConsoleAttach(
browser: Browser,
extensionId: string,
offConsole: string[],
): void {
let offscreenAttached = false;
/**
* Targetcreated handler — checks each new target for the offscreen
* extension URL pattern, attaches the console listener on the first
* match.
*/
const onTargetCreated = async (
target: { type: () => string; url: () => string; asPage: () => Promise<Page> },
): Promise<void> => {
if (offscreenAttached) {
return;
}
const url = target.url();
if (
target.type() === 'background_page' &&
url.includes(extensionId) &&
url.includes('offscreen')
) {
offscreenAttached = true;
try {
const offPage = await target.asPage();
/**
* Per-message callback — same tag format as the SW attach
* (`[off:<type>] <text>`).
*/
const onOffConsole = (msg: { type: () => string; text: () => string }): void => {
const line = `[off:${msg.type()}] ${msg.text()}`;
offConsole.push(line);
process.stderr.write(line + '\n');
};
offPage.on('console', onOffConsole);
} catch (offAttachErr) {
process.stderr.write(
`(launch: offscreen console attach skipped — ${String(offAttachErr)})\n`,
);
}
}
};
browser.on('targetcreated', onTargetCreated);
}
/**
* Configure the harness page's CDP session to use the per-run
* `downloadsDir` so A5 (SAVE_ARCHIVE → chrome.downloads.download) can
* poll a known directory without colliding with the operator's real
* downloads. Uses CDP `Browser.setDownloadBehavior` with
* `behavior: 'allow'` + the explicit path.
*
* @param harnessPage - The opened harness page handle.
* @param downloadsDir - Absolute path to the downloads directory.
*/
async function configureDownloadsDir(
harnessPage: Page,
downloadsDir: string,
): Promise<void> {
const session = await harnessPage.createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: downloadsDir,
});
}
/**
* Launch Chrome with the test bundle as an unpacked MV3 extension,
* open the extension-internal harness page + a victim about:blank
* page, configure downloads, attach SW + offscreen console listeners,
* and return the assembled handles.
*
* Caller MUST close the browser in a `finally` block:
* ```typescript
* const handles = await launchHarnessBrowser();
* try {
* // ... run assertions ...
* } finally {
* await handles.browser.close();
* }
* ```
*
* @param opts - Override headless / downloadsDir.
* @returns Assembled HarnessHandles.
*/
export async function launchHarnessBrowser(
opts: LaunchOptions = {},
): Promise<HarnessHandles> {
assertBundlePresent();
const headless = opts.headless ?? process.env.HEADLESS !== '0';
const downloadsDir = opts.downloadsDir ?? mkdtempSync(join(tmpdir(), 'mokosh-uat-'));
const browser = await puppeteer.launch({
enableExtensions: [DIST_TEST_DIR],
headless,
pipe: true,
protocolTimeout: PROTOCOL_TIMEOUT_MS,
args: [
'--no-sandbox',
// DO NOT add --auto-select-desktop-capture-source — unreliable
// in --headless=new per 01-11-SUMMARY falsification 4; the
// synthetic getDisplayMedia (offscreen-hooks.ts:installFake)
// bypasses Chrome's picker entirely.
],
});
const extensionId = await resolveExtensionIdWithPolling(browser);
// Accumulating console buffers — empty until SW + offscreen attach.
const swConsole: string[] = [];
const offConsole: string[] = [];
// Open the victim page FIRST so it's already in the tab list when
// the harness page opens. About:blank's `tab.url` resolves to
// 'about:blank' (truthy), passing production
// chrome.tabs.query({active:true}) presence checks.
const victimPage = await browser.newPage();
await victimPage.goto('about:blank');
// Open the harness page; attach console + pageerror listeners
// BEFORE the goto so we don't miss bootstrap-time messages.
const harnessPage = await browser.newPage();
/**
* Named callback per project style — forwards all page-side console
* events to stderr with the `[page:<type>]` tag.
*/
const onPageConsole = (msg: { type: () => string; text: () => string }): void => {
const line = `[page:${msg.type()}] ${msg.text()}`;
process.stderr.write(line + '\n');
};
harnessPage.on('console', onPageConsole);
/**
* Named callback — page errors get an explicit `[page:ERROR]` tag
* separate from the console events so the operator can spot them in
* the stderr stream.
*/
const onPageError = (err: unknown): void => {
const msg = err instanceof Error ? err.message : String(err);
const line = `[page:ERROR] ${msg}`;
process.stderr.write(line + '\n');
};
harnessPage.on('pageerror', onPageError);
// Best-effort SW console attach — the SW target is usually ready
// by the time the extension finishes loading, but slow CI may need
// a brief poll (handled inside `attachSwConsoleBestEffort`).
await attachSwConsoleBestEffort(browser, extensionId, swConsole);
// Register the offscreen console attach BEFORE opening the harness
// page so the listener catches the offscreen target whenever it
// spawns (which happens later, when the page calls
// chrome.offscreen.createDocument from inside an assertion).
registerOffscreenConsoleAttach(browser, extensionId, offConsole);
// Configure downloads via CDP. This MUST happen on the harness
// page's CDP session (not the browser's default session) per
// puppeteer's per-page session model.
await configureDownloadsDir(harnessPage, downloadsDir);
// Bring the victim page to front so chrome.tabs.query({active:true})
// returns it (not the harness page) when production startVideoCapture
// runs. The harness page can still be evaluated against — Puppeteer's
// page handle doesn't care about active-tab state.
await victimPage.bringToFront();
// Open the harness page; wait for window.__mokoshHarness to install.
const harnessUrl = `chrome-extension://${extensionId}/tests/uat/extension-page-harness.html`;
await harnessPage.goto(harnessUrl, {
waitUntil: 'domcontentloaded',
timeout: HARNESS_GOTO_TIMEOUT_MS,
});
await harnessPage.waitForFunction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- waitForFunction runs in browser context where window types are loose.
() => (window as any).__mokoshHarness !== undefined,
{ timeout: HARNESS_BOOTSTRAP_TIMEOUT_MS },
);
return {
browser,
extensionId,
harnessPage,
victimPage,
downloadsDir,
swConsole,
offConsole,
};
}