// tests/uat/lib/launch.ts — Plan 01-11 harness launch helper. // // Wraps puppeteer.launch with the project's invariants: // - enableExtensions points at the absolute path to dist-test/ (the // test bundle that carries the gated test hooks per Plan 01-11 // Task 2). NOT dist/ — that would defeat the harness entirely. // - headless defaults to true (CI-friendly); HEADLESS=0 env opens a // real Chrome window for local debugging. // - --auto-select-desktop-capture-source="Entire screen" auto-accepts // the screen-share picker so getDisplayMedia resolves without // operator interaction (RESEARCH §9). The literal string is // en_US-locale-sensitive; document the fallback in tests/uat/README.md. // - Downloads land in a fresh per-run temp dir so assertion 5 // (SAVE_ARCHIVE) can poll for session_report_*.zip without // colliding with operator downloads. // // References: // - puppeteer.launch options: https://pptr.dev/api/puppeteer.launchoptions // - puppeteer extension API: https://pptr.dev/guides/extensions // - Chrome --auto-select-desktop-capture-source: // https://source.chromium.org/chromium/chromium/src/+/main:media/capture/video/chromeos/camera_app_device_provider.cc // (search for the flag in chrome://flags or the Chromium source tree) import { execSync } from 'node:child_process'; 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 CDPSession, type Extension, type Page, type WebWorker, } from 'puppeteer'; /// 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'); /** * Handles returned from `launchHarnessBrowser`. All references are * live for the lifetime of the browser; the caller MUST close the * browser to release them. */ export interface HarnessHandles { readonly browser: Browser; readonly extension: Extension; readonly extensionId: string; /** * Service worker handle (for completeness / future use). NOTE: per * the architecture refinement documented in tests/uat/lib/sw.ts, * the harness's chrome.* state queries go through the `popup` page * (which has full extension chrome.* access AND a stable Puppeteer * lifetime). Direct sw.evaluate is unreliable in Chrome 148 + * headless + Puppeteer 25 (the SW suspends + worker() returns * "Protocol error: No target with given id found"). The SW handle * is kept here for harness wave-3 assertion 11 / 12 (where we may * need a worker reference for diagnostics). */ readonly sw: WebWorker; readonly downloadsDir: string; /** * A pre-opened blank page the harness can use to invoke * `triggerExtensionAction` (Puppeteer requires a page in the active * tab for the toolbar-click simulation). */ readonly page: Page; /** * The extension popup page, opened at * chrome-extension:///src/popup/index.html. This page * is the harness's primary chrome.* query surface (see * tests/uat/lib/sw.ts file header for rationale). */ readonly popup: Page; } /** * Optional launch overrides. Defaults are CI-friendly; HEADLESS=0 * environment variable flips to headful for local debugging. */ export interface LaunchOptions { /** Override the dist-test directory (test isolation). */ readonly distTestDir?: string; /** Override the downloads directory (default: fresh tempdir per call). */ readonly downloadsDir?: string; /** Force headless / headful regardless of HEADLESS env. */ readonly headless?: boolean; } /** * Create a per-run downloads directory under the OS tmpdir. Caller is * responsible for cleanup (typically deferred to OS tmpdir GC). * * @returns Absolute path to the freshly-created downloads directory. */ function makeDownloadsDir(): string { return mkdtempSync(join(tmpdir(), 'mokosh-uat-downloads-')); } /** * Verify the dist-test directory exists and is a directory. Fails * loudly with an actionable message — the caller likely forgot to * run `npm run build:test` before invoking the harness. * * @param distTestDir - Absolute path to dist-test. * @throws If the directory does not exist or is not a directory. */ function assertDistTestPresent(distTestDir: string): void { if (!existsSync(distTestDir)) { throw new Error( `dist-test/ missing at ${distTestDir}. ` + `Run \`npm run build:test\` before launching the harness ` + `(or invoke via \`npm run test:uat\` which does it for you).`, ); } const stat = statSync(distTestDir); if (!stat.isDirectory()) { throw new Error( `dist-test/ exists at ${distTestDir} but is not a directory.`, ); } } /** * Resolve whether to run headless. HEADLESS=0 forces headful; * anything else (including undefined) is headless. Explicit * `options.headless` overrides the env entirely. * * @param options - Optional launch overrides. * @returns true for headless, false for headful. */ function resolveHeadless(options: LaunchOptions): boolean { if (options.headless !== undefined) { return options.headless; } return process.env.HEADLESS !== '0'; } /** * Locate the SW target via the extension ID. Polls puppeteer's target * list because the SW is registered asynchronously after the extension * loads. Times out at 10s — if the SW is missing after that, either * dist-test/ is corrupted or the SW bundle threw at module init (which * would be caught by sw-bundle-import.test.ts BEFORE the harness ever * runs; but defensively, we surface a clear diagnostic here). * * @param browser - Puppeteer Browser handle. * @param extensionId - The extension's runtime id. * @returns The SW WebWorker handle. * @throws If no SW target appears within 10s. */ async function waitForSwTarget( browser: Browser, extensionId: string, ): Promise { const target = await browser.waitForTarget( (t) => t.type() === 'service_worker' && t.url().startsWith(`chrome-extension://${extensionId}`), { timeout: 10_000 }, ); const sw = await target.worker(); if (sw === null) { throw new Error( `Service worker target found for extension ${extensionId} but ` + `its worker() returned null — the SW likely crashed at init.`, ); } return sw; } /** * Configure the per-page download behavior via CDP so files land in * our temp downloadsDir. Puppeteer 25's high-level downloads API is * still in flux; the raw CDP call is stable across versions. * * @param page - Page whose downloads should be redirected. * @param downloadsDir - Absolute path to capture downloads. */ async function setDownloadBehavior( page: Page, downloadsDir: string, ): Promise { const cdpClient: CDPSession = await page.target().createCDPSession(); await cdpClient.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: downloadsDir, eventsEnabled: true, }); } /** * Launch a Chrome instance with the test bundle loaded as an unpacked * MV3 extension; wire downloads to a per-run temp dir; return all * handles the harness needs. Caller MUST `await handles.browser.close()`. * * @param options - Optional overrides (mostly for isolation in tests). * @returns Resolved handles to browser, extension, SW, page, downloadsDir. * @throws If dist-test/ missing OR SW target never appears. */ export async function launchHarnessBrowser( options: LaunchOptions = {}, ): Promise { const distTestDir = options.distTestDir ?? DIST_TEST_DIR; assertDistTestPresent(distTestDir); const downloadsDir = options.downloadsDir ?? makeDownloadsDir(); const headless = resolveHeadless(options); // Pre-flight: verify the operator's chrome binary supports the // auto-select picker flag. The string is locale-specific; en_US // uses "Entire screen". This pre-flight does NOT verify the locale // matches — it only verifies Puppeteer can find a Chromium binary // at all (a missing binary fails the launch with a confusing message // otherwise). // Suppress noisy `puppeteer --version` check; if it fails, the launch // itself will surface the same diagnostic. try { execSync('node ./node_modules/puppeteer/lib/cjs/puppeteer/node/cli.js --help', { stdio: 'ignore', timeout: 5_000, }); } catch { // Best-effort. The actual launch will fail loudly if the binary is // truly missing. } const browser = await puppeteer.launch({ enableExtensions: [distTestDir], headless, pipe: true, args: [ '--no-sandbox', // RESEARCH §9: auto-accept the screen-share picker so // getDisplayMedia resolves without operator interaction. The // literal string is en_US-locale-sensitive; tests/uat/README.md // documents the fallback for other locales. '--auto-select-desktop-capture-source=Entire screen', // DO NOT add --use-fake-ui-for-media-stream (RESEARCH §9 Pitfall: // conflicts with auto-select). ], }); // Resolve the extension ID. Puppeteer 25's browser.extensions() returns // a Map with all enabled extensions — BUT the map is // populated asynchronously after the extension's manifest loads. // Empirically: extension appears within ~100ms on local hardware but // the very first call right after launch returns Map(0). Poll until // extension registers OR 5s elapses; surface a clear diagnostic on // timeout (probably means dist-test/ is malformed). let extensionsMap = await browser.extensions(); const POLL_TIMEOUT_MS = 5_000; const POLL_INTERVAL_MS = 100; const pollStart = Date.now(); 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( `Puppeteer launched Chrome but no extensions loaded after ${POLL_TIMEOUT_MS}ms — ` + `verify enableExtensions path points at a valid unpacked extension: ${distTestDir}. ` + `Common causes: dist-test/ missing the manifest.json, manifest version mismatch ` + `(Chrome requires MV3 — verify "manifest_version": 3), or chrome binary ` + `incompatible with the unpacked extension shape.`, ); } const [extensionId, extension] = entries[0]; // Wait for the SW target to appear + capture its worker handle. const sw = await waitForSwTarget(browser, extensionId); // Give the SW's module init a tick to complete. Empirically the // service-worker-loader.js → assets/index-*.js dynamic import // resolves quickly, but `chrome.action.onClicked.addListener` (and // the gated test-hook addListener monkey-patches) all run inside // the module body — a brief settle ensures the hook surface is // installed BEFORE the harness's first `sw.evaluate(() => // globalThis.__mokoshTest...)` query. await new Promise((r) => setTimeout(r, 500)); // Pre-open a blank page; configure downloads. The blank page is // also the page the harness uses for triggerExtensionAction. const page = await browser.newPage(); await page.goto('about:blank'); await setDownloadBehavior(page, downloadsDir); // Open the extension popup as a separate Page. This is the harness's // primary chrome.* query surface — see tests/uat/lib/sw.ts file // header for the architecture rationale. The popup page has full // extension chrome.* access AND a stable Puppeteer lifetime. Loading // the URL also wakes the SW (chrome-extension:// page load IS a SW // wake-up event in MV3). const popup = await browser.newPage(); await popup.goto( `chrome-extension://${extensionId}/src/popup/index.html`, { waitUntil: 'domcontentloaded', timeout: 10_000 }, ); return { browser, extension, extensionId, sw, downloadsDir, page, popup, }; }