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: "item/commandExecution/outputDelta", type: "tool_call", status: "output_chunk", outputSummary: `${gatewayPayload("req_1", "op_one")}${gatewayPayload("req_2", "op_two")}` } ], assistantStreams: [ { label: "assistant:stream", status: "streaming", chunkCount: 3, text: "first readable sentence middle detail final conclusion", updatedAt: "2026-05-26T12:29:28.000Z", waitingFor: "turn/completed" } ] }); }); 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(); } }); test("trace result-ready fixture is reconciled into final assistant result", 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({ traceId: "trc_fixture_result_ready", events: [{ label: "result:completed", type: "result", status: "completed", terminal: true, message: "Code Agent result is ready for short-connection polling." }] }); }); const before = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()); assert.equal(before.status, "running"); assert.match(before.text, /Trace scroll contract fixture/); const after = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.reconcileTraceResultFixture({ traceId: "trc_fixture_result_ready", conversationId: "cnv_fixture_result_ready", sessionId: "ses_fixture_result_ready", status: "completed", provider: "codex-stdio", model: "gpt-test", backend: "hwlab-cloud-api/codex-app-server-stdio", capabilityLevel: "long-lived-codex-stdio-session", runner: { kind: "codex-app-server-stdio-runner" }, session: { sessionId: "ses_fixture_result_ready", status: "idle", idleTimeoutMs: 1800000, lastTraceId: "trc_fixture_result_ready", longLivedSession: true, durableSession: true, codexStdio: true, writeCapable: true }, sessionMode: "codex-app-server-stdio-long-lived", implementationType: "repo-owned-codex-app-server-stdio-session", sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" }, longLivedSessionGate: { status: "pass", pass: true }, reply: { content: "最终回复来自 result endpoint,而不是 result-ready trace 文案。" }, providerTrace: { protocol: "codex-app-server-jsonrpc-stdio", command: "codex app-server --listen stdio://", terminalStatus: "completed", threadId: "thread_fixture", turnId: "turn_fixture" } })); assert.equal(after.status, "completed", JSON.stringify(after)); assert.match(after.text, /最终回复来自 result endpoint/); assert.doesNotMatch(after.text, /Code Agent result is ready for short-connection polling/); assert.equal(after.providerTrace.terminalStatus, "completed"); const cardText = await page.locator(".message-card.message-agent").last().innerText(); assert.match(cardText, /最终回复来自 result endpoint/); assert.doesNotMatch(cardText, /Code Agent result is ready for short-connection polling\.$/); await page.close(); } finally { await browser.close(); await server.close(); } }); test("restored pending trace polls result endpoint without token or live provider", async () => { const traceId = "trc_restore_result_ready"; const server = await startStaticServer(webRoot, { [`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "刷新恢复后来自 result endpoint 的回复。"), [`/v1/agent/chat/trace/${traceId}`]: { traceId, status: "completed", eventCount: 1, events: [{ seq: 1, traceId, label: "result:completed", type: "result", status: "completed", terminal: true, message: "Code Agent result is ready for short-connection polling." }], lastEvent: { label: "result:completed", type: "result", status: "completed", terminal: true } } }); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 1366, height: 768 }, deviceScaleFactor: 1 }); await page.addInitScript(({ traceId: injectedTraceId }) => { window.localStorage.setItem("hwlab.workbench.codeAgentSession.v1", JSON.stringify({ version: 1, updatedAtMs: Date.now(), conversationId: "cnv_restore_result_ready", sessionId: "ses_restore_result_ready", threadId: "thread_restore_result_ready", sessionStatus: "busy", chatMessages: [{ id: "msg_restore_result_ready", role: "agent", title: "Code Agent 处理中", text: "正在处理这次 Code Agent 请求", status: "running", traceId: injectedTraceId, conversationId: "cnv_restore_result_ready", sessionId: "ses_restore_result_ready", threadId: "thread_restore_result_ready", retryInput: "restore fixture" }] })); }, { traceId }); 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.waitForFunction(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()?.status === "completed", null, { timeout: 12000 }); const restored = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()); assert.equal(restored.status, "completed", JSON.stringify(restored)); assert.match(restored.text, /刷新恢复后来自 result endpoint/); assert.doesNotMatch(restored.text, /Code Agent result is ready for short-connection polling/); 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 }); } function completedResultFixture(traceId, content) { return { traceId, conversationId: "cnv_fixture_result_ready", sessionId: "ses_fixture_result_ready", status: "completed", provider: "codex-stdio", model: "gpt-test", backend: "hwlab-cloud-api/codex-app-server-stdio", workspace: "/workspace/hwlab", sandbox: "workspace-write", capabilityLevel: "long-lived-codex-stdio-session", reply: { content }, runner: { kind: "codex-app-server-stdio-runner", codexStdio: true, writeCapable: true, durableSession: true, workspace: "/workspace/hwlab", sandbox: "workspace-write", sessionMode: "codex-app-server-stdio-long-lived", implementationType: "repo-owned-codex-app-server-stdio-session", capabilityLevel: "long-lived-codex-stdio-session" }, session: { sessionId: "ses_fixture_result_ready", status: "idle", idleTimeoutMs: 1800000, lastTraceId: traceId, longLivedSession: true, durableSession: true, codexStdio: true, writeCapable: true }, sessionMode: "codex-app-server-stdio-long-lived", implementationType: "repo-owned-codex-app-server-stdio-session", sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" }, longLivedSessionGate: { status: "pass", pass: true }, toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }], skills: [], providerTrace: { protocol: "codex-app-server-jsonrpc-stdio", command: "codex app-server --listen stdio://", terminalStatus: "completed", threadId: "thread_fixture", turnId: "turn_fixture" }, runnerTrace: { traceId, events: [], eventCount: 0, lastEvent: null }, updatedAt: new Date().toISOString() }; } async function startStaticServer(rootDir, jsonRoutes = {}) { const server = createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (Object.hasOwn(jsonRoutes, url.pathname)) { response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify(jsonRoutes[url.pathname])); return; } 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"; }