From c52c7ead1935c856476190418a9bdf157e478a04 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 19:50:26 +0900 Subject: [PATCH 1/8] feat: add atomic paste chips to Web Composer --- web/workspace/deno.json | 3 +- web/workspace/deno.lock | 11 + .../workspace/console/ComposerInput.svelte | 414 ++++++++++++++++++ .../lib/workspace/console/composer-command.ts | 55 +++ .../workspace/console/composer-draft.test.ts | 146 ++++++ .../lib/workspace/console/composer-draft.ts | 95 ++++ .../console/worker-console.ui.test.ts | 45 +- .../workers/[workerId]/console/+page.svelte | 140 +++--- 8 files changed, 839 insertions(+), 70 deletions(-) create mode 100644 web/workspace/src/lib/workspace/console/ComposerInput.svelte create mode 100644 web/workspace/src/lib/workspace/console/composer-draft.test.ts create mode 100644 web/workspace/src/lib/workspace/console/composer-draft.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 5955fbc1..c416e66d 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, @@ -16,6 +16,7 @@ "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", + "@codemirror/commands": "npm:@codemirror/commands@6.9.0", "@codemirror/language": "npm:@codemirror/language@6.12.4", "@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/view": "npm:@codemirror/view@6.43.8", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 25ae28a6..71b8b841 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -4,6 +4,7 @@ "jsr:@std/assert@*": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.14", "npm:@codemirror/autocomplete@6.20.0": "6.20.0", + "npm:@codemirror/commands@6.9.0": "6.9.0", "npm:@codemirror/language@6.12.4": "6.12.4", "npm:@codemirror/state@6.7.1": "6.7.1", "npm:@codemirror/view@6.43.8": "6.43.8", @@ -47,6 +48,15 @@ "@lezer/common" ] }, + "@codemirror/commands@6.9.0": { + "integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==", + "dependencies": [ + "@codemirror/language", + "@codemirror/state", + "@codemirror/view", + "@lezer/common" + ] + }, "@codemirror/language@6.12.4": { "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "dependencies": [ @@ -1012,6 +1022,7 @@ "workspace": { "dependencies": [ "npm:@codemirror/autocomplete@6.20.0", + "npm:@codemirror/commands@6.9.0", "npm:@codemirror/language@6.12.4", "npm:@codemirror/state@6.7.1", "npm:@codemirror/view@6.43.8", diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte new file mode 100644 index 00000000..11b161b4 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -0,0 +1,414 @@ + + +
+ + diff --git a/web/workspace/src/lib/workspace/console/composer-command.ts b/web/workspace/src/lib/workspace/console/composer-command.ts index 743bd77a..dfc828b0 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.ts @@ -80,6 +80,61 @@ export function buildComposerRequest(value: string): ComposerCommandResult { }; } +export function buildComposerSegmentsRequest( + sourceSegments: readonly Segment[], +): ComposerCommandResult { + const hasPaste = sourceSegments.some((segment) => segment.kind === "paste"); + if (!hasPaste) { + const content = sourceSegments.map(segmentContent).join(""); + return buildComposerRequest(content); + } + + const content = sourceSegments.map(segmentContent).join(""); + if (!content.trim()) { + return { ok: false, message: "Input is empty." }; + } + if (content.trimStart().startsWith(":")) { + return { + ok: false, + message: + "Commands cannot include a paste chip. Remove the chip or send it as a message.", + }; + } + + const segments: Segment[] = []; + for (const segment of sourceSegments) { + if (segment.kind === "text") { + segments.push(...parseSigilSegments(segment.content)); + } else { + segments.push(segment); + } + } + return { + ok: true, + request: { + kind: "user", + content, + segments: coalesceTextSegments(segments), + }, + }; +} + +function segmentContent(segment: Segment): string { + switch (segment.kind) { + case "text": + case "paste": + return segment.content; + case "file_ref": + return `@${segment.path}`; + case "flow": + return segment.selector; + case "paste_artifact": + return ""; + default: + return ""; + } +} + function buildColonCommand(commandLine: string): ComposerCommandResult { const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean); if (!name) { diff --git a/web/workspace/src/lib/workspace/console/composer-draft.test.ts b/web/workspace/src/lib/workspace/console/composer-draft.test.ts new file mode 100644 index 00000000..973654e7 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-draft.test.ts @@ -0,0 +1,146 @@ +import type { Segment } from "$lib/generated/protocol.ts"; +import { + type ComposerPaste, + composerPasteToken, + pasteChipLabel, + snapshotComposerDraft, +} from "$lib/workspace/console/composer-draft.ts"; +import { buildComposerSegmentsRequest } from "$lib/workspace/console/composer-command.ts"; +import { measureComposerPaste } from "$lib/workspace/console/composer-paste.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +function assert( + condition: unknown, + message = "assertion failed", +): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEquals(actual: unknown, expected: unknown): void { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`expected ${expectedJson}, received ${actualJson}`); + } +} + +function paste(id: number, content: string): ComposerPaste { + const measurement = measureComposerPaste(content); + return { + id, + content, + chars: measurement.charCount, + lines: measurement.logicalLineCount, + }; +} + +Deno.test("composer draft preserves mixed Text and Paste order exactly", () => { + const unicodeCrlf = "🙂界\r\nsecond\r\n"; + const trailingNewline = `${"x".repeat(51)}\n`; + const registry = new Map([ + [11, paste(1, unicodeCrlf)], + [12, paste(2, trailingNewline)], + ]); + const document = `before ${composerPasteToken(11)} middle ${ + composerPasteToken(12) + } after`; + + const snapshot = snapshotComposerDraft(document, registry); + + assertEquals( + snapshot.content, + `before ${unicodeCrlf} middle ${trailingNewline} after`, + ); + assertEquals(snapshot.segments, [ + { kind: "text", content: "before " }, + { + kind: "paste", + id: 1, + content: unicodeCrlf, + chars: 12, + lines: 3, + }, + { kind: "text", content: " middle " }, + { + kind: "paste", + id: 2, + content: trailingNewline, + chars: 52, + lines: 2, + }, + { kind: "text", content: " after" }, + ]); + assertEquals(snapshot.pastes.map((entry) => entry.key), [11, 12]); +}); + +Deno.test("composer paste chip label is compact and accessible", () => { + assertEquals( + pasteChipLabel({ id: 4, content: "payload", chars: 7, lines: 1 }), + "Clipboard #4 · 7 chars · 1 line", + ); +}); + +Deno.test("typed composer restoration retains Paste ids and metadata", () => { + const original: Segment[] = [ + { kind: "text", content: "prefix\n" }, + { + kind: "paste", + id: 9, + content: "alpha\r\nbeta\r\n", + chars: 13, + lines: 3, + }, + { kind: "text", content: "\nsuffix" }, + ]; + const registry = new Map([ + [31, original[1] as Extract], + ]); + const restored = snapshotComposerDraft( + `prefix\n${composerPasteToken(31)}\nsuffix`, + registry, + ); + + assertEquals(restored.segments, original); +}); + +Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries", () => { + const segments: Segment[] = [ + { kind: "text", content: "inspect @src/main.rs then " }, + { + kind: "paste", + id: 2, + content: "a\r\nb\r\n", + chars: 6, + lines: 3, + }, + { kind: "text", content: " exactly" }, + ]; + + const result = buildComposerSegmentsRequest(segments); + assert(result.ok); + assertEquals(result.request, { + kind: "user", + content: "inspect @src/main.rs then a\r\nb\r\n exactly", + segments: [ + { kind: "text", content: "inspect " }, + { kind: "file_ref", path: "src/main.rs" }, + { kind: "text", content: " then " }, + segments[1], + segments[2], + ], + }); +}); + +Deno.test("plain short-paste Text retains the existing composer request path", () => { + const result = buildComposerSegmentsRequest([ + { kind: "text", content: " short\r\npaste\r\n " }, + ]); + assert(result.ok); + assertEquals(result.request, { + kind: "user", + content: "short\r\npaste", + }); +}); diff --git a/web/workspace/src/lib/workspace/console/composer-draft.ts b/web/workspace/src/lib/workspace/console/composer-draft.ts new file mode 100644 index 00000000..7a204a10 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-draft.ts @@ -0,0 +1,95 @@ +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 ComposerDraftSnapshot { + document: string; + content: string; + segments: Segment[]; + pastes: ComposerPasteAtom[]; +} + +export function composerPasteToken(key: number): string { + return `${PASTE_TOKEN_PREFIX}${key}${PASTE_TOKEN_SUFFIX}`; +} + +export function composerPasteAtoms( + document: string, + registry: ReadonlyMap, +): ComposerPasteAtom[] { + const atoms: ComposerPasteAtom[] = []; + for (const match of document.matchAll(PASTE_TOKEN_PATTERN)) { + const key = Number(match[1]); + const paste = registry.get(key); + if (!paste || match.index === undefined) continue; + atoms.push({ + ...paste, + key, + from: match.index, + to: match.index + match[0].length, + }); + } + return atoms; +} + +function appendTextSegment(segments: Segment[], content: string): void { + if (content.length === 0) return; + const previous = segments.at(-1); + if (previous?.kind === "text") { + previous.content += content; + } else { + segments.push({ kind: "text", content }); + } +} + +export function snapshotComposerDraft( + document: string, + registry: ReadonlyMap, +): ComposerDraftSnapshot { + const pastes = composerPasteAtoms(document, registry); + const segments: Segment[] = []; + let content = ""; + let cursor = 0; + + for (const paste of pastes) { + const text = document.slice(cursor, paste.from); + appendTextSegment(segments, text); + content += text; + segments.push({ + kind: "paste", + id: paste.id, + content: paste.content, + chars: paste.chars, + lines: paste.lines, + }); + content += paste.content; + cursor = paste.to; + } + + const trailingText = document.slice(cursor); + appendTextSegment(segments, trailingText); + content += trailingText; + + return { document, content, segments, pastes }; +} + +export function pasteChipLabel(paste: ComposerPaste): string { + const chars = paste.chars === 1 ? "char" : "chars"; + const lines = paste.lines === 1 ? "line" : "lines"; + return `Clipboard #${paste.id} · ${paste.chars} ${chars} · ${paste.lines} ${lines}`; +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index e4d55536..1271fbf9 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -554,18 +554,22 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn ); }); -Deno.test("Worker Console composer fits to content without manual resize", async () => { +Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => { const consolePage = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", import.meta.url, ), ); + const composerInput = await Deno.readTextFile( + new URL("./ComposerInput.svelte", import.meta.url), + ); assert( - consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") && + consolePage.includes("') && !consolePage.includes("handleComposerShellClick") && - consolePage.includes("bind:this={composerTextareaElement}") && + consolePage.includes("bind:this={composerInputElement}") && + consolePage.includes("onchange={handleComposerChange}") && consolePage.includes( 'event.key === "PageUp" || event.key === "PageDown"', ) && @@ -577,10 +581,35 @@ Deno.test("Worker Console composer fits to content without manual resize", async consolePage.includes("pointer-events: auto") && consolePage.includes('class="composer-send-icon"') && consolePage.includes('d="M8 6L12 2L16 6"') && - consolePage.includes(".console-composer textarea") && - consolePage.includes("resize: none") && - consolePage.includes("overflow-y: hidden"), - "Console composer should autosize to content, cap at ten rows, wrap input and icon send button, and disable manual resize", + composerInput.includes("max-height: 10rem") && + composerInput.includes("EditorView.lineWrapping") && + composerInput.includes("overflow-y: auto"), + "Console composer should use the bounded chip-capable editor with wrapping, page scrolling, and the icon send button", + ); +}); + +Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => { + const consolePage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", + import.meta.url, + ), + ); + const composerInput = await Deno.readTextFile( + new URL("./ComposerInput.svelte", import.meta.url), + ); + assert( + composerInput.includes("handleComposerPaste(event, insertPasteChip)") && + composerInput.includes("EditorView.atomicRanges") && + composerInput.includes('key: "Backspace"') && + composerInput.includes('key: "Delete"') && + composerInput.includes('chip.setAttribute("aria-label", label)') && + composerInput.includes("restoreSegments(segments: readonly Segment[])") && + consolePage.includes("buildComposerSegmentsRequest(value.segments)") && + 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 +771,7 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac 'const composerEditable = $derived(protocolState === "open" && !sending);', ) && consolePage.includes('sendControl({ method: "cancel" }, "Stop")') && - consolePage.includes("enabled: canSubmitDraft") && + consolePage.includes("onsubmit={handleComposerSubmit}") && consolePage.includes("disabled={!composerEditable}") && consolePage.includes("class:stop={workerRunning}") && consolePage.includes('"Stop Worker"') && diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index e459f0eb..a497b64d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -1,22 +1,21 @@ `, { headers: { "content-type": "text/html; charset=utf-8" } }, ); }); diff --git a/tools/web-ux/scenarios/workspace-control-plane.json b/tools/web-ux/scenarios/workspace-control-plane.json index e23962fc..1b548f29 100644 --- a/tools/web-ux/scenarios/workspace-control-plane.json +++ b/tools/web-ux/scenarios/workspace-control-plane.json @@ -15,13 +15,16 @@ { "id": "owner", "label": "Workspace owner", - "auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/owner.json" }, + "auth": { "kind": "storage-state", "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/owner.json" }, "login": { "path": "/", "successUrl": "/w/" } }, { "id": "non-owner", "label": "Authenticated non-owner", - "auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/non-owner.json" }, + "auth": { + "kind": "storage-state", + "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/non-owner.json" + }, "login": { "path": "/", "successUrl": "/w/" } } ], diff --git a/tools/web-ux/src/artifacts.ts b/tools/web-ux/src/artifacts.ts index ebfbc178..64db7585 100644 --- a/tools/web-ux/src/artifacts.ts +++ b/tools/web-ux/src/artifacts.ts @@ -62,13 +62,70 @@ export async function writePrivateJson(path: string, value: unknown): Promise { + const files: string[] = []; + const visit = async (directory: string) => { + for await (const entry of Deno.readDir(directory)) { + const path = resolve(directory, entry.name); + if (entry.isDirectory) await visit(path); + else if (entry.isFile) files.push(path); + } + }; + await visit(root); + return files; +} + +export async function assertReviewBundleIsSecretFree( + root: string, + exactSecrets: string[] = [], +): Promise { + const secrets = exactSecrets.filter(Boolean); + const overlapLength = Math.max(256, ...secrets.map((secret) => secret.length + 1)); + for (const path of await artifactFiles(root)) { + const isText = TEXT_ARTIFACT_EXTENSIONS.some((extension) => path.endsWith(extension)); + const file = await Deno.open(path, { read: true }); + const decoder = new TextDecoder(); + let overlap = ""; + try { + const buffer = new Uint8Array(64 * 1024); + while (true) { + const count = await file.read(buffer); + if (count === null) break; + const content = overlap + decoder.decode(buffer.subarray(0, count), { stream: true }); + for (const secret of secrets) { + if (content.includes(secret)) { + throw new Error( + `review bundle artifact contains configured secret text: ${relative(root, path)}`, + ); + } + } + if (isText) assertBundleIsSecretFree(content, secrets); + overlap = content.slice(-overlapLength); + } + const final = overlap + decoder.decode(); + for (const secret of secrets) { + if (final.includes(secret)) { + throw new Error( + `review bundle artifact contains configured secret text: ${relative(root, path)}`, + ); + } + } + if (isText) assertBundleIsSecretFree(final, secrets); + } finally { + file.close(); + } + } } export async function sha256File(path: string): Promise { diff --git a/tools/web-ux/src/capture.ts b/tools/web-ux/src/capture.ts index 4608723e..05ddc18c 100644 --- a/tools/web-ux/src/capture.ts +++ b/tools/web-ux/src/capture.ts @@ -3,12 +3,14 @@ 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 { @@ -21,7 +23,9 @@ import type { CaptureError, CaptureEvidence, CapturePoint, + DiagnosticSummary, Interaction, + InteractionEvidence, Persona, ReadyCondition, ReviewContext, @@ -43,6 +47,32 @@ export type CaptureOptions = { }; 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(); @@ -73,6 +103,20 @@ async function sourceState(): Promise { } } +async function repositoryRoot(): Promise { + try { + const result = await new Deno.Command("git", { + args: ["rev-parse", "--show-toplevel"], + stdout: "piped", + stderr: "null", + }).output(); + if (result.success) return resolve(new TextDecoder().decode(result.stdout).trim()); + } catch { + // Fall back to the invocation directory outside a Git checkout. + } + return resolve(Deno.cwd()); +} + function selectById( values: T[], requested: string[] | undefined, @@ -162,7 +206,7 @@ export function isVisibleUiErrorText(content: string): boolean { async function collectVisibleUiErrors( page: Page, - errors: CaptureError[], + collector: ErrorCollector, secrets: string[], ): Promise { const alerts = page.locator('[role="alert"], [aria-live="assertive"]'); @@ -172,8 +216,10 @@ async function collectVisibleUiErrors( const content = (await alert.innerText().catch(() => "")).trim(); if (!isVisibleUiErrorText(content)) continue; const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`; - if (!errors.some((error) => error.kind === "document" && error.message === message)) { - errors.push({ kind: "document", message }); + if ( + !collector.errors.some((error) => error.kind === "document" && error.message === message) + ) { + recordError(collector, { kind: "document", message }); } } } @@ -181,19 +227,24 @@ async function collectVisibleUiErrors( async function capturePoint( page: Page, runDirectory: string, + repositoryRoot: string, persona: Persona, route: RouteScenario, viewport: Viewport, point: CapturePoint, documentResponse: Response | null, - errors: CaptureError[], + collector: ErrorCollector, + executedInteractions: InteractionEvidence[], scenario: Scenario, ): Promise { const startedAt = new Date().toISOString(); - for (const interaction of point.interaction ?? []) await performInteraction(page, interaction); + for (const interaction of point.interaction ?? []) { + await performInteraction(page, interaction); + executedInteractions.push(interactionEvidence(interaction)); + } if (point.ready) await waitReady(page, point.ready); await hideRedactedSelectors(page, scenario.redact?.selectors ?? []); - await collectVisibleUiErrors(page, errors, scenario.redact?.text ?? []); + await collectVisibleUiErrors(page, collector, scenario.redact?.text ?? []); const directory = join( runDirectory, "captures", @@ -208,7 +259,8 @@ async function capturePoint( await makePrivate(viewportScreenshot); const screenshots: ScreenshotEvidence[] = [{ kind: "viewport", - path: relative(runDirectory, viewportScreenshot), + bundlePath: relative(runDirectory, viewportScreenshot), + workdirPath: workdirLogicalPath(repositoryRoot, viewportScreenshot), sha256: await sha256File(viewportScreenshot), }]; if (point.fullPage) { @@ -217,19 +269,23 @@ async function capturePoint( await makePrivate(fullPageScreenshot); screenshots.push({ kind: "full-page", - path: relative(runDirectory, fullPageScreenshot), + bundlePath: relative(runDirectory, fullPageScreenshot), + workdirPath: workdirLogicalPath(repositoryRoot, fullPageScreenshot), sha256: await sha256File(fullPageScreenshot), }); } - let snapshotPath: string | null = null; + let snapshot: { bundlePath: string; workdirPath: string | null } | null = null; try { - const snapshot = await page.locator("body").ariaSnapshot({ timeout: 5_000 }); - const redacted = redactText(snapshot, scenario.redact?.text ?? []); + 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 }); - snapshotPath = relative(runDirectory, target); + snapshot = { + bundlePath: relative(runDirectory, target), + workdirPath: workdirLogicalPath(repositoryRoot, target), + }; } catch (error) { - errors.push({ + recordError(collector, { kind: "tool", message: `accessibility snapshot failed: ${ bounded(error instanceof Error ? error.message : String(error)) @@ -238,14 +294,22 @@ async function capturePoint( } return { persona: { id: persona.id, label: persona.label }, - route: { id: route.id, path: route.path, goal: route.goal, dataState: route.dataState }, + route: { + id: route.id, + path: route.path, + goal: route.goal, + dataState: route.dataState, + ready: route.ready, + }, viewport, theme: scenario.colorScheme ?? "light", - capturePoint: { id: point.id, label: point.label }, + capturePoint: { id: point.id, label: point.label, ready: point.ready ?? null }, + interactions: [...executedInteractions], document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null }, screenshots, - snapshotPath, - errors: [...errors], + snapshot, + errors: [...collector.errors], + errorSummary: errorSummary(collector), startedAt, finishedAt: new Date().toISOString(), }; @@ -267,14 +331,18 @@ function escapeHtml(value: string): string { async function createContactSheet( browser: Browser, runDirectory: string, + repositoryRoot: string, captures: CaptureEvidence[], -): Promise<{ html: string | null; png: string | null }> { +): Promise<{ + html: { bundlePath: string; workdirPath: string | null } | null; + png: { bundlePath: string; workdirPath: string | null } | null; +}> { const cells: string[] = []; 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.path)); + const bytes = await Deno.readFile(join(runDirectory, screenshot.bundlePath)); cells.push( `
${ escapeHtml(capture.persona.label) @@ -305,11 +373,21 @@ async function createContactSheet( } finally { await page.close(); } - return { html: relative(runDirectory, htmlPath), png: relative(runDirectory, pngPath) }; + return { + html: { + bundlePath: relative(runDirectory, htmlPath), + workdirPath: workdirLogicalPath(repositoryRoot, htmlPath), + }, + png: { + bundlePath: relative(runDirectory, pngPath), + workdirPath: workdirLogicalPath(repositoryRoot, pngPath), + }, + }; } export async function capture(options: CaptureOptions): Promise { const scenarioPath = resolve(options.scenarioPath); + const repository = await repositoryRoot(); const scenario = await loadScenario(scenarioPath); const baseUrl = validateBaseUrl( interpolateEnvironment( @@ -332,8 +410,13 @@ export async function capture(options: CaptureOptions): Promise { let browser: Browser | null = null; let processes: RunningProcess[] = []; const captures: CaptureEvidence[] = []; - const diagnostics: CaptureError[] = []; - let contactSheet = { html: null as string | null, png: null as string | null }; + 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 { @@ -362,11 +445,17 @@ export async function capture(options: CaptureOptions): Promise { }); try { for (const route of routes) { - const routeErrors: CaptureError[] = []; + const routeCollector: ErrorCollector = { + errors: [], + observed: 0, + limit: CAPTURE_ERROR_LIMIT, + }; + const routeErrors = routeCollector.errors; + const executedInteractions: InteractionEvidence[] = []; const page = await context.newPage(); page.on("console", (message) => { if (message.type() === "error") { - routeErrors.push({ + recordError(routeCollector, { kind: "console", message: bounded(redactText(message.text(), secrets)), }); @@ -375,7 +464,7 @@ export async function capture(options: CaptureOptions): Promise { page.on( "pageerror", (error) => - routeErrors.push({ + recordError(routeCollector, { kind: "page", message: bounded(redactText(error.message, secrets)), }), @@ -383,7 +472,7 @@ export async function capture(options: CaptureOptions): Promise { page.on( "requestfailed", (request) => - routeErrors.push({ + recordError(routeCollector, { kind: "request", message: bounded( redactText(request.failure()?.errorText ?? "request failed", secrets), @@ -393,7 +482,7 @@ export async function capture(options: CaptureOptions): Promise { ); page.on("response", (response) => { if (response.status() >= 400) { - routeErrors.push({ + recordError(routeCollector, { kind: "request", message: `HTTP ${response.status()}`, url: safeUrl(response.url()), @@ -426,7 +515,7 @@ export async function capture(options: CaptureOptions): Promise { } }); if (response && response.status() >= 400) { - routeErrors.push({ + recordError(routeCollector, { kind: "document", message: `document returned HTTP ${response.status()}`, url: safeUrl(response.url()), @@ -438,19 +527,21 @@ export async function capture(options: CaptureOptions): Promise { await capturePoint( page, runDirectory, + repository, persona, route, viewport, point, response, - routeErrors, + routeCollector, + executedInteractions, scenario, ), ); } } catch (error) { status = "completed-with-errors"; - routeErrors.push({ + recordError(routeCollector, { kind: "tool", message: bounded( redactText(error instanceof Error ? error.message : String(error), secrets), @@ -463,14 +554,17 @@ export async function capture(options: CaptureOptions): Promise { path: route.path, goal: route.goal, dataState: route.dataState, + ready: route.ready, }, viewport, theme: scenario.colorScheme ?? "light", - capturePoint: { id: "failed", label: "Capture failed" }, + capturePoint: { id: "failed", label: "Capture failed", ready: null }, + interactions: [...executedInteractions], document: { url: safeUrl(page.url()), status: null }, screenshots: [], - snapshotPath: null, - errors: routeErrors, + snapshot: null, + errors: [...routeErrors], + errorSummary: errorSummary(routeCollector), startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), }); @@ -483,24 +577,24 @@ export async function capture(options: CaptureOptions): Promise { } } } - contactSheet = await createContactSheet(browser, runDirectory, captures); + contactSheet = await createContactSheet(browser, runDirectory, repository, captures); if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors"; } catch (error) { status = "failed"; - diagnostics.push({ + recordError(globalCollector, { kind: "tool", message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)), }); } finally { if (browser) { await browser.close().catch((error) => - diagnostics.push({ + recordError(globalCollector, { kind: "tool", message: `browser cleanup failed: ${bounded(String(error))}`, }) ); } - diagnostics.push(...await stopOwnedProcesses(processes)); + for (const error of await stopOwnedProcesses(processes)) recordError(globalCollector, error); } if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors"; const manifest: ReviewContext = { @@ -509,7 +603,7 @@ export async function capture(options: CaptureOptions): Promise { scenario: { id: scenario.id, title: scenario.title, - sourcePath: relative(Deno.cwd(), scenarioPath), + sourcePath: workdirLogicalPath(repository, scenarioPath), }, source: await sourceState(), baseUrl: safeUrl(baseUrl), @@ -524,10 +618,12 @@ export async function capture(options: CaptureOptions): Promise { 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")}`); } diff --git a/tools/web-ux/src/compare.ts b/tools/web-ux/src/compare.ts index 05f8d767..a6b95fca 100644 --- a/tools/web-ux/src/compare.ts +++ b/tools/web-ux/src/compare.ts @@ -39,8 +39,8 @@ async function readManifest(path: string): Promise { } function viewportScreenshot(capture: CaptureEvidence): string | null { - return capture.screenshots.find((item) => item.kind === "viewport")?.path ?? - capture.screenshots[0]?.path ?? null; + return capture.screenshots.find((item) => item.kind === "viewport")?.bundlePath ?? + capture.screenshots[0]?.bundlePath ?? null; } function dataUrl(bytes: Uint8Array): string { diff --git a/tools/web-ux/src/processes.ts b/tools/web-ux/src/processes.ts index 7fca0876..27082f15 100644 --- a/tools/web-ux/src/processes.ts +++ b/tools/web-ux/src/processes.ts @@ -1,11 +1,15 @@ import { dirname, isAbsolute, resolve } from "@std/path"; -import { bounded, redactText } from "./artifacts.ts"; +import { bounded, redactText, writePrivateJson } from "./artifacts.ts"; import type { CaptureError, OwnedProcess } from "./types.ts"; +export const PROCESS_LOG_BYTE_LIMIT = 1024 * 1024; +const PROCESS_STOP_TIMEOUT_MS = 3_000; + export type RunningProcess = { id: string; pid: number; child: Deno.ChildProcess; + status: Promise; output: Promise; }; @@ -20,15 +24,45 @@ async function appendOutput( 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; - await file.write(new TextEncoder().encode(redactText(value, secrets))); + bytesObserved += encoder.encode(value).length; + pending += value; + if (pending.length > overlapCharacters * 2) { + const splitAt = pending.length - overlapCharacters; + await writeRedacted(pending.slice(0, splitAt)); + pending = pending.slice(splitAt); + } } + await writeRedacted(pending); } finally { file.close(); + await writePrivateJson(`${destination}.meta.json`, { + schemaVersion: 1, + byteLimit: PROCESS_LOG_BYTE_LIMIT, + bytesObserved, + bytesWritten, + truncated, + }); } } @@ -83,17 +117,19 @@ export async function startOwnedProcesses( `${logsDirectory}/${specification.id}.stderr.log`, 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), - child.status.then((status) => { + status.then((status) => { throw new Error( `owned process ${specification.id} exited before readiness: ${status.code}`, ); @@ -145,23 +181,64 @@ function tryKill(pid: number, signal: Deno.Signal): void { } } +async function livePids(pids: number[]): Promise { + if (Deno.build.os === "windows") return []; + if (pids.length === 0) return []; + try { + const result = await new Deno.Command("ps", { + args: ["-o", "pid=", "-p", pids.join(",")], + stdout: "piped", + stderr: "null", + }).output(); + if (!result.success && result.code !== 1) return pids; + const live = new Set( + new TextDecoder().decode(result.stdout).trim().split(/\s+/).map(Number).filter( + Number.isFinite, + ), + ); + return pids.filter((pid) => live.has(pid)); + } catch { + return pids; + } +} + +async function waitForPidsToExit(pids: number[], timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let live = await livePids(pids); + while (live.length > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + live = await livePids(live); + } + return live; +} + export async function stopOwnedProcesses(processes: RunningProcess[]): Promise { const diagnostics: CaptureError[] = []; for (const process of [...processes].reverse()) { try { - for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGTERM"); + const descendants = await descendantPids(process.pid); tryKill(process.pid, "SIGTERM"); + for (const pid of descendants) tryKill(pid, "SIGTERM"); let timer: number | undefined; - const exited = await Promise.race([ - process.child.status.then(() => true), - new Promise((resolve) => { - timer = setTimeout(() => resolve(false), 3_000); - }), - ]).finally(() => clearTimeout(timer)); - if (!exited) { - for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGKILL"); + const [parentExited, liveDescendants] = await Promise.all([ + Promise.race([ + process.status.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), PROCESS_STOP_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timer)), + waitForPidsToExit(descendants, PROCESS_STOP_TIMEOUT_MS), + ]); + if (!parentExited || liveDescendants.length > 0) { + const lateDescendants = await descendantPids(process.pid); + const forceTargets = [...new Set([...liveDescendants, ...lateDescendants])]; + for (const pid of forceTargets) tryKill(pid, "SIGKILL"); tryKill(process.pid, "SIGKILL"); - await process.child.status; + await process.status; + const survivors = await waitForPidsToExit(forceTargets, 1_000); + if (survivors.length > 0) { + throw new Error(`descendant processes did not exit: ${survivors.join(",")}`); + } } await process.output; } catch (error) { diff --git a/tools/web-ux/src/scenario.ts b/tools/web-ux/src/scenario.ts index b09fbd53..fa0b7796 100644 --- a/tools/web-ux/src/scenario.ts +++ b/tools/web-ux/src/scenario.ts @@ -70,6 +70,9 @@ function parseCapturePoint(value: unknown, at: string): CapturePoint { 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`); @@ -148,6 +151,9 @@ function parseRoute(value: unknown, at: string): RouteScenario { 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`), @@ -201,12 +207,15 @@ export async function loadScenario(sourcePath: string): Promise { 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}]`) ); @@ -239,6 +248,7 @@ export async function loadScenario(sourcePath: string): Promise { }; 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); @@ -266,5 +276,10 @@ export async function loadScenario(sourcePath: string): Promise { uniqueIds(personas, "scenario.personas"); uniqueIds(routes, "scenario.routes"); for (const route of routes) uniqueIds(route.capturePoints, `route ${route.id} capturePoints`); + const captureCount = personas.length * scenario.viewports.length * + routes.reduce((total, route) => total + route.capturePoints.length, 0); + if (captureCount > 200) { + throw new Error(`scenario capture matrix must not exceed 200 items (received ${captureCount})`); + } return scenario; } diff --git a/tools/web-ux/src/types.ts b/tools/web-ux/src/types.ts index 296639a5..bfee1633 100644 --- a/tools/web-ux/src/types.ts +++ b/tools/web-ux/src/types.ts @@ -80,22 +80,51 @@ export type CaptureError = { status?: number; }; -export type ScreenshotEvidence = { +export type ArtifactReference = { + bundlePath: string; + workdirPath: string | null; +}; + +export type ScreenshotEvidence = ArtifactReference & { kind: "viewport" | "full-page"; - path: string; sha256: string; }; +export type InteractionEvidence = + | { action: "click"; selector: string } + | { action: "fill"; selector: string; value: "[REDACTED]" } + | { action: "press"; selector: string; key: string } + | { action: "wait"; ready: ReadyCondition }; + +export type DiagnosticSummary = { + observed: number; + retained: number; + truncated: boolean; + limit: number; +}; + export type CaptureEvidence = { persona: { id: string; label: string }; - route: { id: string; path: string; goal: string; dataState: string }; + route: { + id: string; + path: string; + goal: string; + dataState: string; + ready: ReadyCondition; + }; viewport: Viewport; theme: string; - capturePoint: { id: string; label: string }; + capturePoint: { + id: string; + label: string; + ready: ReadyCondition | null; + }; + interactions: InteractionEvidence[]; document: { url: string; status: number | null }; screenshots: ScreenshotEvidence[]; - snapshotPath: string | null; + snapshot: ArtifactReference | null; errors: CaptureError[]; + errorSummary: DiagnosticSummary; startedAt: string; finishedAt: string; }; @@ -103,7 +132,7 @@ export type CaptureEvidence = { export type ReviewContext = { schemaVersion: 1; runId: string; - scenario: { id: string; title: string; sourcePath: string }; + scenario: { id: string; title: string; sourcePath: string | null }; source: { revision: string | null; dirty: boolean | null }; baseUrl: string; browser: { name: "chromium"; version: string }; @@ -111,6 +140,10 @@ export type ReviewContext = { status: "completed" | "completed-with-errors" | "failed"; filters: { personas: string[]; routes: string[]; viewports: string[] }; captures: CaptureEvidence[]; - contactSheet: { html: string | null; png: string | null }; + contactSheet: { + html: ArtifactReference | null; + png: ArtifactReference | null; + }; diagnostics: CaptureError[]; + diagnosticSummary: DiagnosticSummary; }; diff --git a/tools/web-ux/tests/lifecycle_test.ts b/tools/web-ux/tests/lifecycle_test.ts index 6e5775b3..735a2111 100644 --- a/tools/web-ux/tests/lifecycle_test.ts +++ b/tools/web-ux/tests/lifecycle_test.ts @@ -6,7 +6,11 @@ import { safeUrl, writePrivateJson, } from "../src/artifacts.ts"; -import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +import { + PROCESS_LOG_BYTE_LIMIT, + startOwnedProcesses, + stopOwnedProcesses, +} from "../src/processes.ts"; Deno.test("redaction removes common credentials and query values", () => { const redacted = redactText( @@ -69,6 +73,99 @@ Deno.test("owned process is terminated and its logs are redacted", async () => { const log = await Deno.readTextFile(logPath); assertEquals(log.includes("secret-value"), false); assertStringIncludes(log, "[REDACTED]"); + const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`)); + assertEquals(metadata.truncated, false); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("owned process logs stop at the byte limit and record truncation", async () => { + const directory = await Deno.makeTempDir(); + const scenario = join(directory, "scenario.json"); + await Deno.writeTextFile(scenario, "{}"); + try { + const processes = await startOwnedProcesses( + [{ + id: "large-output", + command: Deno.execPath(), + args: [ + "eval", + `console.log("x".repeat(${ + PROCESS_LOG_BYTE_LIMIT + 32_768 + })); setInterval(() => {}, 1000)`, + ], + }], + scenario, + join(directory, "logs"), + [], + ); + const logPath = join(directory, "logs", "large-output.stdout.log"); + for (let attempt = 0; attempt < 100; attempt++) { + try { + if ((await Deno.stat(logPath)).size >= PROCESS_LOG_BYTE_LIMIT) break; + } catch { + // The output pump creates the file asynchronously. + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assertEquals(await stopOwnedProcesses(processes), []); + assertEquals((await Deno.stat(logPath)).size, PROCESS_LOG_BYTE_LIMIT); + const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`)); + assertEquals(metadata.byteLimit, PROCESS_LOG_BYTE_LIMIT); + assertEquals(metadata.truncated, true); + assertEquals(metadata.bytesWritten, PROCESS_LOG_BYTE_LIMIT); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("forced cleanup terminates a TERM-resistant descendant", async () => { + if (Deno.build.os === "windows") return; + const directory = await Deno.makeTempDir(); + const scenario = join(directory, "scenario.json"); + const childPidPath = join(directory, "child.pid"); + await Deno.writeTextFile(scenario, "{}"); + try { + const childProgram = 'Deno.addSignalListener("SIGTERM", () => {}); setInterval(() => {}, 1000)'; + const parentProgram = ` + const child = new Deno.Command(Deno.execPath(), { + args: ["eval", ${JSON.stringify(childProgram)}], + stdout: "null", + stderr: "null" + }).spawn(); + Deno.writeTextFileSync(Deno.args[0], String(child.pid)); + Deno.addSignalListener("SIGTERM", () => {}); + setInterval(() => {}, 1000); + `; + const processes = await startOwnedProcesses( + [{ + id: "process-tree", + command: Deno.execPath(), + args: ["eval", parentProgram, childPidPath], + }], + scenario, + join(directory, "logs"), + [], + ); + let childPid = 0; + for (let attempt = 0; attempt < 100; attempt++) { + try { + childPid = Number(await Deno.readTextFile(childPidPath)); + if (childPid > 0) break; + } catch { + // The fixture publishes its descendant PID after spawn. + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assertEquals(childPid > 0, true); + assertEquals(await stopOwnedProcesses(processes), []); + const status = await new Deno.Command("ps", { + args: ["-p", String(childPid), "-o", "pid="], + stdout: "piped", + stderr: "null", + }).output(); + assertEquals(new TextDecoder().decode(status.stdout).trim(), ""); } finally { await Deno.remove(directory, { recursive: true }); } diff --git a/tools/web-ux/tests/scenario_test.ts b/tools/web-ux/tests/scenario_test.ts index 961464ee..abbb4c1d 100644 --- a/tools/web-ux/tests/scenario_test.ts +++ b/tools/web-ux/tests/scenario_test.ts @@ -1,7 +1,12 @@ import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import { join } from "@std/path"; import { cleanup } from "../src/lifecycle.ts"; -import { interpolateEnvironment, loadScenario, validateBaseUrl } from "../src/scenario.ts"; +import { + interpolateEnvironment, + loadScenario, + resolveScenarioPath, + validateBaseUrl, +} from "../src/scenario.ts"; function minimalScenario(extra = ""): string { return `{ @@ -77,6 +82,16 @@ Deno.test("environment interpolation fails closed", () => { ); }); +Deno.test("committed auth profiles resolve outside the repository", async () => { + const source = "scenarios/workspace-control-plane.json"; + const scenario = await loadScenario(source); + for (const persona of scenario.personas) { + if (persona.auth.kind !== "storage-state") continue; + const statePath = resolveScenarioPath(source, persona.auth.path); + assertEquals(statePath.startsWith(Deno.cwd()), false); + } +}); + Deno.test("cleanup removes only complete review bundles beyond retention", async () => { const directory = await Deno.makeTempDir(); try {