// 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). // // 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. // // Usage: // tsx tests/uat/a6.test.ts // HEADLESS=0 tsx tests/uat/a6.test.ts # debug view // // 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/ 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; } /** * Verify the test bundle is present; fail loudly if missing. * * @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. */ 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[] = []; 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 }, ); 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); 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(); } catch (closeErr) { process.stderr.write(`(non-fatal: browser close threw: ${String(closeErr)})\n`); } } return exitCode; } const code = await main(); process.exit(code);