Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs
T

426 lines
17 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: "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";
}