diff --git a/devshell.nix b/devshell.nix index db8ec581..8364784b 100644 --- a/devshell.nix +++ b/devshell.nix @@ -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"; diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md new file mode 100644 index 00000000..e78bab0c --- /dev/null +++ b/tools/web-ux/README.md @@ -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='' +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`. diff --git a/tools/web-ux/browser-tests/capture_smoke_test.ts b/tools/web-ux/browser-tests/capture_smoke_test.ts new file mode 100644 index 00000000..550f1689 --- /dev/null +++ b/tools/web-ux/browser-tests/capture_smoke_test.ts @@ -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 { + 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 }); + } +}); diff --git a/tools/web-ux/browser-tests/fixture_server.ts b/tools/web-ux/browser-tests/fixture_server.ts new file mode 100644 index 00000000..e2de13f5 --- /dev/null +++ b/tools/web-ux/browser-tests/fixture_server.ts @@ -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 + ? '' + : '

Ask a Workspace owner to change repository access.

'; + return new Response( + `${title}
Workspace

${title}

main

SSH repository access is configured.

${action}${canary}
`, + { headers: { "content-type": "text/html; charset=utf-8" } }, + ); +}); diff --git a/tools/web-ux/cli.ts b/tools/web-ux/cli.ts new file mode 100644 index 00000000..2c5472f9 --- /dev/null +++ b/tools/web-ux/cli.ts @@ -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 --persona [--base-url ] [--import-state ] [--expires-in-hours ] [--headless] + deno task web-ux auth --scenario --persona --delete + deno task web-ux capture --scenario [--output ] [--base-url ] [--run-id ] [--personas ] [--routes ] [--viewports ] [--headed] + deno task web-ux compare --before --after --output [--threshold <0..1>] + deno task web-ux cleanup --output [--keep ] [--older-than-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; flags: Set }; + +export function parseArguments(args: string[]): Arguments { + const command = args.shift() ?? "help"; + const values = new Map(); + const flags = new Set(); + 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 { + 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); + } +} diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json new file mode 100644 index 00000000..7bf2388b --- /dev/null +++ b/tools/web-ux/deno.json @@ -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 + } +} diff --git a/tools/web-ux/deno.lock b/tools/web-ux/deno.lock new file mode 100644 index 00000000..2dee45f5 --- /dev/null +++ b/tools/web-ux/deno.lock @@ -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" + ] + } +} diff --git a/tools/web-ux/scenarios/anonymous-entry.json b/tools/web-ux/scenarios/anonymous-entry.json new file mode 100644 index 00000000..95a329a5 --- /dev/null +++ b/tools/web-ux/scenarios/anonymous-entry.json @@ -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 }] + } + ] +} diff --git a/tools/web-ux/scenarios/workspace-control-plane.json b/tools/web-ux/scenarios/workspace-control-plane.json new file mode 100644 index 00000000..1b548f29 --- /dev/null +++ b/tools/web-ux/scenarios/workspace-control-plane.json @@ -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 } + ] + } + ] +} diff --git a/tools/web-ux/src/artifacts.ts b/tools/web-ux/src/artifacts.ts new file mode 100644 index 00000000..64db7585 --- /dev/null +++ b/tools/web-ux/src/artifacts.ts @@ -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 { + 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 { + if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600); +} + +export async function writePrivateJson(path: string, value: unknown): Promise { + 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 { + 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 { + 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 { + 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(""); +} diff --git a/tools/web-ux/src/auth_state.ts b/tools/web-ux/src/auth_state.ts new file mode 100644 index 00000000..25a9288e --- /dev/null +++ b/tools/web-ux/src/auth_state.ts @@ -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 { + 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 { + 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; + 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 { + for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) { + try { + await Deno.remove(path); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + } +} diff --git a/tools/web-ux/src/capture.ts b/tools/web-ux/src/capture.ts new file mode 100644 index 00000000..05ddc18c --- /dev/null +++ b/tools/web-ux/src/capture.ts @@ -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 { + 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 { + 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( + 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, +): 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 { + 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 { + 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(label: string, operation: () => Promise): Promise { + 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 { + 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 { + 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 { + 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll( + '"', + """, + ); +} + +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( + `
${ + escapeHtml(capture.persona.label) + } · ${escapeHtml(capture.route.id)}
${ + escapeHtml(viewportId(capture.viewport)) + } · ${escapeHtml(capture.capturePoint.label)}
${ + escapeHtml(capture.route.dataState) + }${ + capture.errors.length > 0 + ? `
${capture.errors.length} captured error(s)` + : "" + }
`, + ); + } + if (cells.length === 0) return { html: null, png: null }; + const html = + `Web UX review contact sheet
${ + cells.join("") + }
`; + 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 { + 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") + }`; +} diff --git a/tools/web-ux/src/compare.ts b/tools/web-ux/src/compare.ts new file mode 100644 index 00000000..a6b95fca --- /dev/null +++ b/tools/web-ux/src/compare.ts @@ -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 { + 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll( + '"', + """, + ); +} + +export async function compare(options: CompareOptions): Promise { + 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( + `

${escapeHtml(pair.key)}

${ + pair.dimensionMismatch + ? "dimension mismatch" + : `${pair.changedPixels} / ${pair.totalPixels} pixels changed` + }

before
after
diff
`, + ); + } + const html = + `Web UX comparison${ + 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; +} diff --git a/tools/web-ux/src/lifecycle.ts b/tools/web-ux/src/lifecycle.ts new file mode 100644 index 00000000..fc6eae4a --- /dev/null +++ b/tools/web-ux/src/lifecycle.ts @@ -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; + 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 { + 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 { + 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; +} diff --git a/tools/web-ux/src/processes.ts b/tools/web-ux/src/processes.ts new file mode 100644 index 00000000..27082f15 --- /dev/null +++ b/tools/web-ux/src/processes.ts @@ -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; + output: Promise; +}; + +async function appendOutput( + stream: ReadableStream, + destination: string, + secrets: string[], +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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((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; +} diff --git a/tools/web-ux/src/scenario.ts b/tools/web-ux/src/scenario.ts new file mode 100644 index 00000000..fa0b7796 --- /dev/null +++ b/tools/web-ux/src/scenario.ts @@ -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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${at} must be an object`); + } + return value as Record; +} + +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(); + 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 { + 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; +} diff --git a/tools/web-ux/src/types.ts b/tools/web-ux/src/types.ts new file mode 100644 index 00000000..bfee1633 --- /dev/null +++ b/tools/web-ux/src/types.ts @@ -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; + 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; +}; diff --git a/tools/web-ux/tests/auth_state_test.ts b/tools/web-ux/tests/auth_state_test.ts new file mode 100644 index 00000000..d3685fea --- /dev/null +++ b/tools/web-ux/tests/auth_state_test.ts @@ -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 }); + } +}); diff --git a/tools/web-ux/tests/cli_test.ts b/tools/web-ux/tests/cli_test.ts new file mode 100644 index 00000000..dbd6a4fb --- /dev/null +++ b/tools/web-ux/tests/cli_test.ts @@ -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"); +}); diff --git a/tools/web-ux/tests/lifecycle_test.ts b/tools/web-ux/tests/lifecycle_test.ts new file mode 100644 index 00000000..735a2111 --- /dev/null +++ b/tools/web-ux/tests/lifecycle_test.ts @@ -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 }); + } +}); diff --git a/tools/web-ux/tests/scenario_test.ts b/tools/web-ux/tests/scenario_test.ts new file mode 100644 index 00000000..abbb4c1d --- /dev/null +++ b/tools/web-ux/tests/scenario_test.ts @@ -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 }); + } +}); diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 5955fbc1..c416e66d 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 25ae28a6..71b8b841 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -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", diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte new file mode 100644 index 00000000..36672a8c --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -0,0 +1,527 @@ + + +
+ + diff --git a/web/workspace/src/lib/workspace/console/composer-command.ts b/web/workspace/src/lib/workspace/console/composer-command.ts index 743bd77a..e8fcfc91 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.ts @@ -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) { diff --git a/web/workspace/src/lib/workspace/console/composer-draft.test.ts b/web/workspace/src/lib/workspace/console/composer-draft.test.ts new file mode 100644 index 00000000..b4987b93 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-draft.test.ts @@ -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([ + [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([ + [31, original[1] as Extract], + ]); + 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", + }); +}); diff --git a/web/workspace/src/lib/workspace/console/composer-draft.ts b/web/workspace/src/lib/workspace/console/composer-draft.ts new file mode 100644 index 00000000..ad0c399d --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-draft.ts @@ -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, +): 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, + 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}`; +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index e4d55536..9959e841 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -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("') && !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"') && diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index e459f0eb..d8d9cdf4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -1,22 +1,21 @@