test: verify console history in production browser

This commit is contained in:
2026-09-30 09:36:02 +09:00
parent 6d0eaef6bc
commit aa144ee6fa
3 changed files with 428 additions and 1 deletions
@@ -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 <port> <build-root>");
}
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<string, string> = {
".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<Response> {
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);
});
@@ -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<number> {
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<void> {
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<number> {
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;
}
});
+1 -1
View File
@@ -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,