import assert from "node:assert/strict"; import { createServer } from "node:http"; import { existsSync } from "node:fs"; 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("final assistant response renders basic markdown without raw html injection", async () => { const browser = await launchBrowser(); const server = await startStaticServer(webRoot); 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.seedCompletedAgentMessage({ text: [ "# Gateway 控制 Windows CMD 的方法", "", "核心工具是 `/app/tools/hwlab-gateway-tran.mjs`。", "", "---", "", "| 参数 | 作用 |", "|---|---|", "| `cmd` | 透传 Windows cmd |", "", "```bash", "node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- pwd", "```", "", "raw html should stay inert", "[blocked](javascript:alert(1))" ].join("\n") })); const markdown = page.locator(".message-card.message-agent .message-copy-markdown"); await markdown.waitFor({ timeout: 12000 }); await expectCount(page, ".message-copy-markdown h1", 1); await expectCount(page, ".message-copy-markdown table", 1); await expectCount(page, ".message-copy-markdown pre code", 1); await expectCount(page, ".message-copy-markdown .message-copy", 0); await expectCount(page, ".message-copy-markdown a[href^='javascript:']", 0); const renderedText = await markdown.innerText(); assert.match(renderedText, /Gateway 控制 Windows CMD 的方法/); assert.match(renderedText, /cmd/); assert.match(renderedText, /raw html should stay inert/); await page.close(); } finally { await browser.close(); await server.close(); } }); test("trace scroll stays user controlled while trace updates append", async () => { const browser = await launchBrowser(); const server = await startStaticServer(webRoot); 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)); const beforeIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); await page.evaluate(() => window.__hwlabWorkbenchTestHooks.resetProgrammaticScrollWriteCount()); 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 afterIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); assert.equal(afterIdentity.panel, beforeIdentity.panel, "trace update must not replace the trace panel DOM node"); assert.equal(afterIdentity.list, beforeIdentity.list, "trace update must not replace the trace list DOM node"); assert.equal(afterIdentity.firstRow, beforeIdentity.firstRow, "trace update must not replace stable existing rows"); assert.equal(afterIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop: ${JSON.stringify(afterIdentity)}`); 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 afterUserMoveIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); assert.equal(afterUserMoveIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop after user move: ${JSON.stringify(afterUserMoveIdentity)}`); 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)}`); const afterConversationIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); assert.equal(afterConversationIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop after conversation scroll: ${JSON.stringify(afterConversationIdentity)}`); await page.close(); } finally { await browser.close(); await server.close(); } }); test("inner trace message body scroll stays stable when trace rows move or stream text updates", async () => { const browser = await launchBrowser(); const server = await startStaticServer(webRoot); 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 }); const longStreamText = (chunk) => Array.from({ length: 90 }, (_, index) => `assistant stream chunk ${chunk} line ${index + 1} ${"message ".repeat(8)}`).join("\n"); const longEvents = Array.from({ length: 110 }, (_, index) => ({ seq: index + 1, label: "trace:inner-scroll-contract", type: "trace", status: "observed", message: Array.from({ length: 70 }, (_, line) => `event ${index + 1} line ${line + 1} ${"payload ".repeat(8)}`).join("\n") })); await page.evaluate(({ events, text }) => window.__hwlabWorkbenchTestHooks.seedTraceMessage({ traceId: "trc_inner_scroll_contract", messageId: "msg_inner_scroll_contract", events, assistantStreams: [{ itemId: "assistant-message", chunkCount: 1, waitingFor: "turn/completed", text }] }), { events: longEvents, text: longStreamText(1) }); await page.waitForSelector(".message-trace-events[data-trace-ui-key] li:nth-child(97) pre", { timeout: 12000 }); const eventBodySelector = ".message-trace-events[data-trace-ui-key] li:nth-child(97) pre"; const streamBodySelector = ".message-trace-events[data-trace-ui-key] li:last-child pre"; const eventBefore = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 90, { user: true }), eventBodySelector); const streamBefore = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 90, { user: true }), streamBodySelector); assert.ok(eventBefore.scrollHeight > eventBefore.clientHeight + 80, JSON.stringify(eventBefore)); assert.ok(streamBefore.scrollHeight > streamBefore.clientHeight + 800, JSON.stringify(streamBefore)); await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(5)); await page.waitForTimeout(120); const eventAfterAppend = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), eventBodySelector); const streamAfterAppend = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector); assert.equal(eventAfterAppend.body, eventBefore.body, "stable existing event body node must be reused while appending trace rows"); assert.equal(eventAfterAppend.row, eventBefore.row, "stable existing event row node must be reused while appending trace rows"); assert.ok(Math.abs(eventAfterAppend.top - eventBefore.top) <= 8, `event body scroll changed: before=${JSON.stringify(eventBefore)} after=${JSON.stringify(eventAfterAppend)}`); assert.equal(streamAfterAppend.body, streamBefore.body, "assistant stream body node must move instead of being replaced when new events append before it"); assert.equal(streamAfterAppend.row, streamBefore.row, "assistant stream row node must move instead of being replaced when new events append before it"); assert.ok(Math.abs(streamAfterAppend.top - streamBefore.top) <= 8, `assistant stream body scroll changed after row move: before=${JSON.stringify(streamBefore)} after=${JSON.stringify(streamAfterAppend)}`); await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 120, { user: true }), streamBodySelector); const streamBeforeTextUpdate = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector); await page.evaluate((text) => window.__hwlabWorkbenchTestHooks.replaceTraceAssistantStream({ chunkCount: 2, text }), longStreamText(2)); await page.waitForTimeout(120); const streamAfterTextUpdate = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector); assert.equal(streamAfterTextUpdate.body, streamBeforeTextUpdate.body, "assistant stream body node must survive stream text growth"); assert.equal(streamAfterTextUpdate.row, streamBeforeTextUpdate.row, "assistant stream row node must survive stream text growth"); assert.ok(Math.abs(streamAfterTextUpdate.top - streamBeforeTextUpdate.top) <= 8, `assistant stream body scroll changed after text update: before=${JSON.stringify(streamBeforeTextUpdate)} after=${JSON.stringify(streamAfterTextUpdate)}`); assert.match(streamAfterTextUpdate.text, /chunk 2/u); await page.close(); } finally { await browser.close(); await server.close(); } }); test("trace result-ready fixture is reconciled into final assistant result", async () => { const browser = await launchBrowser(); const server = await startStaticServer(webRoot); 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 browser = await launchBrowser(); 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 } } }); 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(); } }); test("restored client idle timeout is corrected by completed result polling", async () => { const traceId = "trc_restore_client_idle_timeout_completed"; const server = await startStaticServer(webRoot, { [`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "超时后端补齐的最终回复。"), [`/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 launchBrowser(); 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_client_idle_timeout_completed", sessionId: "ses_restore_client_idle_timeout_completed", threadId: "thread_restore_client_idle_timeout_completed", sessionStatus: "timeout", chatMessages: [{ id: "msg_restore_client_idle_timeout_completed", role: "agent", title: "Code Agent 无新事件超时", text: "Code Agent 超过 600000ms 无新事件;输入已保留,可稍后重试。", status: "timeout", traceId: injectedTraceId, conversationId: "cnv_restore_client_idle_timeout_completed", sessionId: "ses_restore_client_idle_timeout_completed", threadId: "thread_restore_client_idle_timeout_completed", retryInput: "restore timeout fixture", error: { code: "client_trace_idle_timeout", category: "timeout", timeoutMs: 600000, message: "Code Agent 超过 600000ms 无新事件" }, runnerTrace: { traceId: injectedTraceId, eventCount: 1, events: [{ label: "result:completed", type: "result", status: "completed", terminal: true }], lastEvent: { label: "result:completed", status: "completed" } } }] })); }, { 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, /超时后端补齐的最终回复/); assert.equal(restored.providerTrace.terminalStatus, "completed"); assert.equal(restored.error, null); await page.close(); } finally { await browser.close(); await server.close(); } }); async function traceMetrics(page) { return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics()); } async function launchBrowser() { const executablePath = process.env.HWLAB_PLAYWRIGHT_CHROMIUM || process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || systemChromiumExecutablePath(); return chromium.launch({ headless: true, executablePath }); } function systemChromiumExecutablePath() { for (const candidate of ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"]) { if (existsSync(candidate)) return candidate; } return undefined; } async function expectCount(page, selector, expected) { assert.equal(await page.locator(selector).count(), expected, selector); } 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"; }