import { existsSync, mkdirSync, rmSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { chromium, firefox, webkit, type Browser, type BrowserContext, type BrowserType, type Page } from "playwright"; import { repoRoot } from "./config"; import { emitError, emitJson } from "./output"; type BrowserName = "chromium" | "firefox" | "webkit"; type WaitUntil = "load" | "domcontentloaded" | "networkidle" | "commit"; type PlaywrightCommand = "help" | "open" | "screenshot" | "eval" | "session-list" | "session-delete"; interface ParsedOptions { command: PlaywrightCommand | string; commandArgs: string[]; browserName: BrowserName; dryRun: boolean; headless: boolean; sessionId: string; timeoutMs: number; waitUntil: WaitUntil; screenshotPath: string | null; selector: string | null; fullPage: boolean; } const stateRoot = join(repoRoot, ".state", "playwright-cli"); const defaultScreenshotPath = join(stateRoot, "screenshots", "latest.png"); const supportedCommands = ["open", "screenshot", "eval", "session-list", "session-delete"]; const unsupportedInteractiveCommands = new Set([ "click", "dblclick", "fill", "type", "press", "hover", "drag", "select", "check", "uncheck", "snapshot", "tab-list", "tab-new", "tab-close", "tab-select", "close", "config", ]); function usage(): Record { return { command: "playwright-cli", output: "json", usage: [ "bun scripts/playwright-cli.ts open [--session id] [--screenshot path] [--headed|--headless]", "bun scripts/playwright-cli.ts screenshot [path] [--session id] [--selector css] [--full-page]", "bun scripts/playwright-cli.ts eval [--session id]", "bun scripts/playwright-cli.ts session-list", "bun scripts/playwright-cli.ts session-delete [sessionId]", ], defaults: { browser: "chromium", headless: true, session: "default", waitUntil: "domcontentloaded", timeoutMs: 30_000, stateRoot, }, behavior: [ "This is a repo-owned short-run wrapper for commander browser checks, not the external agent-skill passthrough.", "It runs headless by default, so screenshots/open checks work on hosts without an X server.", "Named sessions persist Playwright storageState JSON between invocations; no long-running browser daemon or element-ref click session is kept.", "Use --headed only when a display is available; on headless hosts, run headed checks with xvfb-run -a bun scripts/playwright-cli.ts ... --headed. The xvfb fallback requires both xvfb-run and xauth.", ], unsupported: { reason: "persistent interactive session commands require a browser daemon that this wrapper does not provide", commands: Array.from(unsupportedInteractiveCommands).sort(), }, externalSkillGap: { observed: "The external playwright skill at ~/.agents/skills/playwright currently behaves like npx playwright passthrough while its SKILL.md may advertise richer session commands.", sourceOfTruth: "Track repo-owned commander checks here; update the external skill source separately if the skill itself must expose daemon/session commands.", }, }; } function parseArgs(argv: string[]): ParsedOptions { let browserName: BrowserName = "chromium"; let dryRun = false; let headless = true; let sessionId = "default"; let timeoutMs = 30_000; let waitUntil: WaitUntil = "domcontentloaded"; let screenshotPath: string | null = null; let selector: string | null = null; let fullPage = false; const positional: string[] = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] ?? ""; const next = argv[index + 1]; if (arg === "--help" || arg === "-h" || arg === "help") { positional.push("help"); } else if (arg === "--dry-run") { dryRun = true; } else if (arg === "--headless") { headless = true; } else if (arg === "--headed") { headless = false; } else if (arg === "--full-page" || arg === "--fullPage") { fullPage = true; } else if (arg.startsWith("--session=")) { sessionId = nonEmptyValue("--session", arg.slice("--session=".length)); } else if (arg === "--session") { sessionId = nonEmptyValue("--session", next); index += 1; } else if (arg.startsWith("--browser=")) { browserName = parseBrowser(arg.slice("--browser=".length)); } else if (arg === "--browser") { browserName = parseBrowser(next); index += 1; } else if (arg.startsWith("--timeout-ms=")) { timeoutMs = parsePositiveInteger("--timeout-ms", arg.slice("--timeout-ms=".length)); } else if (arg === "--timeout-ms") { timeoutMs = parsePositiveInteger("--timeout-ms", next); index += 1; } else if (arg.startsWith("--wait-until=")) { waitUntil = parseWaitUntil(arg.slice("--wait-until=".length)); } else if (arg === "--wait-until") { waitUntil = parseWaitUntil(next); index += 1; } else if (arg.startsWith("--screenshot=") || arg.startsWith("--filename=")) { const value = arg.includes("--screenshot=") ? arg.slice("--screenshot=".length) : arg.slice("--filename=".length); screenshotPath = resolvePath(nonEmptyValue("--screenshot", value)); } else if (arg === "--screenshot" || arg === "--filename") { screenshotPath = resolvePath(nonEmptyValue(arg, next)); index += 1; } else if (arg.startsWith("--selector=")) { selector = nonEmptyValue("--selector", arg.slice("--selector=".length)); } else if (arg === "--selector") { selector = nonEmptyValue("--selector", next); index += 1; } else { positional.push(arg); } } const [command = "help", ...commandArgs] = positional; return { command, commandArgs, browserName, dryRun, headless, sessionId, timeoutMs, waitUntil, screenshotPath, selector, fullPage }; } function parseBrowser(value: string | undefined): BrowserName { const browser = nonEmptyValue("--browser", value); if (browser === "chromium" || browser === "chrome") return "chromium"; if (browser === "firefox") return "firefox"; if (browser === "webkit") return "webkit"; throw new Error(`--browser must be chromium, firefox, or webkit; received ${browser}`); } function parseWaitUntil(value: string | undefined): WaitUntil { const waitUntil = nonEmptyValue("--wait-until", value); if (waitUntil === "load" || waitUntil === "domcontentloaded" || waitUntil === "networkidle" || waitUntil === "commit") return waitUntil; throw new Error(`--wait-until must be load, domcontentloaded, networkidle, or commit; received ${waitUntil}`); } function parsePositiveInteger(name: string, value: string | undefined): number { const parsed = Number(nonEmptyValue(name, value)); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`); return parsed; } function nonEmptyValue(name: string, value: string | undefined): string { if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`); return value; } function resolvePath(value: string): string { return resolve(repoRoot, value); } function safeSessionId(value: string): string { if (!/^[A-Za-z0-9._-]{1,80}$/u.test(value)) { throw new Error("--session must contain only letters, numbers, dot, underscore, or dash, max 80 characters"); } return value; } function sessionsDir(): string { return join(stateRoot, "sessions"); } function sessionFile(sessionId: string): string { return join(sessionsDir(), `${safeSessionId(sessionId)}.storage.json`); } function browserType(name: BrowserName): BrowserType { if (name === "firefox") return firefox; if (name === "webkit") return webkit; return chromium; } function redactOptions(options: ParsedOptions): Record { return { command: options.command, commandArgs: options.commandArgs, browser: options.browserName, headless: options.headless, sessionId: options.sessionId, timeoutMs: options.timeoutMs, waitUntil: options.waitUntil, screenshotPath: options.screenshotPath, selector: options.selector, fullPage: options.fullPage, sessionStatePath: sessionFile(options.sessionId), }; } async function createContext(options: ParsedOptions): Promise<{ browser: Browser; context: BrowserContext; close: () => Promise }> { let browser: Browser; try { browser = await browserType(options.browserName).launch({ headless: options.headless }); } catch (error) { if (!options.headless && isDisplayFailure(error)) { throw new Error(`headed browser launch failed because no display is available; rerun with --headless or use xvfb-run -a bun scripts/playwright-cli.ts ${process.argv.slice(2).join(" ")}`); } throw error; } const storageState = sessionFile(options.sessionId); const context = await browser.newContext(existsSync(storageState) ? { storageState } : {}); return { browser, context, close: async () => { await context.close(); await browser.close(); }, }; } function isDisplayFailure(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /Target page, context or browser has been closed|Missing X server|no display|DISPLAY|xvfb-run|Looks like you launched a headed browser/u.test(message); } async function gotoPage(page: Page, url: string, options: ParsedOptions): Promise { const response = await page.goto(url, { timeout: options.timeoutMs, waitUntil: options.waitUntil }); return response?.status() ?? null; } async function runOpen(options: ParsedOptions): Promise> { const url = nonEmptyValue("open url", options.commandArgs[0]); const sessionStatePath = sessionFile(options.sessionId); const screenshotPath = options.screenshotPath; const plan = { ok: true, action: "open", url, browser: options.browserName, headless: options.headless, sessionId: options.sessionId, sessionStatePath, timeoutMs: options.timeoutMs, waitUntil: options.waitUntil, screenshotPath, }; if (options.dryRun) return { ...plan, dryRun: true, mutation: false }; mkdirSync(sessionsDir(), { recursive: true }); const { context, close } = await createContext(options); try { const page = await context.newPage(); const status = await gotoPage(page, url, options); const title = await page.title(); if (screenshotPath !== null) { mkdirSync(dirname(screenshotPath), { recursive: true }); await page.screenshot({ path: screenshotPath, fullPage: options.fullPage }); } await context.storageState({ path: sessionStatePath }); return { ...plan, dryRun: false, mutation: true, status, finalUrl: page.url(), title, sessionSaved: true, }; } finally { await close(); } } async function runScreenshot(options: ParsedOptions): Promise> { const url = nonEmptyValue("screenshot url", options.commandArgs[0]); const positionalPath = options.commandArgs[1] === undefined ? null : resolvePath(options.commandArgs[1]); const screenshotPath = options.screenshotPath ?? positionalPath ?? defaultScreenshotPath; const sessionStatePath = sessionFile(options.sessionId); const plan = { ok: true, action: "screenshot", url, browser: options.browserName, headless: options.headless, sessionId: options.sessionId, sessionStatePath, timeoutMs: options.timeoutMs, waitUntil: options.waitUntil, screenshotPath, selector: options.selector, fullPage: options.fullPage, }; if (options.dryRun) return { ...plan, dryRun: true, mutation: false }; mkdirSync(sessionsDir(), { recursive: true }); mkdirSync(dirname(screenshotPath), { recursive: true }); const { context, close } = await createContext(options); try { const page = await context.newPage(); const status = await gotoPage(page, url, options); const title = await page.title(); if (options.selector !== null) { await page.locator(options.selector).screenshot({ path: screenshotPath, timeout: options.timeoutMs }); } else { await page.screenshot({ path: screenshotPath, fullPage: options.fullPage }); } await context.storageState({ path: sessionStatePath }); return { ...plan, dryRun: false, mutation: true, status, finalUrl: page.url(), title, sessionSaved: true, }; } finally { await close(); } } async function runEval(options: ParsedOptions): Promise> { const url = nonEmptyValue("eval url", options.commandArgs[0]); const expression = nonEmptyValue("eval expression", options.commandArgs[1]); const sessionStatePath = sessionFile(options.sessionId); const plan = { ok: true, action: "eval", url, browser: options.browserName, headless: options.headless, sessionId: options.sessionId, sessionStatePath, timeoutMs: options.timeoutMs, waitUntil: options.waitUntil, expressionPreview: expression.length > 200 ? `${expression.slice(0, 200)}...` : expression, }; if (options.dryRun) return { ...plan, dryRun: true, mutation: false }; mkdirSync(sessionsDir(), { recursive: true }); const { context, close } = await createContext(options); try { const page = await context.newPage(); const status = await gotoPage(page, url, options); const value = await page.evaluate((source) => { return Function(`"use strict"; return (${source});`)(); }, expression); await context.storageState({ path: sessionStatePath }); return { ...plan, dryRun: false, mutation: true, status, finalUrl: page.url(), value, sessionSaved: true, }; } finally { await close(); } } function runSessionList(): Record { if (!existsSync(sessionsDir())) return { ok: true, sessions: [], stateRoot }; const glob = new Bun.Glob("*.storage.json"); const sessions = Array.from(glob.scanSync({ cwd: sessionsDir() })).map((file) => file.replace(/\.storage\.json$/u, "")); return { ok: true, sessions: sessions.sort(), stateRoot }; } function runSessionDelete(options: ParsedOptions): Record { const target = options.commandArgs[0] ?? options.sessionId; const file = sessionFile(target); const existed = existsSync(file); if (options.dryRun) return { ok: true, dryRun: true, mutation: false, sessionId: target, path: file, existed }; if (existed) rmSync(file); return { ok: true, dryRun: false, mutation: existed, sessionId: target, path: file, deleted: existed }; } function unsupportedCommand(command: string, options: ParsedOptions): Record { return { ok: false, error: "unsupported-command", command, supportedCommands, reason: unsupportedInteractiveCommands.has(command) ? "This repo-owned wrapper is short-run/headless and does not keep a live page with element refs between invocations." : "Unknown Playwright wrapper command.", actualBehavior: "Use open/screenshot/eval for bounded commander checks, or call npx playwright directly when you intentionally need upstream Playwright CLI behavior.", next: [ "bun scripts/playwright-cli.ts screenshot /tmp/page.png --session ", "bun scripts/playwright-cli.ts open --session --screenshot /tmp/page.png", "xvfb-run -a bun scripts/playwright-cli.ts open --headed --screenshot /tmp/page.png", ], launchPlan: redactOptions(options), }; } function displayUnavailableError(error: unknown): Record { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: "display-unavailable", reason: "A headed browser requires an X server/display, but this host does not expose one.", message, next: [ "rerun the same command without --headed", `xvfb-run -a bun scripts/playwright-cli.ts ${process.argv.slice(2).join(" ")}`, ], }; } export async function runPlaywrightCli(argv: string[]): Promise { const options = parseArgs(argv); const commandName = `playwright-cli ${argv.join(" ")}`.trim(); if (options.command === "help" || options.command === "--help" || options.command === "-h") { emitJson(commandName, usage()); return; } if (options.command === "open") { emitJson(commandName, await runOpen(options)); return; } if (options.command === "screenshot") { emitJson(commandName, await runScreenshot(options)); return; } if (options.command === "eval") { emitJson(commandName, await runEval(options)); return; } if (options.command === "session-list") { emitJson(commandName, runSessionList()); return; } if (options.command === "session-delete") { emitJson(commandName, runSessionDelete(options)); return; } emitJson(commandName, unsupportedCommand(options.command, options), false); process.exitCode = 1; } export function handlePlaywrightCliError(error: unknown): void { if (isDisplayFailure(error)) { emitJson("playwright-cli", displayUnavailableError(error), false); process.exitCode = 1; return; } emitError("playwright-cli", error); process.exitCode = 1; }