fix: bound Web UX review evidence and cleanup
This commit is contained in:
@@ -5,4 +5,3 @@
|
|||||||
.worktree
|
.worktree
|
||||||
*.local*
|
*.local*
|
||||||
.env
|
.env
|
||||||
.web-ux/
|
|
||||||
|
|||||||
+20
-14
@@ -51,15 +51,17 @@ screen-owned selector.
|
|||||||
```sh
|
```sh
|
||||||
export WEB_UX_BASE_URL='http://127.0.0.1:5173'
|
export WEB_UX_BASE_URL='http://127.0.0.1:5173'
|
||||||
export WORKSPACE_ID='<workspace-id>'
|
export WORKSPACE_ID='<workspace-id>'
|
||||||
|
export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentication fixtures
|
## Authentication fixtures
|
||||||
|
|
||||||
Authentication state is local sensitive material. `.web-ux/` is gitignored, files are written with
|
Authentication state is local sensitive material stored under `$XDG_STATE_HOME/yoi/web-ux/auth/`,
|
||||||
mode `0600`, state contents are never copied into a review bundle, and the CLI never prints cookies
|
outside the Repository and Workdir. Files are written with mode `0600`, state contents are never
|
||||||
or credentials. Each profile has a sidecar binding it to the exact persona and base URL origin with
|
copied into a review bundle, and the CLI never prints cookies or credentials. Each profile has a
|
||||||
a 12-hour default expiry. Capture fails explicitly when metadata is missing, the origin differs, or
|
sidecar binding it to the exact persona and base URL origin with a 12-hour default expiry. Capture
|
||||||
the profile has expired; it never silently reuses or refreshes that state.
|
fails explicitly when metadata is missing, the origin differs, or the profile has expired; it never
|
||||||
|
silently reuses or refreshes that state.
|
||||||
|
|
||||||
For an interactive Passkey/browser login:
|
For an interactive Passkey/browser login:
|
||||||
|
|
||||||
@@ -125,11 +127,15 @@ The command exits `2` when it produced evidence but observed UI/tool errors, and
|
|||||||
capture itself failed. It continues other route/persona captures after a bounded route failure.
|
capture itself failed. It continues other route/persona captures after a bounded route failure.
|
||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `review-context.json` for the exact context, hashes, HTTP status, and failures;
|
- `review-context.json` for the exact context, hashes, HTTP status, retained/truncated diagnostic
|
||||||
- `contact-sheet.png` with an image-capable reviewer for composition, hierarchy, density, clipping,
|
counts, route and capture-point readiness, and the redacted interaction sequence;
|
||||||
empty/error states, and permission-specific affordances;
|
- `contact-sheet.png` through its manifest `workdirPath` with an image-capable reviewer for
|
||||||
- each `accessibility.md` for landmark/name/state evidence that a screenshot cannot prove;
|
composition, hierarchy, density, clipping, empty/error states, and permission-specific
|
||||||
- `process-logs/` when the scenario owns a server process. Logs are redacted before writing.
|
affordances;
|
||||||
|
- each `accessibility.md` through its manifest `workdirPath` for landmark/name/state evidence that a
|
||||||
|
screenshot cannot prove;
|
||||||
|
- `process-logs/` when the scenario owns a server process. Each stdout/stderr stream is redacted,
|
||||||
|
capped at 1 MiB, and paired with truncation metadata.
|
||||||
|
|
||||||
The implementing agent must inspect the actual contact sheet (for example with `ViewImage`), record
|
The implementing agent must inspect the actual contact sheet (for example with `ViewImage`), record
|
||||||
concrete findings, fix them, recapture under the same persona/route/viewport filters, and inspect
|
concrete findings, fix them, recapture under the same persona/route/viewport filters, and inspect
|
||||||
@@ -166,10 +172,10 @@ deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days
|
|||||||
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14
|
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14
|
||||||
```
|
```
|
||||||
|
|
||||||
Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and the repository
|
Cleanup recognizes only directories containing `review-context.json`. The repository `target/` tree
|
||||||
`target/` tree are ignored by Git. `capture` defaults to `target/web-ux` when `--output` is omitted.
|
is ignored by Git, while authentication state remains outside the repository. `capture` defaults to
|
||||||
Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable
|
`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the
|
||||||
review evidence is required.
|
approved immutable artifact channel when durable review evidence is required.
|
||||||
|
|
||||||
## Adding a scenario
|
## Adding a scenario
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ async function freePort(): Promise<number> {
|
|||||||
|
|
||||||
Deno.test("browser smoke captures distinct owner and non-owner evidence and cleans its server", async () => {
|
Deno.test("browser smoke captures distinct owner and non-owner evidence and cleans its server", async () => {
|
||||||
const directory = await Deno.makeTempDir();
|
const directory = await Deno.makeTempDir();
|
||||||
|
const previousSecret = Deno.env.get("WEB_UX_FIXTURE_SECRET");
|
||||||
|
const fixtureSecret = "fixture-canary-secret";
|
||||||
|
Deno.env.set("WEB_UX_FIXTURE_SECRET", fixtureSecret);
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
try {
|
try {
|
||||||
@@ -45,6 +48,10 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
|
|||||||
id: "browser-smoke",
|
id: "browser-smoke",
|
||||||
title: "Browser smoke",
|
title: "Browser smoke",
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
redact: {
|
||||||
|
selectors: ["[data-web-ux-redact]"],
|
||||||
|
text: ["${WEB_UX_FIXTURE_SECRET}"],
|
||||||
|
},
|
||||||
personas: [
|
personas: [
|
||||||
{ id: "owner", label: "Owner", auth: { kind: "storage-state", path: "auth/owner.json" } },
|
{ id: "owner", label: "Owner", auth: { kind: "storage-state", path: "auth/owner.json" } },
|
||||||
{
|
{
|
||||||
@@ -61,17 +68,26 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
|
|||||||
goal: "Verify permission-specific composition",
|
goal: "Verify permission-specific composition",
|
||||||
dataState: "Deterministic fixture repository",
|
dataState: "Deterministic fixture repository",
|
||||||
ready: { kind: "selector", selector: "main" },
|
ready: { kind: "selector", selector: "main" },
|
||||||
capturePoints: [{ id: "initial", label: "Initial" }],
|
capturePoints: [{
|
||||||
|
id: "initial",
|
||||||
|
label: "Initial",
|
||||||
|
interaction: [{
|
||||||
|
action: "wait",
|
||||||
|
ready: { kind: "selector", selector: "h1" },
|
||||||
|
}],
|
||||||
|
}],
|
||||||
}],
|
}],
|
||||||
processes: [{
|
processes: [{
|
||||||
id: "fixture-server",
|
id: "fixture-server",
|
||||||
command: Deno.execPath(),
|
command: Deno.execPath(),
|
||||||
args: [
|
args: [
|
||||||
"run",
|
"run",
|
||||||
|
"--allow-env",
|
||||||
"--allow-net",
|
"--allow-net",
|
||||||
join(Deno.cwd(), "browser-tests/fixture_server.ts"),
|
join(Deno.cwd(), "browser-tests/fixture_server.ts"),
|
||||||
String(port),
|
String(port),
|
||||||
],
|
],
|
||||||
|
env: { WEB_UX_FIXTURE_SECRET: "${WEB_UX_FIXTURE_SECRET}" },
|
||||||
readyUrl: `${baseUrl}/health`,
|
readyUrl: `${baseUrl}/health`,
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
@@ -81,15 +97,28 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
|
|||||||
outputDirectory: join(directory, "artifacts"),
|
outputDirectory: join(directory, "artifacts"),
|
||||||
runId: "multi-persona",
|
runId: "multi-persona",
|
||||||
});
|
});
|
||||||
assertEquals(manifest.status, "completed");
|
assertEquals(manifest.status, "completed-with-errors");
|
||||||
assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]);
|
assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]);
|
||||||
assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true);
|
assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true);
|
||||||
assertEquals(manifest.contactSheet.png, "contact-sheet.png");
|
assertEquals(manifest.captures[0].route.ready.kind, "selector");
|
||||||
|
assertEquals(manifest.captures[0].interactions[0].action, "wait");
|
||||||
|
assertEquals(manifest.captures[0].errorSummary, {
|
||||||
|
observed: 150,
|
||||||
|
retained: 100,
|
||||||
|
truncated: true,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
assertEquals(manifest.contactSheet.png?.bundlePath, "contact-sheet.png");
|
||||||
const runDirectory = join(directory, "artifacts", "multi-persona");
|
const runDirectory = join(directory, "artifacts", "multi-persona");
|
||||||
const reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json"));
|
const reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json"));
|
||||||
assertEquals(reviewContext.includes('"cookies"'), false);
|
assertEquals(reviewContext.includes('"cookies"'), false);
|
||||||
|
assertEquals(reviewContext.includes(fixtureSecret), false);
|
||||||
|
const processLog = await Deno.readTextFile(
|
||||||
|
join(runDirectory, "process-logs", "fixture-server.stdout.log"),
|
||||||
|
);
|
||||||
|
assertEquals(processLog.includes(fixtureSecret), false);
|
||||||
if (Deno.build.os !== "windows") {
|
if (Deno.build.os !== "windows") {
|
||||||
const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].path);
|
const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].bundlePath);
|
||||||
assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600);
|
assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600);
|
||||||
}
|
}
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
@@ -97,6 +126,8 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea
|
|||||||
TypeError,
|
TypeError,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (previousSecret === undefined) Deno.env.delete("WEB_UX_FIXTURE_SECRET");
|
||||||
|
else Deno.env.set("WEB_UX_FIXTURE_SECRET", previousSecret);
|
||||||
await Deno.remove(directory, { recursive: true });
|
await Deno.remove(directory, { recursive: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const port = Number(Deno.args[0]);
|
const port = Number(Deno.args[0]);
|
||||||
if (!Number.isInteger(port) || port <= 0) throw new Error("port is required");
|
if (!Number.isInteger(port) || port <= 0) throw new Error("port is required");
|
||||||
|
|
||||||
|
const canary = Deno.env.get("WEB_UX_FIXTURE_SECRET") ?? "";
|
||||||
|
console.log(`Authorization: Bearer ${canary}`);
|
||||||
|
|
||||||
Deno.serve({ hostname: "127.0.0.1", port }, (request) => {
|
Deno.serve({ hostname: "127.0.0.1", port }, (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname === "/health") return new Response("ok");
|
if (url.pathname === "/health") return new Response("ok");
|
||||||
@@ -11,7 +14,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, (request) => {
|
|||||||
? '<button type="button">Add repository</button>'
|
? '<button type="button">Add repository</button>'
|
||||||
: '<p role="note">Ask a Workspace owner to change repository access.</p>';
|
: '<p role="note">Ask a Workspace owner to change repository access.</p>';
|
||||||
return new Response(
|
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>`,
|
`<!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}<span data-web-ux-redact>${canary}</span><script>for(let index=0;index<150;index++)console.error('fixture error '+index)</script></section></main></body></html>`,
|
||||||
{ headers: { "content-type": "text/html; charset=utf-8" } },
|
{ headers: { "content-type": "text/html; charset=utf-8" } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,13 +15,16 @@
|
|||||||
{
|
{
|
||||||
"id": "owner",
|
"id": "owner",
|
||||||
"label": "Workspace owner",
|
"label": "Workspace owner",
|
||||||
"auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/owner.json" },
|
"auth": { "kind": "storage-state", "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/owner.json" },
|
||||||
"login": { "path": "/", "successUrl": "/w/" }
|
"login": { "path": "/", "successUrl": "/w/" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "non-owner",
|
"id": "non-owner",
|
||||||
"label": "Authenticated non-owner",
|
"label": "Authenticated non-owner",
|
||||||
"auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/non-owner.json" },
|
"auth": {
|
||||||
|
"kind": "storage-state",
|
||||||
|
"path": "${XDG_STATE_HOME}/yoi/web-ux/auth/non-owner.json"
|
||||||
|
},
|
||||||
"login": { "path": "/", "successUrl": "/w/" }
|
"login": { "path": "/", "successUrl": "/w/" }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -62,13 +62,70 @@ export async function writePrivateJson(path: string, value: unknown): Promise<vo
|
|||||||
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
|
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logicalPath(repositoryRoot: string, path: string): string {
|
export function workdirLogicalPath(repositoryRoot: string, path: string): string | null {
|
||||||
const absolute = resolve(path);
|
const absolute = resolve(path);
|
||||||
const logical = relative(repositoryRoot, absolute);
|
const logical = relative(repositoryRoot, absolute);
|
||||||
if (logical === "" || (!logical.startsWith("..") && !logical.startsWith("/"))) {
|
if (logical === "" || (!logical.startsWith("..") && !logical.startsWith("/"))) {
|
||||||
return logical || ".";
|
return logical || ".";
|
||||||
}
|
}
|
||||||
return absolute;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_ARTIFACT_EXTENSIONS = [".json", ".md", ".html", ".log", ".txt"];
|
||||||
|
|
||||||
|
async function artifactFiles(root: string): Promise<string[]> {
|
||||||
|
const files: string[] = [];
|
||||||
|
const visit = async (directory: string) => {
|
||||||
|
for await (const entry of Deno.readDir(directory)) {
|
||||||
|
const path = resolve(directory, entry.name);
|
||||||
|
if (entry.isDirectory) await visit(path);
|
||||||
|
else if (entry.isFile) files.push(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await visit(root);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertReviewBundleIsSecretFree(
|
||||||
|
root: string,
|
||||||
|
exactSecrets: string[] = [],
|
||||||
|
): Promise<void> {
|
||||||
|
const secrets = exactSecrets.filter(Boolean);
|
||||||
|
const overlapLength = Math.max(256, ...secrets.map((secret) => secret.length + 1));
|
||||||
|
for (const path of await artifactFiles(root)) {
|
||||||
|
const isText = TEXT_ARTIFACT_EXTENSIONS.some((extension) => path.endsWith(extension));
|
||||||
|
const file = await Deno.open(path, { read: true });
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let overlap = "";
|
||||||
|
try {
|
||||||
|
const buffer = new Uint8Array(64 * 1024);
|
||||||
|
while (true) {
|
||||||
|
const count = await file.read(buffer);
|
||||||
|
if (count === null) break;
|
||||||
|
const content = overlap + decoder.decode(buffer.subarray(0, count), { stream: true });
|
||||||
|
for (const secret of secrets) {
|
||||||
|
if (content.includes(secret)) {
|
||||||
|
throw new Error(
|
||||||
|
`review bundle artifact contains configured secret text: ${relative(root, path)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isText) assertBundleIsSecretFree(content, secrets);
|
||||||
|
overlap = content.slice(-overlapLength);
|
||||||
|
}
|
||||||
|
const final = overlap + decoder.decode();
|
||||||
|
for (const secret of secrets) {
|
||||||
|
if (final.includes(secret)) {
|
||||||
|
throw new Error(
|
||||||
|
`review bundle artifact contains configured secret text: ${relative(root, path)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isText) assertBundleIsSecretFree(final, secrets);
|
||||||
|
} finally {
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sha256File(path: string): Promise<string> {
|
export async function sha256File(path: string): Promise<string> {
|
||||||
|
|||||||
+134
-38
@@ -3,12 +3,14 @@ import { type Browser, chromium, type Page, type Response } from "playwright";
|
|||||||
import { validateAuthState } from "./auth_state.ts";
|
import { validateAuthState } from "./auth_state.ts";
|
||||||
import {
|
import {
|
||||||
assertBundleIsSecretFree,
|
assertBundleIsSecretFree,
|
||||||
|
assertReviewBundleIsSecretFree,
|
||||||
bounded,
|
bounded,
|
||||||
ensurePrivateDirectory,
|
ensurePrivateDirectory,
|
||||||
makePrivate,
|
makePrivate,
|
||||||
redactText,
|
redactText,
|
||||||
safeUrl,
|
safeUrl,
|
||||||
sha256File,
|
sha256File,
|
||||||
|
workdirLogicalPath,
|
||||||
} from "./artifacts.ts";
|
} from "./artifacts.ts";
|
||||||
import { type RunningProcess, startOwnedProcesses, stopOwnedProcesses } from "./processes.ts";
|
import { type RunningProcess, startOwnedProcesses, stopOwnedProcesses } from "./processes.ts";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +23,9 @@ import type {
|
|||||||
CaptureError,
|
CaptureError,
|
||||||
CaptureEvidence,
|
CaptureEvidence,
|
||||||
CapturePoint,
|
CapturePoint,
|
||||||
|
DiagnosticSummary,
|
||||||
Interaction,
|
Interaction,
|
||||||
|
InteractionEvidence,
|
||||||
Persona,
|
Persona,
|
||||||
ReadyCondition,
|
ReadyCondition,
|
||||||
ReviewContext,
|
ReviewContext,
|
||||||
@@ -43,6 +47,32 @@ export type CaptureOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SourceState = { revision: string | null; dirty: boolean | null };
|
type SourceState = { revision: string | null; dirty: boolean | null };
|
||||||
|
type ErrorCollector = { errors: CaptureError[]; observed: number; limit: number };
|
||||||
|
|
||||||
|
const CAPTURE_ERROR_LIMIT = 100;
|
||||||
|
|
||||||
|
function recordError(collector: ErrorCollector, error: CaptureError): void {
|
||||||
|
collector.observed++;
|
||||||
|
if (collector.errors.length < collector.limit) collector.errors.push(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorSummary(collector: ErrorCollector): DiagnosticSummary {
|
||||||
|
return {
|
||||||
|
observed: collector.observed,
|
||||||
|
retained: collector.errors.length,
|
||||||
|
truncated: collector.observed > collector.errors.length,
|
||||||
|
limit: collector.limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function interactionEvidence(interaction: Interaction): InteractionEvidence {
|
||||||
|
if (interaction.action === "wait") return { action: "wait", ready: interaction.ready };
|
||||||
|
if (interaction.action === "click") return { action: "click", selector: interaction.selector };
|
||||||
|
if (interaction.action === "fill") {
|
||||||
|
return { action: "fill", selector: interaction.selector, value: "[REDACTED]" };
|
||||||
|
}
|
||||||
|
return { action: "press", selector: interaction.selector, key: interaction.key };
|
||||||
|
}
|
||||||
|
|
||||||
function slug(value: string): string {
|
function slug(value: string): string {
|
||||||
return value.replaceAll(/[^a-zA-Z0-9.-]+/g, "-").replaceAll(/^-+|-+$/g, "").toLowerCase();
|
return value.replaceAll(/[^a-zA-Z0-9.-]+/g, "-").replaceAll(/^-+|-+$/g, "").toLowerCase();
|
||||||
@@ -73,6 +103,20 @@ async function sourceState(): Promise<SourceState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function repositoryRoot(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await new Deno.Command("git", {
|
||||||
|
args: ["rev-parse", "--show-toplevel"],
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "null",
|
||||||
|
}).output();
|
||||||
|
if (result.success) return resolve(new TextDecoder().decode(result.stdout).trim());
|
||||||
|
} catch {
|
||||||
|
// Fall back to the invocation directory outside a Git checkout.
|
||||||
|
}
|
||||||
|
return resolve(Deno.cwd());
|
||||||
|
}
|
||||||
|
|
||||||
function selectById<T extends { id: string }>(
|
function selectById<T extends { id: string }>(
|
||||||
values: T[],
|
values: T[],
|
||||||
requested: string[] | undefined,
|
requested: string[] | undefined,
|
||||||
@@ -162,7 +206,7 @@ export function isVisibleUiErrorText(content: string): boolean {
|
|||||||
|
|
||||||
async function collectVisibleUiErrors(
|
async function collectVisibleUiErrors(
|
||||||
page: Page,
|
page: Page,
|
||||||
errors: CaptureError[],
|
collector: ErrorCollector,
|
||||||
secrets: string[],
|
secrets: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const alerts = page.locator('[role="alert"], [aria-live="assertive"]');
|
const alerts = page.locator('[role="alert"], [aria-live="assertive"]');
|
||||||
@@ -172,8 +216,10 @@ async function collectVisibleUiErrors(
|
|||||||
const content = (await alert.innerText().catch(() => "")).trim();
|
const content = (await alert.innerText().catch(() => "")).trim();
|
||||||
if (!isVisibleUiErrorText(content)) continue;
|
if (!isVisibleUiErrorText(content)) continue;
|
||||||
const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`;
|
const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`;
|
||||||
if (!errors.some((error) => error.kind === "document" && error.message === message)) {
|
if (
|
||||||
errors.push({ kind: "document", message });
|
!collector.errors.some((error) => error.kind === "document" && error.message === message)
|
||||||
|
) {
|
||||||
|
recordError(collector, { kind: "document", message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,19 +227,24 @@ async function collectVisibleUiErrors(
|
|||||||
async function capturePoint(
|
async function capturePoint(
|
||||||
page: Page,
|
page: Page,
|
||||||
runDirectory: string,
|
runDirectory: string,
|
||||||
|
repositoryRoot: string,
|
||||||
persona: Persona,
|
persona: Persona,
|
||||||
route: RouteScenario,
|
route: RouteScenario,
|
||||||
viewport: Viewport,
|
viewport: Viewport,
|
||||||
point: CapturePoint,
|
point: CapturePoint,
|
||||||
documentResponse: Response | null,
|
documentResponse: Response | null,
|
||||||
errors: CaptureError[],
|
collector: ErrorCollector,
|
||||||
|
executedInteractions: InteractionEvidence[],
|
||||||
scenario: Scenario,
|
scenario: Scenario,
|
||||||
): Promise<CaptureEvidence> {
|
): Promise<CaptureEvidence> {
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
for (const interaction of point.interaction ?? []) await performInteraction(page, interaction);
|
for (const interaction of point.interaction ?? []) {
|
||||||
|
await performInteraction(page, interaction);
|
||||||
|
executedInteractions.push(interactionEvidence(interaction));
|
||||||
|
}
|
||||||
if (point.ready) await waitReady(page, point.ready);
|
if (point.ready) await waitReady(page, point.ready);
|
||||||
await hideRedactedSelectors(page, scenario.redact?.selectors ?? []);
|
await hideRedactedSelectors(page, scenario.redact?.selectors ?? []);
|
||||||
await collectVisibleUiErrors(page, errors, scenario.redact?.text ?? []);
|
await collectVisibleUiErrors(page, collector, scenario.redact?.text ?? []);
|
||||||
const directory = join(
|
const directory = join(
|
||||||
runDirectory,
|
runDirectory,
|
||||||
"captures",
|
"captures",
|
||||||
@@ -208,7 +259,8 @@ async function capturePoint(
|
|||||||
await makePrivate(viewportScreenshot);
|
await makePrivate(viewportScreenshot);
|
||||||
const screenshots: ScreenshotEvidence[] = [{
|
const screenshots: ScreenshotEvidence[] = [{
|
||||||
kind: "viewport",
|
kind: "viewport",
|
||||||
path: relative(runDirectory, viewportScreenshot),
|
bundlePath: relative(runDirectory, viewportScreenshot),
|
||||||
|
workdirPath: workdirLogicalPath(repositoryRoot, viewportScreenshot),
|
||||||
sha256: await sha256File(viewportScreenshot),
|
sha256: await sha256File(viewportScreenshot),
|
||||||
}];
|
}];
|
||||||
if (point.fullPage) {
|
if (point.fullPage) {
|
||||||
@@ -217,19 +269,23 @@ async function capturePoint(
|
|||||||
await makePrivate(fullPageScreenshot);
|
await makePrivate(fullPageScreenshot);
|
||||||
screenshots.push({
|
screenshots.push({
|
||||||
kind: "full-page",
|
kind: "full-page",
|
||||||
path: relative(runDirectory, fullPageScreenshot),
|
bundlePath: relative(runDirectory, fullPageScreenshot),
|
||||||
|
workdirPath: workdirLogicalPath(repositoryRoot, fullPageScreenshot),
|
||||||
sha256: await sha256File(fullPageScreenshot),
|
sha256: await sha256File(fullPageScreenshot),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let snapshotPath: string | null = null;
|
let snapshot: { bundlePath: string; workdirPath: string | null } | null = null;
|
||||||
try {
|
try {
|
||||||
const snapshot = await page.locator("body").ariaSnapshot({ timeout: 5_000 });
|
const accessibility = await page.locator("body").ariaSnapshot({ timeout: 5_000 });
|
||||||
const redacted = redactText(snapshot, scenario.redact?.text ?? []);
|
const redacted = redactText(accessibility, scenario.redact?.text ?? []);
|
||||||
const target = join(directory, "accessibility.md");
|
const target = join(directory, "accessibility.md");
|
||||||
await Deno.writeTextFile(target, redacted, { mode: 0o600 });
|
await Deno.writeTextFile(target, redacted, { mode: 0o600 });
|
||||||
snapshotPath = relative(runDirectory, target);
|
snapshot = {
|
||||||
|
bundlePath: relative(runDirectory, target),
|
||||||
|
workdirPath: workdirLogicalPath(repositoryRoot, target),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push({
|
recordError(collector, {
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
message: `accessibility snapshot failed: ${
|
message: `accessibility snapshot failed: ${
|
||||||
bounded(error instanceof Error ? error.message : String(error))
|
bounded(error instanceof Error ? error.message : String(error))
|
||||||
@@ -238,14 +294,22 @@ async function capturePoint(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
persona: { id: persona.id, label: persona.label },
|
persona: { id: persona.id, label: persona.label },
|
||||||
route: { id: route.id, path: route.path, goal: route.goal, dataState: route.dataState },
|
route: {
|
||||||
|
id: route.id,
|
||||||
|
path: route.path,
|
||||||
|
goal: route.goal,
|
||||||
|
dataState: route.dataState,
|
||||||
|
ready: route.ready,
|
||||||
|
},
|
||||||
viewport,
|
viewport,
|
||||||
theme: scenario.colorScheme ?? "light",
|
theme: scenario.colorScheme ?? "light",
|
||||||
capturePoint: { id: point.id, label: point.label },
|
capturePoint: { id: point.id, label: point.label, ready: point.ready ?? null },
|
||||||
|
interactions: [...executedInteractions],
|
||||||
document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null },
|
document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null },
|
||||||
screenshots,
|
screenshots,
|
||||||
snapshotPath,
|
snapshot,
|
||||||
errors: [...errors],
|
errors: [...collector.errors],
|
||||||
|
errorSummary: errorSummary(collector),
|
||||||
startedAt,
|
startedAt,
|
||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -267,14 +331,18 @@ function escapeHtml(value: string): string {
|
|||||||
async function createContactSheet(
|
async function createContactSheet(
|
||||||
browser: Browser,
|
browser: Browser,
|
||||||
runDirectory: string,
|
runDirectory: string,
|
||||||
|
repositoryRoot: string,
|
||||||
captures: CaptureEvidence[],
|
captures: CaptureEvidence[],
|
||||||
): Promise<{ html: string | null; png: string | null }> {
|
): Promise<{
|
||||||
|
html: { bundlePath: string; workdirPath: string | null } | null;
|
||||||
|
png: { bundlePath: string; workdirPath: string | null } | null;
|
||||||
|
}> {
|
||||||
const cells: string[] = [];
|
const cells: string[] = [];
|
||||||
for (const capture of captures) {
|
for (const capture of captures) {
|
||||||
const screenshot = capture.screenshots.find((item) => item.kind === "viewport") ??
|
const screenshot = capture.screenshots.find((item) => item.kind === "viewport") ??
|
||||||
capture.screenshots[0];
|
capture.screenshots[0];
|
||||||
if (!screenshot) continue;
|
if (!screenshot) continue;
|
||||||
const bytes = await Deno.readFile(join(runDirectory, screenshot.path));
|
const bytes = await Deno.readFile(join(runDirectory, screenshot.bundlePath));
|
||||||
cells.push(
|
cells.push(
|
||||||
`<figure><img src="${screenshotDataUrl(bytes)}"><figcaption><strong>${
|
`<figure><img src="${screenshotDataUrl(bytes)}"><figcaption><strong>${
|
||||||
escapeHtml(capture.persona.label)
|
escapeHtml(capture.persona.label)
|
||||||
@@ -305,11 +373,21 @@ async function createContactSheet(
|
|||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
return { html: relative(runDirectory, htmlPath), png: relative(runDirectory, pngPath) };
|
return {
|
||||||
|
html: {
|
||||||
|
bundlePath: relative(runDirectory, htmlPath),
|
||||||
|
workdirPath: workdirLogicalPath(repositoryRoot, htmlPath),
|
||||||
|
},
|
||||||
|
png: {
|
||||||
|
bundlePath: relative(runDirectory, pngPath),
|
||||||
|
workdirPath: workdirLogicalPath(repositoryRoot, pngPath),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
||||||
const scenarioPath = resolve(options.scenarioPath);
|
const scenarioPath = resolve(options.scenarioPath);
|
||||||
|
const repository = await repositoryRoot();
|
||||||
const scenario = await loadScenario(scenarioPath);
|
const scenario = await loadScenario(scenarioPath);
|
||||||
const baseUrl = validateBaseUrl(
|
const baseUrl = validateBaseUrl(
|
||||||
interpolateEnvironment(
|
interpolateEnvironment(
|
||||||
@@ -332,8 +410,13 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
let browser: Browser | null = null;
|
let browser: Browser | null = null;
|
||||||
let processes: RunningProcess[] = [];
|
let processes: RunningProcess[] = [];
|
||||||
const captures: CaptureEvidence[] = [];
|
const captures: CaptureEvidence[] = [];
|
||||||
const diagnostics: CaptureError[] = [];
|
const globalCollector: ErrorCollector = {
|
||||||
let contactSheet = { html: null as string | null, png: null as string | null };
|
errors: [],
|
||||||
|
observed: 0,
|
||||||
|
limit: CAPTURE_ERROR_LIMIT,
|
||||||
|
};
|
||||||
|
const diagnostics = globalCollector.errors;
|
||||||
|
let contactSheet: ReviewContext["contactSheet"] = { html: null, png: null };
|
||||||
let browserVersion = "unknown";
|
let browserVersion = "unknown";
|
||||||
let status: ReviewContext["status"] = "completed";
|
let status: ReviewContext["status"] = "completed";
|
||||||
try {
|
try {
|
||||||
@@ -362,11 +445,17 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
const routeErrors: CaptureError[] = [];
|
const routeCollector: ErrorCollector = {
|
||||||
|
errors: [],
|
||||||
|
observed: 0,
|
||||||
|
limit: CAPTURE_ERROR_LIMIT,
|
||||||
|
};
|
||||||
|
const routeErrors = routeCollector.errors;
|
||||||
|
const executedInteractions: InteractionEvidence[] = [];
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
page.on("console", (message) => {
|
page.on("console", (message) => {
|
||||||
if (message.type() === "error") {
|
if (message.type() === "error") {
|
||||||
routeErrors.push({
|
recordError(routeCollector, {
|
||||||
kind: "console",
|
kind: "console",
|
||||||
message: bounded(redactText(message.text(), secrets)),
|
message: bounded(redactText(message.text(), secrets)),
|
||||||
});
|
});
|
||||||
@@ -375,7 +464,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
page.on(
|
page.on(
|
||||||
"pageerror",
|
"pageerror",
|
||||||
(error) =>
|
(error) =>
|
||||||
routeErrors.push({
|
recordError(routeCollector, {
|
||||||
kind: "page",
|
kind: "page",
|
||||||
message: bounded(redactText(error.message, secrets)),
|
message: bounded(redactText(error.message, secrets)),
|
||||||
}),
|
}),
|
||||||
@@ -383,7 +472,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
page.on(
|
page.on(
|
||||||
"requestfailed",
|
"requestfailed",
|
||||||
(request) =>
|
(request) =>
|
||||||
routeErrors.push({
|
recordError(routeCollector, {
|
||||||
kind: "request",
|
kind: "request",
|
||||||
message: bounded(
|
message: bounded(
|
||||||
redactText(request.failure()?.errorText ?? "request failed", secrets),
|
redactText(request.failure()?.errorText ?? "request failed", secrets),
|
||||||
@@ -393,7 +482,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
);
|
);
|
||||||
page.on("response", (response) => {
|
page.on("response", (response) => {
|
||||||
if (response.status() >= 400) {
|
if (response.status() >= 400) {
|
||||||
routeErrors.push({
|
recordError(routeCollector, {
|
||||||
kind: "request",
|
kind: "request",
|
||||||
message: `HTTP ${response.status()}`,
|
message: `HTTP ${response.status()}`,
|
||||||
url: safeUrl(response.url()),
|
url: safeUrl(response.url()),
|
||||||
@@ -426,7 +515,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (response && response.status() >= 400) {
|
if (response && response.status() >= 400) {
|
||||||
routeErrors.push({
|
recordError(routeCollector, {
|
||||||
kind: "document",
|
kind: "document",
|
||||||
message: `document returned HTTP ${response.status()}`,
|
message: `document returned HTTP ${response.status()}`,
|
||||||
url: safeUrl(response.url()),
|
url: safeUrl(response.url()),
|
||||||
@@ -438,19 +527,21 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
await capturePoint(
|
await capturePoint(
|
||||||
page,
|
page,
|
||||||
runDirectory,
|
runDirectory,
|
||||||
|
repository,
|
||||||
persona,
|
persona,
|
||||||
route,
|
route,
|
||||||
viewport,
|
viewport,
|
||||||
point,
|
point,
|
||||||
response,
|
response,
|
||||||
routeErrors,
|
routeCollector,
|
||||||
|
executedInteractions,
|
||||||
scenario,
|
scenario,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status = "completed-with-errors";
|
status = "completed-with-errors";
|
||||||
routeErrors.push({
|
recordError(routeCollector, {
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
message: bounded(
|
message: bounded(
|
||||||
redactText(error instanceof Error ? error.message : String(error), secrets),
|
redactText(error instanceof Error ? error.message : String(error), secrets),
|
||||||
@@ -463,14 +554,17 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
path: route.path,
|
path: route.path,
|
||||||
goal: route.goal,
|
goal: route.goal,
|
||||||
dataState: route.dataState,
|
dataState: route.dataState,
|
||||||
|
ready: route.ready,
|
||||||
},
|
},
|
||||||
viewport,
|
viewport,
|
||||||
theme: scenario.colorScheme ?? "light",
|
theme: scenario.colorScheme ?? "light",
|
||||||
capturePoint: { id: "failed", label: "Capture failed" },
|
capturePoint: { id: "failed", label: "Capture failed", ready: null },
|
||||||
|
interactions: [...executedInteractions],
|
||||||
document: { url: safeUrl(page.url()), status: null },
|
document: { url: safeUrl(page.url()), status: null },
|
||||||
screenshots: [],
|
screenshots: [],
|
||||||
snapshotPath: null,
|
snapshot: null,
|
||||||
errors: routeErrors,
|
errors: [...routeErrors],
|
||||||
|
errorSummary: errorSummary(routeCollector),
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -483,24 +577,24 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contactSheet = await createContactSheet(browser, runDirectory, captures);
|
contactSheet = await createContactSheet(browser, runDirectory, repository, captures);
|
||||||
if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors";
|
if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status = "failed";
|
status = "failed";
|
||||||
diagnostics.push({
|
recordError(globalCollector, {
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)),
|
message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close().catch((error) =>
|
await browser.close().catch((error) =>
|
||||||
diagnostics.push({
|
recordError(globalCollector, {
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
message: `browser cleanup failed: ${bounded(String(error))}`,
|
message: `browser cleanup failed: ${bounded(String(error))}`,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
diagnostics.push(...await stopOwnedProcesses(processes));
|
for (const error of await stopOwnedProcesses(processes)) recordError(globalCollector, error);
|
||||||
}
|
}
|
||||||
if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors";
|
if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors";
|
||||||
const manifest: ReviewContext = {
|
const manifest: ReviewContext = {
|
||||||
@@ -509,7 +603,7 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
scenario: {
|
scenario: {
|
||||||
id: scenario.id,
|
id: scenario.id,
|
||||||
title: scenario.title,
|
title: scenario.title,
|
||||||
sourcePath: relative(Deno.cwd(), scenarioPath),
|
sourcePath: workdirLogicalPath(repository, scenarioPath),
|
||||||
},
|
},
|
||||||
source: await sourceState(),
|
source: await sourceState(),
|
||||||
baseUrl: safeUrl(baseUrl),
|
baseUrl: safeUrl(baseUrl),
|
||||||
@@ -524,10 +618,12 @@ export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
|||||||
captures,
|
captures,
|
||||||
contactSheet,
|
contactSheet,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
diagnosticSummary: errorSummary(globalCollector),
|
||||||
};
|
};
|
||||||
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||||
assertBundleIsSecretFree(serialized, secrets);
|
assertBundleIsSecretFree(serialized, secrets);
|
||||||
await Deno.writeTextFile(join(runDirectory, "review-context.json"), serialized, { mode: 0o600 });
|
await Deno.writeTextFile(join(runDirectory, "review-context.json"), serialized, { mode: 0o600 });
|
||||||
|
await assertReviewBundleIsSecretFree(runDirectory, secrets);
|
||||||
if (status === "failed") {
|
if (status === "failed") {
|
||||||
throw new Error(`capture failed; inspect ${join(runDirectory, "review-context.json")}`);
|
throw new Error(`capture failed; inspect ${join(runDirectory, "review-context.json")}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ async function readManifest(path: string): Promise<ReviewContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function viewportScreenshot(capture: CaptureEvidence): string | null {
|
function viewportScreenshot(capture: CaptureEvidence): string | null {
|
||||||
return capture.screenshots.find((item) => item.kind === "viewport")?.path ??
|
return capture.screenshots.find((item) => item.kind === "viewport")?.bundlePath ??
|
||||||
capture.screenshots[0]?.path ?? null;
|
capture.screenshots[0]?.bundlePath ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataUrl(bytes: Uint8Array): string {
|
function dataUrl(bytes: Uint8Array): string {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { dirname, isAbsolute, resolve } from "@std/path";
|
import { dirname, isAbsolute, resolve } from "@std/path";
|
||||||
import { bounded, redactText } from "./artifacts.ts";
|
import { bounded, redactText, writePrivateJson } from "./artifacts.ts";
|
||||||
import type { CaptureError, OwnedProcess } from "./types.ts";
|
import type { CaptureError, OwnedProcess } from "./types.ts";
|
||||||
|
|
||||||
|
export const PROCESS_LOG_BYTE_LIMIT = 1024 * 1024;
|
||||||
|
const PROCESS_STOP_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
export type RunningProcess = {
|
export type RunningProcess = {
|
||||||
id: string;
|
id: string;
|
||||||
pid: number;
|
pid: number;
|
||||||
child: Deno.ChildProcess;
|
child: Deno.ChildProcess;
|
||||||
|
status: Promise<Deno.CommandStatus>;
|
||||||
output: Promise<void>;
|
output: Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,15 +24,45 @@ async function appendOutput(
|
|||||||
write: true,
|
write: true,
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const overlapCharacters = Math.max(512, ...secrets.map((secret) => secret.length + 128));
|
||||||
|
let pending = "";
|
||||||
|
let bytesObserved = 0;
|
||||||
|
let bytesWritten = 0;
|
||||||
|
let truncated = false;
|
||||||
|
const writeRedacted = async (value: string) => {
|
||||||
|
const encoded = encoder.encode(redactText(value, secrets));
|
||||||
|
const remaining = Math.max(0, PROCESS_LOG_BYTE_LIMIT - bytesWritten);
|
||||||
|
if (encoded.length > remaining) truncated = true;
|
||||||
|
if (remaining > 0) {
|
||||||
|
const output = encoded.subarray(0, remaining);
|
||||||
|
await file.write(output);
|
||||||
|
bytesWritten += output.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
await file.write(new TextEncoder().encode(redactText(value, secrets)));
|
bytesObserved += encoder.encode(value).length;
|
||||||
|
pending += value;
|
||||||
|
if (pending.length > overlapCharacters * 2) {
|
||||||
|
const splitAt = pending.length - overlapCharacters;
|
||||||
|
await writeRedacted(pending.slice(0, splitAt));
|
||||||
|
pending = pending.slice(splitAt);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
await writeRedacted(pending);
|
||||||
} finally {
|
} finally {
|
||||||
file.close();
|
file.close();
|
||||||
|
await writePrivateJson(`${destination}.meta.json`, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
byteLimit: PROCESS_LOG_BYTE_LIMIT,
|
||||||
|
bytesObserved,
|
||||||
|
bytesWritten,
|
||||||
|
truncated,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,17 +117,19 @@ export async function startOwnedProcesses(
|
|||||||
`${logsDirectory}/${specification.id}.stderr.log`,
|
`${logsDirectory}/${specification.id}.stderr.log`,
|
||||||
secrets,
|
secrets,
|
||||||
);
|
);
|
||||||
|
const status = child.status;
|
||||||
const process = {
|
const process = {
|
||||||
id: specification.id,
|
id: specification.id,
|
||||||
pid: child.pid,
|
pid: child.pid,
|
||||||
child,
|
child,
|
||||||
|
status,
|
||||||
output: Promise.all([stdout, stderr]).then(() => undefined),
|
output: Promise.all([stdout, stderr]).then(() => undefined),
|
||||||
};
|
};
|
||||||
running.push(process);
|
running.push(process);
|
||||||
if (specification.readyUrl) {
|
if (specification.readyUrl) {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
waitForReady(specification.readyUrl, specification.readyTimeoutMs ?? 30_000),
|
waitForReady(specification.readyUrl, specification.readyTimeoutMs ?? 30_000),
|
||||||
child.status.then((status) => {
|
status.then((status) => {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`owned process ${specification.id} exited before readiness: ${status.code}`,
|
`owned process ${specification.id} exited before readiness: ${status.code}`,
|
||||||
);
|
);
|
||||||
@@ -145,23 +181,64 @@ function tryKill(pid: number, signal: Deno.Signal): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function livePids(pids: number[]): Promise<number[]> {
|
||||||
|
if (Deno.build.os === "windows") return [];
|
||||||
|
if (pids.length === 0) return [];
|
||||||
|
try {
|
||||||
|
const result = await new Deno.Command("ps", {
|
||||||
|
args: ["-o", "pid=", "-p", pids.join(",")],
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "null",
|
||||||
|
}).output();
|
||||||
|
if (!result.success && result.code !== 1) return pids;
|
||||||
|
const live = new Set(
|
||||||
|
new TextDecoder().decode(result.stdout).trim().split(/\s+/).map(Number).filter(
|
||||||
|
Number.isFinite,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return pids.filter((pid) => live.has(pid));
|
||||||
|
} catch {
|
||||||
|
return pids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPidsToExit(pids: number[], timeoutMs: number): Promise<number[]> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let live = await livePids(pids);
|
||||||
|
while (live.length > 0 && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
live = await livePids(live);
|
||||||
|
}
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
export async function stopOwnedProcesses(processes: RunningProcess[]): Promise<CaptureError[]> {
|
export async function stopOwnedProcesses(processes: RunningProcess[]): Promise<CaptureError[]> {
|
||||||
const diagnostics: CaptureError[] = [];
|
const diagnostics: CaptureError[] = [];
|
||||||
for (const process of [...processes].reverse()) {
|
for (const process of [...processes].reverse()) {
|
||||||
try {
|
try {
|
||||||
for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGTERM");
|
const descendants = await descendantPids(process.pid);
|
||||||
tryKill(process.pid, "SIGTERM");
|
tryKill(process.pid, "SIGTERM");
|
||||||
|
for (const pid of descendants) tryKill(pid, "SIGTERM");
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
const exited = await Promise.race([
|
const [parentExited, liveDescendants] = await Promise.all([
|
||||||
process.child.status.then(() => true),
|
Promise.race([
|
||||||
|
process.status.then(() => true),
|
||||||
new Promise<boolean>((resolve) => {
|
new Promise<boolean>((resolve) => {
|
||||||
timer = setTimeout(() => resolve(false), 3_000);
|
timer = setTimeout(() => resolve(false), PROCESS_STOP_TIMEOUT_MS);
|
||||||
}),
|
}),
|
||||||
]).finally(() => clearTimeout(timer));
|
]).finally(() => clearTimeout(timer)),
|
||||||
if (!exited) {
|
waitForPidsToExit(descendants, PROCESS_STOP_TIMEOUT_MS),
|
||||||
for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGKILL");
|
]);
|
||||||
|
if (!parentExited || liveDescendants.length > 0) {
|
||||||
|
const lateDescendants = await descendantPids(process.pid);
|
||||||
|
const forceTargets = [...new Set([...liveDescendants, ...lateDescendants])];
|
||||||
|
for (const pid of forceTargets) tryKill(pid, "SIGKILL");
|
||||||
tryKill(process.pid, "SIGKILL");
|
tryKill(process.pid, "SIGKILL");
|
||||||
await process.child.status;
|
await process.status;
|
||||||
|
const survivors = await waitForPidsToExit(forceTargets, 1_000);
|
||||||
|
if (survivors.length > 0) {
|
||||||
|
throw new Error(`descendant processes did not exit: ${survivors.join(",")}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await process.output;
|
await process.output;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ function parseCapturePoint(value: unknown, at: string): CapturePoint {
|
|||||||
if (source.ready !== undefined) result.ready = parseReady(source.ready, `${at}.ready`);
|
if (source.ready !== undefined) result.ready = parseReady(source.ready, `${at}.ready`);
|
||||||
if (source.interaction !== undefined) {
|
if (source.interaction !== undefined) {
|
||||||
if (!Array.isArray(source.interaction)) throw new Error(`${at}.interaction must be an array`);
|
if (!Array.isArray(source.interaction)) throw new Error(`${at}.interaction must be an array`);
|
||||||
|
if (source.interaction.length > 20) {
|
||||||
|
throw new Error(`${at}.interaction must not exceed 20 items`);
|
||||||
|
}
|
||||||
result.interaction = source.interaction.map((raw, index) => {
|
result.interaction = source.interaction.map((raw, index) => {
|
||||||
const action = record(raw, `${at}.interaction[${index}]`);
|
const action = record(raw, `${at}.interaction[${index}]`);
|
||||||
const name = text(action.action, `${at}.interaction[${index}].action`);
|
const name = text(action.action, `${at}.interaction[${index}].action`);
|
||||||
@@ -148,6 +151,9 @@ function parseRoute(value: unknown, at: string): RouteScenario {
|
|||||||
if (!Array.isArray(source.capturePoints) || source.capturePoints.length === 0) {
|
if (!Array.isArray(source.capturePoints) || source.capturePoints.length === 0) {
|
||||||
throw new Error(`${at}.capturePoints must have at least one item`);
|
throw new Error(`${at}.capturePoints must have at least one item`);
|
||||||
}
|
}
|
||||||
|
if (source.capturePoints.length > 12) {
|
||||||
|
throw new Error(`${at}.capturePoints must not exceed 12 items`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: identifier(source.id, `${at}.id`),
|
id: identifier(source.id, `${at}.id`),
|
||||||
label: text(source.label, `${at}.label`),
|
label: text(source.label, `${at}.label`),
|
||||||
@@ -201,12 +207,15 @@ export async function loadScenario(sourcePath: string): Promise<Scenario> {
|
|||||||
if (!Array.isArray(source.personas) || source.personas.length === 0) {
|
if (!Array.isArray(source.personas) || source.personas.length === 0) {
|
||||||
throw new Error("scenario.personas must have at least one item");
|
throw new Error("scenario.personas must have at least one item");
|
||||||
}
|
}
|
||||||
|
if (source.personas.length > 8) throw new Error("scenario.personas must not exceed 8 items");
|
||||||
if (!Array.isArray(source.viewports) || source.viewports.length === 0) {
|
if (!Array.isArray(source.viewports) || source.viewports.length === 0) {
|
||||||
throw new Error("scenario.viewports must have at least one item");
|
throw new Error("scenario.viewports must have at least one item");
|
||||||
}
|
}
|
||||||
|
if (source.viewports.length > 8) throw new Error("scenario.viewports must not exceed 8 items");
|
||||||
if (!Array.isArray(source.routes) || source.routes.length === 0) {
|
if (!Array.isArray(source.routes) || source.routes.length === 0) {
|
||||||
throw new Error("scenario.routes must have at least one item");
|
throw new Error("scenario.routes must have at least one item");
|
||||||
}
|
}
|
||||||
|
if (source.routes.length > 40) throw new Error("scenario.routes must not exceed 40 items");
|
||||||
const personas = source.personas.map((value, index) =>
|
const personas = source.personas.map((value, index) =>
|
||||||
parsePersona(value, `scenario.personas[${index}]`)
|
parsePersona(value, `scenario.personas[${index}]`)
|
||||||
);
|
);
|
||||||
@@ -239,6 +248,7 @@ export async function loadScenario(sourcePath: string): Promise<Scenario> {
|
|||||||
};
|
};
|
||||||
if (source.processes !== undefined) {
|
if (source.processes !== undefined) {
|
||||||
if (!Array.isArray(source.processes)) throw new Error("scenario.processes must be an array");
|
if (!Array.isArray(source.processes)) throw new Error("scenario.processes must be an array");
|
||||||
|
if (source.processes.length > 8) throw new Error("scenario.processes must not exceed 8 items");
|
||||||
scenario.processes = source.processes.map((value, index) => {
|
scenario.processes = source.processes.map((value, index) => {
|
||||||
const at = `scenario.processes[${index}]`;
|
const at = `scenario.processes[${index}]`;
|
||||||
const process = record(value, at);
|
const process = record(value, at);
|
||||||
@@ -266,5 +276,10 @@ export async function loadScenario(sourcePath: string): Promise<Scenario> {
|
|||||||
uniqueIds(personas, "scenario.personas");
|
uniqueIds(personas, "scenario.personas");
|
||||||
uniqueIds(routes, "scenario.routes");
|
uniqueIds(routes, "scenario.routes");
|
||||||
for (const route of routes) uniqueIds(route.capturePoints, `route ${route.id} capturePoints`);
|
for (const route of routes) uniqueIds(route.capturePoints, `route ${route.id} capturePoints`);
|
||||||
|
const captureCount = personas.length * scenario.viewports.length *
|
||||||
|
routes.reduce((total, route) => total + route.capturePoints.length, 0);
|
||||||
|
if (captureCount > 200) {
|
||||||
|
throw new Error(`scenario capture matrix must not exceed 200 items (received ${captureCount})`);
|
||||||
|
}
|
||||||
return scenario;
|
return scenario;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,22 +80,51 @@ export type CaptureError = {
|
|||||||
status?: number;
|
status?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScreenshotEvidence = {
|
export type ArtifactReference = {
|
||||||
|
bundlePath: string;
|
||||||
|
workdirPath: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScreenshotEvidence = ArtifactReference & {
|
||||||
kind: "viewport" | "full-page";
|
kind: "viewport" | "full-page";
|
||||||
path: string;
|
|
||||||
sha256: string;
|
sha256: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InteractionEvidence =
|
||||||
|
| { action: "click"; selector: string }
|
||||||
|
| { action: "fill"; selector: string; value: "[REDACTED]" }
|
||||||
|
| { action: "press"; selector: string; key: string }
|
||||||
|
| { action: "wait"; ready: ReadyCondition };
|
||||||
|
|
||||||
|
export type DiagnosticSummary = {
|
||||||
|
observed: number;
|
||||||
|
retained: number;
|
||||||
|
truncated: boolean;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type CaptureEvidence = {
|
export type CaptureEvidence = {
|
||||||
persona: { id: string; label: string };
|
persona: { id: string; label: string };
|
||||||
route: { id: string; path: string; goal: string; dataState: string };
|
route: {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
goal: string;
|
||||||
|
dataState: string;
|
||||||
|
ready: ReadyCondition;
|
||||||
|
};
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
theme: string;
|
theme: string;
|
||||||
capturePoint: { id: string; label: string };
|
capturePoint: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
ready: ReadyCondition | null;
|
||||||
|
};
|
||||||
|
interactions: InteractionEvidence[];
|
||||||
document: { url: string; status: number | null };
|
document: { url: string; status: number | null };
|
||||||
screenshots: ScreenshotEvidence[];
|
screenshots: ScreenshotEvidence[];
|
||||||
snapshotPath: string | null;
|
snapshot: ArtifactReference | null;
|
||||||
errors: CaptureError[];
|
errors: CaptureError[];
|
||||||
|
errorSummary: DiagnosticSummary;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
finishedAt: string;
|
finishedAt: string;
|
||||||
};
|
};
|
||||||
@@ -103,7 +132,7 @@ export type CaptureEvidence = {
|
|||||||
export type ReviewContext = {
|
export type ReviewContext = {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
runId: string;
|
runId: string;
|
||||||
scenario: { id: string; title: string; sourcePath: string };
|
scenario: { id: string; title: string; sourcePath: string | null };
|
||||||
source: { revision: string | null; dirty: boolean | null };
|
source: { revision: string | null; dirty: boolean | null };
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
browser: { name: "chromium"; version: string };
|
browser: { name: "chromium"; version: string };
|
||||||
@@ -111,6 +140,10 @@ export type ReviewContext = {
|
|||||||
status: "completed" | "completed-with-errors" | "failed";
|
status: "completed" | "completed-with-errors" | "failed";
|
||||||
filters: { personas: string[]; routes: string[]; viewports: string[] };
|
filters: { personas: string[]; routes: string[]; viewports: string[] };
|
||||||
captures: CaptureEvidence[];
|
captures: CaptureEvidence[];
|
||||||
contactSheet: { html: string | null; png: string | null };
|
contactSheet: {
|
||||||
|
html: ArtifactReference | null;
|
||||||
|
png: ArtifactReference | null;
|
||||||
|
};
|
||||||
diagnostics: CaptureError[];
|
diagnostics: CaptureError[];
|
||||||
|
diagnosticSummary: DiagnosticSummary;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
safeUrl,
|
safeUrl,
|
||||||
writePrivateJson,
|
writePrivateJson,
|
||||||
} from "../src/artifacts.ts";
|
} from "../src/artifacts.ts";
|
||||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
import {
|
||||||
|
PROCESS_LOG_BYTE_LIMIT,
|
||||||
|
startOwnedProcesses,
|
||||||
|
stopOwnedProcesses,
|
||||||
|
} from "../src/processes.ts";
|
||||||
|
|
||||||
Deno.test("redaction removes common credentials and query values", () => {
|
Deno.test("redaction removes common credentials and query values", () => {
|
||||||
const redacted = redactText(
|
const redacted = redactText(
|
||||||
@@ -69,6 +73,99 @@ Deno.test("owned process is terminated and its logs are redacted", async () => {
|
|||||||
const log = await Deno.readTextFile(logPath);
|
const log = await Deno.readTextFile(logPath);
|
||||||
assertEquals(log.includes("secret-value"), false);
|
assertEquals(log.includes("secret-value"), false);
|
||||||
assertStringIncludes(log, "[REDACTED]");
|
assertStringIncludes(log, "[REDACTED]");
|
||||||
|
const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`));
|
||||||
|
assertEquals(metadata.truncated, false);
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(directory, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("owned process logs stop at the byte limit and record truncation", async () => {
|
||||||
|
const directory = await Deno.makeTempDir();
|
||||||
|
const scenario = join(directory, "scenario.json");
|
||||||
|
await Deno.writeTextFile(scenario, "{}");
|
||||||
|
try {
|
||||||
|
const processes = await startOwnedProcesses(
|
||||||
|
[{
|
||||||
|
id: "large-output",
|
||||||
|
command: Deno.execPath(),
|
||||||
|
args: [
|
||||||
|
"eval",
|
||||||
|
`console.log("x".repeat(${
|
||||||
|
PROCESS_LOG_BYTE_LIMIT + 32_768
|
||||||
|
})); setInterval(() => {}, 1000)`,
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
scenario,
|
||||||
|
join(directory, "logs"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const logPath = join(directory, "logs", "large-output.stdout.log");
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
try {
|
||||||
|
if ((await Deno.stat(logPath)).size >= PROCESS_LOG_BYTE_LIMIT) break;
|
||||||
|
} catch {
|
||||||
|
// The output pump creates the file asynchronously.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
assertEquals(await stopOwnedProcesses(processes), []);
|
||||||
|
assertEquals((await Deno.stat(logPath)).size, PROCESS_LOG_BYTE_LIMIT);
|
||||||
|
const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`));
|
||||||
|
assertEquals(metadata.byteLimit, PROCESS_LOG_BYTE_LIMIT);
|
||||||
|
assertEquals(metadata.truncated, true);
|
||||||
|
assertEquals(metadata.bytesWritten, PROCESS_LOG_BYTE_LIMIT);
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(directory, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("forced cleanup terminates a TERM-resistant descendant", async () => {
|
||||||
|
if (Deno.build.os === "windows") return;
|
||||||
|
const directory = await Deno.makeTempDir();
|
||||||
|
const scenario = join(directory, "scenario.json");
|
||||||
|
const childPidPath = join(directory, "child.pid");
|
||||||
|
await Deno.writeTextFile(scenario, "{}");
|
||||||
|
try {
|
||||||
|
const childProgram = 'Deno.addSignalListener("SIGTERM", () => {}); setInterval(() => {}, 1000)';
|
||||||
|
const parentProgram = `
|
||||||
|
const child = new Deno.Command(Deno.execPath(), {
|
||||||
|
args: ["eval", ${JSON.stringify(childProgram)}],
|
||||||
|
stdout: "null",
|
||||||
|
stderr: "null"
|
||||||
|
}).spawn();
|
||||||
|
Deno.writeTextFileSync(Deno.args[0], String(child.pid));
|
||||||
|
Deno.addSignalListener("SIGTERM", () => {});
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`;
|
||||||
|
const processes = await startOwnedProcesses(
|
||||||
|
[{
|
||||||
|
id: "process-tree",
|
||||||
|
command: Deno.execPath(),
|
||||||
|
args: ["eval", parentProgram, childPidPath],
|
||||||
|
}],
|
||||||
|
scenario,
|
||||||
|
join(directory, "logs"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let childPid = 0;
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
try {
|
||||||
|
childPid = Number(await Deno.readTextFile(childPidPath));
|
||||||
|
if (childPid > 0) break;
|
||||||
|
} catch {
|
||||||
|
// The fixture publishes its descendant PID after spawn.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
assertEquals(childPid > 0, true);
|
||||||
|
assertEquals(await stopOwnedProcesses(processes), []);
|
||||||
|
const status = await new Deno.Command("ps", {
|
||||||
|
args: ["-p", String(childPid), "-o", "pid="],
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "null",
|
||||||
|
}).output();
|
||||||
|
assertEquals(new TextDecoder().decode(status.stdout).trim(), "");
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(directory, { recursive: true });
|
await Deno.remove(directory, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
|
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
|
||||||
import { join } from "@std/path";
|
import { join } from "@std/path";
|
||||||
import { cleanup } from "../src/lifecycle.ts";
|
import { cleanup } from "../src/lifecycle.ts";
|
||||||
import { interpolateEnvironment, loadScenario, validateBaseUrl } from "../src/scenario.ts";
|
import {
|
||||||
|
interpolateEnvironment,
|
||||||
|
loadScenario,
|
||||||
|
resolveScenarioPath,
|
||||||
|
validateBaseUrl,
|
||||||
|
} from "../src/scenario.ts";
|
||||||
|
|
||||||
function minimalScenario(extra = ""): string {
|
function minimalScenario(extra = ""): string {
|
||||||
return `{
|
return `{
|
||||||
@@ -77,6 +82,16 @@ Deno.test("environment interpolation fails closed", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("committed auth profiles resolve outside the repository", async () => {
|
||||||
|
const source = "scenarios/workspace-control-plane.json";
|
||||||
|
const scenario = await loadScenario(source);
|
||||||
|
for (const persona of scenario.personas) {
|
||||||
|
if (persona.auth.kind !== "storage-state") continue;
|
||||||
|
const statePath = resolveScenarioPath(source, persona.auth.path);
|
||||||
|
assertEquals(statePath.startsWith(Deno.cwd()), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("cleanup removes only complete review bundles beyond retention", async () => {
|
Deno.test("cleanup removes only complete review bundles beyond retention", async () => {
|
||||||
const directory = await Deno.makeTempDir();
|
const directory = await Deno.makeTempDir();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user