/** * lib/chromium.ts * * All Chromium lifecycle concerns for the showcase demo: * - killing leftover Playwright Chromium processes (startup AND shutdown) * - launching a persona browser (standard, fullscreen, or --app record mode) * - centrally closing every persona at the end of the run, no matter the * mode (record / capture / dev). * * The cleanup matters because the demo spawns up to 3 Chromium instances * plus their user-data dirs; leaving them orphaned eats memory, blocks * ports for the next run, and pollutes the user's dock. */ import { chromium, type Browser, type BrowserContext, type Page, } from "playwright"; import { execSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { FALLBACK_USER_KEY, type Persona } from "../personas.js"; import { injectMouseCursor, verifyMouseCursor } from "./mouse-cursor.js"; export interface PersonaHandle { browser: Browser; context: BrowserContext; page: Page; } export interface LaunchOptions { persona: Persona; headless: boolean; windowPosition?: { x: number; y: number }; /** * If true, Chromium starts in OS fullscreen (no window chrome, no menu bar * on macOS). Used for Alice (the recorded persona) so the screencast looks * clean. Forces `viewport: null` so the page uses the real window size. */ fullscreen?: boolean; /** * Recording-friendly app mode for Alice. Drops the URL bar, tabs and * automation banner entirely (--app=) and starts in OS fullscreen. * Implies headless=false and viewport=null. Uses a persistent context * because --app needs a launch-time URL hook to keep the chrome-less * window attached to the Playwright session. */ recordMode?: boolean; } /** * Common launch flags reused by every persona. Removing --enable-automation * (via ignoreDefaultArgs) and the AutomationControlled blink feature is what * actually hides the "Chrome is being controlled by automated test software" * yellow banner across recent Chromium versions. */ const SHARED_LAUNCH_ARGS = [ "--disable-infobars", "--disable-blink-features=AutomationControlled", // Suppress the "Translate this page?" prompt that Chrome surfaces // whenever the page language differs from the UI locale. Dropping // both the core Translate feature and its UI overlay means the // recording never gets a translate bubble over the editor, even // on a fresh user-data-dir. "--disable-features=Translate,TranslateUI", // Lock the UI language to English so Chrome doesn't re-enable // translate heuristics based on the OS locale. "--lang=en-US", ]; const IGNORE_DEFAULT_ARGS = ["--enable-automation"]; /** * Kill any Chromium instance left over from a previous Playwright run. * * We match on the ms-playwright install path so we only target the * automation browser - the user's regular Chrome/Chromium is untouched. * Non-fatal: if pkill finds nothing (exit 1) or isn't available, we * just move on and let Playwright launch fresh browsers. * * Called both at startup (defensive: previous run may have crashed) and * at shutdown (belt-and-braces: even if browser.close() hangs, this * guarantees no leftover windows). */ export function killLeftoverChromiums(): void { // Match both the headful Chromium binary (Alice's window) and the // headless-shell binary (Bob/Carol). The `ms-playwright` segment is // unique to the Playwright install path so we never touch the user's // real Chrome/Chromium. const patterns = [ "ms-playwright/.*Chromium", "ms-playwright/.*chrome-headless-shell", ]; let killed = false; for (const p of patterns) { try { execSync(`pkill -f '${p}'`, { stdio: "ignore" }); killed = true; } catch { // pkill exits 1 when no process matches - normal on a clean start. } } if (killed) { console.log("[showcase] killed leftover Playwright Chromium instances"); // Give the OS a beat to reclaim ports / window handles before we // spawn fresh browsers, otherwise launch can flake on macOS. execSync("sleep 0.4"); } } export async function launchPersona( url: string, opts: LaunchOptions, ): Promise { // ---------------- Recording mode (Alice only, --record) ---------------- if (opts.recordMode) { const userDataDir = mkdtempSync(join(tmpdir(), "showcase-record-")); const launchArgs = [ ...SHARED_LAUNCH_ARGS, `--app=${url}`, "--start-fullscreen", ]; const context = await chromium.launchPersistentContext(userDataDir, { headless: false, viewport: null, // Advertise an English locale so the page is tagged as en-US // in `navigator.language` and the translate heuristics can't // fire regardless of the host OS settings. locale: "en-US", args: launchArgs, ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, }); // Draw a visible mouse pointer on Alice's recorded window. Needs // to run before the first nav so the overlay is live for the // boot and every in-page navigation (e.g. opening the published // article). await injectMouseCursor(context); // Inject persona identity for any future navigation. The first --app // load already happened, so we set localStorage on the live page and // then reload once to get a clean boot under the persona identity. const persona = JSON.stringify({ name: opts.persona.name, color: opts.persona.color, }); await context.addInitScript( ({ key, value }) => { try { localStorage.setItem(key, value); } catch { /* ignore */ } }, { key: FALLBACK_USER_KEY, value: persona }, ); const page = context.pages()[0] || (await context.waitForEvent("page")); await page.evaluate( ({ key, value }) => { try { localStorage.setItem(key, value); } catch { /* ignore */ } }, { key: FALLBACK_USER_KEY, value: persona }, ); await page.reload({ waitUntil: "domcontentloaded", timeout: 20_000 }); await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 }); const cursorMounted = await verifyMouseCursor(page); console.log( cursorMounted ? "[showcase] mouse cursor overlay mounted" : "[showcase] WARN: mouse cursor overlay did NOT mount", ); // Wrap context.close() into the same Browser-shaped object the rest of // the script expects so we don't have to branch on close everywhere. const browser = { close: async () => { try { await context.close(); } finally { try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* ignore */ } } }, } as unknown as Browser; return { browser, context, page }; } // ---------------- Standard mode (windowed or fullscreen) ---------------- const launchArgs = [...SHARED_LAUNCH_ARGS]; if (opts.fullscreen) { launchArgs.push("--start-fullscreen"); } else { launchArgs.push("--window-size=1280,800"); if (opts.windowPosition) { launchArgs.push( `--window-position=${opts.windowPosition.x},${opts.windowPosition.y}`, ); } } const browser = await chromium.launch({ headless: opts.headless, args: launchArgs, ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, }); // Playwright forbids `deviceScaleFactor` when `viewport` is null (real // window size). For fullscreen we accept the OS-native DPR. const context = await browser.newContext( opts.fullscreen ? { viewport: null } : { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }, ); await injectMouseCursor(context); await context.addInitScript( ({ key, value }) => { try { localStorage.setItem(key, value); } catch { /* ignore */ } }, { key: FALLBACK_USER_KEY, value: JSON.stringify({ name: opts.persona.name, color: opts.persona.color, }), }, ); const page = await context.newPage(); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 }); await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 }); const cursorMounted = await verifyMouseCursor(page); console.log( cursorMounted ? "[showcase] mouse cursor overlay mounted" : "[showcase] WARN: mouse cursor overlay did NOT mount", ); return { browser, context, page }; } /** * Gracefully close every persona handle, then invoke `killLeftoverChromiums` * as a hard safety net. Runs under a timeout so a hanging browser never * blocks the script from exiting. Idempotent: safe to call multiple * times (e.g. from `finally` AND a SIGINT handler). */ export async function shutdownAllPersonas( handles: Array, options: { graceMs?: number } = {}, ): Promise { const graceMs = options.graceMs ?? 4_000; await Promise.race([ Promise.allSettled( handles .filter((h): h is PersonaHandle => Boolean(h)) .map((h) => h.browser.close()), ), new Promise((resolve) => setTimeout(resolve, graceMs)), ]); // Hard reset regardless of how browser.close() went. Any Chromium // process still alive after graceful close is either hung or was // spawned as a child we don't know about - nuke them. killLeftoverChromiums(); }