/// 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; entry_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 Composer stays editable before connection and during admission without losing history focus or later edits", () => fixture(async (page, base, errors) => { await page.evaluate(() => localStorage.setItem( "yoi.composer-history.v1.workspace.console-live-review", JSON.stringify({ version: 1, entries: [{ segments: [{ kind: "text", content: "previous input" }], preserveExactText: false }] }), )); let release!: () => void; const waiting = new Promise((resolve) => { release = resolve; }); await page.route("**/workers/worker-a/session", async (route) => { await waiting; await route.continue(); }); try { await page.reload({ waitUntil: "domcontentloaded" }); await page.locator(".cm-content[contenteditable=true]").waitFor(); await draft(page, "offline draft"); const editor = page.locator(".cm-content"); const assertFocused = async () => assert(await editor.evaluate((node) => node === document.activeElement && node.getAttribute("contenteditable") === "true" )); await page.keyboard.press("Control+Enter"); assertEquals((await state(base)).methods.filter((method) => method.method === "submit"), []); assert(await page.locator(".composer-send-button").isDisabled()); await page.keyboard.press("ArrowUp"); assertEquals(await editor.textContent(), "previous input"); await assertFocused(); await page.keyboard.press("ArrowDown"); assertEquals(await editor.textContent(), "offline draft"); await assertFocused(); await page.keyboard.type(" edited"); assertEquals(await editor.textContent(), "offline draft edited"); release(); await page.getByText("Ready. Choose a Feature", { exact: false }).waitFor(); await page.waitForFunction(() => !(document.querySelector(".composer-send-button") as HTMLButtonElement)?.disabled); await assertFocused(); await control(base, { submit: "hold" }); await page.keyboard.press("Control+Enter"); await eventually(base, (value) => value.methods.filter((method) => method.method === "submit").length === 1); assert(await page.locator(".composer-send-button").isDisabled()); await assertFocused(); await page.keyboard.type(" while pending"); await page.keyboard.press("Control+Enter"); assertEquals((await state(base)).methods.filter((method) => method.method === "submit").length, 1); await control(base, { accept_pending: true }); await page.getByRole("button", { name: "Stop Worker", exact: true }).waitFor(); assertEquals(await editor.textContent(), "offline draft edited while pending"); await assertFocused(); await page.keyboard.type(" while running"); assertEquals(await editor.textContent(), "offline draft edited while pending while running"); await page.keyboard.press("ArrowUp"); assertEquals(await editor.textContent(), "offline draft edited"); await assertFocused(); await page.keyboard.press("ArrowDown"); assertEquals(await editor.textContent(), "offline draft edited while pending while running"); await assertFocused(); assertEquals(errors, []); } finally { release(); } })); 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 deleted pending upload cancels without blocking Submit", () => 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(); // Keyboard deletion cancels staging; a separate Remove action is not needed. 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") }); // Submit immediately, without waiting for upload completion or cancellation HTTP. await page.keyboard.type("finish late deletion"); await page.keyboard.press("Control+Enter"); const completed = await eventually( base, (value) => value.deletes.includes(lateId) && value.held_uploads.length === 0 && value.methods.filter((method) => method.method === "submit").length === 2, ); assertEquals(await page.locator(".composer-attachments").count(), 0); assertEquals( completed.methods.filter((method) => method.method === "submit").at(-1)?.params?.input, [{ kind: "text", content: "finish late deletion" }], ); // A delayed fixture release after cancellation cannot revive a resource or intent. // Component tests separately inject a late completion callback against the tombstone. await page.waitForFunction(() => document.querySelector(".cm-content")?.textContent === ""); await control(base, { release_uploads: true }); const late = await state(base); assert(!late.uploads.includes(lateId)); assertEquals(late.methods.filter((method) => method.method === "submit").length, 2); assertEquals(await page.locator(".composer-typed-chip").count(), 0); await page.screenshot({ path: join(caseOutput, "late-upload-completed.png") }); 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 pending-upload Delete, range, cut and replacement cancel staging and allow immediate text Submit", () => fixture(async (page, base, errors, caseOutput) => { await control(base, { upload: "hold" }); const results = []; for (const [index, operation] of ["Delete", "range", "cut", "replace"].entries()) { await attach(page, `keyboard-${operation}.md`); const held = await eventually(base, (value) => value.held_uploads.length === 1); const uploadId = held.held_uploads[0]; await page.locator(".composer-typed-chip").filter({ hasText: `Uploading keyboard-${operation}.md`, }).waitFor(); await page.screenshot({ path: join(caseOutput, `keyboard-${operation}-pending.png`) }); await page.locator(".cm-content").focus(); if (operation === "Delete") { await page.keyboard.press("Control+Home"); await page.keyboard.press("Delete"); } else { await page.keyboard.press("Control+a"); if (operation === "range") await page.keyboard.press("Backspace"); else if (operation === "cut") await page.keyboard.press("Control+x"); else await page.keyboard.type("replacement "); } assertEquals(await page.locator(".composer-typed-chip").count(), 0); await page.screenshot({ path: join(caseOutput, `keyboard-${operation}-deleted.png`) }); const text = `${operation === "replace" ? "replacement " : ""}text after ${operation}`; await page.keyboard.type(`text after ${operation}`); await page.keyboard.press("Control+Enter"); const accepted = await eventually( base, (value) => value.deletes.includes(uploadId) && value.held_uploads.length === 0 && value.methods.filter((method) => method.method === "submit").length === index + 1, ); assertEquals( accepted.methods.filter((method) => method.method === "submit").at(-1)?.params?.input, [{ kind: "text", content: text }], ); assertEquals(await page.locator(".composer-attachments").count(), 0); await page.waitForFunction(() => document.querySelector(".cm-content")?.textContent === ""); await control(base, { release_uploads: true }); assert(!(await state(base)).uploads.includes(uploadId)); await page.screenshot({ path: join(caseOutput, `keyboard-${operation}-accepted.png`) }); results.push({ operation, held, accepted }); } assertEquals(errors, []); await Deno.writeTextFile( join(caseOutput, "keyboard-cancellation-result.json"), JSON.stringify({ status: "pass", results, 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), ); })); Deno.test("production rewind selects transcript rows locally and sends only entry_id on Enter", () => fixture(async (page, base, errors, caseOutput) => { await control(base, { seed_rewind_history: true }); await page.reload(); await page.getByText("Ready. Choose a Feature", { exact: false }).waitFor(); await draft(page, ":rewind"); await page.keyboard.press("Enter"); const selected = page.locator(".rewind-selected"); await selected.waitFor(); assert((await selected.innerText()).includes("Review the release notes")); assertEquals((await state(base)).methods, [], "selection must not request a candidate list"); assertEquals(await page.locator(".rewind-targets").count(), 0); await page.keyboard.press("ArrowUp"); assert((await selected.innerText()).includes("Earlier rewind input")); await page.keyboard.press("ArrowDown"); assert((await selected.innerText()).includes("Review the release notes")); assert(await page.locator(".cm-content").evaluate((node) => node === document.activeElement)); await page.screenshot({ path: join(caseOutput, "rewind-selection.png") }); await page.keyboard.press("Escape"); assertEquals(await selected.count(), 0); assertEquals((await state(base)).methods, []); await draft(page, ":rewind"); await page.keyboard.press("Enter"); await selected.waitFor(); await control(base, { rewind: "reject" }); await page.keyboard.press("Enter"); await page.getByText("Synthetic rewind rejection", { exact: true }).first().waitFor(); await selected.waitFor(); await control(base, { rewind: "hold" }); await page.keyboard.press("Enter"); await page.keyboard.press("Enter"); const sent = await eventually(base, (value) => value.methods.filter((method) => method.method === "rewind_to").length === 2); assertEquals(sent.methods, [ { method: "rewind_to", params: { entry_id: "user-1" } }, { method: "rewind_to", params: { entry_id: "user-1" } }, ]); await control(base, { apply_rewind: true }); await page.waitForFunction(() => document.querySelector(".cm-content")?.textContent === "Review the release notes before sending."); assertEquals(await selected.count(), 0); assertEquals(await page.locator(".cm-content").getAttribute("contenteditable"), "true"); await page.keyboard.type(" edited"); assertEquals(await page.locator(".cm-content").textContent(), "Review the release notes before sending. edited"); assertEquals(errors, []); await page.screenshot({ path: join(caseOutput, "rewind-applied.png") }); }, true));