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",
|
"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",
|
"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": "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": {
|
"fmt": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user