diff --git a/tests/uat/a6.test.ts b/tests/uat/a6.test.ts index d20d800..52fd061 100644 --- a/tests/uat/a6.test.ts +++ b/tests/uat/a6.test.ts @@ -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 - // 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 { 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`); } diff --git a/tests/uat/lib/assertions.ts b/tests/uat/lib/assertions.ts new file mode 100644 index 0000000..a4e7db0 --- /dev/null +++ b/tests/uat/lib/assertions.ts @@ -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; + readonly diagnostics: ReadonlyArray; + 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; + readonly offConsole: ReadonlyArray; +} + +/** + * 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, + buffers: ConsoleBuffers, +): Promise { + 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( + probe: () => Promise | T, + predicate: (value: T) => boolean, + timeoutMs: number, + description: string, +): Promise { + 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)}`, + ); +} diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts new file mode 100644 index 0000000..eb0e658 --- /dev/null +++ b/tests/uat/lib/harness-page-driver.ts @@ -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 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; + readonly diagnostics: ReadonlyArray; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3D wires driveA13`); +} diff --git a/tests/uat/lib/launch.ts b/tests/uat/lib/launch.ts new file mode 100644 index 0000000..181e11d --- /dev/null +++ b/tests/uat/lib/launch.ts @@ -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:///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:] `. */ + readonly swConsole: string[]; + /** Accumulating offscreen console log lines, format `[off:] `. */ + 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 { + 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 { + 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:]` 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 }, + ): Promise => { + 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:] `). + */ + 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 { + 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 { + 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:]` 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, + }; +}