tfrere's picture
tfrere HF Staff
feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
f469961
Raw
History Blame Contribute Delete
9.56 kB
/**
* 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<PersonaHandle> {
// ---------------- 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<PersonaHandle | null | undefined>,
options: { graceMs?: number } = {},
): Promise<void> {
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<void>((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();
}