feat: add repeatable Web UX inspection workbench
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { assertEquals, assertThrows } from "@std/assert";
|
||||
import { parseArguments } from "../cli.ts";
|
||||
import { isVisibleUiErrorText } from "../src/capture.ts";
|
||||
|
||||
Deno.test("CLI parses bounded capture filters", () => {
|
||||
const parsed = parseArguments([
|
||||
"capture",
|
||||
"--scenario",
|
||||
"scenario.json",
|
||||
"--personas",
|
||||
"owner,non-owner",
|
||||
"--headed",
|
||||
]);
|
||||
assertEquals(parsed.command, "capture");
|
||||
assertEquals(parsed.values.get("personas"), ["owner,non-owner"]);
|
||||
assertEquals(parsed.flags.has("headed"), true);
|
||||
});
|
||||
|
||||
Deno.test("visible UI error classification ignores ordinary status text", () => {
|
||||
assertEquals(isVisibleUiErrorText("Refresh failed (401 Unauthorized)"), true);
|
||||
assertEquals(isVisibleUiErrorText("Workspace list loaded"), false);
|
||||
});
|
||||
|
||||
Deno.test("CLI rejects positional and missing option values", () => {
|
||||
assertThrows(() => parseArguments(["capture", "scenario.json"]), Error, "unexpected argument");
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import {
|
||||
assertBundleIsSecretFree,
|
||||
redactText,
|
||||
safeUrl,
|
||||
writePrivateJson,
|
||||
} from "../src/artifacts.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
|
||||
Deno.test("redaction removes common credentials and query values", () => {
|
||||
const redacted = redactText(
|
||||
"Authorization: Bearer abc.def cookie=session-value token=secret-value",
|
||||
["abc.def"],
|
||||
);
|
||||
assertStringIncludes(redacted, "[REDACTED]");
|
||||
assertEquals(redacted.includes("abc.def"), false);
|
||||
assertEquals(redacted.includes("session-value"), false);
|
||||
assertEquals(
|
||||
safeUrl("https://user:pass@example.test/path?token=secret#fragment"),
|
||||
"https://example.test/path?token=%5BREDACTED%5D",
|
||||
);
|
||||
assertRejects(
|
||||
async () => assertBundleIsSecretFree('{"authorization":"Bearer abc"}'),
|
||||
Error,
|
||||
"forbidden secret marker",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("private JSON state uses owner-only permissions", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const path = join(directory, "state", "owner.json");
|
||||
await writePrivateJson(path, { cookies: [], origins: [] });
|
||||
assertEquals(JSON.parse(await Deno.readTextFile(path)), { cookies: [], origins: [] });
|
||||
if (Deno.build.os !== "windows") assertEquals((await Deno.stat(path)).mode! & 0o777, 0o600);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("owned process is terminated and its logs are redacted", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
const scenario = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(scenario, "{}");
|
||||
try {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "fixture",
|
||||
command: Deno.execPath(),
|
||||
args: ["eval", 'console.log("authorization: secret-value"); setInterval(() => {}, 1000)'],
|
||||
}],
|
||||
scenario,
|
||||
join(directory, "logs"),
|
||||
["secret-value"],
|
||||
);
|
||||
assertEquals(processes.length, 1);
|
||||
const logPath = join(directory, "logs", "fixture.stdout.log");
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
try {
|
||||
if ((await Deno.readTextFile(logPath)).length > 0) break;
|
||||
} catch {
|
||||
// The output pump creates the file asynchronously.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
const diagnostics = await stopOwnedProcesses(processes);
|
||||
assertEquals(diagnostics, []);
|
||||
const log = await Deno.readTextFile(logPath);
|
||||
assertEquals(log.includes("secret-value"), false);
|
||||
assertStringIncludes(log, "[REDACTED]");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import { cleanup } from "../src/lifecycle.ts";
|
||||
import { interpolateEnvironment, loadScenario, validateBaseUrl } from "../src/scenario.ts";
|
||||
|
||||
function minimalScenario(extra = ""): string {
|
||||
return `{
|
||||
"schemaVersion": 1,
|
||||
"id": "test-screen",
|
||||
"title": "Test screen",
|
||||
"baseUrl": "http://127.0.0.1:5173",
|
||||
"personas": [{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}],
|
||||
"viewports": [{"label":"desktop","width":1000,"height":800}],
|
||||
"routes": [{
|
||||
"id":"home","label":"Home","path":"/","goal":"Inspect home",
|
||||
"dataState":"Fixture data","ready":{"kind":"selector","selector":"main"},
|
||||
"capturePoints":[{"id":"initial","label":"Initial"}]
|
||||
}]${extra}
|
||||
}`;
|
||||
}
|
||||
|
||||
Deno.test("scenario parser preserves explicit visual review context", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const path = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(path, minimalScenario());
|
||||
const scenario = await loadScenario(path);
|
||||
assertEquals(scenario.personas[0].auth, { kind: "anonymous" });
|
||||
assertEquals(scenario.routes[0].goal, "Inspect home");
|
||||
assertEquals(scenario.routes[0].dataState, "Fixture data");
|
||||
assertEquals(scenario.routes[0].ready, {
|
||||
kind: "selector",
|
||||
selector: "main",
|
||||
timeoutMs: undefined,
|
||||
});
|
||||
assertEquals(scenario.reducedMotion, "reduce");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("scenario parser rejects duplicate persona identity", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const path = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(
|
||||
path,
|
||||
minimalScenario().replace(
|
||||
'[{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}]',
|
||||
'[{"id":"same","label":"First","auth":{"kind":"anonymous"}},{"id":"same","label":"Second","auth":{"kind":"anonymous"}}]',
|
||||
),
|
||||
);
|
||||
await assertRejects(() => loadScenario(path), Error, "duplicate id: same");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("base URL rejects embedded credentials and non-http schemes", () => {
|
||||
assertThrows(
|
||||
() => validateBaseUrl("https://user:secret@example.test"),
|
||||
Error,
|
||||
"must not contain credentials",
|
||||
);
|
||||
assertThrows(() => validateBaseUrl("file:///tmp/index.html"), Error, "must use http or https");
|
||||
});
|
||||
|
||||
Deno.test("environment interpolation fails closed", () => {
|
||||
assertEquals(
|
||||
interpolateEnvironment("/w/${WORKSPACE_ID}", { WORKSPACE_ID: "W-test" }),
|
||||
"/w/W-test",
|
||||
);
|
||||
assertThrows(
|
||||
() => interpolateEnvironment("${MISSING}", {}),
|
||||
Error,
|
||||
"required environment variable is missing",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("cleanup removes only complete review bundles beyond retention", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
for (const name of ["one", "two", "three"]) {
|
||||
const run = join(directory, name);
|
||||
await Deno.mkdir(run);
|
||||
await Deno.writeTextFile(join(run, "review-context.json"), "{}");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
const unrelated = join(directory, "auth");
|
||||
await Deno.mkdir(unrelated);
|
||||
await Deno.writeTextFile(join(unrelated, "state.json"), "secret");
|
||||
const removed = await cleanup({ outputDirectory: directory, keep: 1 });
|
||||
assertEquals(removed.length, 2);
|
||||
assertEquals(await Deno.readTextFile(join(unrelated, "state.json")), "secret");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user