| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 }; |
| |
| |
| |
| |
| |
| fullscreen?: boolean; |
| |
| |
| |
| |
| |
| |
| |
| recordMode?: boolean; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| const SHARED_LAUNCH_ARGS = [ |
| "--disable-infobars", |
| "--disable-blink-features=AutomationControlled", |
| |
| |
| |
| |
| |
| "--disable-features=Translate,TranslateUI", |
| |
| |
| "--lang=en-US", |
| ]; |
| const IGNORE_DEFAULT_ARGS = ["--enable-automation"]; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function killLeftoverChromiums(): void { |
| |
| |
| |
| |
| 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 { |
| |
| } |
| } |
| if (killed) { |
| console.log("[showcase] killed leftover Playwright Chromium instances"); |
| |
| |
| execSync("sleep 0.4"); |
| } |
| } |
|
|
| export async function launchPersona( |
| url: string, |
| opts: LaunchOptions, |
| ): Promise<PersonaHandle> { |
| |
| 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, |
| |
| |
| |
| locale: "en-US", |
| args: launchArgs, |
| ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, |
| }); |
|
|
| |
| |
| |
| |
| await injectMouseCursor(context); |
|
|
| |
| |
| |
| const persona = JSON.stringify({ |
| name: opts.persona.name, |
| color: opts.persona.color, |
| }); |
| await context.addInitScript( |
| ({ key, value }) => { |
| try { |
| localStorage.setItem(key, value); |
| } catch { |
| |
| } |
| }, |
| { 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 { |
| |
| } |
| }, |
| { 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", |
| ); |
|
|
| |
| |
| const browser = { |
| close: async () => { |
| try { |
| await context.close(); |
| } finally { |
| try { |
| rmSync(userDataDir, { recursive: true, force: true }); |
| } catch { |
| |
| } |
| } |
| }, |
| } as unknown as Browser; |
|
|
| return { browser, context, page }; |
| } |
|
|
| |
| 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, |
| }); |
|
|
| |
| |
| 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 { |
| |
| } |
| }, |
| { |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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)), |
| ]); |
| |
| |
| |
| killLeftoverChromiums(); |
| } |
|
|