Merge remote-tracking branch 'origin/develop' into work/T-578-durable-workdir-removal

This commit is contained in:
2026-09-02 00:01:26 +09:00
29 changed files with 4119 additions and 70 deletions
+4
View File
@@ -4,11 +4,15 @@ pkgs.mkShell {
nixfmt
deno
git
playwright-driver.browsers
rustc
cargo
pkgs.sccache
];
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
# sccache is additive to Cargo's shared build-dir, so keep its disk usage bounded.
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
SCCACHE_CACHE_SIZE = "5G";
+191
View File
@@ -0,0 +1,191 @@
# Web UX inspection workbench
`tools/web-ux` is a development-only Playwright workbench for repeatable visual inspection of the
real Web Workspace. It does not add a Yoi product Skill, Flow, Runtime capability, or browser
automation route.
The workbench produces a **review context bundle** rather than treating a screenshot as evidence by
itself. Every capture records the persona, route, viewport, theme, intended user goal, expected data
state, sanitized document URL/status, console/page/request failures, screenshot hashes, an
accessibility snapshot, source revision, and browser version.
## Environment
Enter the repository dev shell. The shell supplies the Nix-pinned Chromium build and sets
`PLAYWRIGHT_BROWSERS_PATH`; Playwright does not download a browser at runtime.
```sh
nix develop
cd tools/web-ux
deno task check
deno task test
deno task test:browser
```
`test:browser` starts a deterministic fixture server owned by the test, captures distinct owner and
non-owner contexts, verifies the review bundle, and proves server/browser cleanup. It must run
inside `nix develop` so it uses the pinned browser.
The npm Playwright version in `deno.json` must match `pkgs.playwright-driver.version` in the pinned
Nixpkgs input. Update both as one toolchain change.
## Scenario contract
Scenarios are reviewed JSON files under `scenarios/`. A scenario fixes:
- personas and whether each uses an isolated anonymous context or a local Playwright storage-state
file;
- explicit routes and user goals;
- expected data state, viewports, theme, locale, timezone, and reduced-motion mode;
- an explicit readiness condition for every route and optional interaction/capture-point conditions;
- selectors and exact environment-derived text that must be redacted;
- optional processes owned by the capture command, including an HTTP readiness URL.
`${UPPER_CASE_ENV}` values are expanded at runtime. URLs with embedded credentials are rejected.
Route readiness is bounded and retried twice; it never relies on a fixed sleep. `network-idle` is
available but should be used only for screens whose contract actually reaches idle. Prefer a stable
screen-owned selector.
`workspace-control-plane.json` expects:
```sh
export WEB_UX_BASE_URL='http://127.0.0.1:5173'
export WORKSPACE_ID='<workspace-id>'
export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
```
## Authentication fixtures
Authentication state is local sensitive material stored under `$XDG_STATE_HOME/yoi/web-ux/auth/`,
outside the Repository and Workdir. Files are written with mode `0600`, state contents are never
copied into a review bundle, and the CLI never prints cookies or credentials. Each profile has a
sidecar binding it to the exact persona and base URL origin with a 12-hour default expiry. Capture
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:
```sh
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner
```
The command opens Chromium at the configured login route, waits up to five minutes for the
scenario's success URL, saves `storageState`, and closes the browser in `finally`. Repeat for
`non-owner` using a real account with that permission projection.
A test fixture may already provide Playwright-compatible `{ cookies, origins }` state. Import it
without putting its value on the command line:
```sh
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner \
--import-state /private/path/owner-state.json \
--expires-in-hours 8
```
Delete both the profile and its metadata when it is no longer needed:
```sh
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner \
--delete
```
Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a
scenario, process arguments, a Repository URL, or `redact.text`. `redact.text` is only a final
defense for a secret already supplied through an environment-owned fixture; it is not a credential
transport.
## Capture and inspect
Capture a stable multi-persona bundle:
```sh
deno task web-ux capture \
--scenario scenarios/workspace-control-plane.json \
--output ../../target/web-ux \
--run-id before-change
```
Use filters for a bounded feedback loop:
```sh
deno task web-ux capture \
--scenario scenarios/workspace-control-plane.json \
--output ../../target/web-ux \
--run-id ticket-list-after \
--personas owner,non-owner \
--routes tickets \
--viewports desktop
```
The command exits `2` when it produced evidence but observed UI/tool errors, and exits `1` when
capture itself failed. It continues other route/persona captures after a bounded route failure.
Inspect:
- `review-context.json` for the exact context, hashes, HTTP status, retained/truncated diagnostic
counts, route and capture-point readiness, and the redacted interaction sequence;
- `contact-sheet.png` through its manifest `workdirPath` with an image-capable reviewer for
composition, hierarchy, density, clipping, empty/error states, and permission-specific
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
concrete findings, fix them, recapture under the same persona/route/viewport filters, and inspect
the new evidence. Playwright success alone is not visual acceptance.
## Compare before and after
```sh
deno task web-ux compare \
--before ../../target/web-ux/before-change/review-context.json \
--after ../../target/web-ux/after-change/review-context.json \
--output ../../target/web-ux/before-vs-after
```
`comparison.html` and `comparison.png` show before, after, and pixel diff side by side.
`comparison.json` records changed-pixel counts, dimension mismatches, unmatched capture keys, and
diff hashes. Pixel differences are orientation evidence, not a correctness verdict; explain expected
animation/font/data changes and inspect the actual UI.
Capture keys are stable across runs: `persona / route / viewport / capture-point`. Keep those
identities unchanged when comparing the same user task.
## Process and artifact cleanup
The capture command owns only processes declared in its scenario. It starts them without a shell,
records bounded/redacted output, and terminates the process and descendants on success, capture
failure, or interruption observed by the command. It never stops an existing Yoi Server or Runtime
that it did not start.
Old complete review bundles can be removed without touching auth state or arbitrary directories:
```sh
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 --dry-run
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14
```
Cleanup recognizes only directories containing `review-context.json`. The repository `target/` tree
is ignored by Git, while authentication state remains outside the repository. `capture` defaults to
`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the
approved immutable artifact channel when durable review evidence is required.
## Adding a scenario
1. Name the concrete user task and expected data state; do not write “looks correct”.
2. Use the smallest persona/route/viewport matrix that proves the intended contract, including
owner/non-owner/anonymous boundaries when permissions affect composition.
3. Choose a screen-owned readiness selector or response. Avoid arbitrary sleeps.
4. Add capture points only for meaningful visual states (initial, expanded detail, error, empty, and
so on).
5. Mark sensitive DOM regions with `[data-web-ux-redact]` or scenario selectors; never use review
artifacts to transport secrets.
6. Run `deno task check`, `deno task test`, one real capture, and inspect `contact-sheet.png` plus
`review-context.json`.
@@ -0,0 +1,133 @@
import { assertEquals, assertRejects } from "@std/assert";
import { join } from "@std/path";
import { writeAuthMetadata } from "../src/auth_state.ts";
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 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 baseUrl = `http://127.0.0.1:${port}`;
try {
const authDirectory = join(directory, "auth");
await Deno.mkdir(authDirectory);
for (const persona of ["owner", "non-owner"]) {
const storageState = join(authDirectory, `${persona}.json`);
await Deno.writeTextFile(
storageState,
JSON.stringify({
cookies: [{
name: "persona",
value: persona,
domain: "127.0.0.1",
path: "/",
expires: -1,
httpOnly: true,
secure: false,
sameSite: "Lax",
}],
origins: [],
}),
);
await writeAuthMetadata(storageState, persona, baseUrl, 1);
}
const scenarioPath = join(directory, "scenario.json");
await Deno.writeTextFile(
scenarioPath,
JSON.stringify({
schemaVersion: 1,
id: "browser-smoke",
title: "Browser smoke",
baseUrl,
redact: {
selectors: ["[data-web-ux-redact]"],
text: ["${WEB_UX_FIXTURE_SECRET}"],
},
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",
interaction: [{
action: "wait",
ready: { kind: "selector", selector: "h1" },
}],
}],
}],
processes: [{
id: "fixture-server",
command: Deno.execPath(),
args: [
"run",
"--allow-env",
"--allow-net",
join(Deno.cwd(), "browser-tests/fixture_server.ts"),
String(port),
],
env: { WEB_UX_FIXTURE_SECRET: "${WEB_UX_FIXTURE_SECRET}" },
readyUrl: `${baseUrl}/health`,
}],
}),
);
const manifest = await capture({
scenarioPath,
outputDirectory: join(directory, "artifacts"),
runId: "multi-persona",
});
assertEquals(manifest.status, "completed-with-errors");
assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]);
assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true);
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 reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json"));
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") {
const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].bundlePath);
assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600);
}
await assertRejects(
() => fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(500) }),
TypeError,
);
} 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 });
}
});
@@ -0,0 +1,20 @@
const port = Number(Deno.args[0]);
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) => {
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}<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" } },
);
});
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys
import { dirname, fromFileUrl, resolve } from "@std/path";
import { authenticate, cleanup } from "./src/lifecycle.ts";
import { capture, describeCapture } from "./src/capture.ts";
import { compare } from "./src/compare.ts";
const DEFAULT_OUTPUT = resolve(dirname(fromFileUrl(import.meta.url)), "../..", "target/web-ux");
const HELP = `Web UX inspection workbench
Usage:
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--expires-in-hours <hours>] [--headless]
deno task web-ux auth --scenario <file> --persona <id> --delete
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", "expires-in-hours"],
["headless", "delete"],
);
const deleting = args.flags.has("delete");
if (deleting && optional(args, "import-state")) {
throw new Error("--delete cannot be combined with --import-state");
}
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"),
expiresInHours: integer(args, "expires-in-hours"),
delete: deleting,
headless: args.flags.has("headless"),
});
console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`);
return 0;
}
if (args.command === "capture") {
rejectUnknown(args, [
"scenario",
"output",
"base-url",
"run-id",
"personas",
"routes",
"viewports",
], ["headed"]);
const outputDirectory = optional(args, "output") ?? DEFAULT_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);
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"lock": true,
"imports": {
"@std/assert": "jsr:@std/assert@1.0.19",
"@std/path": "jsr:@std/path@1.1.4",
"pixelmatch": "npm:pixelmatch@7.1.0",
"playwright": "npm:playwright@1.59.1",
"pngjs": "npm:pngjs@7.0.0"
},
"tasks": {
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts"
},
"fmt": {
"lineWidth": 100
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@1.0.19": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/path@1.1.4": "1.1.4",
"npm:pixelmatch@7.1.0": "7.1.0",
"npm:playwright@1.59.1": "1.59.1",
"npm:pngjs@7.0.0": "7.0.0"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
"scripts": true
},
"pixelmatch@7.1.0": {
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dependencies": [
"pngjs"
],
"bin": true
},
"playwright-core@1.59.1": {
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"bin": true
},
"playwright@1.59.1": {
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dependencies": [
"playwright-core"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
},
"pngjs@7.0.0": {
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1.0.19",
"jsr:@std/path@1.1.4",
"npm:pixelmatch@7.1.0",
"npm:playwright@1.59.1",
"npm:pngjs@7.0.0"
]
}
}
@@ -0,0 +1,37 @@
{
"schemaVersion": 1,
"id": "anonymous-entry",
"title": "Anonymous entry and authentication review",
"baseUrl": "${WEB_UX_BASE_URL}",
"locale": "en-US",
"timezone": "UTC",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{ "id": "anonymous", "label": "Anonymous visitor", "auth": { "kind": "anonymous" } }
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 },
{ "label": "mobile", "width": 390, "height": 844, "deviceScaleFactor": 1 }
],
"routes": [
{
"id": "entry",
"label": "Authentication entry",
"path": "/",
"goal": "Understand the product and begin authentication without seeing Workspace-private content.",
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
"capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }]
},
{
"id": "account",
"label": "Account entry",
"path": "/account",
"goal": "Understand current authentication state and the available account action without Workspace-private content.",
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
"capturePoints": [{ "id": "initial", "label": "Anonymous account screen", "fullPage": true }]
}
]
}
@@ -0,0 +1,93 @@
{
"schemaVersion": 1,
"id": "workspace-control-plane",
"title": "Workspace control-plane owner and non-owner review",
"baseUrl": "${WEB_UX_BASE_URL}",
"locale": "en-US",
"timezone": "UTC",
"colorScheme": "light",
"reducedMotion": "reduce",
"redact": {
"selectors": ["[data-web-ux-redact]", "input[type=password]"],
"text": []
},
"personas": [
{
"id": "owner",
"label": "Workspace owner",
"auth": { "kind": "storage-state", "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/owner.json" },
"login": { "path": "/", "successUrl": "/w/" }
},
{
"id": "non-owner",
"label": "Authenticated non-owner",
"auth": {
"kind": "storage-state",
"path": "${XDG_STATE_HOME}/yoi/web-ux/auth/non-owner.json"
},
"login": { "path": "/", "successUrl": "/w/" }
}
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 },
{ "label": "narrow", "width": 900, "height": 900, "deviceScaleFactor": 1 }
],
"routes": [
{
"id": "workspace-home",
"label": "Workspace overview",
"path": "/w/${WORKSPACE_ID}",
"goal": "Orient the user and expose the highest-value Workspace actions without internal authority noise.",
"dataState": "Dogfood Workspace with current Runtime and Ticket data.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Initial viewport", "fullPage": true }]
},
{
"id": "tickets",
"label": "Ticket lanes",
"path": "/w/${WORKSPACE_ID}/tickets",
"goal": "Scan actionable Ticket lanes and reach the primary authoring action in the initial viewport.",
"dataState": "Planning, ready, queued, in-progress, and completed Tickets from the selected Workspace.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 30000 },
"capturePoints": [{ "id": "initial", "label": "Loaded Ticket lanes", "fullPage": true }]
},
{
"id": "workers",
"label": "Workers",
"path": "/w/${WORKSPACE_ID}/workers",
"goal": "Find current Worker state and the new-Worker action without exposing transport internals as the primary content.",
"dataState": "Current Workspace Worker projection with mixed lifecycle states.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }]
},
{
"id": "settings",
"label": "Workspace settings",
"path": "/w/${WORKSPACE_ID}/settings",
"goal": "Reach the relevant Workspace settings area without presenting owner-only destinations as usable actions to a non-owner.",
"dataState": "Current permission projection for the selected Workspace.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Workspace settings", "fullPage": true }]
},
{
"id": "repositories",
"label": "Repository settings",
"path": "/w/${WORKSPACE_ID}/settings/repositories",
"goal": "Review repository access as an owner and verify non-owner composition does not expose unusable owner actions.",
"dataState": "Workspace repository catalog projected through current permissions.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }]
},
{
"id": "repository-access",
"label": "Repository access",
"path": "/w/${WORKSPACE_ID}/settings/repository-access",
"goal": "Review credential and host-trust bindings as an owner and verify non-owner composition fails closed without secret material.",
"dataState": "Configured repository access bindings projected without credential bytes.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [
{ "id": "initial", "label": "Repository access settings", "fullPage": true }
]
}
]
}
+135
View File
@@ -0,0 +1,135 @@
import { dirname, relative, resolve } from "@std/path";
const SECRET_PATTERNS: RegExp[] = [
/\b(authorization|cookie|set-cookie|x-csrf-token)\b\s*[:=]\s*[^\s,;]+/gi,
/\b(bearer)\s+[a-z0-9._~+\/-]+=*/gi,
/\b(session|token|credential|password|passkey|private[_ -]?key)\b\s*[:=]\s*["']?[^\s,"'};]+/gi,
];
export function redactText(value: string, exactSecrets: string[] = []): string {
let result = value;
for (const secret of exactSecrets) {
if (secret) result = result.replaceAll(secret, "[REDACTED]");
}
for (const pattern of SECRET_PATTERNS) result = result.replaceAll(pattern, "$1=[REDACTED]");
return result;
}
export function bounded(value: string, maximum = 1000): string {
const normalized = value.replaceAll(/\s+/g, " ").trim();
return normalized.length <= maximum ? normalized : `${normalized.slice(0, maximum - 1)}`;
}
export function safeUrl(value: string, baseUrl?: string): string {
try {
const url = new URL(value, baseUrl);
url.username = "";
url.password = "";
for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, "[REDACTED]");
url.hash = "";
return url.toString();
} catch {
return "[invalid-url]";
}
}
export function assertBundleIsSecretFree(serialized: string, exactSecrets: string[] = []): void {
const lower = serialized.toLowerCase();
for (const forbidden of ["authorization:", "set-cookie:", "cookie:", "bearer "]) {
if (lower.includes(forbidden)) {
throw new Error(`review bundle contains forbidden secret marker: ${forbidden}`);
}
}
for (const secret of exactSecrets) {
if (secret && serialized.includes(secret)) {
throw new Error("review bundle contains configured secret text");
}
}
}
export async function ensurePrivateDirectory(path: string): Promise<void> {
await Deno.mkdir(path, { recursive: true, mode: 0o700 });
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o700);
}
export async function makePrivate(path: string): Promise<void> {
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
}
export async function writePrivateJson(path: string, value: unknown): Promise<void> {
await ensurePrivateDirectory(dirname(path));
await Deno.writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
}
export function workdirLogicalPath(repositoryRoot: string, path: string): string | null {
const absolute = resolve(path);
const logical = relative(repositoryRoot, absolute);
if (logical === "" || (!logical.startsWith("..") && !logical.startsWith("/"))) {
return logical || ".";
}
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> {
const bytes = await Deno.readFile(path);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
+87
View File
@@ -0,0 +1,87 @@
import { writePrivateJson } from "./artifacts.ts";
export type AuthStateMetadata = {
schemaVersion: 1;
personaId: string;
baseOrigin: string;
createdAt: string;
expiresAt: string;
};
export function authMetadataPath(storageStatePath: string): string {
return `${storageStatePath}.meta.json`;
}
function baseOrigin(baseUrl: string): string {
return new URL(baseUrl).origin;
}
export async function writeAuthMetadata(
storageStatePath: string,
personaId: string,
baseUrl: string,
expiresInHours: number,
): Promise<void> {
if (!Number.isFinite(expiresInHours) || expiresInHours <= 0) {
throw new Error("auth state expiry must be a positive number of hours");
}
const createdAt = new Date();
const metadata: AuthStateMetadata = {
schemaVersion: 1,
personaId,
baseOrigin: baseOrigin(baseUrl),
createdAt: createdAt.toISOString(),
expiresAt: new Date(createdAt.getTime() + expiresInHours * 60 * 60 * 1000).toISOString(),
};
await writePrivateJson(authMetadataPath(storageStatePath), metadata);
}
export async function validateAuthState(
storageStatePath: string,
personaId: string,
baseUrl: string,
now = new Date(),
): Promise<void> {
await Deno.stat(storageStatePath);
let parsed: unknown;
try {
parsed = JSON.parse(await Deno.readTextFile(authMetadataPath(storageStatePath)));
} catch (error) {
throw new Error(
`auth state metadata is missing or invalid for ${personaId}; run the auth command again: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`auth state metadata is invalid for ${personaId}`);
}
const metadata = parsed as Partial<AuthStateMetadata>;
if (metadata.schemaVersion !== 1 || metadata.personaId !== personaId) {
throw new Error(`auth state metadata does not match persona ${personaId}`);
}
if (metadata.baseOrigin !== baseOrigin(baseUrl)) {
throw new Error(
`auth state for ${personaId} belongs to ${metadata.baseOrigin ?? "an unknown origin"}, not ${
baseOrigin(baseUrl)
}`,
);
}
const expiresAt = Date.parse(metadata.expiresAt ?? "");
if (!Number.isFinite(expiresAt)) throw new Error(`auth state expiry is invalid for ${personaId}`);
if (expiresAt <= now.getTime()) {
throw new Error(
`auth state expired for ${personaId} at ${metadata.expiresAt}; run the auth command again`,
);
}
}
export async function deleteAuthState(storageStatePath: string): Promise<void> {
for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) {
try {
await Deno.remove(path);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
}
}
+637
View File
@@ -0,0 +1,637 @@
import { basename, dirname, join, relative, resolve } from "@std/path";
import { type Browser, chromium, type Page, type Response } from "playwright";
import { validateAuthState } from "./auth_state.ts";
import {
assertBundleIsSecretFree,
assertReviewBundleIsSecretFree,
bounded,
ensurePrivateDirectory,
makePrivate,
redactText,
safeUrl,
sha256File,
workdirLogicalPath,
} from "./artifacts.ts";
import { type RunningProcess, startOwnedProcesses, stopOwnedProcesses } from "./processes.ts";
import {
interpolateEnvironment,
loadScenario,
resolveScenarioPath,
validateBaseUrl,
} from "./scenario.ts";
import type {
CaptureError,
CaptureEvidence,
CapturePoint,
DiagnosticSummary,
Interaction,
InteractionEvidence,
Persona,
ReadyCondition,
ReviewContext,
RouteScenario,
Scenario,
ScreenshotEvidence,
Viewport,
} from "./types.ts";
export type CaptureOptions = {
scenarioPath: string;
outputDirectory: string;
baseUrl?: string;
runId?: string;
personas?: string[];
routes?: string[];
viewports?: string[];
headed?: boolean;
};
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 {
return value.replaceAll(/[^a-zA-Z0-9.-]+/g, "-").replaceAll(/^-+|-+$/g, "").toLowerCase();
}
function viewportId(viewport: Viewport): string {
return viewport.label ?? `${viewport.width}x${viewport.height}`;
}
function timestampId(): string {
return new Date().toISOString().replaceAll(/[:.]/g, "-");
}
async function sourceState(): Promise<SourceState> {
try {
const [revision, status] = await Promise.all([
new Deno.Command("git", { args: ["rev-parse", "HEAD"], stdout: "piped", stderr: "null" })
.output(),
new Deno.Command("git", { args: ["status", "--porcelain"], stdout: "piped", stderr: "null" })
.output(),
]);
return {
revision: revision.success ? new TextDecoder().decode(revision.stdout).trim() : null,
dirty: status.success ? new TextDecoder().decode(status.stdout).trim().length > 0 : null,
};
} catch {
return { revision: null, dirty: null };
}
}
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 }>(
values: T[],
requested: string[] | undefined,
kind: string,
): T[] {
if (!requested || requested.length === 0) return values;
const requestedSet = new Set(requested);
const selected = values.filter((value) => requestedSet.has(value.id));
const missing = [...requestedSet].filter((id) => !selected.some((value) => value.id === id));
if (missing.length > 0) throw new Error(`unknown ${kind}: ${missing.join(", ")}`);
return selected;
}
function selectViewports(values: Viewport[], requested: string[] | undefined): Viewport[] {
if (!requested || requested.length === 0) return values;
const requestedSet = new Set(requested);
const selected = values.filter((value) => requestedSet.has(viewportId(value)));
const missing = [...requestedSet].filter((id) =>
!selected.some((value) => viewportId(value) === id)
);
if (missing.length > 0) throw new Error(`unknown viewports: ${missing.join(", ")}`);
return selected;
}
function responseMatches(
response: Response,
ready: Extract<ReadyCondition, { kind: "response" }>,
): boolean {
const pattern = new RegExp(ready.urlPattern);
return pattern.test(response.url()) &&
(ready.status === undefined || response.status() === ready.status);
}
async function waitReady(
page: Page,
ready: ReadyCondition,
navigation?: Response | null,
): Promise<void> {
const timeout = ready.timeoutMs ?? 15_000;
if (ready.kind === "selector") {
await page.locator(ready.selector).first().waitFor({ state: "visible", timeout });
return;
}
if (ready.kind === "network-idle") {
await page.waitForLoadState("networkidle", { timeout });
return;
}
if (navigation && responseMatches(navigation, ready)) return;
await page.waitForResponse((response) => responseMatches(response, ready), { timeout });
}
async function performInteraction(page: Page, interaction: Interaction): Promise<void> {
if (interaction.action === "wait") return await waitReady(page, interaction.ready);
const locator = page.locator(interaction.selector).first();
const timeout = interaction.timeoutMs ?? 10_000;
if (interaction.action === "click") return await locator.click({ timeout });
if (interaction.action === "fill") {
return await locator.fill(interpolateEnvironment(interaction.value), { timeout });
}
await locator.press(interaction.key, { timeout });
}
async function retry<T>(label: string, operation: () => Promise<T>): Promise<T> {
let last: unknown;
for (let attempt = 1; attempt <= 2; attempt++) {
try {
return await operation();
} catch (error) {
last = error;
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 350));
}
}
throw new Error(
`${label} failed after 2 attempts: ${last instanceof Error ? last.message : String(last)}`,
);
}
async function hideRedactedSelectors(page: Page, selectors: string[]): Promise<void> {
if (selectors.length === 0) return;
const escaped = selectors.join(",\n");
await page.addStyleTag({ content: `${escaped} { visibility: hidden !important; }` });
}
export function isVisibleUiErrorText(content: string): boolean {
return /\b(error|failed|unauthorized|forbidden|not found)\b/i.test(content);
}
async function collectVisibleUiErrors(
page: Page,
collector: ErrorCollector,
secrets: string[],
): Promise<void> {
const alerts = page.locator('[role="alert"], [aria-live="assertive"]');
for (let index = 0; index < await alerts.count(); index++) {
const alert = alerts.nth(index);
if (!await alert.isVisible().catch(() => false)) continue;
const content = (await alert.innerText().catch(() => "")).trim();
if (!isVisibleUiErrorText(content)) continue;
const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`;
if (
!collector.errors.some((error) => error.kind === "document" && error.message === message)
) {
recordError(collector, { kind: "document", message });
}
}
}
async function capturePoint(
page: Page,
runDirectory: string,
repositoryRoot: string,
persona: Persona,
route: RouteScenario,
viewport: Viewport,
point: CapturePoint,
documentResponse: Response | null,
collector: ErrorCollector,
executedInteractions: InteractionEvidence[],
scenario: Scenario,
): Promise<CaptureEvidence> {
const startedAt = new Date().toISOString();
for (const interaction of point.interaction ?? []) {
await performInteraction(page, interaction);
executedInteractions.push(interactionEvidence(interaction));
}
if (point.ready) await waitReady(page, point.ready);
await hideRedactedSelectors(page, scenario.redact?.selectors ?? []);
await collectVisibleUiErrors(page, collector, scenario.redact?.text ?? []);
const directory = join(
runDirectory,
"captures",
persona.id,
route.id,
viewportId(viewport),
point.id,
);
await ensurePrivateDirectory(directory);
const viewportScreenshot = join(directory, "viewport.png");
await page.screenshot({ path: viewportScreenshot, fullPage: false, animations: "disabled" });
await makePrivate(viewportScreenshot);
const screenshots: ScreenshotEvidence[] = [{
kind: "viewport",
bundlePath: relative(runDirectory, viewportScreenshot),
workdirPath: workdirLogicalPath(repositoryRoot, viewportScreenshot),
sha256: await sha256File(viewportScreenshot),
}];
if (point.fullPage) {
const fullPageScreenshot = join(directory, "full-page.png");
await page.screenshot({ path: fullPageScreenshot, fullPage: true, animations: "disabled" });
await makePrivate(fullPageScreenshot);
screenshots.push({
kind: "full-page",
bundlePath: relative(runDirectory, fullPageScreenshot),
workdirPath: workdirLogicalPath(repositoryRoot, fullPageScreenshot),
sha256: await sha256File(fullPageScreenshot),
});
}
let snapshot: { bundlePath: string; workdirPath: string | null } | null = null;
try {
const accessibility = await page.locator("body").ariaSnapshot({ timeout: 5_000 });
const redacted = redactText(accessibility, scenario.redact?.text ?? []);
const target = join(directory, "accessibility.md");
await Deno.writeTextFile(target, redacted, { mode: 0o600 });
snapshot = {
bundlePath: relative(runDirectory, target),
workdirPath: workdirLogicalPath(repositoryRoot, target),
};
} catch (error) {
recordError(collector, {
kind: "tool",
message: `accessibility snapshot failed: ${
bounded(error instanceof Error ? error.message : String(error))
}`,
});
}
return {
persona: { id: persona.id, label: persona.label },
route: {
id: route.id,
path: route.path,
goal: route.goal,
dataState: route.dataState,
ready: route.ready,
},
viewport,
theme: scenario.colorScheme ?? "light",
capturePoint: { id: point.id, label: point.label, ready: point.ready ?? null },
interactions: [...executedInteractions],
document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null },
screenshots,
snapshot,
errors: [...collector.errors],
errorSummary: errorSummary(collector),
startedAt,
finishedAt: new Date().toISOString(),
};
}
function screenshotDataUrl(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `data:image/png;base64,${btoa(binary)}`;
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll(
'"',
"&quot;",
);
}
async function createContactSheet(
browser: Browser,
runDirectory: string,
repositoryRoot: string,
captures: CaptureEvidence[],
): Promise<{
html: { bundlePath: string; workdirPath: string | null } | null;
png: { bundlePath: string; workdirPath: string | null } | null;
}> {
const cells: string[] = [];
for (const capture of captures) {
const screenshot = capture.screenshots.find((item) => item.kind === "viewport") ??
capture.screenshots[0];
if (!screenshot) continue;
const bytes = await Deno.readFile(join(runDirectory, screenshot.bundlePath));
cells.push(
`<figure><img src="${screenshotDataUrl(bytes)}"><figcaption><strong>${
escapeHtml(capture.persona.label)
} · ${escapeHtml(capture.route.id)}</strong><br>${
escapeHtml(viewportId(capture.viewport))
} · ${escapeHtml(capture.capturePoint.label)}<br><small>${
escapeHtml(capture.route.dataState)
}</small>${
capture.errors.length > 0
? `<br><strong class="errors">${capture.errors.length} captured error(s)</strong>`
: ""
}</figcaption></figure>`,
);
}
if (cells.length === 0) return { html: null, png: null };
const html =
`<!doctype html><meta charset="utf-8"><title>Web UX review contact sheet</title><style>body{margin:0;padding:20px;background:#e8e8e8;color:#111;font:14px system-ui}main{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}figure{margin:0;background:white;border:1px solid #aaa;padding:10px;box-shadow:0 2px 8px #0002}img{width:100%;height:auto;display:block;border:1px solid #ddd}figcaption{padding-top:8px;line-height:1.45}small{color:#555}.errors{color:#b42318}</style><main>${
cells.join("")
}</main>`;
const htmlPath = join(runDirectory, "contact-sheet.html");
const pngPath = join(runDirectory, "contact-sheet.png");
await Deno.writeTextFile(htmlPath, html, { mode: 0o600 });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
try {
await page.setContent(html, { waitUntil: "load" });
await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" });
await makePrivate(pngPath);
} finally {
await page.close();
}
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> {
const scenarioPath = resolve(options.scenarioPath);
const repository = await repositoryRoot();
const scenario = await loadScenario(scenarioPath);
const baseUrl = validateBaseUrl(
interpolateEnvironment(
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
),
);
const personas = selectById(scenario.personas, options.personas, "personas");
const routes = selectById(scenario.routes, options.routes, "routes");
const viewports = selectViewports(scenario.viewports, options.viewports);
const runId = slug(options.runId ?? `${scenario.id}-${timestampId()}`);
const runDirectory = resolve(options.outputDirectory, runId);
try {
await Deno.stat(runDirectory);
throw new Error(`run directory already exists: ${runDirectory}`);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
await ensurePrivateDirectory(runDirectory);
const secrets = scenario.redact?.text ?? [];
let browser: Browser | null = null;
let processes: RunningProcess[] = [];
const captures: CaptureEvidence[] = [];
const globalCollector: ErrorCollector = {
errors: [],
observed: 0,
limit: CAPTURE_ERROR_LIMIT,
};
const diagnostics = globalCollector.errors;
let contactSheet: ReviewContext["contactSheet"] = { html: null, png: null };
let browserVersion = "unknown";
let status: ReviewContext["status"] = "completed";
try {
processes = await startOwnedProcesses(
scenario.processes ?? [],
scenarioPath,
join(runDirectory, "process-logs"),
secrets,
);
browser = await chromium.launch({ headless: !options.headed });
browserVersion = browser.version();
for (const persona of personas) {
const storageState = persona.auth.kind === "storage-state"
? resolveScenarioPath(scenarioPath, persona.auth.path)
: undefined;
if (storageState) await validateAuthState(storageState, persona.id, baseUrl);
for (const viewport of viewports) {
const context = await browser.newContext({
storageState,
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
locale: scenario.locale,
timezoneId: scenario.timezone,
colorScheme: scenario.colorScheme,
reducedMotion: scenario.reducedMotion,
});
try {
for (const route of routes) {
const routeCollector: ErrorCollector = {
errors: [],
observed: 0,
limit: CAPTURE_ERROR_LIMIT,
};
const routeErrors = routeCollector.errors;
const executedInteractions: InteractionEvidence[] = [];
const page = await context.newPage();
page.on("console", (message) => {
if (message.type() === "error") {
recordError(routeCollector, {
kind: "console",
message: bounded(redactText(message.text(), secrets)),
});
}
});
page.on(
"pageerror",
(error) =>
recordError(routeCollector, {
kind: "page",
message: bounded(redactText(error.message, secrets)),
}),
);
page.on(
"requestfailed",
(request) =>
recordError(routeCollector, {
kind: "request",
message: bounded(
redactText(request.failure()?.errorText ?? "request failed", secrets),
),
url: safeUrl(request.url()),
}),
);
page.on("response", (response) => {
if (response.status() >= 400) {
recordError(routeCollector, {
kind: "request",
message: `HTTP ${response.status()}`,
url: safeUrl(response.url()),
status: response.status(),
});
}
});
try {
const routePath = interpolateEnvironment(route.path);
const targetUrl = new URL(routePath, `${baseUrl}/`).toString();
const response = await retry(`navigate ${route.id}`, async () => {
const ready = route.ready;
const responseReady = ready.kind === "response"
? page.waitForResponse(
(candidate) => responseMatches(candidate, ready),
{ timeout: ready.timeoutMs ?? 15_000 },
)
: null;
try {
const navigation = await page.goto(targetUrl, {
waitUntil: "domcontentloaded",
timeout: 20_000,
});
if (responseReady) await responseReady;
else await waitReady(page, ready, navigation);
return navigation;
} catch (error) {
responseReady?.catch(() => undefined);
throw error;
}
});
if (response && response.status() >= 400) {
recordError(routeCollector, {
kind: "document",
message: `document returned HTTP ${response.status()}`,
url: safeUrl(response.url()),
status: response.status(),
});
}
for (const point of route.capturePoints) {
captures.push(
await capturePoint(
page,
runDirectory,
repository,
persona,
route,
viewport,
point,
response,
routeCollector,
executedInteractions,
scenario,
),
);
}
} catch (error) {
status = "completed-with-errors";
recordError(routeCollector, {
kind: "tool",
message: bounded(
redactText(error instanceof Error ? error.message : String(error), secrets),
),
});
captures.push({
persona: { id: persona.id, label: persona.label },
route: {
id: route.id,
path: route.path,
goal: route.goal,
dataState: route.dataState,
ready: route.ready,
},
viewport,
theme: scenario.colorScheme ?? "light",
capturePoint: { id: "failed", label: "Capture failed", ready: null },
interactions: [...executedInteractions],
document: { url: safeUrl(page.url()), status: null },
screenshots: [],
snapshot: null,
errors: [...routeErrors],
errorSummary: errorSummary(routeCollector),
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
});
} finally {
await page.close();
}
}
} finally {
await context.close();
}
}
}
contactSheet = await createContactSheet(browser, runDirectory, repository, captures);
if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors";
} catch (error) {
status = "failed";
recordError(globalCollector, {
kind: "tool",
message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)),
});
} finally {
if (browser) {
await browser.close().catch((error) =>
recordError(globalCollector, {
kind: "tool",
message: `browser cleanup failed: ${bounded(String(error))}`,
})
);
}
for (const error of await stopOwnedProcesses(processes)) recordError(globalCollector, error);
}
if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors";
const manifest: ReviewContext = {
schemaVersion: 1,
runId,
scenario: {
id: scenario.id,
title: scenario.title,
sourcePath: workdirLogicalPath(repository, scenarioPath),
},
source: await sourceState(),
baseUrl: safeUrl(baseUrl),
browser: { name: "chromium", version: browserVersion },
createdAt: new Date().toISOString(),
status,
filters: {
personas: personas.map((item) => item.id),
routes: routes.map((item) => item.id),
viewports: viewports.map(viewportId),
},
captures,
contactSheet,
diagnostics,
diagnosticSummary: errorSummary(globalCollector),
};
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
assertBundleIsSecretFree(serialized, secrets);
await Deno.writeTextFile(join(runDirectory, "review-context.json"), serialized, { mode: 0o600 });
await assertReviewBundleIsSecretFree(runDirectory, secrets);
if (status === "failed") {
throw new Error(`capture failed; inspect ${join(runDirectory, "review-context.json")}`);
}
return manifest;
}
export function describeCapture(manifest: ReviewContext, outputDirectory: string): string {
return `${manifest.status}: ${manifest.captures.length} capture(s); ${
join(outputDirectory, manifest.runId, "review-context.json")
}`;
}
+173
View File
@@ -0,0 +1,173 @@
import { Buffer } from "node:buffer";
import { basename, dirname, join, relative, resolve } from "@std/path";
import { chromium } from "playwright";
import pixelmatch from "pixelmatch";
import { PNG } from "pngjs";
import { ensurePrivateDirectory, makePrivate, sha256File } from "./artifacts.ts";
import type { CaptureEvidence, ReviewContext } from "./types.ts";
export type CompareOptions = {
before: string;
after: string;
outputDirectory: string;
threshold?: number;
};
type Pair = {
key: string;
before: CaptureEvidence;
after: CaptureEvidence;
beforePath: string;
afterPath: string;
diffPath: string;
changedPixels: number;
totalPixels: number;
dimensionMismatch: boolean;
};
function key(capture: CaptureEvidence): string {
const viewport = capture.viewport.label ?? `${capture.viewport.width}x${capture.viewport.height}`;
return [capture.persona.id, capture.route.id, viewport, capture.capturePoint.id].join("/");
}
async function readManifest(path: string): Promise<ReviewContext> {
const parsed = JSON.parse(await Deno.readTextFile(path));
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.captures)) {
throw new Error(`not a web-ux review context: ${path}`);
}
return parsed;
}
function viewportScreenshot(capture: CaptureEvidence): string | null {
return capture.screenshots.find((item) => item.kind === "viewport")?.bundlePath ??
capture.screenshots[0]?.bundlePath ?? null;
}
function dataUrl(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `data:image/png;base64,${btoa(binary)}`;
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll(
'"',
"&quot;",
);
}
export async function compare(options: CompareOptions): Promise<string> {
const beforeManifestPath = resolve(options.before);
const afterManifestPath = resolve(options.after);
const [before, after] = await Promise.all([
readManifest(beforeManifestPath),
readManifest(afterManifestPath),
]);
const outputDirectory = resolve(options.outputDirectory);
await ensurePrivateDirectory(outputDirectory);
const afterByKey = new Map(after.captures.map((capture) => [key(capture), capture]));
const pairs: Pair[] = [];
const unmatchedBefore: string[] = [];
for (const earlier of before.captures) {
const later = afterByKey.get(key(earlier));
const earlierScreenshot = viewportScreenshot(earlier);
const laterScreenshot = later ? viewportScreenshot(later) : null;
if (!later || !earlierScreenshot || !laterScreenshot) {
unmatchedBefore.push(key(earlier));
continue;
}
afterByKey.delete(key(earlier));
const beforePath = resolve(dirname(beforeManifestPath), earlierScreenshot);
const afterPath = resolve(dirname(afterManifestPath), laterScreenshot);
const [beforeImage, afterImage] = [
PNG.sync.read(Buffer.from(Deno.readFileSync(beforePath))),
PNG.sync.read(Buffer.from(Deno.readFileSync(afterPath))),
];
const dimensionMismatch = beforeImage.width !== afterImage.width ||
beforeImage.height !== afterImage.height;
const width = Math.max(beforeImage.width, afterImage.width);
const height = Math.max(beforeImage.height, afterImage.height);
const diff = new PNG({ width, height, fill: true });
let changedPixels = width * height;
if (!dimensionMismatch) {
changedPixels = pixelmatch(beforeImage.data, afterImage.data, diff.data, width, height, {
threshold: options.threshold ?? 0.1,
includeAA: false,
});
}
const diffPath = join(outputDirectory, "diffs", `${key(earlier).replaceAll("/", "--")}.png`);
await ensurePrivateDirectory(dirname(diffPath));
await Deno.writeFile(diffPath, PNG.sync.write(diff), { mode: 0o600 });
await makePrivate(diffPath);
pairs.push({
key: key(earlier),
before: earlier,
after: later,
beforePath,
afterPath,
diffPath,
changedPixels,
totalPixels: width * height,
dimensionMismatch,
});
}
const cells: string[] = [];
for (const pair of pairs) {
const [beforeBytes, afterBytes, diffBytes] = await Promise.all([
Deno.readFile(pair.beforePath),
Deno.readFile(pair.afterPath),
Deno.readFile(pair.diffPath),
]);
cells.push(
`<section><h2>${escapeHtml(pair.key)}</h2><p>${
pair.dimensionMismatch
? "dimension mismatch"
: `${pair.changedPixels} / ${pair.totalPixels} pixels changed`
}</p><div class="row"><figure><img src="${
dataUrl(beforeBytes)
}"><figcaption>before</figcaption></figure><figure><img src="${
dataUrl(afterBytes)
}"><figcaption>after</figcaption></figure><figure><img src="${
dataUrl(diffBytes)
}"><figcaption>diff</figcaption></figure></div></section>`,
);
}
const html =
`<!doctype html><meta charset="utf-8"><title>Web UX comparison</title><style>body{margin:0;padding:20px;background:#e8e8e8;color:#111;font:14px system-ui}section{background:white;border:1px solid #aaa;margin:0 0 24px;padding:12px}.row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}figure{margin:0}img{width:100%;height:auto;border:1px solid #ddd}figcaption{text-align:center;padding:6px}h2{font-size:16px;margin:0}p{color:#555}</style>${
cells.join("")
}`;
const htmlPath = join(outputDirectory, "comparison.html");
const pngPath = join(outputDirectory, "comparison.png");
await Deno.writeTextFile(htmlPath, html, { mode: 0o600 });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } });
await page.setContent(html, { waitUntil: "load" });
await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" });
await makePrivate(pngPath);
} finally {
await browser.close();
}
const report = {
schemaVersion: 1,
before: beforeManifestPath,
after: afterManifestPath,
createdAt: new Date().toISOString(),
threshold: options.threshold ?? 0.1,
pairs: await Promise.all(pairs.map(async (pair) => ({
key: pair.key,
changedPixels: pair.changedPixels,
totalPixels: pair.totalPixels,
changedRatio: pair.totalPixels === 0 ? 0 : pair.changedPixels / pair.totalPixels,
dimensionMismatch: pair.dimensionMismatch,
diff: relative(outputDirectory, pair.diffPath),
diffSha256: await sha256File(pair.diffPath),
}))),
unmatchedBefore,
unmatchedAfter: [...afterByKey.keys()],
contactSheet: { html: basename(htmlPath), png: basename(pngPath) },
};
const reportPath = join(outputDirectory, "comparison.json");
await Deno.writeTextFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
return reportPath;
}
+124
View File
@@ -0,0 +1,124 @@
import { dirname, resolve } from "@std/path";
import { chromium } from "playwright";
import { ensurePrivateDirectory, makePrivate, writePrivateJson } from "./artifacts.ts";
import { deleteAuthState, writeAuthMetadata } from "./auth_state.ts";
import {
interpolateEnvironment,
loadScenario,
resolveScenarioPath,
validateBaseUrl,
} from "./scenario.ts";
export type AuthOptions = {
scenarioPath: string;
personaId: string;
baseUrl?: string;
importState?: string;
timeoutMs?: number;
expiresInHours?: number;
delete?: boolean;
headless?: boolean;
};
function validateStorageState(value: unknown): { cookies: unknown[]; origins: unknown[] } {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("storage state must be an object");
}
const source = value as Record<string, unknown>;
if (!Array.isArray(source.cookies) || !Array.isArray(source.origins)) {
throw new Error("storage state must contain cookies and origins arrays");
}
return { cookies: source.cookies, origins: source.origins };
}
export async function authenticate(options: AuthOptions): Promise<string> {
const scenarioPath = resolve(options.scenarioPath);
const scenario = await loadScenario(scenarioPath);
const persona = scenario.personas.find((candidate) => candidate.id === options.personaId);
if (!persona) throw new Error(`unknown persona: ${options.personaId}`);
if (persona.auth.kind !== "storage-state") {
throw new Error(`persona ${persona.id} is anonymous and has no auth state`);
}
const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path);
if (options.delete) {
await deleteAuthState(outputPath);
return outputPath;
}
await ensurePrivateDirectory(dirname(outputPath));
const baseUrl = validateBaseUrl(
interpolateEnvironment(
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
),
);
const expiresInHours = options.expiresInHours ?? 12;
if (options.importState) {
const imported = validateStorageState(
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
);
await writePrivateJson(outputPath, imported);
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
return outputPath;
}
if (!persona.login) {
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
}
const browser = await chromium.launch({ headless: options.headless ?? false });
try {
const context = await browser.newContext();
const page = await context.newPage();
const loginUrl = new URL(interpolateEnvironment(persona.login.path ?? "/"), `${baseUrl}/`)
.toString();
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
const success = new RegExp(interpolateEnvironment(persona.login.successUrl));
if (!success.test(page.url())) {
await page.waitForURL((url) => success.test(url.toString()), {
timeout: options.timeoutMs ?? 300_000,
});
}
await context.storageState({ path: outputPath });
await makePrivate(outputPath);
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
return outputPath;
} finally {
await browser.close();
}
}
export type CleanupOptions = {
outputDirectory: string;
keep: number;
olderThanDays?: number;
dryRun?: boolean;
};
export async function cleanup(options: CleanupOptions): Promise<string[]> {
const outputDirectory = resolve(options.outputDirectory);
const candidates: { path: string; modified: number }[] = [];
try {
for await (const entry of Deno.readDir(outputDirectory)) {
if (!entry.isDirectory) continue;
const path = resolve(outputDirectory, entry.name);
try {
await Deno.stat(resolve(path, "review-context.json"));
const stat = await Deno.stat(path);
candidates.push({ path, modified: stat.mtime?.getTime() ?? 0 });
} catch {
// Only complete review bundle directories are owned by cleanup.
}
}
} catch (error) {
if (error instanceof Deno.errors.NotFound) return [];
throw error;
}
candidates.sort((left, right) => right.modified - left.modified);
const cutoff = options.olderThanDays === undefined
? Number.POSITIVE_INFINITY
: Date.now() - options.olderThanDays * 24 * 60 * 60 * 1000;
const removed: string[] = [];
for (const [index, candidate] of candidates.entries()) {
if (index < options.keep || candidate.modified > cutoff) continue;
removed.push(candidate.path);
if (!options.dryRun) await Deno.remove(candidate.path, { recursive: true });
}
return removed;
}
+254
View File
@@ -0,0 +1,254 @@
import { dirname, isAbsolute, resolve } from "@std/path";
import { bounded, redactText, writePrivateJson } from "./artifacts.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 = {
id: string;
pid: number;
child: Deno.ChildProcess;
status: Promise<Deno.CommandStatus>;
output: Promise<void>;
};
async function appendOutput(
stream: ReadableStream<Uint8Array>,
destination: string,
secrets: string[],
): Promise<void> {
const file = await Deno.open(destination, {
create: true,
append: true,
write: true,
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 {
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
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 {
file.close();
await writePrivateJson(`${destination}.meta.json`, {
schemaVersion: 1,
byteLimit: PROCESS_LOG_BYTE_LIMIT,
bytesObserved,
bytesWritten,
truncated,
});
}
}
async function waitForReady(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError = "not attempted";
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) });
const status = response.status;
await response.body?.cancel();
if (status < 500) return;
lastError = `HTTP ${status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`process readiness timed out for ${url}: ${bounded(lastError, 200)}`);
}
export async function startOwnedProcesses(
specifications: OwnedProcess[],
scenarioPath: string,
logsDirectory: string,
secrets: string[],
): Promise<RunningProcess[]> {
const running: RunningProcess[] = [];
await Deno.mkdir(logsDirectory, { recursive: true });
try {
for (const specification of specifications) {
const cwd = specification.cwd === undefined
? dirname(resolve(scenarioPath))
: isAbsolute(specification.cwd)
? specification.cwd
: resolve(dirname(scenarioPath), specification.cwd);
const child = new Deno.Command(specification.command, {
args: specification.args ?? [],
cwd,
env: specification.env,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn();
const stdout = appendOutput(
child.stdout,
`${logsDirectory}/${specification.id}.stdout.log`,
secrets,
);
const stderr = appendOutput(
child.stderr,
`${logsDirectory}/${specification.id}.stderr.log`,
secrets,
);
const status = child.status;
const process = {
id: specification.id,
pid: child.pid,
child,
status,
output: Promise.all([stdout, stderr]).then(() => undefined),
};
running.push(process);
if (specification.readyUrl) {
await Promise.race([
waitForReady(specification.readyUrl, specification.readyTimeoutMs ?? 30_000),
status.then((status) => {
throw new Error(
`owned process ${specification.id} exited before readiness: ${status.code}`,
);
}),
]);
}
}
return running;
} catch (error) {
await stopOwnedProcesses(running);
throw error;
}
}
async function descendantPids(parentPid: number): Promise<number[]> {
if (Deno.build.os === "windows") return [];
try {
const result = await new Deno.Command("ps", {
args: ["-eo", "pid=,ppid="],
stdout: "piped",
stderr: "null",
}).output();
if (!result.success) return [];
const rows = new TextDecoder().decode(result.stdout).trim().split("\n").map((line) =>
line.trim().split(/\s+/).map(Number)
);
const descendants: number[] = [];
const queue = [parentPid];
while (queue.length > 0) {
const parent = queue.shift()!;
for (const [pid, ppid] of rows) {
if (ppid === parent && !descendants.includes(pid)) {
descendants.push(pid);
queue.push(pid);
}
}
}
return descendants.reverse();
} catch {
return [];
}
}
function tryKill(pid: number, signal: Deno.Signal): void {
try {
Deno.kill(pid, signal);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
}
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[]> {
const diagnostics: CaptureError[] = [];
for (const process of [...processes].reverse()) {
try {
const descendants = await descendantPids(process.pid);
tryKill(process.pid, "SIGTERM");
for (const pid of descendants) tryKill(pid, "SIGTERM");
let timer: number | undefined;
const [parentExited, liveDescendants] = await Promise.all([
Promise.race([
process.status.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), PROCESS_STOP_TIMEOUT_MS);
}),
]).finally(() => clearTimeout(timer)),
waitForPidsToExit(descendants, PROCESS_STOP_TIMEOUT_MS),
]);
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");
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;
} catch (error) {
diagnostics.push({
kind: "tool",
message: `failed to clean process ${process.id}: ${
bounded(error instanceof Error ? error.message : String(error), 500)
}`,
});
}
}
return diagnostics;
}
+285
View File
@@ -0,0 +1,285 @@
import { isAbsolute, join, resolve } from "@std/path";
import type {
CapturePoint,
Persona,
ReadyCondition,
RouteScenario,
Scenario,
Viewport,
} from "./types.ts";
function record(value: unknown, at: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${at} must be an object`);
}
return value as Record<string, unknown>;
}
function text(value: unknown, at: string): string {
if (typeof value !== "string" || value.trim() === "") throw new Error(`${at} must be text`);
return value;
}
function positiveInteger(value: unknown, at: string): number {
if (!Number.isInteger(value) || (value as number) <= 0) {
throw new Error(`${at} must be a positive integer`);
}
return value as number;
}
function identifier(value: unknown, at: string): string {
const result = text(value, at);
if (!/^[a-z0-9][a-z0-9-]*$/.test(result)) {
throw new Error(`${at} must contain lowercase ASCII letters, digits, or hyphens`);
}
return result;
}
function stringArray(value: unknown, at: string): string[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error(`${at} must be an array`);
return value.map((item, index) => text(item, `${at}[${index}]`));
}
function parseReady(value: unknown, at: string): ReadyCondition {
const source = record(value, at);
const kind = text(source.kind, `${at}.kind`);
const timeoutMs = source.timeoutMs === undefined
? undefined
: positiveInteger(source.timeoutMs, `${at}.timeoutMs`);
if (kind === "selector") {
return { kind, selector: text(source.selector, `${at}.selector`), timeoutMs };
}
if (kind === "response") {
const status = source.status === undefined
? undefined
: positiveInteger(source.status, `${at}.status`);
return { kind, urlPattern: text(source.urlPattern, `${at}.urlPattern`), status, timeoutMs };
}
if (kind === "network-idle") return { kind, timeoutMs };
throw new Error(`${at}.kind is unsupported: ${kind}`);
}
function parseCapturePoint(value: unknown, at: string): CapturePoint {
const source = record(value, at);
const result: CapturePoint = {
id: identifier(source.id, `${at}.id`),
label: text(source.label, `${at}.label`),
fullPage: source.fullPage === undefined ? false : Boolean(source.fullPage),
};
if (source.ready !== undefined) result.ready = parseReady(source.ready, `${at}.ready`);
if (source.interaction !== undefined) {
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) => {
const action = record(raw, `${at}.interaction[${index}]`);
const name = text(action.action, `${at}.interaction[${index}].action`);
if (name === "wait") {
return {
action: name,
ready: parseReady(action.ready, `${at}.interaction[${index}].ready`),
};
}
const selector = text(action.selector, `${at}.interaction[${index}].selector`);
const timeoutMs = action.timeoutMs === undefined
? undefined
: positiveInteger(action.timeoutMs, `${at}.interaction[${index}].timeoutMs`);
if (name === "click") return { action: name, selector, timeoutMs };
if (name === "fill") {
return {
action: name,
selector,
value: text(action.value, `${at}.interaction[${index}].value`),
timeoutMs,
};
}
if (name === "press") {
return {
action: name,
selector,
key: text(action.key, `${at}.interaction[${index}].key`),
timeoutMs,
};
}
throw new Error(`${at}.interaction[${index}].action is unsupported: ${name}`);
});
}
return result;
}
function parsePersona(value: unknown, at: string): Persona {
const source = record(value, at);
const auth = record(source.auth, `${at}.auth`);
const kind = text(auth.kind, `${at}.auth.kind`);
const persona: Persona = {
id: identifier(source.id, `${at}.id`),
label: text(source.label, `${at}.label`),
auth: kind === "anonymous"
? { kind }
: kind === "storage-state"
? { kind, path: text(auth.path, `${at}.auth.path`) }
: (() => {
throw new Error(`${at}.auth.kind is unsupported: ${kind}`);
})(),
};
if (source.login !== undefined) {
const login = record(source.login, `${at}.login`);
persona.login = {
path: login.path === undefined ? "/" : text(login.path, `${at}.login.path`),
successUrl: text(login.successUrl, `${at}.login.successUrl`),
};
}
return persona;
}
function parseViewport(value: unknown, at: string): Viewport {
const source = record(value, at);
return {
width: positiveInteger(source.width, `${at}.width`),
height: positiveInteger(source.height, `${at}.height`),
label: source.label === undefined ? undefined : identifier(source.label, `${at}.label`),
deviceScaleFactor: source.deviceScaleFactor === undefined
? 1
: positiveInteger(source.deviceScaleFactor, `${at}.deviceScaleFactor`),
};
}
function parseRoute(value: unknown, at: string): RouteScenario {
const source = record(value, at);
if (!Array.isArray(source.capturePoints) || source.capturePoints.length === 0) {
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 {
id: identifier(source.id, `${at}.id`),
label: text(source.label, `${at}.label`),
path: text(source.path, `${at}.path`),
goal: text(source.goal, `${at}.goal`),
dataState: text(source.dataState, `${at}.dataState`),
ready: parseReady(source.ready, `${at}.ready`),
capturePoints: source.capturePoints.map((point, index) =>
parseCapturePoint(point, `${at}.capturePoints[${index}]`)
),
};
}
function uniqueIds(values: { id: string }[], at: string): void {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value.id)) throw new Error(`${at} contains duplicate id: ${value.id}`);
seen.add(value.id);
}
}
export function interpolateEnvironment(value: string, environment = Deno.env.toObject()): string {
return value.replaceAll(/\$\{([A-Z][A-Z0-9_]*)\}/g, (_match, name: string) => {
const replacement = environment[name];
if (replacement === undefined) {
throw new Error(`required environment variable is missing: ${name}`);
}
return replacement;
});
}
export function validateBaseUrl(value: string): string {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("base URL must use http or https");
}
if (url.username || url.password) throw new Error("base URL must not contain credentials");
url.pathname = url.pathname.replace(/\/$/, "");
return url.toString().replace(/\/$/, "");
}
export function resolveScenarioPath(sourcePath: string, value: string): string {
const expanded = interpolateEnvironment(value);
return isAbsolute(expanded) ? expanded : resolve(join(sourcePath, "..", expanded));
}
export async function loadScenario(sourcePath: string): Promise<Scenario> {
const parsed = JSON.parse(await Deno.readTextFile(sourcePath));
const source = record(parsed, "scenario");
if (source.schemaVersion !== 1) throw new Error("scenario.schemaVersion must equal 1");
if (!Array.isArray(source.personas) || source.personas.length === 0) {
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) {
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) {
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) =>
parsePersona(value, `scenario.personas[${index}]`)
);
const routes = source.routes.map((value, index) =>
parseRoute(value, `scenario.routes[${index}]`)
);
const scenario: Scenario = {
schemaVersion: 1,
id: identifier(source.id, "scenario.id"),
title: text(source.title, "scenario.title"),
baseUrl: source.baseUrl === undefined ? undefined : text(source.baseUrl, "scenario.baseUrl"),
locale: source.locale === undefined ? "en-US" : text(source.locale, "scenario.locale"),
timezone: source.timezone === undefined ? "UTC" : text(source.timezone, "scenario.timezone"),
colorScheme: source.colorScheme === "dark" ? "dark" : "light",
reducedMotion: source.reducedMotion === "no-preference" ? "no-preference" : "reduce",
redact: source.redact === undefined ? undefined : (() => {
const redact = record(source.redact, "scenario.redact");
return {
selectors: stringArray(redact.selectors, "scenario.redact.selectors"),
text: stringArray(redact.text, "scenario.redact.text").map((value) =>
interpolateEnvironment(value)
),
};
})(),
personas,
viewports: source.viewports.map((value, index) =>
parseViewport(value, `scenario.viewports[${index}]`)
),
routes,
};
if (source.processes !== undefined) {
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) => {
const at = `scenario.processes[${index}]`;
const process = record(value, at);
const env = process.env === undefined ? undefined : record(process.env, `${at}.env`);
return {
id: identifier(process.id, `${at}.id`),
command: text(process.command, `${at}.command`),
args: stringArray(process.args, `${at}.args`),
cwd: process.cwd === undefined ? undefined : text(process.cwd, `${at}.cwd`),
env: env === undefined ? undefined : Object.fromEntries(
Object.entries(env).map((
[key, raw],
) => [key, interpolateEnvironment(text(raw, `${at}.env.${key}`))]),
),
readyUrl: process.readyUrl === undefined ? undefined : validateBaseUrl(
interpolateEnvironment(text(process.readyUrl, `${at}.readyUrl`)),
),
readyTimeoutMs: process.readyTimeoutMs === undefined
? undefined
: positiveInteger(process.readyTimeoutMs, `${at}.readyTimeoutMs`),
};
});
uniqueIds(scenario.processes, "scenario.processes");
}
uniqueIds(personas, "scenario.personas");
uniqueIds(routes, "scenario.routes");
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;
}
+149
View File
@@ -0,0 +1,149 @@
export type Viewport = {
width: number;
height: number;
label?: string;
deviceScaleFactor?: number;
};
export type Persona = {
id: string;
label: string;
auth: { kind: "anonymous" } | { kind: "storage-state"; path: string };
login?: {
path?: string;
successUrl: string;
};
};
export type ReadyCondition =
| { kind: "selector"; selector: string; timeoutMs?: number }
| { kind: "response"; urlPattern: string; status?: number; timeoutMs?: number }
| { kind: "network-idle"; timeoutMs?: number };
export type Interaction =
| { action: "click"; selector: string; timeoutMs?: number }
| { action: "fill"; selector: string; value: string; timeoutMs?: number }
| { action: "press"; selector: string; key: string; timeoutMs?: number }
| { action: "wait"; ready: ReadyCondition };
export type CapturePoint = {
id: string;
label: string;
interaction?: Interaction[];
ready?: ReadyCondition;
fullPage?: boolean;
};
export type RouteScenario = {
id: string;
label: string;
path: string;
goal: string;
dataState: string;
ready: ReadyCondition;
capturePoints: CapturePoint[];
};
export type OwnedProcess = {
id: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
readyUrl?: string;
readyTimeoutMs?: number;
};
export type Scenario = {
schemaVersion: 1;
id: string;
title: string;
baseUrl?: string;
locale?: string;
timezone?: string;
colorScheme?: "light" | "dark";
reducedMotion?: "reduce" | "no-preference";
redact?: {
selectors?: string[];
text?: string[];
};
personas: Persona[];
viewports: Viewport[];
routes: RouteScenario[];
processes?: OwnedProcess[];
};
export type CaptureError = {
kind: "console" | "page" | "request" | "document" | "tool";
message: string;
url?: string;
status?: number;
};
export type ArtifactReference = {
bundlePath: string;
workdirPath: string | null;
};
export type ScreenshotEvidence = ArtifactReference & {
kind: "viewport" | "full-page";
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 = {
persona: { id: string; label: string };
route: {
id: string;
path: string;
goal: string;
dataState: string;
ready: ReadyCondition;
};
viewport: Viewport;
theme: string;
capturePoint: {
id: string;
label: string;
ready: ReadyCondition | null;
};
interactions: InteractionEvidence[];
document: { url: string; status: number | null };
screenshots: ScreenshotEvidence[];
snapshot: ArtifactReference | null;
errors: CaptureError[];
errorSummary: DiagnosticSummary;
startedAt: string;
finishedAt: string;
};
export type ReviewContext = {
schemaVersion: 1;
runId: string;
scenario: { id: string; title: string; sourcePath: string | null };
source: { revision: string | null; dirty: boolean | null };
baseUrl: string;
browser: { name: "chromium"; version: string };
createdAt: string;
status: "completed" | "completed-with-errors" | "failed";
filters: { personas: string[]; routes: string[]; viewports: string[] };
captures: CaptureEvidence[];
contactSheet: {
html: ArtifactReference | null;
png: ArtifactReference | null;
};
diagnostics: CaptureError[];
diagnosticSummary: DiagnosticSummary;
};
+56
View File
@@ -0,0 +1,56 @@
import { assertRejects } from "@std/assert";
import { join } from "@std/path";
import {
authMetadataPath,
deleteAuthState,
validateAuthState,
writeAuthMetadata,
} from "../src/auth_state.ts";
Deno.test("auth state is bound to persona, base origin, and expiry", async () => {
const directory = await Deno.makeTempDir();
try {
const state = join(directory, "owner.json");
await Deno.writeTextFile(state, '{"cookies":[],"origins":[]}');
await writeAuthMetadata(state, "owner", "https://example.test/path", 1);
await validateAuthState(state, "owner", "https://example.test/other");
await assertRejects(
() => validateAuthState(state, "non-owner", "https://example.test"),
Error,
"does not match persona",
);
await assertRejects(
() => validateAuthState(state, "owner", "https://other.test"),
Error,
"belongs to https://example.test",
);
await assertRejects(
() =>
validateAuthState(
state,
"owner",
"https://example.test",
new Date(Date.now() + 2 * 60 * 60 * 1000),
),
Error,
"auth state expired",
);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("auth state deletion removes state and metadata idempotently", async () => {
const directory = await Deno.makeTempDir();
try {
const state = join(directory, "owner.json");
await Deno.writeTextFile(state, "{}");
await writeAuthMetadata(state, "owner", "http://127.0.0.1:3000", 1);
await deleteAuthState(state);
await deleteAuthState(state);
await assertRejects(() => Deno.stat(state), Deno.errors.NotFound);
await assertRejects(() => Deno.stat(authMetadataPath(state)), Deno.errors.NotFound);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
+26
View File
@@ -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");
});
+172
View File
@@ -0,0 +1,172 @@
import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
import { join } from "@std/path";
import {
assertBundleIsSecretFree,
redactText,
safeUrl,
writePrivateJson,
} from "../src/artifacts.ts";
import {
PROCESS_LOG_BYTE_LIMIT,
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]");
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 {
await Deno.remove(directory, { recursive: true });
}
});
+113
View File
@@ -0,0 +1,113 @@
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
import { join } from "@std/path";
import { cleanup } from "../src/lifecycle.ts";
import {
interpolateEnvironment,
loadScenario,
resolveScenarioPath,
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("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 () => {
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 });
}
});
+2 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -16,6 +16,7 @@
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
"@codemirror/commands": "npm:@codemirror/commands@6.9.0",
"@codemirror/language": "npm:@codemirror/language@6.12.4",
"@codemirror/state": "npm:@codemirror/state@6.7.1",
"@codemirror/view": "npm:@codemirror/view@6.43.8",
+11
View File
@@ -4,6 +4,7 @@
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"npm:@codemirror/autocomplete@6.20.0": "6.20.0",
"npm:@codemirror/commands@6.9.0": "6.9.0",
"npm:@codemirror/language@6.12.4": "6.12.4",
"npm:@codemirror/state@6.7.1": "6.7.1",
"npm:@codemirror/view@6.43.8": "6.43.8",
@@ -47,6 +48,15 @@
"@lezer/common"
]
},
"@codemirror/commands@6.9.0": {
"integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==",
"dependencies": [
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common"
]
},
"@codemirror/language@6.12.4": {
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dependencies": [
@@ -1012,6 +1022,7 @@
"workspace": {
"dependencies": [
"npm:@codemirror/autocomplete@6.20.0",
"npm:@codemirror/commands@6.9.0",
"npm:@codemirror/language@6.12.4",
"npm:@codemirror/state@6.7.1",
"npm:@codemirror/view@6.43.8",
@@ -0,0 +1,527 @@
<script lang="ts">
import {
Compartment,
EditorSelection,
EditorState,
Prec,
StateEffect,
StateField,
} from "@codemirror/state";
import {
Decoration,
EditorView,
keymap,
WidgetType,
type DecorationSet,
} from "@codemirror/view";
import {
defaultKeymap,
history,
historyKeymap,
invertedEffects,
isolateHistory,
} from "@codemirror/commands";
import { onMount } from "svelte";
import type { Segment } from "$lib/generated/protocol.ts";
import {
measureComposerPaste,
type ComposerPasteMeasurement,
} from "$lib/workspace/console/composer-paste.ts";
import {
composerDeletionRange,
composerPasteAtoms,
composerPasteToken,
pasteChipLabel,
snapshotComposerDraft,
type ComposerDraftSnapshot,
type ComposerPaste,
type ComposerTextPaste,
} from "$lib/workspace/console/composer-draft.ts";
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
interface Props {
disabled?: boolean;
ariaLabel?: string;
ariaKeyShortcuts?: string;
onchange?: (snapshot: ComposerDraftSnapshot) => void;
onkeydown?: (event: KeyboardEvent) => void;
onsubmit?: () => void;
}
let {
disabled = false,
ariaLabel = "Message",
ariaKeyShortcuts = "Meta+Enter Control+Enter",
onchange,
onkeydown,
onsubmit,
}: Props = $props();
let mountElement: HTMLDivElement;
let view: EditorView | null = null;
let nextPasteId = 1;
let nextPasteKey = 1;
const editable = new Compartment();
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
create: () => new Map(),
update(registry, transaction) {
const additions = transaction.effects.filter((effect) => effect.is(registerPaste));
if (additions.length === 0) return registry;
const next = new Map(registry);
for (const addition of additions) {
next.set(addition.value.key, addition.value.paste);
}
return next;
},
});
const registerTextPaste = StateEffect.define<ComposerTextPaste>();
const restoreTextPastes = StateEffect.define<readonly ComposerTextPaste[]>();
const textPasteState = StateField.define<readonly ComposerTextPaste[]>({
create: () => [],
update(textPastes, transaction) {
const restored = transaction.effects.find((effect) =>
effect.is(restoreTextPastes)
);
if (restored) return restored.value;
const retained: ComposerTextPaste[] = [];
for (const paste of textPastes) {
let touched = false;
transaction.changes.iterChangedRanges((from, to) => {
const replacesContent = from < paste.to && to > paste.from;
const insertsInside = from === to && from > paste.from && from < paste.to;
if (replacesContent || insertsInside) touched = true;
});
if (touched) continue;
retained.push({
...paste,
from: transaction.changes.mapPos(paste.from, 1),
to: transaction.changes.mapPos(paste.to, -1),
});
}
for (const effect of transaction.effects) {
if (effect.is(registerTextPaste)) retained.push(effect.value);
}
return retained;
},
});
class PasteChipWidget extends WidgetType {
readonly paste: ComposerPaste;
constructor(paste: ComposerPaste) {
super();
this.paste = paste;
}
override eq(other: PasteChipWidget): boolean {
return other.paste.id === this.paste.id &&
other.paste.content === this.paste.content &&
other.paste.chars === this.paste.chars &&
other.paste.lines === this.paste.lines;
}
override toDOM(): HTMLElement {
const chip = document.createElement("span");
const label = pasteChipLabel(this.paste);
chip.className = "composer-paste-chip";
chip.textContent = label;
chip.title = label;
chip.setAttribute("role", "note");
chip.setAttribute("aria-label", label);
return chip;
}
override ignoreEvent(): boolean {
return false;
}
}
function pasteDecorations(state: EditorState): DecorationSet {
const registry = state.field(pasteRegistry);
return Decoration.set(
composerPasteAtoms(state.doc.toString(), registry).map((paste) =>
Decoration.replace({
widget: new PasteChipWidget(paste),
inclusive: false,
}).range(paste.from, paste.to)
),
true,
);
}
const pasteChips = [
pasteRegistry,
textPasteState,
invertedEffects.of((transaction) =>
transaction.docChanged
? [restoreTextPastes.of(transaction.startState.field(textPasteState))]
: []
),
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
];
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
if (!state) {
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
}
return snapshotComposerDraft(
state.doc.toString(),
state.field(pasteRegistry),
state.field(textPasteState),
);
}
function emitChange(): void {
onchange?.(currentSnapshot());
}
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
if (!view) return;
const selection = view.state.selection.main;
const key = nextPasteKey++;
const paste: ComposerPaste = {
id: nextPasteId++,
content,
chars: measurement.charCount,
lines: measurement.logicalLineCount,
};
const token = composerPasteToken(key);
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: token },
selection: EditorSelection.cursor(selection.from + token.length),
effects: registerPaste.of({ key, paste }),
annotations: isolateHistory.of("full"),
userEvent: "input.paste",
});
}
function insertTextPaste(content: string): void {
if (!view) return;
const selection = view.state.selection.main;
const rendered = view.state.toText(content).toString();
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: rendered },
selection: EditorSelection.cursor(selection.from + rendered.length),
effects: registerTextPaste.of({
from: selection.from,
to: selection.from + rendered.length,
rendered,
content,
}),
annotations: isolateHistory.of("full"),
userEvent: "input.paste",
});
}
function handlePasteEvent(event: ClipboardEvent): boolean {
if (disabled || view?.state.readOnly) return false;
const content = event.clipboardData?.getData("text/plain");
if (!content) return false;
const measurement = measureComposerPaste(content);
event.preventDefault();
if (measurement.presentation === "chip") {
insertPasteChip(content, measurement);
} else {
insertTextPaste(content);
}
return true;
}
function selectedClipboardContent(state: EditorState): string | null {
const selection = state.selection.main;
if (selection.empty) return null;
const document = state.doc.sliceString(selection.from, selection.to);
const registry = state.field(pasteRegistry);
const selectedRegistry = new Map<number, ComposerPaste>();
for (const atom of composerPasteAtoms(state.doc.toString(), registry)) {
if (atom.from >= selection.from && atom.to <= selection.to) {
selectedRegistry.set(atom.key, atom);
}
}
const selectedTextPastes = state.field(textPasteState)
.filter((paste) => paste.from >= selection.from && paste.to <= selection.to)
.map((paste) => ({
...paste,
from: paste.from - selection.from,
to: paste.to - selection.from,
}));
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
}
function deleteAdjacentPasteFromView(
currentView: EditorView,
direction: "backward" | "forward",
): boolean {
if (currentView.state.readOnly) return false;
const selection = currentView.state.selection.main;
const pastes = composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
);
const deletion = composerDeletionRange(selection, pastes, direction);
if (!deletion) return false;
currentView.dispatch({
changes: deletion,
selection: EditorSelection.cursor(deletion.from),
annotations: isolateHistory.of("full"),
userEvent: "delete",
});
return true;
}
onMount(() => {
view = new EditorView({
parent: mountElement,
state: EditorState.create({
extensions: [
history(),
Prec.highest(keymap.of([
{
key: "Mod-z",
run: (currentView) => currentView.state.readOnly,
},
{
key: "Mod-Shift-z",
run: (currentView) => currentView.state.readOnly,
},
{
key: "Mod-y",
run: (currentView) => currentView.state.readOnly,
},
{
key: "Backspace",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "backward"),
},
{
key: "Delete",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "forward"),
},
])),
keymap.of([...defaultKeymap, ...historyKeymap]),
pasteChips,
editable.of([
EditorView.editable.of(!disabled),
EditorState.readOnly.of(disabled),
]),
EditorState.allowMultipleSelections.of(false),
EditorView.lineWrapping,
EditorView.contentAttributes.of({
"aria-label": ariaLabel,
"aria-keyshortcuts": ariaKeyShortcuts,
"aria-multiline": "true",
role: "textbox",
spellcheck: "true",
}),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange();
}
}),
Prec.high(EditorView.domEventHandlers({
paste(event) {
return handlePasteEvent(event);
},
copy(event, currentView) {
const content = selectedClipboardContent(currentView.state);
if (content === null || !event.clipboardData) return false;
event.preventDefault();
event.clipboardData.setData("text/plain", content);
return true;
},
cut(event, currentView) {
if (disabled) return false;
const content = selectedClipboardContent(currentView.state);
if (content === null || !event.clipboardData) return false;
event.preventDefault();
event.clipboardData.setData("text/plain", content);
const selection = currentView.state.selection.main;
currentView.dispatch({
changes: { from: selection.from, to: selection.to },
selection: EditorSelection.cursor(selection.from),
userEvent: "delete.cut",
});
return true;
},
keydown(event) {
onkeydown?.(event);
if (event.defaultPrevented) return true;
if (
shouldSubmitChatKey(event, {
mode: "mod-enter",
modKey: "auto",
enabled: !disabled,
})
) {
event.preventDefault();
onsubmit?.();
return true;
}
return false;
},
})),
EditorView.theme({
"&": { backgroundColor: "transparent" },
".cm-scroller": { fontFamily: "inherit" },
".cm-content": { caretColor: "var(--text-strong)" },
"&.cm-focused": { outline: "none" },
}),
],
}),
});
emitChange();
return () => {
view?.destroy();
view = null;
};
});
$effect(() => {
const isDisabled = disabled;
view?.dispatch({
effects: editable.reconfigure([
EditorView.editable.of(!isDisabled),
EditorState.readOnly.of(isDisabled),
]),
});
});
export function snapshot(): ComposerDraftSnapshot {
return currentSnapshot();
}
export function focus(): void {
view?.focus();
}
export function containsTarget(target: EventTarget | null): boolean {
return target instanceof Node && Boolean(view?.dom.contains(target));
}
export function cursor(): number {
return view?.state.selection.main.head ?? 0;
}
export function replaceRange(from: number, to: number, content: string): void {
if (!view || view.state.readOnly) return;
view.dispatch({
changes: { from, to, insert: content },
selection: EditorSelection.cursor(from + content.length),
userEvent: "input.complete",
});
}
export function clear(): void {
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: "" },
selection: EditorSelection.cursor(0),
userEvent: "input",
});
}
export function restoreSegments(
segments: readonly Segment[],
preserveExactText = false,
): void {
if (!view) return;
let document = "";
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
const textEffects: StateEffect<ComposerTextPaste>[] = [];
let highestPasteId = nextPasteId - 1;
for (const segment of segments) {
if (segment.kind === "text") {
const rendered = view.state.toText(segment.content).toString();
const from = document.length;
document += rendered;
if (preserveExactText) {
textEffects.push(registerTextPaste.of({
from,
to: from + rendered.length,
rendered,
content: segment.content,
}));
}
} else if (segment.kind === "paste") {
const key = nextPasteKey++;
const paste: ComposerPaste = {
id: segment.id,
content: segment.content,
chars: segment.chars,
lines: segment.lines,
};
highestPasteId = Math.max(highestPasteId, paste.id);
document += composerPasteToken(key);
pasteEffects.push(registerPaste.of({ key, paste }));
} else if (segment.kind === "file_ref") {
document += `@${segment.path}`;
}
}
nextPasteId = highestPasteId + 1;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: EditorSelection.cursor(document.length),
effects: [...pasteEffects, ...textEffects],
userEvent: "input.restore",
});
}
</script>
<div class="composer-input" class:disabled bind:this={mountElement}></div>
<style>
.composer-input {
min-width: 0;
flex: 1;
color: var(--text-strong);
font: inherit;
}
.composer-input.disabled {
opacity: 0.56;
}
.composer-input :global(.cm-editor) {
min-height: 5.35rem;
max-height: 10rem;
}
.composer-input :global(.cm-scroller) {
overflow-y: auto;
}
.composer-input :global(.cm-content) {
min-height: 5.35rem;
padding: 0.55rem 3.4rem 3rem 0.65rem;
line-height: 1.45;
}
.composer-input :global(.cm-line) {
padding: 0;
}
.composer-input :global(.composer-paste-chip) {
display: inline-flex;
align-items: center;
max-width: min(26rem, 70vw);
margin: 0 0.15rem;
padding: 0.08rem 0.42rem;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line));
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
vertical-align: baseline;
white-space: nowrap;
}
</style>
@@ -80,6 +80,85 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
};
}
export interface ComposerSegmentsRequestOptions {
preserveExactText?: boolean;
}
export function buildComposerSegmentsRequest(
sourceSegments: readonly Segment[],
options: ComposerSegmentsRequestOptions = {},
): ComposerCommandResult {
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
if (!hasPaste) {
const content = sourceSegments.map(segmentContent).join("");
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
return buildComposerRequest(content);
}
if (!content.trim()) {
return { ok: false, message: "Input is empty." };
}
const segments = coalesceTextSegments(
sourceSegments.flatMap((segment) =>
segment.kind === "text"
? parseSigilSegments(segment.content)
: [segment]
),
);
return {
ok: true,
request: { kind: "user", content, segments },
};
}
const content = sourceSegments.map(segmentContent).join("");
if (!content.trim()) {
return { ok: false, message: "Input is empty." };
}
const leadingText = sourceSegments[0]?.kind === "text"
? sourceSegments[0].content
: "";
if (leadingText.trimStart().startsWith(":")) {
return {
ok: false,
message:
"Commands cannot include a paste chip. Remove the chip or send it as a message.",
};
}
const segments: Segment[] = [];
for (const segment of sourceSegments) {
if (segment.kind === "text") {
segments.push(...parseSigilSegments(segment.content));
} else {
segments.push(segment);
}
}
return {
ok: true,
request: {
kind: "user",
content,
segments: coalesceTextSegments(segments),
},
};
}
function segmentContent(segment: Segment): string {
switch (segment.kind) {
case "text":
case "paste":
return segment.content;
case "file_ref":
return `@${segment.path}`;
case "flow":
return segment.selector;
case "paste_artifact":
return "";
default:
return "";
}
}
function buildColonCommand(commandLine: string): ComposerCommandResult {
const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean);
if (!name) {
@@ -0,0 +1,235 @@
import type { Segment } from "$lib/generated/protocol.ts";
import {
composerDeletionRange,
type ComposerPaste,
composerPasteToken,
pasteChipLabel,
snapshotComposerDraft,
} from "$lib/workspace/console/composer-draft.ts";
import { buildComposerSegmentsRequest } from "$lib/workspace/console/composer-command.ts";
import { measureComposerPaste } from "$lib/workspace/console/composer-paste.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assert(
condition: unknown,
message = "assertion failed",
): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
}
}
function paste(id: number, content: string): ComposerPaste {
const measurement = measureComposerPaste(content);
return {
id,
content,
chars: measurement.charCount,
lines: measurement.logicalLineCount,
};
}
Deno.test("composer draft preserves mixed Text and Paste order exactly", () => {
const unicodeCrlf = "🙂界\r\nsecond\r\n";
const trailingNewline = `${"x".repeat(51)}\n`;
const registry = new Map<number, ComposerPaste>([
[11, paste(1, unicodeCrlf)],
[12, paste(2, trailingNewline)],
]);
const document = `before ${composerPasteToken(11)} middle ${
composerPasteToken(12)
} after`;
const snapshot = snapshotComposerDraft(document, registry);
assertEquals(
snapshot.content,
`before ${unicodeCrlf} middle ${trailingNewline} after`,
);
assertEquals(snapshot.segments, [
{ kind: "text", content: "before " },
{
kind: "paste",
id: 1,
content: unicodeCrlf,
chars: 12,
lines: 3,
},
{ kind: "text", content: " middle " },
{
kind: "paste",
id: 2,
content: trailingNewline,
chars: 52,
lines: 2,
},
{ kind: "text", content: " after" },
]);
assertEquals(snapshot.pastes.map((entry) => entry.key), [11, 12]);
});
Deno.test("selection deletion covers mixed Text and every selected paste chip", () => {
const pastes = [
{ ...paste(1, "first"), key: 10, from: 2, to: 5 },
{ ...paste(2, "second"), key: 11, from: 8, to: 11 },
];
assertEquals(
composerDeletionRange({ from: 1, to: 12, head: 12 }, pastes, "backward"),
{ from: 1, to: 12 },
);
assertEquals(
composerDeletionRange({ from: 2, to: 11, head: 2 }, pastes, "forward"),
{ from: 2, to: 11 },
);
assertEquals(
composerDeletionRange({ from: 5, to: 5, head: 5 }, pastes, "backward"),
{ from: 2, to: 5 },
);
assertEquals(
composerDeletionRange({ from: 8, to: 8, head: 8 }, pastes, "forward"),
{ from: 8, to: 11 },
);
});
Deno.test("composer paste chip label is compact and accessible", () => {
assertEquals(
pasteChipLabel({ id: 4, content: "payload", chars: 7, lines: 1 }),
"Clipboard #4 · 7 chars · 1 line",
);
});
Deno.test("typed composer restoration retains Paste ids and metadata", () => {
const original: Segment[] = [
{ kind: "text", content: "prefix\n" },
{
kind: "paste",
id: 9,
content: "alpha\r\nbeta\r\n",
chars: 13,
lines: 3,
},
{ kind: "text", content: "\nsuffix" },
];
const registry = new Map<number, ComposerPaste>([
[31, original[1] as Extract<Segment, { kind: "paste" }>],
]);
const restored = snapshotComposerDraft(
`prefix\n${composerPasteToken(31)}\nsuffix`,
registry,
);
assertEquals(restored.segments, original);
});
Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries", () => {
const segments: Segment[] = [
{ kind: "text", content: "inspect @src/main.rs then " },
{
kind: "paste",
id: 2,
content: "a\r\nb\r\n",
chars: 6,
lines: 3,
},
{ kind: "text", content: " exactly" },
];
const result = buildComposerSegmentsRequest(segments);
assert(result.ok);
assertEquals(result.request, {
kind: "user",
content: "inspect @src/main.rs then a\r\nb\r\n exactly",
segments: [
{ kind: "text", content: "inspect " },
{ kind: "file_ref", path: "src/main.rs" },
{ kind: "text", content: " then " },
segments[1],
segments[2],
],
});
});
Deno.test("short-paste Text preserves CRLF, trailing newline, and surrounding whitespace", () => {
const original = " short\r\npaste\r\n ";
const rendered = " short\npaste\n ";
const snapshot = snapshotComposerDraft(rendered, new Map(), [{
from: 0,
to: rendered.length,
rendered,
content: original,
}]);
assertEquals(snapshot.content, original);
assertEquals(snapshot.segments, [{ kind: "text", content: original }]);
assertEquals(snapshot.textPastes.length, 1);
const result = buildComposerSegmentsRequest(snapshot.segments, {
preserveExactText: snapshot.textPastes.length > 0,
});
assert(result.ok);
assertEquals(result.request, {
kind: "user",
content: original,
segments: [{ kind: "text", content: original }],
});
});
Deno.test("edited short-paste provenance falls back to visible Text", () => {
const snapshot = snapshotComposerDraft("changed", new Map(), [{
from: 0,
to: 5,
rendered: "short",
content: "short\r\n",
}]);
assertEquals(snapshot.content, "changed");
assertEquals(snapshot.segments, [{ kind: "text", content: "changed" }]);
assertEquals(snapshot.textPastes, []);
});
Deno.test("Paste content beginning with a colon remains opaque user input", () => {
const directPaste: Segment = {
kind: "paste",
id: 3,
content: ":not-a-command\r\n",
chars: 16,
lines: 2,
};
const direct = buildComposerSegmentsRequest([directPaste]);
assert(direct.ok);
assertEquals(direct.request, {
kind: "user",
content: ":not-a-command\r\n",
segments: [directPaste],
});
const afterWhitespace = buildComposerSegmentsRequest([
{ kind: "text", content: " " },
directPaste,
]);
assert(afterWhitespace.ok);
assert(afterWhitespace.request);
assertEquals(afterWhitespace.request.kind, "user");
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
});
Deno.test("plain short-paste Text retains the existing composer request path", () => {
const result = buildComposerSegmentsRequest([
{ kind: "text", content: " short\r\npaste\r\n " },
]);
assert(result.ok);
assertEquals(result.request, {
kind: "user",
content: "short\r\npaste",
});
});
@@ -0,0 +1,151 @@
import type { Segment } from "$lib/generated/protocol.ts";
const PASTE_TOKEN_PREFIX = "\uFFF9";
const PASTE_TOKEN_SUFFIX = "\uFFFB";
const PASTE_TOKEN_PATTERN = /\uFFF9(\d+)\uFFFB/g;
export interface ComposerPaste {
id: number;
content: string;
chars: number;
lines: number;
}
export interface ComposerPasteAtom extends ComposerPaste {
key: number;
from: number;
to: number;
}
export interface ComposerTextPaste {
from: number;
to: number;
rendered: string;
content: string;
}
export interface ComposerSelection {
from: number;
to: number;
head: number;
}
export function composerDeletionRange(
selection: ComposerSelection,
pastes: readonly ComposerPasteAtom[],
direction: "backward" | "forward",
): { from: number; to: number } | null {
if (selection.from !== selection.to) {
return { from: selection.from, to: selection.to };
}
const paste = direction === "backward"
? pastes.find((candidate) => candidate.to === selection.head)
: pastes.find((candidate) => candidate.from === selection.head);
return paste ? { from: paste.from, to: paste.to } : null;
}
export interface ComposerDraftSnapshot {
document: string;
content: string;
segments: Segment[];
pastes: ComposerPasteAtom[];
textPastes: ComposerTextPaste[];
}
export function composerPasteToken(key: number): string {
return `${PASTE_TOKEN_PREFIX}${key}${PASTE_TOKEN_SUFFIX}`;
}
export function composerPasteAtoms(
document: string,
registry: ReadonlyMap<number, ComposerPaste>,
): ComposerPasteAtom[] {
const atoms: ComposerPasteAtom[] = [];
for (const match of document.matchAll(PASTE_TOKEN_PATTERN)) {
const key = Number(match[1]);
const paste = registry.get(key);
if (!paste || match.index === undefined) continue;
atoms.push({
...paste,
key,
from: match.index,
to: match.index + match[0].length,
});
}
return atoms;
}
function appendTextSegment(segments: Segment[], content: string): void {
if (content.length === 0) return;
const previous = segments.at(-1);
if (previous?.kind === "text") {
previous.content += content;
} else {
segments.push({ kind: "text", content });
}
}
export function snapshotComposerDraft(
document: string,
registry: ReadonlyMap<number, ComposerPaste>,
candidateTextPastes: readonly ComposerTextPaste[] = [],
): ComposerDraftSnapshot {
const pastes = composerPasteAtoms(document, registry);
const textPastes = candidateTextPastes
.filter((paste) =>
paste.from >= 0 &&
paste.to <= document.length &&
document.slice(paste.from, paste.to) === paste.rendered
)
.sort((left, right) => left.from - right.from);
const events = [
...pastes.map((paste) => ({
kind: "paste" as const,
from: paste.from,
to: paste.to,
paste,
})),
...textPastes.map((paste) => ({
kind: "text_paste" as const,
from: paste.from,
to: paste.to,
paste,
})),
].sort((left, right) => left.from - right.from);
const segments: Segment[] = [];
let content = "";
let cursor = 0;
for (const event of events) {
if (event.from < cursor) continue;
const text = document.slice(cursor, event.from);
appendTextSegment(segments, text);
content += text;
if (event.kind === "paste") {
segments.push({
kind: "paste",
id: event.paste.id,
content: event.paste.content,
chars: event.paste.chars,
lines: event.paste.lines,
});
} else {
appendTextSegment(segments, event.paste.content);
}
content += event.paste.content;
cursor = event.to;
}
const trailingText = document.slice(cursor);
appendTextSegment(segments, trailingText);
content += trailingText;
return { document, content, segments, pastes, textPastes };
}
export function pasteChipLabel(paste: ComposerPaste): string {
const chars = paste.chars === 1 ? "char" : "chars";
const lines = paste.lines === 1 ? "line" : "lines";
return `Clipboard #${paste.id} · ${paste.chars} ${chars} · ${paste.lines} ${lines}`;
}
@@ -554,18 +554,22 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
);
});
Deno.test("Worker Console composer fits to content without manual resize", async () => {
Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const composerInput = await Deno.readTextFile(
new URL("./ComposerInput.svelte", import.meta.url),
);
assert(
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
consolePage.includes("<ComposerInput") &&
consolePage.includes('<div class="composer-input-shell">') &&
!consolePage.includes("handleComposerShellClick") &&
consolePage.includes("bind:this={composerTextareaElement}") &&
consolePage.includes("bind:this={composerInputElement}") &&
consolePage.includes("onchange={handleComposerChange}") &&
consolePage.includes(
'event.key === "PageUp" || event.key === "PageDown"',
) &&
@@ -577,10 +581,46 @@ Deno.test("Worker Console composer fits to content without manual resize", async
consolePage.includes("pointer-events: auto") &&
consolePage.includes('class="composer-send-icon"') &&
consolePage.includes('d="M8 6L12 2L16 6"') &&
consolePage.includes(".console-composer textarea") &&
consolePage.includes("resize: none") &&
consolePage.includes("overflow-y: hidden"),
"Console composer should autosize to content, cap at ten rows, wrap input and icon send button, and disable manual resize",
composerInput.includes("max-height: 10rem") &&
composerInput.includes("EditorView.lineWrapping") &&
composerInput.includes("overflow-y: auto"),
"Console composer should use the bounded chip-capable editor with wrapping, page scrolling, and the icon send button",
);
});
Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const composerInput = await Deno.readTextFile(
new URL("./ComposerInput.svelte", import.meta.url),
);
assert(
composerInput.includes('measurement.presentation === "chip"') &&
composerInput.includes("registerTextPaste") &&
composerInput.includes("EditorView.atomicRanges") &&
composerInput.includes('key: "Backspace"') &&
composerInput.includes('key: "Delete"') &&
composerInput.includes(
"composerDeletionRange(selection, pastes, direction)",
) &&
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
composerInput.includes('key: "Mod-z"') &&
composerInput.includes("if (!view || view.state.readOnly) return") &&
composerInput.includes("if (currentView.state.readOnly) return false") &&
consolePage.includes("activeComposerTargetKey !== targetKey") &&
consolePage.includes("if (!composerEditable) return") &&
composerInput.includes('chip.setAttribute("aria-label", label)') &&
composerInput.includes("preserveExactText = false") &&
consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
consolePage.includes("switchComposerTarget(target)") &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
"Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority",
);
});
@@ -742,7 +782,7 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
'const composerEditable = $derived(protocolState === "open" && !sending);',
) &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
consolePage.includes("enabled: canSubmitDraft") &&
consolePage.includes("onsubmit={handleComposerSubmit}") &&
consolePage.includes("disabled={!composerEditable}") &&
consolePage.includes("class:stop={workerRunning}") &&
consolePage.includes('"Stop Worker"') &&
@@ -1,22 +1,21 @@
<script lang="ts">
import { tick, untrack } from "svelte";
import { tick, untrack, type SvelteComponent } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import { chatSubmit } from "$lib/workspace/console/chat-submit";
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
import {
buildComposerRequest,
buildComposerSegmentsRequest,
type WorkerConsoleInputRequest,
} from "$lib/workspace/console/composer-command";
import {
applyCompletion,
completionTokenAt,
localCommandCompletions,
type ComposerCompletionEntry,
type ComposerCompletionToken,
} from "$lib/workspace/console/composer-completion";
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import {
consoleWorkerViews,
@@ -103,7 +102,33 @@
untrack(() => data.worker?.state ?? null),
);
let workerError = $state<string | null>(untrack(() => data.workerError));
let draft = $state("");
type ComposerInputHandle = {
snapshot(): ComposerDraftSnapshot;
focus(): void;
containsTarget(target: EventTarget | null): boolean;
cursor(): number;
replaceRange(from: number, to: number, content: string): void;
clear(): void;
restoreSegments(
segments: readonly Segment[],
preserveExactText?: boolean,
): void;
};
type ComposerDraftCache = {
segments: Segment[];
preserveExactText: boolean;
};
const EMPTY_DRAFT: ComposerDraftSnapshot = {
document: "",
content: "",
segments: [],
pastes: [],
textPastes: [],
};
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
let completionEntries = $state<ComposerCompletionEntry[]>([]);
let completionToken = $state<ComposerCompletionToken | null>(null);
let completionBusy = $state(false);
@@ -130,7 +155,13 @@
let timelineOpen = $state(false);
let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null;
let composerInputElement = $state<
(SvelteComponent & ComposerInputHandle) | null
>(null);
const composerDrafts = new Map<string, ComposerDraftCache>();
let activeComposerTargetKey = untrack(
() => `${workspaceId}:${runtimeId}:${workerId}`,
);
let timelineRailDragCleanup: (() => void) | null = null;
let autoFollowConsole = $state(true);
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
@@ -196,7 +227,7 @@
const inputReady = $derived(workerState === "idle");
const composerEditable = $derived(protocolState === "open" && !sending);
const canSubmitDraft = $derived(inputReady && composerEditable);
const canSend = $derived(canSubmitDraft && draft.trim().length > 0);
const canSend = $derived(canSubmitDraft && draft.content.trim().length > 0);
const canStopFromComposer = $derived(workerRunning && composerEditable);
const composerSubmitDisabled = $derived(
workerRunning ? !canStopFromComposer : !canSend,
@@ -321,14 +352,14 @@
scheduleObservationFlush();
}
async function applyComposerCompletion(event: KeyboardEvent) {
const target = event.currentTarget;
if (!(target instanceof HTMLTextAreaElement)) {
return;
}
async function applyComposerCompletion() {
if (!composerEditable || !composerInputElement) return;
const input = composerInputElement;
const targetKey = activeComposerTargetKey;
const document = draft.document;
const token = completionTokenAt(
draft,
target.selectionStart ?? draft.length,
document,
input.cursor(),
);
completionToken = token;
completionError = null;
@@ -340,15 +371,24 @@
completionBusy = true;
try {
const entries = await resolveCompletionEntries(token);
if (
!composerEditable ||
composerInputElement !== input ||
activeComposerTargetKey !== targetKey ||
draft.document !== document
) {
return;
}
completionEntries = entries;
if (entries.length === 0) {
completionError = `No completions for ${token.sigil}${token.prefix}`;
return;
}
const applied = applyCompletion(draft, token, entries[0]);
draft = applied.value;
await tick();
target.setSelectionRange(applied.cursor, applied.cursor);
input.replaceRange(
token.start,
token.end,
`${entries[0].value} `,
);
composerNotice =
entries.length > 1
? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)`
@@ -404,7 +444,8 @@
return;
}
event.preventDefault();
void applyComposerCompletion(event);
if (!composerEditable) return;
void applyComposerCompletion();
}
function scrollConsoleByPage(direction: 1 | -1) {
@@ -465,13 +506,13 @@
}
function handleWorkerControlShortcut(event: KeyboardEvent) {
const composerFocused = event.target === composerTextareaElement;
const composerFocused = composerInputElement?.containsTarget(event.target) ?? false;
const command = resolveWorkerControlShortcut(event, {
protocolOpen: protocolState === "open",
running: workerRunning,
paused: workerPaused,
composerFocused,
draftBlank: draft.trim().length === 0,
draftBlank: draft.content.trim().length === 0,
editableTarget: isEditableTarget(event.target) && !composerFocused,
hasSelection: targetHasSelection(event.target),
});
@@ -529,16 +570,53 @@
}
}
function handleComposerSubmit(value = draft) {
function cachedComposerDraft(snapshot: ComposerDraftSnapshot): ComposerDraftCache {
return {
segments: [...snapshot.segments],
preserveExactText: snapshot.textPastes.length > 0,
};
}
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
draft = snapshot;
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
}
function switchComposerTarget(target: ConsoleTarget) {
const nextKey = `${target.workspaceId}:${target.runtimeId}:${target.workerId}`;
if (nextKey === activeComposerTargetKey) return;
if (composerInputElement) {
composerDrafts.set(
activeComposerTargetKey,
cachedComposerDraft(composerInputElement.snapshot()),
);
}
activeComposerTargetKey = nextKey;
const restored = composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(
restored.segments,
restored.preserveExactText,
);
});
}
function handleComposerSubmit() {
if (workerRunning) {
sendControl({ method: "cancel" }, "Stop");
return;
}
void submitDraft(value);
void submitDraft(composerInputElement?.snapshot() ?? draft);
}
async function submitDraft(value = draft) {
const command = buildComposerRequest(value);
async function submitDraft(value: ComposerDraftSnapshot) {
const command = buildComposerSegmentsRequest(value.segments, {
preserveExactText: value.textPastes.length > 0,
});
if (!command.ok) {
composerNotice = null;
sendError = command.message;
@@ -546,7 +624,7 @@
}
composerNotice = command.notice ?? null;
if (!command.request) {
draft = "";
composerInputElement?.clear();
return;
}
if (sending || !inputReady) {
@@ -558,7 +636,7 @@
try {
const method = composerRequestToProtocolMethod(command.request);
sendProtocolMethod(method);
draft = "";
composerInputElement?.clear();
if (method.method === "run" || method.method === "notify") {
liveWorkerState = "running";
}
@@ -1242,6 +1320,7 @@
$effect(() => {
const target = consoleTarget;
switchComposerTarget(target);
const targetWorker = data.worker;
const targetWorkerError = data.workerError;
workerViewSelectionGeneration += 1;
@@ -1521,19 +1600,15 @@
<form class="console-composer" onsubmit={sendMessage}>
<div class="composer-input-shell">
<textarea
id="worker-console-message"
aria-label="Console input"
aria-keyshortcuts="Meta+Enter Control+Enter"
bind:this={composerTextareaElement}
bind:value={draft}
use:chatSubmit={{
enabled: canSubmitDraft,
onSubmit: (value) => handleComposerSubmit(value),
}}
use:fitTextarea={{ value: draft, maxRows: 10 }}
<ComposerInput
bind:this={composerInputElement}
ariaLabel="Console input"
ariaKeyShortcuts="Meta+Enter Control+Enter"
disabled={!composerEditable}
onchange={handleComposerChange}
onkeydown={handleComposerKeydown}
disabled={!composerEditable}></textarea>
onsubmit={handleComposerSubmit}
/>
<div class="composer-input-footer">
<div class="composer-footer-slot">
{#if completionBusy || completionError || completionEntries.length > 0}
@@ -1875,27 +1950,6 @@
min-width: 0;
}
.console-composer textarea {
box-sizing: border-box;
width: 100%;
min-height: 5.35rem;
resize: none;
overflow-y: hidden;
border: 0;
border-radius: 14px;
background: transparent;
padding: 0.55rem 3.4rem 3rem 0.65rem;
font: inherit;
line-height: 1.45;
color: var(--text-strong);
outline: none;
cursor: text;
}
.console-composer textarea:disabled {
color: var(--text-muted);
}
.composer-send-button {
display: inline-grid;
width: 2.35rem;