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));