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