// 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, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve as resolvePath } from 'node:path'; import { fileURLToPath, pathToFileURL } 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. The victim URL must satisfy two constraints: // 1. `chrome.tabs.query({active:true,currentWindow:true})` returns a // tab with non-empty `.id` (so production `saveArchive` proceeds); // every real tab does, so this is automatic. // 2. `chrome.tabs.captureVisibleTab(...)` (used inside saveArchive's // screenshot capture path) succeeds. captureVisibleTab needs // EITHER `` host permission to match the active tab's // URL OR `activeTab` granted (which only fires on a real user // gesture, not a Puppeteer-scripted call). `` matches // `http://`, `https://`, `file://`, `ftp://` — but NOT // `about:blank` and NOT `data:` URLs (Chromium treats `data:` // with an opaque origin and rejects the capture under // "activeTab permission not in effect"). The canonical scheme // that DOES satisfy `` without needing a localhost // server is `file://`, so we write a stub HTML file inside the // per-run `downloadsDir` (already mkdtempSync'd above) and load // it as `file://`. // // Plan 01-13 Wave 3B deviation (Rule 3 — blocking issue): the original // `about:blank` victim page (carried over from the c647f61 prototype // because A6 does not exercise captureVisibleTab) breaks A5's // SAVE_ARCHIVE round-trip. The file:// victim is the simplest fix // that does not touch production code or add network dependencies // (an http://localhost server would also work but introduces port- // allocation race conditions on CI). const victimHtmlPath = resolvePath(downloadsDir, 'mokosh-uat-victim.html'); const victimHtml = '' + 'Mokosh UAT Victim' + '

UAT victim page

' + '

This tab exists so chrome.tabs.captureVisibleTab succeeds during A5 (SAVE_ARCHIVE).

' + ''; writeFileSync(victimHtmlPath, victimHtml, 'utf8'); const victimUrl = pathToFileURL(victimHtmlPath).toString(); const victimPage = await browser.newPage(); await victimPage.goto(victimUrl); // 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, }; }