Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs
T
2026-05-26 00:37:29 +08:00

226 lines
9.5 KiB
JavaScript

import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultCredentials = Object.freeze({
username: "admin",
password: "hwlab2026"
});
test("trace scroll stays user controlled while trace updates append", async () => {
const server = await startStaticServer(webRoot);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1366, height: 768 },
deviceScaleFactor: 1
});
await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 });
await login(page);
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 });
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.seedTraceMessage({ count: 90 }));
await page.waitForSelector(".message-trace-events[data-trace-ui-key]", { timeout: 12000 });
const initial = await traceMetrics(page);
assert.ok(initial.traceScrollHeight > initial.traceClientHeight + 200, JSON.stringify(initial));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(1_000_000, { user: false }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(8));
await page.waitForTimeout(80);
const pinned = await traceMetrics(page);
assert.ok(pinned.traceBottomGap <= 2, `trace should stay pinned at bottom when already at bottom: ${JSON.stringify(pinned)}`);
const userMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(180, { user: true }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
await page.waitForTimeout(120);
const afterAppend = await traceMetrics(page);
assert.ok(Math.abs(afterAppend.traceTop - userMiddle.traceTop) <= 8, `trace update fought the user's scroll position: before=${JSON.stringify(userMiddle)} after=${JSON.stringify(afterAppend)}`);
const userMoved = await page.evaluate(() => {
const before = window.__hwlabWorkbenchTestHooks.traceScrollMetrics();
return window.__hwlabWorkbenchTestHooks.setTraceScrollTop(before.traceTop + 180, { user: true });
});
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
await page.waitForTimeout(120);
const afterUserMove = await traceMetrics(page);
assert.ok(afterUserMove.traceTop >= userMoved.traceTop - 8, `trace update should not snap back after user scrolls down: before=${JSON.stringify(userMoved)} after=${JSON.stringify(afterUserMove)}`);
const conversationMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setConversationScrollTop(40, { user: true }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(4));
await page.waitForTimeout(120);
const afterConversationAppend = await traceMetrics(page);
assert.ok(Math.abs(afterConversationAppend.conversationTop - conversationMiddle.conversationTop) <= 8, `conversation scroll should not snap on trace update: before=${JSON.stringify(conversationMiddle)} after=${JSON.stringify(afterConversationAppend)}`);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
test("trace display full means complete readable timeline, not compacted result window", async () => {
const server = await startStaticServer(webRoot);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1366, height: 768 },
deviceScaleFactor: 1
});
await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 });
await login(page);
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 });
await page.evaluate(() => {
const gatewayPayload = (id, operationId, status = "succeeded") => JSON.stringify({
jsonrpc: "2.0",
id,
result: {
accepted: true,
status,
operationId,
gatewaySessionId: "gws_test",
resourceId: "res_windows_host",
capabilityId: "cap_windows_cmd_exec",
dispatch: {
shellExecuted: true,
dispatchStatus: status,
exitCode: status === "succeeded" ? 0 : 1,
durationMs: 1200,
command: "powershell -EncodedCommand <redacted>",
stdout: `stdout for ${operationId}`,
stderr: ""
}
}
});
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
eventsCompacted: true,
eventCount: 600,
events: [
{
label: "request:accepted-short-connection",
status: "started",
message: "accepted"
},
{
label: "assistant:chunk",
type: "assistant_message",
status: "chunk",
chunk: "first readable sentence "
},
{
label: "assistant:chunk",
type: "assistant_message",
status: "chunk",
chunk: "middle detail "
},
{
label: "assistant:chunk",
type: "assistant_message",
status: "chunk",
chunk: "final conclusion"
},
{
label: "item/commandExecution/outputDelta",
type: "tool_call",
status: "output_chunk",
outputSummary: `${gatewayPayload("req_1", "op_one")}${gatewayPayload("req_2", "op_two")}`
}
]
});
});
await page.waitForSelector(".message-trace-count", { timeout: 12000 });
const replaying = await tracePanelText(page);
assert.match(replaying.count, /完整 trace 回放中/);
assert.doesNotMatch(replaying.count, /压缩窗口|显示全部/);
assert.equal(replaying.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, replaying.rows.join("\n---\n"));
assert.ok(replaying.rows.some((row) => row.includes("op=op_one")), replaying.rows.join("\n"));
assert.ok(replaying.rows.some((row) => row.includes("op=op_two")), replaying.rows.join("\n"));
assert.match(replaying.bodies.join("\n"), /first readable sentence middle detail final conclusion/);
await page.evaluate(() => {
const message = window.__hwlabWorkbenchTestHooks.tracePanelText();
const rows = message.rows.length;
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
fullTraceLoaded: true,
events: Array.from({ length: rows }, (_, index) => ({
label: `full:readable:${index + 1}`,
status: "completed",
message: `full readable event ${index + 1}`
}))
});
});
const full = await tracePanelText(page);
assert.match(full.count, /显示全部可读事件/);
assert.doesNotMatch(full.count, /完整 trace 回放中|压缩窗口/);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
async function traceMetrics(page) {
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics());
}
async function tracePanelText(page) {
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.tracePanelText());
}
async function login(page) {
await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 });
const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false);
if (!loginVisible) {
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
return;
}
await page.locator("#login-username").fill(defaultCredentials.username);
await page.locator("#login-password").fill(defaultCredentials.password);
await page.locator("#login-submit").click();
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
}
async function startStaticServer(rootDir) {
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
const resolved = path.resolve(rootDir, `.${pathname}`);
if (!resolved.startsWith(rootDir)) {
response.writeHead(403).end("forbidden");
return;
}
const body = await readFile(resolved);
response.writeHead(200, { "content-type": contentType(resolved) });
response.end(body);
} catch {
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("not found");
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
url: `http://127.0.0.1:${address.port}/`,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
};
}
function contentType(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
return "application/octet-stream";
}