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 ", 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 compacted = await tracePanelText(page); assert.match(compacted.count, /压缩窗口/); assert.doesNotMatch(compacted.count, /显示全部/); assert.equal(compacted.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, compacted.rows.join("\n---\n")); assert.ok(compacted.rows.some((row) => row.includes("op=op_one")), compacted.rows.join("\n")); assert.ok(compacted.rows.some((row) => row.includes("op=op_two")), compacted.rows.join("\n")); assert.match(compacted.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, /压缩窗口/); 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"; }