226 lines
9.5 KiB
JavaScript
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";
|
|
}
|