fix: add commander playwright wrapper
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<typeof runBun>, 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 <url>")), "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));
|
||||
Reference in New Issue
Block a user