feat: add repeatable Web UX inspection workbench

This commit is contained in:
2026-09-01 22:48:26 +09:00
parent 409245cb52
commit 4a4a01b730
20 changed files with 2307 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys
import { authenticate, cleanup } from "./src/lifecycle.ts";
import { capture, describeCapture } from "./src/capture.ts";
import { compare } from "./src/compare.ts";
const HELP = `Web UX inspection workbench
Usage:
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--headless]
deno task web-ux capture --scenario <file> --output <directory> [--base-url <url>] [--run-id <id>] [--personas <ids>] [--routes <ids>] [--viewports <ids>] [--headed]
deno task web-ux compare --before <review-context.json> --after <review-context.json> --output <directory> [--threshold <0..1>]
deno task web-ux cleanup --output <directory> [--keep <count>] [--older-than-days <days>] [--dry-run]
Comma-separate persona, route, and viewport ids. Auth state is local, mode 0600, and must not be committed.
`;
type Arguments = { command: string; values: Map<string, string[]>; flags: Set<string> };
export function parseArguments(args: string[]): Arguments {
const command = args.shift() ?? "help";
const values = new Map<string, string[]>();
const flags = new Set<string>();
for (let index = 0; index < args.length; index++) {
const token = args[index];
if (!token.startsWith("--")) throw new Error(`unexpected argument: ${token}`);
const name = token.slice(2);
const next = args[index + 1];
if (next === undefined || next.startsWith("--")) {
flags.add(name);
} else {
const items = values.get(name) ?? [];
items.push(next);
values.set(name, items);
index++;
}
}
return { command, values, flags };
}
function optional(args: Arguments, name: string): string | undefined {
const values = args.values.get(name);
if (!values) return undefined;
if (values.length !== 1) throw new Error(`--${name} must be specified once`);
return values[0];
}
function required(args: Arguments, name: string): string {
const value = optional(args, name);
if (!value) throw new Error(`--${name} is required`);
return value;
}
function integer(args: Arguments, name: string, fallback?: number): number | undefined {
const value = optional(args, name);
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(`--${name} must be a non-negative integer`);
}
return parsed;
}
function list(args: Arguments, name: string): string[] | undefined {
const value = optional(args, name);
return value?.split(",").map((item) => item.trim()).filter(Boolean);
}
function rejectUnknown(args: Arguments, allowedValues: string[], allowedFlags: string[]): void {
for (const name of args.values.keys()) {
if (!allowedValues.includes(name)) throw new Error(`unsupported option: --${name}`);
}
for (const name of args.flags) {
if (!allowedFlags.includes(name)) throw new Error(`unsupported flag: --${name}`);
}
}
export async function main(rawArgs: string[]): Promise<number> {
const args = parseArguments([...rawArgs]);
if (args.command === "help" || args.flags.has("help")) {
console.log(HELP);
return 0;
}
if (args.command === "auth") {
rejectUnknown(args, ["scenario", "persona", "base-url", "import-state", "timeout-ms"], [
"headless",
]);
const path = await authenticate({
scenarioPath: required(args, "scenario"),
personaId: required(args, "persona"),
baseUrl: optional(args, "base-url"),
importState: optional(args, "import-state"),
timeoutMs: integer(args, "timeout-ms"),
headless: args.flags.has("headless"),
});
console.log(`auth state saved: ${path}`);
return 0;
}
if (args.command === "capture") {
rejectUnknown(args, [
"scenario",
"output",
"base-url",
"run-id",
"personas",
"routes",
"viewports",
], ["headed"]);
const outputDirectory = required(args, "output");
const manifest = await capture({
scenarioPath: required(args, "scenario"),
outputDirectory,
baseUrl: optional(args, "base-url"),
runId: optional(args, "run-id"),
personas: list(args, "personas"),
routes: list(args, "routes"),
viewports: list(args, "viewports"),
headed: args.flags.has("headed"),
});
console.log(describeCapture(manifest, outputDirectory));
return manifest.status === "completed" ? 0 : 2;
}
if (args.command === "compare") {
rejectUnknown(args, ["before", "after", "output", "threshold"], []);
const thresholdValue = optional(args, "threshold");
const threshold = thresholdValue === undefined ? undefined : Number(thresholdValue);
if (
threshold !== undefined && (!Number.isFinite(threshold) || threshold < 0 || threshold > 1)
) {
throw new Error("--threshold must be between 0 and 1");
}
const report = await compare({
before: required(args, "before"),
after: required(args, "after"),
outputDirectory: required(args, "output"),
threshold,
});
console.log(`comparison saved: ${report}`);
return 0;
}
if (args.command === "cleanup") {
rejectUnknown(args, ["output", "keep", "older-than-days"], ["dry-run"]);
const removed = await cleanup({
outputDirectory: required(args, "output"),
keep: integer(args, "keep", 5)!,
olderThanDays: integer(args, "older-than-days"),
dryRun: args.flags.has("dry-run"),
});
for (const path of removed) {
console.log(`${args.flags.has("dry-run") ? "would remove" : "removed"}: ${path}`);
}
return 0;
}
throw new Error(`unknown command: ${args.command}\n\n${HELP}`);
}
if (import.meta.main) {
try {
Deno.exit(await main(Deno.args));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
Deno.exit(1);
}
}