diff --git a/AGENTS.md b/AGENTS.md index c2384471..f2a86070 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts codex judge --attempt [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。 - `bun scripts/cli.ts codex steer [prompt|--prompt-file path|--prompt-stdin] [--dry-run] [--no-retry|--retry-attempts N]`:通过 Code Queue 私有代理向运行中的 active turn 注入纠偏提示,对 retryable tunnel abort 做有界重试诊断,真实成功只确认写入并返回后续查看命令,不回显 prompt 或完整 task state。 - `bun scripts/cli.ts codex interrupt|cancel `:通过 Code Queue 私有代理中断运行任务或取消 queued/retry_wait 任务,规则见 `docs/reference/cli.md`。 +- `bun scripts/playwright-cli.ts screenshot|open|eval ...`:UniDesk 仓库自带的 Playwright 指挥手测 wrapper,默认 headless,可用 `--session ` 复用 storageState,适合截图、打开页面和一次性 JS 取值;它不实现长驻浏览器 daemon、element ref `click/fill/snapshot` 会返回结构化 unsupported 和 `xvfb-run`/headless 下一步,规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list [--limit N]` / `bun scripts/cli.ts job status latest [--tail-bytes N]`:分页查询 `.state/jobs/` 中的异步任务状态,状态输出只读日志尾部并保留完整日志路径,job 机制见 `docs/reference/cli.md`。 - `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 diff --git a/TEST.md b/TEST.md index 95417c42..daa2e107 100644 --- a/TEST.md +++ b/TEST.md @@ -146,3 +146,7 @@ ## T28 Host Codex Commander Skeleton Contract 阅读 `AGENTS.md` 和 `docs/reference/host-codex-commander.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/host-codex-commander-contract-test.ts`,确认输出 `ok=true`;运行 `bun scripts/cli.ts commander contract`,确认返回 `phase=source-contract`、`serviceId=host-codex-commander`、`daemonImplemented=false`、`liveOperationsImplemented=false`,且 required capabilities 包含 host Codex 进程发现/启动计划、SSH/PTY/stdio bridge、prompt guidance、trace summary、#20/#46 入口和 ClaudeQQ 高风险审批入口;运行 `bun scripts/cli.ts commander plan --dry-run --session-id primary`,确认所有 top-level plan 均为 `mutation=false`,start plan `enabled=false`,不会打开 SSH/PTY/stdio、不会注入 prompt、不会发送 ClaudeQQ;运行 `bun scripts/cli.ts commander plan`,确认非 dry-run 返回非零状态和 `error=dry-run-required`;运行 `bun scripts/cli.ts commander approval request --action code-queue-task-interrupt --task-id --reason '' --dry-run`,确认只生成 ClaudeQQ 审批草案且 `claudeqq.mutation=false`、`sendImplemented=false`;运行 `bun scripts/cli.ts commander approval request --action read-token-file --dry-run`,确认返回 `validation-failed`。本测试不得部署、不得重启 Code Queue backend、不得 cancel/interrupt 运行任务、不得读取或输出 token 明文。 + +## T29 Playwright Commander Wrapper Contract + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 和 `scripts/playwright-cli.ts` 的解释职责)和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/playwright-cli-contract-test.ts`,确认输出 `ok=true`,并确认 `externalSkill` 字段明确记录外部 `~/.agents/skills/playwright` 是否仍是 passthrough/docs mismatch。运行 `bun scripts/playwright-cli.ts help`,确认帮助写明 wrapper 是短生命周期 JSON CLI、默认 headless、`--session` 只保存 storageState、不支持长驻 element-ref daemon,并给出 `xvfb-run -a` headed 兜底;该兜底依赖 `xvfb-run` 和 `xauth`。运行 `bun scripts/playwright-cli.ts --session=hwlab-dev screenshot http://127.0.0.1:16666/ /tmp/hwlab-dev.png --dry-run`,确认 `headless=true`、`sessionId=hwlab-dev`、`mutation=false`,且不会调用上游 `npx playwright`。运行 `bun scripts/playwright-cli.ts --session=hwlab-dev click e3`,确认返回非零状态和结构化 `unsupported-command`,下一步建议包含 `screenshot/open` 和 `xvfb-run -a`。在有可访问测试页面时运行 `bun scripts/playwright-cli.ts screenshot https://example.com /tmp/unidesk-playwright-example.png`,确认截图文件生成且 JSON 中含 `status`、`finalUrl`、`title`、`sessionSaved=true`;该测试不得访问 HWLAB 业务代码、不得部署、不得重启 Code Queue、不得读取或输出 token/secret。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 97f7fb95..c62e32b7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -65,6 +65,19 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 - `e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]` 使用 publicHost 派生的公开 production frontend/dev frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁;CLI 默认输出 check 状态摘要,完整诊断写入 `resultPath`,日常迭代应优先用 `--only` / `--skip` 跑最小必要集合。 +## Playwright Commander Wrapper + +UniDesk 仓库自带 `scripts/playwright-cli.ts` 作为 host commander 浏览器手测 wrapper。它是短生命周期、JSON 输出的命令,不是长驻浏览器 daemon。 + +- `bun scripts/playwright-cli.ts screenshot [path] [--session id] [--selector css] [--full-page]` 默认 headless 打开 URL、保存截图、把 storage state 写到 `.state/playwright-cli/sessions/`,并返回 `status`、`finalUrl`、`title`、`screenshotPath` 等紧凑 JSON。 +- `bun scripts/playwright-cli.ts open [--session id] [--screenshot path]` 执行一次导航;需要证据时加 `--screenshot`。 +- `bun scripts/playwright-cli.ts eval '' [--session id]` 在导航后执行单个表达式并返回结构化值。 +- `session-list` 和 `session-delete [sessionId]` 只管理 storage-state 文件。`--session` 不表示 live page,也不会保留 element refs。 +- `click`、`fill`、`snapshot`、`tab-list`、`close` 等交互式 daemon 命令会返回 `unsupported-command` 和下一步建议,不会透传给 `npx playwright`,因此 `--session=` 不会再被上游 Playwright 当作未知参数。 +- 默认走 headless,适合无 XServer runner。确实需要 headed 时使用 `xvfb-run -a bun scripts/playwright-cli.ts open --headed --screenshot /tmp/page.png`;Code Queue runner 镜像必须包含 `xvfb-run` 和 `xauth` 作为该兜底路径。 + +外部 agent skill `~/.agents/skills/playwright` 是另一个 source of truth。当前宿主上它可能仍是 `npx playwright` passthrough,但 `SKILL.md` 里描述了更丰富的 `--session`、`snapshot` 和 element-ref 操作。外部 skill 分发更新前,UniDesk/HWLAB 指挥手测应使用本仓库 wrapper;不要把外部 skill 文档当成 daemon/session 能力已经可用的证据。 + ## Async Job State 长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log` 与 `.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status ` 查询进度和尾部输出。 diff --git a/scripts/playwright-cli-contract-test.ts b/scripts/playwright-cli-contract-test.ts new file mode 100644 index 00000000..f9797267 --- /dev/null +++ b/scripts/playwright-cli-contract-test.ts @@ -0,0 +1,154 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +type JsonRecord = Record; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function runBun(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { + const result = spawnSync("bun", args, { + cwd: process.cwd(), + encoding: "utf8", + }); + const stdout = String(result.stdout || ""); + let json: JsonRecord | null = null; + try { + json = JSON.parse(stdout) as JsonRecord; + } catch { + json = null; + } + return { + status: result.status, + stdout, + stderr: String(result.stderr || ""), + json, + }; +} + +function nestedRecord(value: unknown, path: string[]): JsonRecord { + let current: unknown = value; + for (const key of path) { + assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current }); + current = (current as JsonRecord)[key]; + } + assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current }); + return current as JsonRecord; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map((item) => String(item)) : []; +} + +function assertOkJson(result: ReturnType, message: string): JsonRecord { + assertCondition(result.status === 0, message, { status: result.status, stdout: result.stdout, stderr: result.stderr }); + assertCondition(result.json?.ok === true, `${message}: expected ok JSON`, result.json ?? { stdout: result.stdout }); + return result.json as JsonRecord; +} + +function readTextIfExists(path: string): string | null { + return existsSync(path) ? readFileSync(path, "utf8") : null; +} + +function inspectExternalSkill(): JsonRecord { + const candidateRoots = [ + process.env.PLAYWRIGHT_SKILL_ROOT, + join(process.env.HOME || "", ".agents", "skills", "playwright"), + "/home/ubuntu/.agents/skills/playwright", + "/root/.agents/skills/playwright", + ].filter((value, index, array): value is string => typeof value === "string" && value.length > 0 && array.indexOf(value) === index); + const skillRoot = candidateRoots.find((candidate) => existsSync(join(candidate, "scripts", "playwright-cli.ts")) && existsSync(join(candidate, "SKILL.md"))) ?? candidateRoots[0] ?? "/home/ubuntu/.agents/skills/playwright"; + const scriptPath = join(skillRoot, "scripts", "playwright-cli.ts"); + const skillPath = join(skillRoot, "SKILL.md"); + const scriptText = readTextIfExists(scriptPath); + const skillText = readTextIfExists(skillPath); + if (scriptText === null || skillText === null) { + return { + checked: false, + reason: "external playwright skill files not found on this host", + skillRoot, + scriptPath, + skillPath, + searchedRoots: candidateRoots, + }; + } + const passthrough = /spawn\(['"]npx(?:\.cmd)?['"]/u.test(scriptText) || scriptText.includes("npx.cmd"); + const advertisesSession = skillText.includes("--session") || skillText.includes("session-list"); + const advertisesSnapshot = skillText.includes("snapshot"); + const implementsSession = scriptText.includes("--session") && scriptText.includes("sessionFile"); + const mismatch = passthrough && (advertisesSession || advertisesSnapshot) && !implementsSession; + return { + checked: true, + skillRoot, + passthrough, + advertisesSession, + advertisesSnapshot, + implementsSession, + mismatch, + repoOwnedResolution: "Use scripts/playwright-cli.ts for commander checks; update the external skill source/distribution separately.", + }; +} + +function runContract(): JsonRecord { + const help = assertOkJson(runBun(["scripts/playwright-cli.ts", "help"]), "playwright help should succeed"); + const helpData = nestedRecord(help.data, []); + const usage = stringArray(helpData.usage); + const behavior = stringArray(helpData.behavior); + assertCondition(usage.some((line) => line.includes("screenshot ")), "help should document screenshot flow", usage); + assertCondition(behavior.some((line) => line.includes("headless by default")), "help should document headless default", behavior); + assertCondition(behavior.some((line) => line.includes("no long-running browser daemon")), "help should state no daemon/session-ref behavior", behavior); + + const dryRun = assertOkJson(runBun([ + "scripts/playwright-cli.ts", + "--session=hwlab-dev", + "screenshot", + "http://127.0.0.1:16666/", + "/tmp/hwlab-dev.png", + "--selector", + "#root", + "--dry-run", + ]), "playwright screenshot dry-run should succeed"); + const dryRunData = nestedRecord(dryRun.data, []); + assertCondition(dryRunData.dryRun === true, "dry-run should be explicit", dryRunData); + assertCondition(dryRunData.headless === true, "headless should default true", dryRunData); + assertCondition(dryRunData.sessionId === "hwlab-dev", "--session should be parsed instead of passed through", dryRunData); + assertCondition(String(dryRunData.screenshotPath || "").endsWith("/tmp/hwlab-dev.png"), "screenshot path should be resolved", dryRunData); + + const headedPlan = assertOkJson(runBun([ + "scripts/playwright-cli.ts", + "open", + "https://example.com", + "--headed", + "--screenshot", + "/tmp/example-headed.png", + "--dry-run", + ]), "headed open dry-run should succeed"); + const headedData = nestedRecord(headedPlan.data, []); + assertCondition(headedData.headless === false, "--headed should opt out of headless", headedData); + assertCondition(String(headedData.screenshotPath || "").endsWith("/tmp/example-headed.png"), "open should accept --screenshot path", headedData); + + const unsupported = runBun(["scripts/playwright-cli.ts", "--session=hwlab-dev", "click", "e3"]); + assertCondition(unsupported.status !== 0, "unsupported interactive command should fail", unsupported); + assertCondition(unsupported.json?.ok === false, "unsupported command should return structured JSON", unsupported.json ?? { stdout: unsupported.stdout }); + const unsupportedData = nestedRecord(unsupported.json?.data, []); + assertCondition(unsupportedData.error === "unsupported-command", "unsupported error should be explicit", unsupportedData); + assertCondition(String(unsupportedData.reason || "").includes("short-run/headless"), "unsupported reason should explain no live refs", unsupportedData); + assertCondition(stringArray(unsupportedData.next).some((line) => line.includes("xvfb-run -a")), "unsupported next steps should include xvfb-run path", unsupportedData); + + const externalSkill = inspectExternalSkill(); + return { + ok: true, + checks: [ + "help documents headless short-run behavior", + "dry-run parses --session without upstream passthrough", + "unsupported interactive commands return compact actionable JSON", + "external skill source gap is observable when present", + ], + externalSkill, + }; +} + +const result = runContract(); +console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/playwright-cli.ts b/scripts/playwright-cli.ts new file mode 100644 index 00000000..80482c5b --- /dev/null +++ b/scripts/playwright-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { handlePlaywrightCliError, runPlaywrightCli } from "./src/playwright-cli"; + +runPlaywrightCli(process.argv.slice(2)).catch(handlePlaywrightCliError); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index a45b3078..37ab72fe 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -12,6 +12,9 @@ interface CheckItem { const syntaxFiles = [ "scripts/cli.ts", + "scripts/playwright-cli.ts", + "scripts/playwright-cli-contract-test.ts", + "scripts/src/playwright-cli.ts", "scripts/src/check.ts", "scripts/src/artifact-registry.ts", "scripts/src/auth-broker.ts", @@ -281,6 +284,8 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default if (options.files) { items.push( fileItem("scripts/cli.ts"), + fileItem("scripts/playwright-cli.ts"), + fileItem("scripts/src/playwright-cli.ts"), fileItem("AGENTS.md"), fileItem("TEST.md"), fileItem("docs/reference/artifact-registry.md"), @@ -335,6 +340,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/gh-cli-issue-guard-contract-test.ts"), fileItem("scripts/gh-cli-pr-files-contract-test.ts"), fileItem("scripts/gh-cli-pr-contract-test.ts"), + fileItem("scripts/playwright-cli-contract-test.ts"), fileItem("scripts/code-queue-pr-preflight-example.ts"), fileItem("scripts/schedule-cli-contract-test.ts"), fileItem("scripts/server-cleanup-plan-contract-test.ts"), @@ -385,6 +391,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000)); items.push(commandItem("gh:pr-files-contract", ["bun", "scripts/gh-cli-pr-files-contract-test.ts"], 30_000)); items.push(commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000)); + items.push(commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000)); items.push(commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000)); } else { items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full")); @@ -416,6 +423,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("gh:issue-guard-contract", "GitHub issue CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("gh:pr-files-contract", "GitHub PR files/stat contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("gh:pr-contract", "GitHub PR CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("playwright:cli-wrapper-contract", "Playwright wrapper/headless/session contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("auth-broker:p0-contract", "Auth Broker P0 skeleton and CLI adapter contract is opt-in with script checks", "--scripts-typecheck or --full")); } if (options.logs) { diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 81d7ae2c..063da4d6 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -74,6 +74,7 @@ export function rootHelp(): unknown { { command: "network perf [--service code-queue --path /api/tasks/overview?limit=30 --count N --concurrency N --label before|after]", description: "Benchmark frontend -> backend-core -> provider/adapter user-service networking and report latency/proxy-mode distributions." }, { command: "ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs", description: "Manage D601 k3s Tekton CI; artifact publish commands build commit-pinned images in CI without deploying CD." }, { command: "e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]", description: "Run selected public/internal/Playwright E2E checks; use --only for focused iteration and rerun without filters for final regression." }, + { command: "bun scripts/playwright-cli.ts screenshot|open|eval ...", description: "Repo-owned Playwright wrapper for commander browser checks; headless by default, supports storage-state --session, and returns JSON guidance for unsupported interactive daemon commands." }, ], }; } diff --git a/scripts/src/playwright-cli.ts b/scripts/src/playwright-cli.ts new file mode 100644 index 00000000..5cc580bb --- /dev/null +++ b/scripts/src/playwright-cli.ts @@ -0,0 +1,467 @@ +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; +} diff --git a/src/components/microservices/code-queue/Dockerfile b/src/components/microservices/code-queue/Dockerfile index 148315bc..899307dd 100644 --- a/src/components/microservices/code-queue/Dockerfile +++ b/src/components/microservices/code-queue/Dockerfile @@ -4,7 +4,7 @@ FROM ${CODE_QUEUE_BASE_IMAGE} ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright ENV UNIDESK_SKILLS_PATH=/root/.agents/skills -RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && command -v gh >/dev/null 2>&1 && command -v rg >/dev/null 2>&1 && command -v cargo >/dev/null 2>&1 && command -v rustc >/dev/null 2>&1 && command -v rustfmt >/dev/null 2>&1 && test -x "$PLAYWRIGHT_BROWSERS_PATH/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell") \ +RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && command -v gh >/dev/null 2>&1 && command -v rg >/dev/null 2>&1 && command -v cargo >/dev/null 2>&1 && command -v rustc >/dev/null 2>&1 && command -v rustfmt >/dev/null 2>&1 && command -v xvfb-run >/dev/null 2>&1 && command -v xauth >/dev/null 2>&1 && test -x "$PLAYWRIGHT_BROWSERS_PATH/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell") \ || (apt-get update \ && apt-get install -y --no-install-recommends \ bash \ @@ -39,6 +39,8 @@ RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && tar \ tini \ unzip \ + xauth \ + xvfb \ xz-utils \ && mkdir -p /usr/local/lib/docker/cli-plugins /root/.docker/cli-plugins \ && ln -sf /usr/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose \