From 9812e68b4d4021ea778a40d9ead4fe545d09e8bf Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 20:14:19 +0900 Subject: [PATCH] fix: synchronize native picker interception in Feature browser tests --- tools/web-ux/README.md | 44 ++ .../console_live_admission_shell_test.ts | 398 +++++++++++++++ .../console_live_fixture_server.ts | 470 ++++++++++++++++++ .../console_live_picker_diagnostics.ts | 72 +++ .../browser-tests/console_live_shell_test.ts | 290 +++++++++++ tools/web-ux/deno.json | 2 +- .../scenarios/console-live-baseline-dark.json | 171 +++++++ .../console-live-baseline-light.json | 171 +++++++ tools/web-ux/scenarios/console-live-dark.json | 318 ++++++++++++ .../web-ux/scenarios/console-live-light.json | 318 ++++++++++++ 10 files changed, 2253 insertions(+), 1 deletion(-) create mode 100644 tools/web-ux/browser-tests/console_live_admission_shell_test.ts create mode 100644 tools/web-ux/browser-tests/console_live_fixture_server.ts create mode 100644 tools/web-ux/browser-tests/console_live_picker_diagnostics.ts create mode 100644 tools/web-ux/browser-tests/console_live_shell_test.ts create mode 100644 tools/web-ux/scenarios/console-live-baseline-dark.json create mode 100644 tools/web-ux/scenarios/console-live-baseline-light.json create mode 100644 tools/web-ux/scenarios/console-live-dark.json create mode 100644 tools/web-ux/scenarios/console-live-light.json diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index 058f424a3..57e9443c6 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -217,6 +217,50 @@ It captures 1440px, 768px, and 390px; `browser-tests/console_pending_shell_test. monospace inheritance, truncation, and retained-session cancellation gating. Live revision-fenced cancellation and state transitions are covered by Console component tests. No real Worker is used. +## Console live Feature invocation fixture + +`scenarios/console-live-light.json` and `console-live-dark.json` capture the **production Console** +with a synthetic live Worker at 1440px, 768px, and 390px. The fixture provides metadata for `review` +(enum, integer, boolean, and Worker-file arguments) and `attach` (the generic attachment client +adapter), a real workspace protocol WebSocket, request-ID-preserving completion responses, and +synthetic upload staging. It never executes a Worker or Feature invocation. No fixture CSS, shell, +or editor overrides are used. + +Build production sources first, optionally from an isolated copy under `target/web-ux`, then select +the build explicitly. From `tools/web-ux`: + +```sh +export WEB_UX_BUILD_ROOT='' +deno task web-ux capture --scenario scenarios/console-live-light.json --output ../../target/web-ux --run-id console-live-light +deno task web-ux capture --scenario scenarios/console-live-dark.json --output ../../target/web-ux --run-id console-live-dark +deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts +``` + +`WEB_UX_LIFECYCLE_OUTPUT` optionally places browser interaction screenshots and JSON evidence at an +explicit artifact path. Tests consume an existing build; they do not rebuild or mutate Web sources. +The lifecycle test covers argument completion, editing/removing/undoing typed chips, native picker +cancellation, generic-adapter staging, literal unselected slash prose, typed Submit values, and +abandoned-file DELETE. Admission tests verify rejection preserves drafts and staged files, +transport send alone cannot clear the draft, authoritative acceptance transfers ownership, and +failed/in-flight-cancelled uploads cannot Submit. A retry/late-success matrix verifies stable upload +IDs, deleted reservation non-resurrection, and abandoned resource cleanup at accepted draft boundaries. +Intentional upload HTTP503 browser errors are recorded separately from unexpected failures. +The Chromium picker helper awaits interception acknowledgement before real gestures (not a timeout +extension or a replacement input); diagnostics retain user activation, input connectivity, and DOM +lifecycle evidence. + +The localhost-only fixture control endpoint sets deterministic `submit: accept | reject | hold` +and `upload: normal | fail | hold` modes; `accept_pending: true` releases held admission responses and +`release_uploads: true` delivers real successful responses for held uploads. +`/fixture-state` exposes actual received protocol methods, HTTP requests, emitted admission events, +upload attempts (including retry IDs), completed uploads, and DELETE IDs for assertions. Only synthetic data is recorded. + +`console-live-baseline-light.json` and `console-live-baseline-dark.json` use the same production +shell/data and capture initial plus unselected literal slash text. They support a pre-Feature HEAD +build, where no implicit Feature completion or typed chip exists. Initial and `slash-plain` capture +keys are shared with the current scenarios for like-for-like comparison; Feature-specific states +are explicitly new behavior, not fabricated baseline controls. + ## Adding a scenario 1. Name the concrete user task and expected data state; do not write “looks correct”. diff --git a/tools/web-ux/browser-tests/console_live_admission_shell_test.ts b/tools/web-ux/browser-tests/console_live_admission_shell_test.ts new file mode 100644 index 000000000..4a56a8404 --- /dev/null +++ b/tools/web-ux/browser-tests/console_live_admission_shell_test.ts @@ -0,0 +1,398 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { Buffer } from "node:buffer"; +import { + observePicker, + pickerDiagnostics, + prepareNativePicker, +} from "./console_live_picker_diagnostics.ts"; + +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const route = "/w/console-live-review/workers/W-901-console-live-fixture/console"; +const output = Deno.env.get("WEB_UX_LIFECYCLE_OUTPUT") ?? + join(root, "target/web-ux/console-live-lifecycle"); +type State = { + methods: { + method: string; + params?: { + input?: import("../../../web/workspace/src/lib/generated/protocol.ts").Segment[]; + submission_request_id?: string; + }; + }[]; + events: { event: string }[]; + requests: { method: string; path: string }[]; + uploads: string[]; + upload_attempts: string[]; + deletes: string[]; + held_uploads: string[]; +}; +const state = async (base: string): Promise => + await (await fetch(base + "/fixture-state")).json(); +const control = async (base: string, value: unknown): Promise => { + const response = await fetch(base + "/fixture-control", { + method: "POST", + body: JSON.stringify(value), + }); + assert(response.ok); + await response.body?.cancel(); +}; +async function eventually(base: string, predicate: (value: State) => boolean): Promise { + const deadline = Date.now() + 5000; + do { + const value = await state(base); + if (predicate(value)) return value; + await new Promise((resolve) => setTimeout(resolve, 25)); + } while (Date.now() < deadline); + throw new Error("Fixture evidence did not reach expected state"); +} +type FixtureBody = ( + page: Page, + base: string, + errors: string[], + caseOutput: string, +) => Promise; +async function fixture(body: FixtureBody, matrix = false): Promise { + for (const theme of matrix ? ["light", "dark"] as const : ["light"] as const) { + for (const width of matrix ? [1440, 768, 390] : [1440]) await fixtureCase(body, theme, width); + } +} +async function fixtureCase( + body: FixtureBody, + theme: "light" | "dark", + width: number, +): Promise { + const caseOutput = join(output, `${theme}-${width}`); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const base = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/console_live_fixture_server.ts"), + String(port), + Deno.env.get("WEB_UX_BUILD_ROOT") ?? join(root, "web/workspace/build"), + ], + stdout: "null", + stderr: "inherit", + }).spawn(); + try { + const deadline = Date.now() + 10000; + let started = false; + while (!started && Date.now() < deadline) { + try { + const response = await fetch(base + "/health"); + started = response.ok; + await response.body?.cancel(); + } catch { /* bounded owned startup */ } + if (!started) await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert(started); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + colorScheme: theme, + reducedMotion: "reduce", + }); + page.setDefaultTimeout(5000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + await prepareNativePicker(page); + await observePicker(page); + await page.goto(base + route); + await page.locator(".cm-content[contenteditable=true]").waitFor(); + await page.getByText("Ready. Choose a Feature", { exact: false }).waitFor(); + await Deno.mkdir(caseOutput, { recursive: true }); + try { + await body(page, base, errors, caseOutput); + await Deno.writeTextFile( + join(caseOutput, `picker-${crypto.randomUUID()}.json`), + JSON.stringify(await pickerDiagnostics(page), null, 2), + ); + } catch (error) { + const id = crypto.randomUUID(); + await page.screenshot({ path: join(caseOutput, `admission-failure-${id}.png`) }); + await Deno.writeTextFile( + join(caseOutput, `admission-failure-${id}.json`), + JSON.stringify( + { + error: String(error), + body: await page.locator("body").innerText(), + errors, + picker: await pickerDiagnostics(page), + fixture: await state(base), + }, + null, + 2, + ), + ); + throw error; + } + } finally { + await browser.close(); + } + } finally { + server.kill("SIGTERM"); + await server.status; + } +} +async function draft(page: Page, text: string): Promise { + await page.locator(".cm-content").click(); + await page.keyboard.press("Control+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(text); +} +async function review(page: Page): Promise { + await draft(page, "/rev"); + await page.getByRole("option").filter({ hasText: "review" }).waitFor(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Escape"); + await page.keyboard.type('mode="brief", count=2, enabled=true)'); + await page.locator(".composer-typed-chip").waitFor(); +} +async function attach(page: Page, name: string): Promise { + await draft(page, "/att"); + await page.getByRole("option").filter({ hasText: "attach" }).waitFor(); + const chooser = page.waitForEvent("filechooser"); + await page.keyboard.press("Enter"); + const nativeChooser = await chooser; + const diagnostics = await pickerDiagnostics(page) as { + kind: string; + active: boolean; + connected: boolean; + disabled?: boolean; + }[]; + const click = diagnostics.filter((record) => record.kind === "input.click").at(-1); + assert( + click?.active && click.connected && !click.disabled, + "native picker must run synchronously with active real gesture and connected enabled input", + ); + await nativeChooser.setFiles({ + name, + mimeType: "text/markdown", + buffer: Buffer.from("Synthetic fixture document"), + }); +} +Deno.test("production leading slash remains responsive after loop fix", () => + fixture(async (page, base, errors, caseOutput) => { + await draft(page, "/"); + await page.getByRole("option").filter({ hasText: "review" }).waitFor(); + assertEquals( + (await state(base)).methods.filter((method) => method.method !== "list_completions"), + [], + ); + assertEquals((await state(base)).uploads, []); + assertEquals(errors, []); + await Deno.writeTextFile( + join(caseOutput, "slash-fix-result.json"), + JSON.stringify({ status: "pass", fixture: await state(base), errors }, null, 2), + ); + })); +Deno.test("production Submit rejection retains typed draft and only authoritative acceptance clears it", () => + fixture(async (page, base, errors, caseOutput) => { + await review(page); + await attach(page, "admission.md"); + await page.locator(".composer-typed-chip").waitFor(); + // Keep the uploaded atom and add a completion-selected Feature next to it. + await page.keyboard.press("Control+End"); + await page.keyboard.type(" /rev"); + await page.getByRole("option").filter({ hasText: "review" }).waitFor(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Escape"); + await page.keyboard.type('mode="brief", count=2, enabled=true)'); + await page.waitForFunction(() => + document.querySelectorAll(".composer-typed-chip").length === 2 + ); + await control(base, { submit: "reject" }); + await page.keyboard.press("Control+Enter"); + await page.getByText("Synthetic admission rejection; draft was not accepted.", { exact: false }) + .waitFor(); + const rejected = await eventually( + base, + (value) => value.events.some((event) => event.event === "submission_rejected"), + ); + assertEquals(await page.locator(".composer-typed-chip").count(), 2); + assertEquals(rejected.deletes, [], "rejection must keep staged file ownership"); + await page.screenshot({ path: join(caseOutput, "admission-rejected.png") }); + await control(base, { submit: "hold" }); + await page.locator(".cm-content[contenteditable=true]").waitFor(); + await page.locator(".cm-content").focus(); + await page.keyboard.press("Control+Enter"); + const pending = await eventually( + base, + (value) => value.methods.filter((method) => method.method === "submit").length === 2, + ); + assertEquals( + await page.locator(".composer-typed-chip").count(), + 2, + "transport send is not admission", + ); + assertEquals(pending.deletes, []); + await page.screenshot({ path: join(caseOutput, "admission-pending.png") }); + await control(base, { accept_pending: true }); + await page.waitForFunction(() => + document.querySelectorAll(".composer-typed-chip").length === 0 + ); + const accepted = await eventually( + base, + (value) => value.events.some((event) => event.event === "submission_accepted"), + ); + assertEquals(accepted.deletes, [], "accepted file ownership transfers; no staging DELETE"); + const submissions = accepted.methods.filter((method) => method.method === "submit"); + assertEquals(submissions.length, 2); + assert( + submissions.every((method) => + method.params?.input?.some((segment) => segment.kind === "uploaded_file") + ), + ); + assert( + submissions.every((method) => + method.params?.input?.some((segment) => segment.kind === "feature_invoke") + ), + ); + assertEquals( + submissions[0].params?.input, + submissions[1].params?.input, + "retry preserves structured invocation IDs and values", + ); + assertEquals(errors, []); + await page.screenshot({ path: join(caseOutput, "admission-accepted.png") }); + await Deno.writeTextFile( + join(caseOutput, "admission-result.json"), + JSON.stringify({ status: "pass", rejected, pending, accepted, errors }, null, 2), + ); + }, true)); +Deno.test("production attachment upload failure and in-flight cancellation never Submit and record DELETE", () => + fixture(async (page, base, errors, caseOutput) => { + await control(base, { upload: "fail" }); + await attach(page, "failure.md"); + await page.getByText("Upload failed (503).", { exact: true }).waitFor(); + await page.screenshot({ path: join(caseOutput, "upload-failed.png") }); + await page.getByRole("button", { name: "Remove failure.md", exact: true }).click(); + const failed = await state(base); + assertEquals(failed.uploads, []); + assertEquals(failed.methods.filter((method) => method.method === "submit"), []); + await control(base, { upload: "hold" }); + await attach(page, "cancel.md"); + await eventually(base, (value) => value.held_uploads.length === 1); + await page.screenshot({ path: join(caseOutput, "upload-pending.png") }); + await page.getByRole("button", { name: "Remove cancel.md", exact: true }).click(); + const cancelled = await eventually( + base, + (value) => value.deletes.length > failed.deletes.length && value.held_uploads.length === 0, + ); + assertEquals(cancelled.uploads, []); + assertEquals(cancelled.methods.filter((method) => method.method === "submit"), []); + assertEquals(await page.locator(".composer-typed-chip").count(), 0); + await page.screenshot({ path: join(caseOutput, "upload-cancelled.png") }); + assert( + cancelled.requests.filter((request) => request.method === "DELETE").every((request) => + request.path.includes("/attachment-uploads/") + ), + ); + // The fixture's deliberate HTTP503 emits a browser resource error, not a page exception. + assert(errors.every((error) => /503/.test(error)), JSON.stringify(errors)); + await Deno.writeTextFile( + join(caseOutput, "upload-failure-cancellation-result.json"), + JSON.stringify({ status: "pass", failed, cancelled, expectedErrors: errors }, null, 2), + ); + }, true)); + +Deno.test("production upload retry retains upload ID and late completion cannot resurrect deleted reservation", () => + fixture(async (page, base, errors, caseOutput) => { + await control(base, { upload: "fail" }); + await attach(page, "retry.md"); + await page.getByText("Upload failed (503).", { exact: true }).waitFor(); + const failed = await state(base); + assertEquals(failed.upload_attempts.length, 1); + await control(base, { upload: "normal" }); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await page.locator(".composer-typed-chip").filter({ hasText: "Attached retry.md" }).waitFor(); + const retried = await state(base); + assertEquals( + retried.upload_attempts, + [failed.upload_attempts[0], failed.upload_attempts[0]], + "retry keeps staging identity", + ); + assertEquals(retried.uploads, [failed.upload_attempts[0]]); + await page.screenshot({ path: join(caseOutput, "upload-retried.png") }); + // Submit authoritative acceptance is covered separately; this draft is abandoned. + await draft(page, "deleted upload"); + await page.keyboard.press("Escape"); + // Clearing/accepting this text boundary releases the abandoned staged resource. + await page.keyboard.press("Control+Enter"); + const deleted = await eventually(base, (value) => value.deletes.includes(retried.uploads[0])); + await control(base, { upload: "hold" }); + await attach(page, "late.md"); + const held = await eventually(base, (value) => value.held_uploads.length === 1); + const lateId = held.held_uploads[0]; + await page.locator(".composer-typed-chip").filter({ hasText: "Uploading late.md" }).waitFor(); + // Delete only the reservation, without aborting its real pending upload. + await page.locator(".cm-content").focus(); + await page.keyboard.press("Control+End"); + await page.keyboard.press("Backspace"); + assertEquals(await page.locator(".composer-typed-chip").count(), 0); + await page.screenshot({ path: join(caseOutput, "late-upload-deleted.png") }); + const response = page.waitForResponse((response) => + response.request().method() === "PUT" && response.url().endsWith(lateId) + ); + await control(base, { release_uploads: true }); + assertEquals((await response).status(), 200); + const late = await eventually( + base, + (value) => value.uploads.includes(lateId) && value.held_uploads.length === 0, + ); + assertEquals( + await page.locator(".composer-typed-chip").count(), + 0, + "late success cannot resurrect deleted atom", + ); + assertEquals( + late.methods.filter((method) => method.method === "submit").length, + 1, + "upload/retry/late completion never submits", + ); + // Deleted resources remain undo-owned until the next accepted draft boundary. + await page.screenshot({ path: join(caseOutput, "late-upload-completed.png") }); + await draft(page, "finish late deletion"); + await page.keyboard.press("Control+Enter"); + const completed = await eventually(base, (value) => value.deletes.includes(lateId)); + assertEquals(completed.methods.filter((method) => method.method === "submit").length, 2); + assertEquals( + completed.methods.filter((method) => method.method === "submit").at(-1)?.params?.input, + [{ kind: "text", content: "finish late deletion" }], + ); + assert(errors.every((error) => /503/.test(error)), JSON.stringify(errors)); + await Deno.writeTextFile( + join(caseOutput, "retry-late-result.json"), + JSON.stringify( + { status: "pass", failed, retried, deleted, held, late, completed, expectedErrors: errors }, + null, + 2, + ), + ); + }, true)); + +Deno.test("production selected invocation preserves multiple positional typed arguments", () => + fixture(async (page, base, errors, caseOutput) => { + await draft(page, "/rev"); + await page.getByRole("option").filter({ hasText: "review" }).waitFor(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Escape"); + await page.keyboard.type('"brief", 2, true)'); + await page.locator(".composer-typed-chip").waitFor({ timeout: 2000 }); + assertEquals((await state(base)).methods.filter((method) => method.method === "submit"), []); + assertEquals(errors, []); + await Deno.writeTextFile( + join(caseOutput, "positional-result.json"), + JSON.stringify({ status: "pass", fixture: await state(base), errors }, null, 2), + ); + })); diff --git a/tools/web-ux/browser-tests/console_live_fixture_server.ts b/tools/web-ux/browser-tests/console_live_fixture_server.ts new file mode 100644 index 000000000..78705e73c --- /dev/null +++ b/tools/web-ux/browser-tests/console_live_fixture_server.ts @@ -0,0 +1,470 @@ +import { extname, join, normalize } from "jsr:@std/path@1.1.4"; + +// Synthetic authority, real production shell and protocol transport. No UI overrides. +const port = Number(Deno.args[0]); +const buildRoot = Deno.args[1] ?? Deno.env.get("WEB_UX_BUILD_ROOT"); +if (!Number.isInteger(port) || !buildRoot) { + throw new Error("usage: console_live_fixture_server.ts "); +} +const workspaceId = "console-live-review"; +const runtimeId = "runtime-a"; +const workerId = "worker-a"; +const workerPath = `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}`; +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +const invocations: + import("../../../web/workspace/src/lib/generated/protocol.ts").FeatureInvocationDescriptor[] = [ + { + identity: "fixture.review", + name: "review", + aliases: ["rv"], + display_name: "Review", + description: "Review a synthetic document without running a real Worker.", + syntax: "parenthesized", + arguments: [ + { + name: "mode", + position: 0, + required: true, + value_type: { kind: "enum", values: ["brief", "thorough"] }, + completion: { kind: "static", values: ["brief", "thorough"] }, + description: "Review detail", + }, + { + name: "count", + position: 1, + required: false, + value_type: { kind: "integer" }, + completion: { kind: "none" }, + description: "Number of examples", + }, + { + name: "enabled", + position: 2, + required: false, + value_type: { kind: "boolean" }, + completion: { kind: "static", values: ["true", "false"] }, + description: "Enable review", + }, + { + name: "path", + position: 3, + required: false, + value_type: { kind: "worker_file" }, + completion: { kind: "worker_file" }, + description: "Worker document", + }, + ], + }, + { + identity: "fixture.attach", + name: "attach", + aliases: [], + display_name: "Attach file", + description: "Stage a client file using the attachment adapter.", + syntax: "parenthesized", + arguments: [{ + name: "file", + position: 0, + required: true, + value_type: { kind: "client_file" }, + completion: { kind: "client_file" }, + }], + client_adapter: "attachment", + }, + ]; +const entries = [ + { + kind: "message", + entry_id: "user-1", + timestamp: 1, + provenance: "human_input", + role: "user", + content: [{ kind: "text", text: "Review the release notes before sending." }], + }, + { + kind: "message", + entry_id: "assistant-1", + timestamp: 2, + provenance: "model_output", + role: "assistant", + content: [{ + kind: "text", + text: + "Ready. Choose a Feature and inspect its arguments, or attach a document. Nothing runs until Submit.", + }], + }, +]; +const session = { + pending_submissions: { revision: 0, notification_count: 0, head_id: null, submissions: [] }, + entries, +}; +const state = { last_command_id: 0, state: { kind: "idle" } }; +const worker = { + worker_id: workerId, + runtime_id: runtimeId, + resource_key: "W-901", + display_name: "Console Live Fixture", + label: "Console Live Fixture", + availability: "observed", + state: "idle", + worker_state: state, + retention_state: "active", + host_id: "fixture-host", + profile: "default", + tags: [], + diagnostics: [], + workspace: { workspace_id: workspaceId, visibility: "workspace", identity: workspaceId }, + implementation: { kind: "builtin", display_hint: "fixture" }, + workdir_attachments: [], +}; +const snapshot = { + event: "snapshot", + data: { + session, + state, + greeting: { + worker_name: worker.label, + cwd: "/synthetic/workdir", + provider: "fixture", + model: "synthetic", + scope_summary: "synthetic read-only files", + tools: [], + context_window: 200000, + context_tokens: 2400, + }, + }, +}; +const methods: unknown[] = []; +const requests: { method: string; path: string }[] = []; +const events: unknown[] = []; +let submitMode: "accept" | "reject" | "hold" = "accept"; +let uploadMode: "normal" | "fail" | "hold" = "normal"; +const pendingAcceptances: (() => void)[] = []; +const cancelledUploads = new Set(); +const heldUploads = new Map void>(); +const releaseHeldUploads = new Map void>(); +const grants = new Map(); +const uploads: string[] = []; +const uploadAttempts: string[] = []; +const deletes: string[] = []; +let historyRequests = 0; +let nextSubscription = 0; +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", + ".wasm": "application/wasm", +}; +async function staticResponse(pathname: string): Promise { + const relative = normalize(pathname.replace(/^\/+/, "") || "index.html"); + if (relative.startsWith("..")) return new Response("not found", { status: 404 }); + let path = join(buildRoot, relative); + try { + if ((await Deno.stat(path)).isDirectory) path = join(path, "index.html"); + return new Response(await Deno.readFile(path), { + headers: { "content-type": mime[extname(path)] ?? "application/octet-stream" }, + }); + } catch { + return new Response(await Deno.readFile(join(buildRoot, "index.html")), { + headers: { "content-type": mime[".html"] }, + }); + } +} +Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + if (url.pathname === "/fixture-state") { + return json({ + methods, + events, + requests, + uploads, + upload_attempts: uploadAttempts, + deletes, + held_uploads: [...heldUploads.keys()], + history_requests: historyRequests, + }); + } + if (url.pathname === "/fixture-control" && request.method === "POST") { + const control = await request.json(); + if (["accept", "reject", "hold"].includes(control.submit)) submitMode = control.submit; + if (["normal", "fail", "hold"].includes(control.upload)) uploadMode = control.upload; + if (control.accept_pending) { for (const accept of pendingAcceptances.splice(0)) accept(); } + if (control.release_uploads) { + for (const release of [...releaseHeldUploads.values()]) release(); + } + return json({ submitMode, uploadMode }); + } + if (url.pathname.startsWith("/api/")) { + requests.push({ method: request.method, path: url.pathname }); + } + if (url.pathname === "/api/workspaces") { + return json([{ + workspace_id: workspaceId, + owner_account_id: "owner", + display_name: "Console Live Review", + state: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }]); + } + if (url.pathname === `/api/w/${workspaceId}/workspace`) { + return json({ + workspace_id: workspaceId, + display_name: "Console Live Review", + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: url.origin, + public_base_url: url.origin, + cookie_name: "fixture", + }, + }, + permissions: { + manage_repositories: false, + manage_secrets: false, + manage_runtimes: false, + delete_workspace: false, + }, + extension_points: { + store: "fixture", + event_stream: { status: "ready", note: "fixture", diagnostics: [] }, + host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] }, + companion_console: { status: "ready", note: "fixture", diagnostics: [] }, + }, + }); + } + if (url.pathname === `/api/w/${workspaceId}/repositories`) { + return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] }); + } + if (url.pathname === `/api/w/${workspaceId}/working-directories`) { + return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); + } + if (url.pathname === `/api/w/${workspaceId}/workers/W-901` || url.pathname === workerPath) { + return json(worker); + } + if (url.pathname === `${workerPath}/session`) return json({ availability: "live_protocol" }); + if (url.pathname === `${workerPath}/session/history`) { + historyRequests++; + return json({ + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + compact_ancestor_lineage_ids: [], + turns: [{ turn_id: "user-1", entries }], + next_cursor: null, + has_more: false, + }, + }); + } + if (url.pathname === `${workerPath}/attachment-upload-grants` && request.method === "POST") { + const id = url.searchParams.get("upload_id")!; + grants.set(id, { + file_name: url.searchParams.get("file_name")!, + media_type: url.searchParams.get("media_type")!, + }); + return json({ upload_id: id, expires_at_ms: Date.now() + 60000 }); + } + if (url.pathname.startsWith(`${workerPath}/attachment-uploads/`)) { + const id = url.pathname.split("/").at(-1)!; + if (request.method === "DELETE") { + deletes.push(id); + grants.delete(id); + cancelledUploads.add(id); + heldUploads.get(id)?.(new Response(null, { status: 204 })); + heldUploads.delete(id); + releaseHeldUploads.delete(id); + return new Response(null, { status: 204 }); + } + const grant = grants.get(id); + if (!grant || request.method !== "PUT") return json({ error: "unknown_upload" }, 404); + uploadAttempts.push(id); + if (uploadMode === "fail") { + await request.body?.cancel(); + return json({ error: "synthetic_upload_failure" }, 503); + } + const bytes = await request.arrayBuffer(); + const hash = Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + const complete = () => { + uploads.push(id); + return json({ + file: { + artifact_id: id, + ...grant, + created_at_ms: Date.now(), + availability: "available", + byte_len: bytes.byteLength, + sha256: hash, + }, + }); + }; + if (uploadMode === "hold") { + if (cancelledUploads.has(id)) return new Response(null, { status: 204 }); + return await new Promise((resolve) => { + heldUploads.set(id, resolve); + releaseHeldUploads.set(id, () => { + heldUploads.delete(id); + releaseHeldUploads.delete(id); + resolve(complete()); + }); + }); + } + return complete(); + } + if (url.pathname.startsWith(`${workerPath}/attachments/`) && request.method === "DELETE") { + deletes.push(url.pathname.split("/").at(-1)!); + return new Response(null, { status: 204 }); + } + if ( + url.pathname === `/api/w/${workspaceId}/protocol/ws` && + request.headers.get("upgrade") === "websocket" + ) { + const { socket, response } = Deno.upgradeWebSocket(request); + const subscriptions = new Map(); + const send = (frame: unknown) => socket.send(JSON.stringify(frame)); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame.frame === "request" && frame.message.method === "subscribe_events") { + const { request_id, selector } = frame.message.params; + const subscription_id = `fixture-${++nextSubscription}`; + subscriptions.set(subscription_id, selector.topic); + send({ + protocol_version: 2, + frame: "response", + message: { + result: "subscribed", + payload: { + request_id, + subscription_id, + selector, + snapshot: selector.topic === "worker_protocol" + ? { topic: "worker_protocol", data: { worker_id: workerId, events: [snapshot] } } + : { topic: "workers", data: { workers: [] } }, + }, + }, + }); + } else if (frame.frame === "worker_protocol") { + const method = frame.message.method; + methods.push(method); + const subscription_id = frame.message.subscription_id; + if (subscriptions.get(subscription_id) !== "worker_protocol") return; + if (method.method === "list_completions") { + const { kind, prefix, request_id, context } = method.params; + let candidates: unknown[] = []; + if (kind === "feature") { + candidates = invocations.filter((descriptor) => + descriptor.name.startsWith(prefix) || + descriptor.aliases.some((alias) => alias.startsWith(prefix)) + ).map((invocation) => ({ + value: invocation.name, + is_dir: false, + description: invocation.description, + invocation, + })); + } else if (kind === "file") { + candidates = ["docs/", "docs/release-notes.md"].filter((value) => + value.startsWith(prefix) + ).map((value) => ({ value, is_dir: value.endsWith("/") })); + } else if (context?.invocation === "fixture.review") { + const descriptor = invocations[0].arguments.find((argument) => + argument.name === context.argument + ); + const values = !context.argument + ? invocations[0].arguments.map((argument) => argument.name + "=") + : descriptor?.completion.kind === "static" + ? descriptor.completion.values ?? [] + : context.argument === "path" + ? ["docs/release-notes.md", "docs/long-release-notes-for-responsive-validation.md"] + : []; + candidates = values.filter((value) => value.startsWith(prefix)).map((value) => ({ + value, + is_dir: false, + })); + } + send({ + protocol_version: 2, + frame: "event", + message: { + event: "event", + data: { + subscription_id, + payload: { + event: "worker_protocol", + data: { + worker_id: workerId, + event: { + event: "completions", + data: { + kind, + prefix, + ...(request_id ? { request_id } : {}), + ...(context ? { context } : {}), + entries: candidates, + }, + }, + }, + }, + }, + }, + }); + } else if (method.method === "submit") { + const emit = (event: unknown) => { + events.push(event); + if (socket.readyState !== WebSocket.OPEN) return; + send({ + protocol_version: 2, + frame: "event", + message: { + event: "event", + data: { + subscription_id, + payload: { event: "worker_protocol", data: { worker_id: workerId, event } }, + }, + }, + }); + }; + const submission_request_id = method.params.submission_request_id; + const accept = () => + emit({ + event: "submission_accepted", + data: { + submission_request_id, + submission_id: `accepted-${submission_request_id}`, + disposition: "started", + }, + }); + if (submitMode === "reject") { + emit({ + event: "submission_rejected", + data: { + submission_request_id, + message: "Synthetic admission rejection; draft was not accepted.", + }, + }); + } else if (submitMode === "hold") pendingAcceptances.push(accept); + else accept(); + } + // Admission responses are synthetic authority only. Never execute a Feature or Worker. + } + }; + return response; + } + if (url.pathname.startsWith("/api/")) { + return json({ error: "unhandled_fixture_request", path: url.pathname }, 404); + } + return await staticResponse(url.pathname); +}); diff --git a/tools/web-ux/browser-tests/console_live_picker_diagnostics.ts b/tools/web-ux/browser-tests/console_live_picker_diagnostics.ts new file mode 100644 index 000000000..6616648ae --- /dev/null +++ b/tools/web-ux/browser-tests/console_live_picker_diagnostics.ts @@ -0,0 +1,72 @@ +/// +import type { Page } from "playwright"; + +// Playwright's listener subscription enables Chromium interception asynchronously. +// Keep it enabled for the Page lifetime and await the browser acknowledgement +// before any real gesture; otherwise keyboard.press can overtake interception. +export async function prepareNativePicker(page: Page): Promise { + page.on("filechooser", () => {}); + const session = await page.context().newCDPSession(page); + await session.send("Page.setInterceptFileChooserDialog", { enabled: true }); +} + +// Observe native controls without substituting them or synthesizing activation. +export async function observePicker(page: Page): Promise { + await page.addInitScript(() => { + const records: unknown[] = []; + Object.assign(window, { __pickerDiagnostics: records }); + const record = (kind: string, element: Element | null, extra = {}) => + records.push({ + kind, + time: performance.now(), + active: navigator.userActivation.isActive, + hasBeenActive: navigator.userActivation.hasBeenActive, + connected: element?.isConnected, + tag: element?.tagName, + className: element?.className, + editor: document.querySelector(".cm-content")?.textContent, + focused: document.activeElement?.className, + ...extra, + }); + document.addEventListener("keydown", (event) => { + if (["Enter", "Tab", "Escape"].includes(event.key)) { + record("keydown", event.target as Element, { key: event.key, trusted: event.isTrusted }); + queueMicrotask(() => record("after-keydown", event.target as Element, { key: event.key })); + } + }, true); + const nativeClick = HTMLInputElement.prototype.click; + HTMLInputElement.prototype.click = function () { + if (this.type === "file") record("input.click", this, { disabled: this.disabled }); + nativeClick.call(this); + if (this.type === "file") record("after-input.click", this); + }; + document.addEventListener("click", (event) => { + if ((event.target as HTMLInputElement)?.type === "file") { + record("input-click-event", event.target as Element, { trusted: event.isTrusted }); + } + }, true); + const observer = new MutationObserver((changes) => { + for (const change of changes) { + for ( + const [kind, nodes] of [ + ["added", change.addedNodes], + ["removed", change.removedNodes], + ] as const + ) { + for (const node of Array.from(nodes)) { + if ( + node instanceof Element && (node.matches("input[type=file], .cm-editor") || + node.querySelector("input[type=file], .cm-editor")) + ) record(kind, node); + } + } + } + }); + observer.observe(document, { childList: true, subtree: true }); + }); +} +export async function pickerDiagnostics(page: Page): Promise { + return await page.evaluate(() => + (window as unknown as { __pickerDiagnostics: unknown }).__pickerDiagnostics + ); +} diff --git a/tools/web-ux/browser-tests/console_live_shell_test.ts b/tools/web-ux/browser-tests/console_live_shell_test.ts new file mode 100644 index 000000000..84c044643 --- /dev/null +++ b/tools/web-ux/browser-tests/console_live_shell_test.ts @@ -0,0 +1,290 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { Buffer } from "node:buffer"; +import { + observePicker, + pickerDiagnostics, + prepareNativePicker, +} from "./console_live_picker_diagnostics.ts"; + +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const route = "/w/console-live-review/workers/W-901-console-live-fixture/console"; +// Build outside this test. Allows isolated snapshots without writes to production sources. +const buildRoot = Deno.env.get("WEB_UX_BUILD_ROOT") ?? join(root, "web/workspace/build"); +const outputRoot = Deno.env.get("WEB_UX_LIFECYCLE_OUTPUT") ?? + join(root, "target/web-ux/console-live-lifecycle"); +async function ready(url: string): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + await response.body?.cancel(); + if (response.ok) return; + } catch { /* owned server startup */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("Owned live fixture did not start"); +} +type Method = { + method: string; + params?: { + input?: Array<{ kind: string; invocation?: { identity: string; arguments: unknown[] } }>; + }; +}; +type FixtureState = { methods: Method[]; uploads: string[]; deletes: string[] }; +async function fixtureState(base: string): Promise { + return await (await fetch(`${base}/fixture-state`)).json(); +} +async function replaceDraft(page: Page, text: string): Promise { + await page.locator(".cm-content").click(); + await page.keyboard.press("Control+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(text); +} +async function chooseReview(page: Page): Promise { + await replaceDraft(page, "/rev"); + await page.getByRole("option").filter({ hasText: "review" }).waitFor(); + await page.keyboard.press("Tab"); +} +Deno.test("production live Console completes, edits/removes typed drafts and stages/cancels attachment adapter", async () => { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const base = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/console_live_fixture_server.ts"), + String(port), + buildRoot, + ], + stdout: "null", + stderr: "inherit", + }).spawn(); + await Deno.mkdir(outputRoot, { recursive: true }); + try { + await ready(`${base}/health`); + const browser = await chromium.launch({ headless: true }); + try { + for (const theme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const context = await browser.newContext({ + viewport: { width, height: width === 390 ? 844 : 900 }, + colorScheme: theme, + reducedMotion: "reduce", + }); + const page = await context.newPage(); + await prepareNativePicker(page); + await observePicker(page); + page.setDefaultTimeout(5000); + const errors: string[] = []; + const acknowledgedDeletes = new Set(); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(String(error))); + page.on( + "requestfailed", + (request) => + errors.push( + `failed ${request.method()} ${request.url()} ${request.failure()?.errorText}`, + ), + ); + page.on("response", (response) => { + if (response.request().method() === "DELETE" && response.status() === 204) { + acknowledgedDeletes.add(response.url()); + } + if (response.status() >= 400) { + errors.push(`HTTP ${response.status()} ${response.url()}`); + } + }); + try { + assertEquals((await page.goto(base + route))?.status(), 200); + await page.locator(".cm-content[contenteditable=true]").waitFor(); + await page.getByText("Ready. Choose a Feature", { exact: false }).waitFor(); + const start = await fixtureState(base); + await chooseReview(page); + await page.getByRole("option").filter({ hasText: "brief" }).waitFor(); + await page.keyboard.type("m"); + await page.getByRole("option").filter({ hasText: "mode=" }).click(); + await page.getByRole("option").filter({ hasText: "brief" }).click(); + await page.keyboard.type(", count=2, enabled="); + await page.getByRole("option").filter({ hasText: "true" }).click(); + await page.keyboard.type(', path="docs/'); + await page.getByRole("option").filter({ hasText: "docs/release-notes.md" }).click(); + await page.keyboard.type(")"); + await page.locator(".composer-typed-chip").waitFor(); + await page.screenshot({ path: join(outputRoot, `${theme}-${width}-invocation.png`) }); + let current = await fixtureState(base); + assertEquals(current.uploads, start.uploads, "Feature discovery must not stage files"); + assertEquals( + current.methods.filter((method) => method.method !== "list_completions"), + start.methods.filter((method) => method.method !== "list_completions"), + "completion must not submit, notify or execute controls", + ); + // Pointer chip edit opens readable syntax, keyboard editing stays unexecuted. + await page.locator(".composer-typed-chip").click(); + await page.waitForFunction(() => + document.querySelector(".cm-content")?.textContent?.includes("count=2") + ); + for ( + let index = 0; + index < ', enabled=true, path="docs/release-notes.md"'.length; + index++ + ) { + await page.keyboard.press("ArrowLeft"); + } + await page.keyboard.press("Backspace"); + await page.keyboard.type("3"); + await page.waitForFunction(() => + document.querySelector(".cm-content")?.textContent?.includes("count=3") + ); + await page.screenshot({ path: join(outputRoot, `${theme}-${width}-editing.png`) }); + await page.keyboard.press("Escape"); + await page.keyboard.press("Control+End"); + await page.keyboard.type(" "); + await page.locator(".composer-typed-chip").waitFor(); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Backspace"); + assertEquals(await page.locator(".composer-typed-chip").count(), 0); + await page.keyboard.press("Control+z"); + await page.locator(".composer-typed-chip").waitFor(); + // Cancel a native picker; no upload and no accidental invocation remains. + await replaceDraft(page, "before /att"); + await page.getByRole("option").filter({ hasText: "attach" }).waitFor(); + const picker = page.waitForEvent("filechooser"); + await page.keyboard.press("Enter"); + await (await picker).setFiles([]); + assertEquals((await fixtureState(base)).uploads, start.uploads); + assertEquals(await page.locator(".composer-typed-chip").count(), 0); + assertEquals((await page.locator(".cm-content").innerText()).trim(), "before"); + await page.screenshot({ + path: join(outputRoot, `${theme}-${width}-picker-cancelled.png`), + }); + // Same generic adapter, this time choose a synthetic local file. + await page.keyboard.type("/att"); + await page.getByRole("option").filter({ hasText: "attach" }).waitFor(); + const uploadPicker = page.waitForEvent("filechooser"); + await page.keyboard.press("Enter"); + await (await uploadPicker).setFiles({ + name: "release-notes.md", + mimeType: "text/markdown", + buffer: Buffer.from("Synthetic release notes"), + }); + await page.locator(".composer-typed-chip").waitFor(); + await page.screenshot({ path: join(outputRoot, `${theme}-${width}-attachment.png`) }); + current = await fixtureState(base); + assertEquals(current.uploads.length, start.uploads.length + 1); + await page.keyboard.press("Control+End"); + await page.keyboard.press("Backspace"); + assertEquals(await page.locator(".composer-typed-chip").count(), 0); + await page.screenshot({ path: join(outputRoot, `${theme}-${width}-deleted.png`) }); + // Staged resource is retained through undo, then released when draft is reset. + await page.keyboard.press("Control+z"); + await page.locator(".composer-typed-chip").waitFor(); + await replaceDraft(page, "literal /review(mode=brief)"); + assertEquals( + await page.locator(".composer-typed-chip").count(), + 0, + "unselected slash prose stays literal", + ); + await page.keyboard.press("Escape"); + // Explicit selection becomes the sole structured Feature payload on Submit. + await chooseReview(page); + await page.keyboard.press("Escape"); + await page.keyboard.type('mode="thorough", count=3, enabled=false)'); + await page.locator(".composer-typed-chip").waitFor(); + const release = page.waitForResponse((response) => + response.request().method() === "DELETE" && response.url().includes("/attachments/") + ); + await page.keyboard.press("Control+Enter"); + await page.waitForFunction(() => !document.querySelector(".composer-typed-chip")); + const releasedResponse = await release; + assertEquals(releasedResponse.status(), 204); + current = await fixtureState(base); + assert( + current.deletes.includes(current.uploads.at(-1)!), + "accepted draft boundary releases the abandoned uploaded atom", + ); + const submissions = current.methods.slice(start.methods.length).filter((method) => + method.method === "submit" + ); + assertEquals(submissions.length, 1); + assertEquals(submissions[0].params?.input?.[0].kind, "feature_invoke"); + assertEquals(submissions[0].params?.input?.[0].invocation?.identity, "fixture.review"); + assertEquals(submissions[0].params?.input?.[0].invocation?.arguments, [ + { name: "mode", value: { kind: "string", value: "thorough" } }, + { name: "count", value: { kind: "integer", value: 3 } }, + { name: "enabled", value: { kind: "boolean", value: false } }, + ]); + // Chromium may report ERR_ABORTED after receiving the empty 204 cleanup + // response. Retain that diagnostic, but verify delivery by both response + // status and server DELETE evidence instead of claiming a lost operation. + assert( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + "no page horizontal overflow", + ); + const pickerEvidence = await pickerDiagnostics(page); + // Snapshot diagnostics only after the final browser awaits; classification + // and serialization must use the same immutable set of observed errors. + const recordedErrors = [...errors]; + const acknowledgedDeleteDiagnostics = recordedErrors.filter((error) => + [...acknowledgedDeletes].some((url) => + error === `failed DELETE ${url} net::ERR_ABORTED` + ) + ); + assertEquals( + recordedErrors.filter((error) => !acknowledgedDeleteDiagnostics.includes(error)), + [], + ); + await Deno.writeTextFile( + join(outputRoot, `${theme}-${width}-result.json`), + JSON.stringify( + { + theme, + width, + errors: recordedErrors, + acknowledgedDeleteDiagnostics, + acknowledgedDeleteResponseStatus: releasedResponse.status(), + picker: pickerEvidence, + status: "pass", + before: start, + after: current, + }, + null, + 2, + ), + ); + } catch (error) { + await page.screenshot({ path: join(outputRoot, `${theme}-${width}-failure.png`) }); + await Deno.writeTextFile( + join(outputRoot, `${theme}-${width}-failure.json`), + JSON.stringify( + { + error: String(error), + errors, + body: await page.locator("body").innerText(), + fixture: await fixtureState(base), + }, + null, + 2, + ), + ); + throw error; + } finally { + await context.close(); + } + } + } + } finally { + await browser.close(); + } + } finally { + server.kill("SIGTERM"); + await server.status; + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index ab994d533..1ca411068 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "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 browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/console-live-baseline-dark.json b/tools/web-ux/scenarios/console-live-baseline-dark.json new file mode 100644 index 000000000..ade8afd63 --- /dev/null +++ b/tools/web-ux/scenarios/console-live-baseline-dark.json @@ -0,0 +1,171 @@ +{ + "schemaVersion": 1, + "id": "console-live-baseline-dark", + "title": "Production Console Feature invocation draft lifecycle", + "baseUrl": "http://127.0.0.1:15286", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Live synthetic Worker", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "console", + "label": "Live Console", + "path": "/w/console-live-review/workers/W-901-console-live-fixture/console", + "goal": "Inspect the unchanged production Console shell and literal slash text before Feature completion existed.", + "dataState": "Live synthetic idle Worker; review enum/integer/boolean/worker-file arguments plus generic attachment adapter. No real account, file, or invocation execution.", + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Idle live Console" + }, + { + "id": "slash-plain", + "label": "Literal unselected slash text", + "interaction": [ + { + "action": "click", + "selector": ".cm-content" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "v" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "w" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "(" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "b" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "f" + }, + { + "action": "press", + "selector": ".cm-content", + "key": ")" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Escape" + } + ], + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + } + } + ] + } + ], + "processes": [ + { + "id": "console-live-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "--allow-env=WEB_UX_BUILD_ROOT", + "browser-tests/console_live_fixture_server.ts", + "15286" + ], + "env": { + "WEB_UX_BUILD_ROOT": "${WEB_UX_BUILD_ROOT}" + }, + "readyUrl": "http://127.0.0.1:15286/health" + } + ] +} diff --git a/tools/web-ux/scenarios/console-live-baseline-light.json b/tools/web-ux/scenarios/console-live-baseline-light.json new file mode 100644 index 000000000..b0e8b3c98 --- /dev/null +++ b/tools/web-ux/scenarios/console-live-baseline-light.json @@ -0,0 +1,171 @@ +{ + "schemaVersion": 1, + "id": "console-live-baseline-light", + "title": "Production Console Feature invocation draft lifecycle", + "baseUrl": "http://127.0.0.1:15286", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Live synthetic Worker", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "console", + "label": "Live Console", + "path": "/w/console-live-review/workers/W-901-console-live-fixture/console", + "goal": "Inspect the unchanged production Console shell and literal slash text before Feature completion existed.", + "dataState": "Live synthetic idle Worker; review enum/integer/boolean/worker-file arguments plus generic attachment adapter. No real account, file, or invocation execution.", + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Idle live Console" + }, + { + "id": "slash-plain", + "label": "Literal unselected slash text", + "interaction": [ + { + "action": "click", + "selector": ".cm-content" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "v" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "w" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "(" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "b" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "f" + }, + { + "action": "press", + "selector": ".cm-content", + "key": ")" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Escape" + } + ], + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + } + } + ] + } + ], + "processes": [ + { + "id": "console-live-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "--allow-env=WEB_UX_BUILD_ROOT", + "browser-tests/console_live_fixture_server.ts", + "15286" + ], + "env": { + "WEB_UX_BUILD_ROOT": "${WEB_UX_BUILD_ROOT}" + }, + "readyUrl": "http://127.0.0.1:15286/health" + } + ] +} diff --git a/tools/web-ux/scenarios/console-live-dark.json b/tools/web-ux/scenarios/console-live-dark.json new file mode 100644 index 000000000..8e43a7ab4 --- /dev/null +++ b/tools/web-ux/scenarios/console-live-dark.json @@ -0,0 +1,318 @@ +{ + "schemaVersion": 1, + "id": "console-live-dark", + "title": "Production Console Feature invocation draft lifecycle", + "baseUrl": "http://127.0.0.1:15286", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Live synthetic Worker", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "console", + "label": "Live Console", + "path": "/w/console-live-review/workers/W-901-console-live-fixture/console", + "goal": "Discover Feature metadata, complete typed arguments and inspect a draft chip without submitting or staging files.", + "dataState": "Live synthetic idle Worker; review enum/integer/boolean/worker-file arguments plus generic attachment adapter. No real account, file, or invocation execution.", + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Idle live Console" + }, + { + "id": "slash-plain", + "label": "Literal unselected slash text", + "interaction": [ + { + "action": "click", + "selector": ".cm-content" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "v" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "w" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "(" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "b" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "f" + }, + { + "action": "press", + "selector": ".cm-content", + "key": ")" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Escape" + } + ], + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + } + }, + { + "id": "feature-menu", + "label": "Feature names", + "interaction": [ + { + "action": "click", + "selector": ".cm-content" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+Space" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "[role=option]" + } + } + ] + }, + { + "id": "argument-menu", + "label": "Argument values", + "interaction": [ + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "v" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+Space" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "[role=option]" + } + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Tab" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "[role=option]" + } + } + ] + }, + { + "id": "typed-chip", + "label": "Selected typed invocation", + "interaction": [ + { + "action": "press", + "selector": ".cm-content", + "key": "Escape" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+End" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "b" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "f" + }, + { + "action": "press", + "selector": ".cm-content", + "key": ")" + } + ], + "ready": { + "kind": "selector", + "selector": ".composer-typed-chip" + } + } + ] + } + ], + "processes": [ + { + "id": "console-live-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "--allow-env=WEB_UX_BUILD_ROOT", + "browser-tests/console_live_fixture_server.ts", + "15286" + ], + "env": { + "WEB_UX_BUILD_ROOT": "${WEB_UX_BUILD_ROOT}" + }, + "readyUrl": "http://127.0.0.1:15286/health" + } + ] +} diff --git a/tools/web-ux/scenarios/console-live-light.json b/tools/web-ux/scenarios/console-live-light.json new file mode 100644 index 000000000..ff9944411 --- /dev/null +++ b/tools/web-ux/scenarios/console-live-light.json @@ -0,0 +1,318 @@ +{ + "schemaVersion": 1, + "id": "console-live-light", + "title": "Production Console Feature invocation draft lifecycle", + "baseUrl": "http://127.0.0.1:15286", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Live synthetic Worker", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "console", + "label": "Live Console", + "path": "/w/console-live-review/workers/W-901-console-live-fixture/console", + "goal": "Discover Feature metadata, complete typed arguments and inspect a draft chip without submitting or staging files.", + "dataState": "Live synthetic idle Worker; review enum/integer/boolean/worker-file arguments plus generic attachment adapter. No real account, file, or invocation execution.", + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Idle live Console" + }, + { + "id": "slash-plain", + "label": "Literal unselected slash text", + "interaction": [ + { + "action": "click", + "selector": ".cm-content" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "v" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "w" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "(" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "b" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "f" + }, + { + "action": "press", + "selector": ".cm-content", + "key": ")" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Escape" + } + ], + "ready": { + "kind": "selector", + "selector": ".cm-content[contenteditable=true]" + } + }, + { + "id": "feature-menu", + "label": "Feature names", + "interaction": [ + { + "action": "click", + "selector": ".cm-content" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+Space" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "[role=option]" + } + } + ] + }, + { + "id": "argument-menu", + "label": "Argument values", + "interaction": [ + { + "action": "press", + "selector": ".cm-content", + "key": "Control+a" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Backspace" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "/" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "v" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+Space" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "[role=option]" + } + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Tab" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "[role=option]" + } + } + ] + }, + { + "id": "typed-chip", + "label": "Selected typed invocation", + "interaction": [ + { + "action": "press", + "selector": ".cm-content", + "key": "Escape" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "Control+End" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "b" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "r" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "i" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "e" + }, + { + "action": "press", + "selector": ".cm-content", + "key": "f" + }, + { + "action": "press", + "selector": ".cm-content", + "key": ")" + } + ], + "ready": { + "kind": "selector", + "selector": ".composer-typed-chip" + } + } + ] + } + ], + "processes": [ + { + "id": "console-live-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "--allow-env=WEB_UX_BUILD_ROOT", + "browser-tests/console_live_fixture_server.ts", + "15286" + ], + "env": { + "WEB_UX_BUILD_ROOT": "${WEB_UX_BUILD_ROOT}" + }, + "readyUrl": "http://127.0.0.1:15286/health" + } + ] +}