From aa144ee6fa0903bccae7a2e63391ef6f5d29f0ec Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 30 Sep 2026 09:36:02 +0900 Subject: [PATCH] test: verify console history in production browser --- .../console_history_fixture_server.ts | 220 ++++++++++++++++++ .../console_history_shell_test.ts | 207 ++++++++++++++++ tools/web-ux/deno.json | 2 +- 3 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 tools/web-ux/browser-tests/console_history_fixture_server.ts create mode 100644 tools/web-ux/browser-tests/console_history_shell_test.ts diff --git a/tools/web-ux/browser-tests/console_history_fixture_server.ts b/tools/web-ux/browser-tests/console_history_fixture_server.ts new file mode 100644 index 000000000..16a78d810 --- /dev/null +++ b/tools/web-ux/browser-tests/console_history_fixture_server.ts @@ -0,0 +1,220 @@ +import { extname, join, normalize } from "jsr:@std/path@1.1.4"; + +const port = Number(Deno.args[0]); +const buildRoot = Deno.args[1]; +if (!Number.isInteger(port) || !buildRoot) { + throw new Error("usage: console_history_fixture_server.ts "); +} + +const workspaceId = "console-history-review"; +const runtimeId = "runtime-a"; +const workerId = "worker-a"; +const workerResourceKey = "W-900"; +let historyRequests = 0; + +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); + +const worker = { + worker_id: workerId, + runtime_id: runtimeId, + resource_key: workerResourceKey, + display_name: "Console Fixture", + label: "Console Fixture", + availability: "observed", + state: "stopped", + retention_state: "retained", + host_id: "fixture-host", + profile: "default", + tags: [], + diagnostics: [], + capabilities: { can_stop: false, can_spawn_followup: false }, + workspace: { + workspace_id: workspaceId, + visibility: "workspace", + identity: workspaceId, + }, + implementation: { kind: "builtin", display_hint: "fixture" }, + workdir_attachments: [], +}; + +function historyEntry(index: number, role: "user" | "assistant") { + const user = role === "user"; + return { + entry_id: `${role}-${index}`, + timestamp: index, + provenance: user ? "human_input" : "model_output", + kind: "message", + role, + content: [{ + kind: "text", + text: user + ? `question ${index}` + : `done ${index}\ndetail ${index}.1\ndetail ${index}.2\ndetail ${index}.3`, + }], + }; +} + +function historyPage(indices: number[], cursor: string | null) { + return { + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + turns: indices.map((index) => ({ + turn_id: `user-${index}`, + entries: [historyEntry(index, "user"), historyEntry(index, "assistant")], + })), + next_cursor: cursor, + has_more: cursor !== null, + }, + }; +} + +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; + +async function staticResponse(pathname: string): Promise { + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); + const normalized = normalize(relative); + if (normalized.startsWith("..")) return new Response("not found", { status: 404 }); + let filePath = join(buildRoot, normalized); + try { + const stat = await Deno.stat(filePath); + if (stat.isDirectory) filePath = join(filePath, "index.html"); + return new Response(await Deno.readFile(filePath), { + headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" }, + }); + } catch { + return new Response(await Deno.readFile(join(buildRoot, "index.html")), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } +} + +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({ history_requests: historyRequests }); + if (url.pathname === "/api/workspaces") { + return json([{ + workspace_id: workspaceId, + owner_account_id: "owner", + display_name: "Console History 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 History Review", + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: `http://127.0.0.1:${port}`, + public_base_url: `http://127.0.0.1:${port}`, + 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/${workerResourceKey}` || + url.pathname === `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}` + ) { + return json(worker); + } + if ( + url.pathname === + `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session` + ) { + return json({ + availability: "retained_snapshot", + identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 0 }, + snapshot: { + pending_submissions: { + revision: 0, + notification_count: 0, + head_id: null, + submissions: [], + }, + entries: [], + }, + }); + } + if ( + url.pathname === + `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session/history` + ) { + historyRequests += 1; + switch (url.searchParams.get("cursor")) { + case null: + return json(historyPage([8, 9, 10, 11, 12], "older-8")); + case "older-8": + return json(historyPage([3, 4, 5, 6, 7], "older-3")); + case "older-3": + return json(historyPage([1, 2], null)); + default: + return json({ availability: "unavailable", reason: "invalid_cursor", message: "invalid" }); + } + } + if ( + url.pathname === `/api/w/${workspaceId}/protocol/ws` && + request.headers.get("upgrade") === "websocket" + ) { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame?.message?.method !== "subscribe_events") return; + const requestId = frame.message.params.request_id; + socket.send(JSON.stringify({ + protocol_version: 1, + frame: "response", + message: { + result: "subscribed", + payload: { + request_id: requestId, + subscription_id: "fixture-workers", + selector: frame.message.params.selector, + snapshot_revision: 1, + snapshot: { topic: "workers", data: { workers: [] } }, + }, + }, + })); + }; + return response; + } + return await staticResponse(url.pathname); +}); diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts new file mode 100644 index 000000000..66bf15fee --- /dev/null +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -0,0 +1,207 @@ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium } from "playwright"; + +const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const workspaceRoot = join(repositoryRoot, "web/workspace"); +const fixtureServer = join( + repositoryRoot, + "tools/web-ux/browser-tests/console_history_fixture_server.ts", +); +const workspaceId = "console-history-review"; + +async function freePort(): Promise { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + return port; +} + +async function waitForServer(url: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + if ((await fetch(url)).ok) return; + } catch { + // Retry until the bounded deadline while the owned server starts. + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`fixture server did not become ready: ${url}`); +} + +async function historyRequestCount(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/fixture-state`); + const state = await response.json(); + return state.history_requests; +} + +Deno.test("production Console pages backward by real turns while preserving both scroll anchors", async () => { + const build = await new Deno.Command(Deno.execPath(), { + args: ["task", "build"], + cwd: workspaceRoot, + stdout: "piped", + stderr: "piped", + }).output(); + if (!build.success) { + throw new Error( + `Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`, + ); + } + + const port = await freePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + fixtureServer, + String(port), + join(workspaceRoot, "build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + + try { + await waitForServer(`${baseUrl}/health`); + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ + viewport: { width: 1440, height: 600 }, + colorScheme: "dark", + }); + const page = await context.newPage(); + const errors: string[] = []; + const responses: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(String(error))); + page.on("response", (response) => { + if (response.status() >= 400 || response.url().includes("/api/")) { + responses.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto( + `${baseUrl}/w/${workspaceId}/workers/W-900-console-fixture/console`, + ); + const transcript = page.getByRole("article", { name: "main transcript" }); + try { + await transcript.getByText("question 8", { exact: true }).waitFor({ timeout: 5_000 }); + } catch (error) { + throw new Error( + `Console did not render retained history: ${error}\nresponses=${ + JSON.stringify(responses) + }\nerrors=${JSON.stringify(errors)}\nbody=${await page.locator("body").innerText()}`, + ); + } + const navigation = page.getByRole("complementary", { name: "Conversation turns" }); + await navigation.getByText("question 8", { exact: true }).waitFor(); + assertEquals(await historyRequestCount(baseUrl), 1); + assertEquals(await transcript.getByText(/^question (8|9|10|11|12)$/).count(), 5); + assertEquals(await navigation.getByText(/^question (8|9|10|11|12)$/).count(), 5); + + const question12 = navigation.getByRole("button", { + name: "Jump to conversation: question 12", + }); + assertEquals(await question12.getByText("detail 12.1", { exact: true }).count(), 1); + assertEquals(await question12.getByText("detail 12.2", { exact: true }).count(), 1); + assertEquals(await question12.getByText("detail 12.3", { exact: true }).count(), 0); + assert(!(await question12.textContent())?.includes("User")); + assert(!(await question12.textContent())?.includes("AI")); + + const consoleScroll = transcript.locator("xpath=.."); + const topAlignment = await page.evaluate(() => { + const document = (globalThis as any).document; + const body = document.querySelector(".console-scroll")?.getBoundingClientRect(); + const turns = document.querySelector(".turn-navigation")?.getBoundingClientRect(); + return body && turns ? Math.abs(body.top - turns.top) : Number.POSITIVE_INFINITY; + }); + assert(topAlignment <= 1, `transcript and turn bar top edges differ by ${topAlignment}px`); + + const navigationAnchoredTop = await navigation + .locator('[data-turn-id="user-8"]') + .evaluate((element) => element.getBoundingClientRect().top); + const anchoredTop = await consoleScroll.evaluate((element) => { + element.scrollTop = 0; + const anchor = element.querySelector('[data-console-line-id*="user-8"]'); + const top = anchor?.getBoundingClientRect().top ?? Number.NaN; + element.dispatchEvent(new Event("scroll")); + return top; + }); + await transcript.getByText("question 3", { exact: true }).waitFor(); + await navigation.getByText("question 3", { exact: true }).waitFor(); + const restoredTop = await transcript + .locator('[data-console-line-id*="user-8"]') + .evaluate((element) => element.getBoundingClientRect().top); + assert( + Math.abs(restoredTop - anchoredTop) <= 1, + `transcript anchor moved from ${anchoredTop}px to ${restoredTop}px`, + ); + await page.waitForTimeout(250); + assertEquals( + await historyRequestCount(baseUrl), + 2, + "a stationary top sentinel must not drain another page", + ); + + const turnList = navigation.locator(".turn-list"); + const navigationAfterPrepend = await navigation + .locator('[data-turn-id="user-8"]') + .evaluate((element) => element.getBoundingClientRect().top); + assert( + Math.abs(navigationAfterPrepend - navigationAnchoredTop) <= 1, + `turn-bar anchor moved from ${navigationAnchoredTop}px to ${navigationAfterPrepend}px`, + ); + await turnList.evaluate((element) => { + element.scrollTop = Math.max(2, element.scrollTop); + element.dispatchEvent(new Event("scroll")); + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll")); + }); + await transcript.getByText("question 1", { exact: true }).waitFor(); + await navigation.getByText("question 1", { exact: true }).waitFor(); + assertEquals(await historyRequestCount(baseUrl), 3); + assertEquals(await page.getByText("Start of conversation", { exact: true }).count(), 2); + + await navigation.getByRole("button", { + name: "Jump to conversation: question 3", + }).click(); + await page.waitForFunction(() => { + const document = (globalThis as any).document; + const element = document.querySelector('[data-console-line-id*="user-3"]'); + const parent = element?.closest(".console-scroll"); + if (!element || !parent) return false; + const item = element.getBoundingClientRect(); + const viewport = parent.getBoundingClientRect(); + return item.top >= viewport.top - 1 && item.top < viewport.bottom; + }); + const jumpGeometry = await transcript + .locator('[data-console-line-id*="user-3"]') + .evaluate((element) => { + const parent = element.closest(".console-scroll")!; + const item = element.getBoundingClientRect(); + const viewport = parent.getBoundingClientRect(); + return { itemTop: item.top, viewportTop: viewport.top, viewportBottom: viewport.bottom }; + }); + assert(jumpGeometry.itemTop >= jumpGeometry.viewportTop - 1); + assert(jumpGeometry.itemTop < jumpGeometry.viewportBottom); + + const centering = await question12.evaluate((button) => { + const turn = button.getBoundingClientRect(); + const bar = button.querySelector(".bar")!.getBoundingClientRect(); + return Math.abs((turn.top + turn.bottom) / 2 - (bar.top + bar.bottom) / 2); + }); + assert(centering <= 1, `turn marker is not vertically centered (${centering}px)`); + assertEquals(errors, []); + 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 69d6761a9..48fe75c03 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" + "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" }, "fmt": { "lineWidth": 100,