// 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. * * Target.type() discrimination: empirically verified via the * `spike-diagnose-offscreen-target.ts` helper at debug session-2 time * (2026-05-21). Despite MV3 abolishing classic MV2 background pages, * Chrome's CDP STILL reports an extension's offscreen document with * `targetInfo.type='background_page'` (Puppeteer's TargetType maps this * to `TargetType.BACKGROUND_PAGE` verbatim per * node_modules/puppeteer-core/lib/puppeteer/cdp/Target.js:107-108). * Observed `browser.targets()` enumeration with active offscreen * recording: * * type=background_page url=chrome-extension://{id}/src/offscreen/index.html * * Plan-04-04 debug session-2 root cause + fix: * - The PREVIOUS implementation only registered a `targetcreated` * listener — but Puppeteer fires `targetcreated` at the moment the * target is initially created, with `type='other'` and `url=''`, * BEFORE the CDP target metadata stabilizes. By the time the * listener checks `target.type()` / `target.url()` they're still * unset. The filter (regardless of which type it checked) never * matched, producing 0 `[off:*]` log lines across all spike runs. * - The new implementation adds an existing-targets enumeration via * `browser.targets()` AFTER a brief settle — at that point all * targets have finalized URLs + types and the offscreen is reliably * discoverable. * - The `targetcreated` listener is retained as a safety net for * environments where the offscreen is created LATER (post-launch), * but it uses the same URL-pattern predicate that works whether or * not the type field has stabilized — the URL is the load-bearing * match criterion; the type check is defense-in-depth. * * @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; /** * Check whether a target matches the offscreen-document criteria. * * Match predicate: URL includes both the extension id AND the * canonical offscreen path '/src/offscreen/' (which is * `chrome.runtime.getURL('src/offscreen/index.html')` per * src/background/index.ts:239). The URL is the load-bearing match * criterion; the type field is intentionally NOT checked here * because: * - At `targetcreated` time, `type()` returns `'other'` and * `url()` is empty; the listener may fire before metadata * stabilizes, so type-based matching at event time is unreliable. * - At `browser.targets()` enumeration time, the offscreen target's * type is `'background_page'` (NOT `'page'`; see function docstring * above for the empirical evidence). * - Either way, the URL pattern is unique to the offscreen document * and uncollidable with the harness page (whose URL ends with * `/tests/uat/extension-page-harness.html`), the welcome page * (`/src/welcome/welcome.html`), the victim page (`file://...`), * or the SW (whose URL ends with `/service-worker-loader.js`). * * Both `url.includes` checks are needed: * - url-includes-extension-id rules out the harness victim page * (a file:// URL with no extension id); * - url-includes-'/src/offscreen/' rules out every other * extension-page realm (harness, welcome, popup). */ const isOffscreenTarget = ( target: { type: () => string; url: () => string }, ): boolean => { const url = target.url(); return url.includes(extensionId) && url.includes('/src/offscreen/'); }; /** * Attach the console listener to a confirmed offscreen page target. * Idempotent via the closed-over `offscreenAttached` flag. */ const attachToOffscreen = async ( target: { asPage: () => Promise; url: () => string }, ): Promise => { if (offscreenAttached) { return; } 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); process.stderr.write( `(launch: offscreen console attached — url=${target.url()})\n`, ); } catch (offAttachErr) { process.stderr.write( `(launch: offscreen console attach skipped — ${String(offAttachErr)})\n`, ); } }; /** * Generic target handler — checks any target for the offscreen * extension URL pattern, attaches the console listener on the first * match. Idempotent via the closed-over `offscreenAttached` flag. * * Bound to BOTH `targetcreated` and `targetchanged` events: * - `targetcreated` fires on initial target creation (but at that * instant the target's url/type may not be stable — see function * docstring above). We still listen here for the case where the * target's metadata is ready synchronously. * - `targetchanged` fires when the target's URL changes — which is * the canonical signal that the offscreen document has finished * navigating to its `chrome.runtime.getURL('src/offscreen/index.html')` * URL after `chrome.offscreen.createDocument`. * Either event firing the matching predicate triggers attach; * subsequent firings are idempotent. */ const onTargetEvent = async ( target: { type: () => string; url: () => string; asPage: () => Promise }, ): Promise => { if (isOffscreenTarget(target)) { await attachToOffscreen(target); } }; browser.on('targetcreated', onTargetEvent); browser.on('targetchanged', onTargetEvent); // Race-condition guard: enumerate already-existing targets in case the // offscreen was created BEFORE this listener was registered. Puppeteer // only fires `targetcreated` for FUTURE targets — already-created ones // are silent unless explicitly enumerated. In the harness flow the // offscreen is created via chrome.offscreen.createDocument inside an // assertion call (after this function returns), so the listener path // is typically sufficient — but probe latency or pre-launch state can // mean the offscreen target exists by the time we register. The check // below is best-effort + idempotent (the `offscreenAttached` flag // prevents double-attach if both code paths fire). // // Plan-04-04 debug session-2 fix: the prior implementation relied // entirely on `targetcreated` and missed the offscreen in every spike // run (zero `[off:*]` lines), creating the observability gap. const enumerateExistingTargets = async (): Promise => { try { const existing = browser.targets(); for (const target of existing) { if (isOffscreenTarget(target)) { await attachToOffscreen(target); break; } } } catch (enumErr) { process.stderr.write( `(launch: offscreen target enumeration skipped — ${String(enumErr)})\n`, ); } }; void enumerateExistingTargets(); } /** * 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, }; }