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
@@ -0,0 +1,99 @@
import { assertEquals, assertRejects } from "@std/assert";
import { join } from "@std/path";
import { capture } from "../src/capture.ts";
async function freePort(): Promise<number> {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
Deno.test("browser smoke captures distinct owner and non-owner evidence and cleans its server", async () => {
const directory = await Deno.makeTempDir();
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
try {
const authDirectory = join(directory, "auth");
await Deno.mkdir(authDirectory);
for (const persona of ["owner", "non-owner"]) {
await Deno.writeTextFile(
join(authDirectory, `${persona}.json`),
JSON.stringify({
cookies: [{
name: "persona",
value: persona,
domain: "127.0.0.1",
path: "/",
expires: -1,
httpOnly: true,
secure: false,
sameSite: "Lax",
}],
origins: [],
}),
);
}
const scenarioPath = join(directory, "scenario.json");
await Deno.writeTextFile(
scenarioPath,
JSON.stringify({
schemaVersion: 1,
id: "browser-smoke",
title: "Browser smoke",
baseUrl,
personas: [
{ id: "owner", label: "Owner", auth: { kind: "storage-state", path: "auth/owner.json" } },
{
id: "non-owner",
label: "Non-owner",
auth: { kind: "storage-state", path: "auth/non-owner.json" },
},
],
viewports: [{ label: "desktop", width: 1000, height: 700 }],
routes: [{
id: "repositories",
label: "Repositories",
path: "/screen",
goal: "Verify permission-specific composition",
dataState: "Deterministic fixture repository",
ready: { kind: "selector", selector: "main" },
capturePoints: [{ id: "initial", label: "Initial" }],
}],
processes: [{
id: "fixture-server",
command: Deno.execPath(),
args: [
"run",
"--allow-net",
join(Deno.cwd(), "browser-tests/fixture_server.ts"),
String(port),
],
readyUrl: `${baseUrl}/health`,
}],
}),
);
const manifest = await capture({
scenarioPath,
outputDirectory: join(directory, "artifacts"),
runId: "multi-persona",
});
assertEquals(manifest.status, "completed");
assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]);
assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true);
assertEquals(manifest.contactSheet.png, "contact-sheet.png");
const runDirectory = join(directory, "artifacts", "multi-persona");
const reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json"));
assertEquals(reviewContext.includes('"cookies"'), false);
if (Deno.build.os !== "windows") {
const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].path);
assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600);
}
await assertRejects(
() => fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(500) }),
TypeError,
);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
@@ -0,0 +1,17 @@
const port = Number(Deno.args[0]);
if (!Number.isInteger(port) || port <= 0) throw new Error("port is required");
Deno.serve({ hostname: "127.0.0.1", port }, (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
const cookie = request.headers.get("cookie") ?? "";
const owner = cookie.includes("persona=owner");
const title = owner ? "Owner repository settings" : "Repository settings";
const action = owner
? '<button type="button">Add repository</button>'
: '<p role="note">Ask a Workspace owner to change repository access.</p>';
return new Response(
`<!doctype html><html><head><title>${title}</title><style>body{font:16px system-ui;margin:0}main{max-width:800px;margin:40px auto}header{border-bottom:1px solid #ccc;padding:16px}section{border:1px solid #ccc;padding:20px}button{background:#06c;color:white;padding:10px 20px}</style></head><body><header>Workspace</header><main><h1>${title}</h1><section><h2>main</h2><p>SSH repository access is configured.</p>${action}</section></main></body></html>`,
{ headers: { "content-type": "text/html; charset=utf-8" } },
);
});