test: verify console history in production browser
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user