test(web): add Workbench Playwright e2e harness

This commit is contained in:
lyon
2026-06-17 18:06:28 +08:00
parent 537d537d0d
commit 1e672c3c7f
16 changed files with 686 additions and 3 deletions
+9
View File
@@ -15,6 +15,7 @@
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.59.1",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/node": "22.7.5", "@types/node": "22.7.5",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
@@ -126,6 +127,8 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg=="],
@@ -506,6 +509,10 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -682,6 +689,8 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+4 -1
View File
@@ -10,7 +10,9 @@
"check:tsc-strict": "bun run scripts/tsc-check.ts --strict", "check:tsc-strict": "bun run scripts/tsc-check.ts --strict",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"build": "bun run scripts/build.ts", "build": "bun run scripts/build.ts",
"test": "vitest run" "test": "vitest run",
"e2e:workbench": "bun run build && bunx playwright test -c playwright.workbench.config.ts",
"e2e:workbench:report": "bunx playwright show-report .state/workbench-e2e/html-report"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^10.7.0", "@vueuse/core": "^10.7.0",
@@ -23,6 +25,7 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.59.1",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@types/node": "22.7.5", "@types/node": "22.7.5",
@@ -0,0 +1,36 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0.
// Responsibility: Playwright configuration for independent Workbench browser regression tests.
import { defineConfig, devices } from "@playwright/test";
const port = Number(process.env.HWLAB_WORKBENCH_E2E_PORT ?? 4173);
const baseURL = `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: "./tests/workbench-e2e/specs",
outputDir: ".state/workbench-e2e/test-results",
timeout: 45_000,
expect: { timeout: 8_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [
["list"],
["html", { outputFolder: ".state/workbench-e2e/html-report", open: "never" }]
],
use: {
baseURL,
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "off"
},
webServer: {
command: `bun scripts/workbench-e2e-server.ts --port ${port} --dist dist`,
url: `${baseURL}/__e2e/health`,
reuseExistingServer: false,
timeout: 30_000
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }
]
});
@@ -0,0 +1,55 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0.
// Responsibility: Read-only helper for capturing and redacting Workbench fixture seeds from a controlled runtime.
const baseUrl = (process.env.HWLAB_WORKBENCH_CAPTURE_BASE_URL ?? "https://hwlab.pikapython.com").replace(/\/+$/u, "");
const projectId = process.env.HWLAB_WORKBENCH_CAPTURE_PROJECT_ID ?? "prj_hwpod_workbench";
const conversationId = process.env.HWLAB_WORKBENCH_CAPTURE_CONVERSATION_ID ?? "";
const authorization = process.env.HWLAB_API_KEY ? `Bearer ${process.env.HWLAB_API_KEY}` : "";
const headers: Record<string, string> = { accept: "application/json" };
if (authorization) headers.authorization = authorization;
const paths = [
`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`,
`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}${conversationId ? `&includeConversationId=${encodeURIComponent(conversationId)}` : ""}`,
conversationId ? `/v1/agent/conversations/${encodeURIComponent(conversationId)}?projectId=${encodeURIComponent(projectId)}` : ""
].filter(Boolean);
const captured: Record<string, unknown> = {
metadata: {
capturedFrom: { origin: baseUrl, projectId, conversationId: conversationId || null },
capturedAt: new Date().toISOString(),
schemaVersion: "workbench-e2e-capture.v1",
redactionVersion: "redact-stable-pseudo-ids.v1",
valuesPrinted: false
},
responses: {}
};
for (const path of paths) {
const response = await fetch(`${baseUrl}${path}`, { headers });
const body = await response.json().catch(() => null);
(captured.responses as Record<string, unknown>)[path] = {
status: response.status,
ok: response.ok,
body: redact(body)
};
}
console.log(JSON.stringify(captured, null, 2));
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== "object") return value;
const output: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (/api[-_]?key|authorization|cookie|token|secret|password|dsn|credential/iu.test(key)) {
output[key] = "<redacted>";
continue;
}
output[key] = redact(raw);
}
return output;
}
export {};
@@ -0,0 +1,303 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0.
// Responsibility: Same-origin fake Workbench API and static server for Playwright browser regression tests.
import { createReadStream, existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { extname, join, resolve, sep } from "node:path";
type JsonRecord = Record<string, unknown>;
interface ConversationRecord extends JsonRecord {
conversationId: string;
sessionId?: string | null;
threadId?: string | null;
status?: string | null;
lastTraceId?: string | null;
messages?: JsonRecord[];
}
interface ScenarioState {
scenarioId: string;
projectId: string;
workspaceId: string;
providerProfile: string;
selectedConversationId: string;
conversations: ConversationRecord[];
traces: Record<string, JsonRecord>;
selectRequests: JsonRecord[];
listOmitSelected: boolean;
conversationDelayMs: number;
terminalScript: boolean;
}
const cwd = process.cwd();
const args = new Map<string, string>();
for (let index = 2; index < process.argv.length; index += 1) {
const key = process.argv[index];
const value = process.argv[index + 1];
if (key?.startsWith("--")) {
args.set(key.slice(2), value && !value.startsWith("--") ? value : "true");
if (value && !value.startsWith("--")) index += 1;
}
}
const port = Number(args.get("port") ?? process.env.HWLAB_WORKBENCH_E2E_PORT ?? 4173);
const distDir = resolve(cwd, args.get("dist") ?? "dist");
const capturePath = resolve(cwd, "tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json");
const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { projectId: string; workspaceId: string; selectedConversationId: string; providerProfile: string; conversations: ConversationRecord[]; traces: Record<string, JsonRecord> } };
let state = createScenarioState("baseline");
const server = createServer((request, response) => {
void handleRequest(request, response).catch((error) => {
json(response, 500, { ok: false, status: 500, error: { code: "e2e_server_error", message: error instanceof Error ? error.message : String(error) } });
});
});
server.listen(port, "127.0.0.1", () => {
console.log(`workbench-e2e-server listening on http://127.0.0.1:${port}`);
});
async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
const method = request.method ?? "GET";
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `127.0.0.1:${port}`}`);
const path = url.pathname;
if (path === "/__e2e/health") return json(response, 200, { ok: true, scenarioId: state.scenarioId });
if (path === "/__e2e/state") return json(response, 200, stateSummary());
if (path === "/__e2e/reset" && method === "POST") {
const body = await readJson(request);
state = createScenarioState(String(body.scenarioId ?? "baseline"));
return json(response, 200, stateSummary());
}
if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload());
if (path === "/v1/workbench/events" && method === "GET") return sse(response);
if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() });
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
const body = await readJson(request);
if (typeof body.providerProfile === "string") state.providerProfile = body.providerProfile;
if (Object.hasOwn(body, "activeTraceId") && body.activeTraceId === null) clearActiveTraceForSelection();
return json(response, 200, { workspace: workspacePayload() });
}
if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") {
const body = await readJson(request);
state.selectRequests.push(redactRequestBody(body));
const conversationId = String(body.conversationId ?? "");
if (conversationId && conversationById(conversationId)) state.selectedConversationId = conversationId;
return json(response, 200, { workspace: workspacePayload() });
}
if (path === "/v1/agent/conversations" && method === "GET") {
await delay(state.conversationDelayMs);
const include = url.searchParams.get("includeConversationId");
const conversations = state.listOmitSelected && include ? state.conversations.filter((item) => item.conversationId !== include) : state.conversations;
return json(response, 200, { conversations: conversations.map((conversation) => conversationSummary(conversation)) });
}
const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
if (conversationMatch && method === "GET") {
const conversation = conversationById(decodeURIComponent(conversationMatch[1] ?? ""));
return conversation ? json(response, 200, { conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } });
}
if (conversationMatch && ["PUT", "DELETE"].includes(method)) return json(response, 200, { ok: true, workspace: workspacePayload() });
const sessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
if (sessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(sessionMatch[1] ?? "")) });
const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u);
if (turnMatch && method === "GET") return json(response, 200, turnPayload(decodeURIComponent(turnMatch[1] ?? "")));
const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u);
if (traceMatch && method === "GET") return json(response, 200, tracePayload(decodeURIComponent(traceMatch[1] ?? ""), url));
if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] });
if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } });
if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] });
if (path === "/v1/hwpod/specs") return json(response, 200, { specs: [] });
if (path === "/v1/hwpod-node-ops") return json(response, 200, { ok: true, status: "ready", events: [], groups: [] });
if (path.startsWith("/v1/") || path.startsWith("/auth/") || path.startsWith("/health")) return json(response, 500, { ok: false, status: 500, error: { code: "unmocked_request", path } });
return staticFile(response, path);
}
function createScenarioState(scenarioId: string): ScenarioState {
const base = structuredClone(capture.scenario);
const id = scenarioId || "baseline";
const selectedConversationId = id === "deep-link" ? "cnv_failed" : base.selectedConversationId;
return {
scenarioId: id,
projectId: base.projectId,
workspaceId: base.workspaceId,
providerProfile: base.providerProfile,
selectedConversationId,
conversations: base.conversations,
traces: base.traces,
selectRequests: [],
listOmitSelected: id === "selected-missing-from-list",
conversationDelayMs: id === "loading" ? 2_500 : 0,
terminalScript: id === "event-replay" || id === "running-to-terminal"
};
}
function workspacePayload(): JsonRecord {
const selected = conversationById(state.selectedConversationId);
return {
workspaceId: state.workspaceId,
projectId: state.projectId,
revision: state.selectRequests.length + 1,
updatedAt: new Date().toISOString(),
selectedConversationId: state.selectedConversationId,
selectedAgentSessionId: selected?.sessionId ?? null,
activeTraceId: selected?.status === "running" ? selected.lastTraceId ?? null : null,
providerProfile: state.providerProfile,
workspace: {
projectId: state.projectId,
selectedConversationId: state.selectedConversationId,
selectedAgentSessionId: selected?.sessionId ?? null,
threadId: selected?.threadId ?? null,
sessionStatus: selected?.status ?? null,
providerProfile: state.providerProfile,
lastTraceId: selected?.lastTraceId ?? null,
updatedAt: new Date().toISOString()
}
};
}
function conversationSummary(conversation: ConversationRecord): ConversationRecord {
const { messages: _messages, ...rest } = conversation;
return rest as ConversationRecord;
}
function conversationById(id: string): ConversationRecord | null {
return state.conversations.find((conversation) => conversation.conversationId === id) ?? null;
}
function sessionPayload(sessionId: string): JsonRecord {
const conversation = state.conversations.find((item) => item.sessionId === sessionId);
return { sessionId, status: conversation?.status ?? "unknown", lastTraceId: conversation?.lastTraceId ?? null, updatedAt: new Date().toISOString() };
}
function turnPayload(traceId: string): JsonRecord {
const trace = state.traces[traceId] ?? { traceId, status: "unknown", events: [] };
const status = String(trace.status ?? "unknown");
return {
...trace,
traceId,
status,
running: ["running", "pending", "accepted"].includes(status),
terminal: ["completed", "failed", "blocked", "timeout", "canceled"].includes(status)
};
}
function tracePayload(traceId: string, url: URL): JsonRecord {
const turn = turnPayload(traceId);
const events = Array.isArray(turn.events) ? turn.events as JsonRecord[] : [];
const sinceSeq = Number(url.searchParams.get("sinceSeq") ?? 0);
const requestedLimit = Number(url.searchParams.get("limit") ?? events.length);
const limit = requestedLimit || events.length || 100;
const filtered = Number.isFinite(sinceSeq) && sinceSeq > 0 ? events.filter((event) => Number(event.seq ?? 0) > sinceSeq) : events;
const page = filtered.slice(0, Math.max(1, Number.isFinite(limit) ? limit : 100));
const lastSeq = Number(page.at(-1)?.seq ?? 0);
const hasMore = filtered.length > page.length;
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: hasMore ? lastSeq : null, range: { sinceSeq, returned: page.length, total: events.length } };
}
function sse(response: ServerResponse): void {
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive"
});
writeSse(response, "workbench.connected", { type: "connected", projectId: state.projectId, workspaceId: state.workspaceId });
if (state.terminalScript) {
const scenarioId = state.scenarioId;
setTimeout(() => {
if (state.scenarioId !== scenarioId) return;
const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: "completed", replyAuthority: true, final: true, message: "事件重放后完成。", terminal: true };
writeSse(response, "workbench.trace.event", { type: "trace.event", traceId: "trc_running", event, snapshot: { traceId: "trc_running", status: "completed", events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: "事件重放后完成。" } } });
completeRunningConversation();
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", traceId: "trc_running", turn: turnPayload("trc_running") });
}, 350);
}
}
function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void {
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
}
function completeRunningConversation(): void {
const conversation = conversationById("cnv_running");
if (!conversation) return;
conversation.status = "completed";
conversation.updatedAt = new Date().toISOString();
const trace = state.traces.trc_running ?? { traceId: "trc_running", status: "running", events: [] };
const finalEvent = { seq: 4, createdAt: new Date().toISOString(), label: "agentrun:terminal:completed", status: "completed", terminal: true };
const events = [...(Array.isArray(trace.events) ? trace.events as JsonRecord[] : []), finalEvent];
state.traces.trc_running = { ...trace, status: "completed", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false, assistantText: "事件重放后完成。", finalResponse: { text: "事件重放后完成。" } };
conversation.messages = (conversation.messages ?? []).map((message) => message.role === "agent" ? { ...message, text: "事件重放后完成。", status: "completed", runnerTrace: state.traces.trc_running } : message);
}
function clearActiveTraceForSelection(): void {
const conversation = conversationById(state.selectedConversationId);
if (conversation?.status === "running") conversation.status = "failed";
}
function stateSummary(): JsonRecord {
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, status: item.status, lastTraceId: item.lastTraceId })) };
}
function authPayload(): JsonRecord {
return { authenticated: true, mode: "server", actor: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, user: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, expiresAt: null };
}
function redactRequestBody(value: JsonRecord): JsonRecord {
const output: JsonRecord = {};
for (const [key, item] of Object.entries(value)) output[key] = /secret|token|key|password|authorization/iu.test(key) ? "<redacted>" : item;
return output;
}
async function readJson(request: IncomingMessage): Promise<JsonRecord> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
if (chunks.length === 0) return {};
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
return parsed && typeof parsed === "object" ? parsed as JsonRecord : {};
} catch {
return {};
}
}
function json(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(body));
}
function staticFile(response: ServerResponse, path: string): void {
const filePath = safeStaticPath(path);
const target = filePath && existsSync(filePath) && statSync(filePath).isFile() ? filePath : join(distDir, "index.html");
response.writeHead(200, { "content-type": contentType(target) });
createReadStream(target).pipe(response);
}
function safeStaticPath(path: string): string | null {
const normalized = resolve(distDir, `.${decodeURIComponent(path === "/" ? "/index.html" : path)}`);
return normalized === distDir || normalized.startsWith(`${distDir}${sep}`) ? normalized : null;
}
function contentType(filePath: string): string {
switch (extname(filePath)) {
case ".html": return "text/html; charset=utf-8";
case ".js": return "text/javascript; charset=utf-8";
case ".css": return "text/css; charset=utf-8";
case ".svg": return "image/svg+xml";
case ".ico": return "image/x-icon";
default: return "application/octet-stream";
}
}
function delay(ms: number): Promise<void> {
return ms > 0 ? new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) : Promise.resolve();
}
+1 -1
View File
@@ -123,7 +123,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void hydrateTraceEvents(messages.value); void hydrateTraceEvents(messages.value);
reattachRestoredActiveTrace(); reattachRestoredActiveTrace();
} }
loading.value = false;
const conversationsResult = await conversationsPromise; const conversationsResult = await conversationsPromise;
let nextConversations = conversations.value; let nextConversations = conversations.value;
if (conversationsResult.ok) { if (conversationsResult.ok) {
@@ -141,6 +140,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
conversationsReady.value = conversations.value.length > 0; conversationsReady.value = conversations.value.length > 0;
error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable"; error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable";
} }
loading.value = false;
await refreshProviderOptions(); await refreshProviderOptions();
restartRealtime("hydrate"); restartRealtime("hydrate");
} }
@@ -0,0 +1,104 @@
{
"metadata": {
"captureId": "d601-v03-workbench-redacted-001",
"capturedFrom": {
"node": "D601",
"lane": "HWLAB v0.3",
"origin": "https://hwlab.pikapython.com",
"workspace": "/home/ubuntu/workspace/hwlab-v03",
"source": "controlled Workbench regression sample, redacted and minimized from live API shape"
},
"capturedAt": "2026-06-17T09:40:00.000Z",
"sourceProbe": {
"probedAt": "2026-06-17T10:24:00.000Z",
"credentialSourceRef": "/root/.config/hwlab-v03/master-server-admin-api-key.env",
"valuesPrinted": false,
"endpoints": [
{ "path": "/auth/session", "status": 200, "topLevelKeys": ["actor", "authMethod", "authenticated", "contractVersion", "session", "sessionExpiresAt", "sessionTtlSeconds", "setupRequired"] },
{ "path": "/v1/workbench/workspace?projectId=prj_hwpod_workbench", "status": 200, "topLevelKeys": ["actor", "contractVersion", "ok", "status", "workspace"] },
{ "path": "/v1/agent/conversations?projectId=prj_hwpod_workbench", "status": 200, "topLevelKeys": ["actor", "contractVersion", "conversations", "count", "defaultConversation", "ok", "projectId", "status"] }
]
},
"schemaVersion": "workbench-e2e-capture.v1",
"redactionVersion": "redact-stable-pseudo-ids.v1",
"valuesPrinted": false,
"notes": [
"Identifiers are stable pseudo IDs preserving conversation/session/thread/trace/message relationships.",
"Secret, cookie, Authorization, DSN, provider token, real user identity and non-public prompt values are omitted."
]
},
"scenario": {
"projectId": "prj_hwpod_workbench",
"workspaceId": "wsp_d601_v03_e2e",
"selectedConversationId": "cnv_running",
"providerProfile": "codex-api",
"conversations": [
{
"conversationId": "cnv_running",
"projectId": "prj_hwpod_workbench",
"sessionId": "ses_running",
"threadId": "thr_running",
"status": "running",
"updatedAt": "2026-06-17T09:39:50.000Z",
"lastTraceId": "trc_running",
"messageCount": 2,
"firstUserMessagePreview": "运行中的板级验证",
"messages": [
{ "id": "msg_running_user", "messageId": "msg_running_user", "role": "user", "title": "用户", "text": "运行中的板级验证", "status": "sent", "createdAt": "2026-06-17T09:39:40.000Z", "conversationId": "cnv_running", "sessionId": "ses_running", "threadId": "thr_running", "turnId": "turn_running" },
{ "id": "msg_running_agent", "messageId": "msg_running_agent", "role": "agent", "title": "Code Agent", "text": "", "status": "running", "createdAt": "2026-06-17T09:39:41.000Z", "conversationId": "cnv_running", "sessionId": "ses_running", "threadId": "thr_running", "traceId": "trc_running", "turnId": "turn_running", "runnerTrace": { "traceId": "trc_running", "status": "running", "sessionId": "ses_running", "threadId": "thr_running", "events": [{ "seq": 1, "createdAt": "2026-06-17T09:39:42.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "running", "message": "正在检查测试工程。" }], "eventCount": 1, "fullTraceLoaded": false, "hasMore": false } }
]
},
{
"conversationId": "cnv_completed",
"projectId": "prj_hwpod_workbench",
"sessionId": "ses_completed",
"threadId": "thr_completed",
"status": "completed",
"updatedAt": "2026-06-17T09:38:00.000Z",
"lastTraceId": "trc_completed",
"messageCount": 2,
"firstUserMessagePreview": "完成态 Trace 渲染验证",
"messages": [
{ "id": "msg_completed_user", "messageId": "msg_completed_user", "role": "user", "title": "用户", "text": "完成态 Trace 渲染验证", "status": "sent", "createdAt": "2026-06-17T09:37:00.000Z", "conversationId": "cnv_completed", "sessionId": "ses_completed", "threadId": "thr_completed", "turnId": "turn_completed" },
{ "id": "msg_completed_agent", "messageId": "msg_completed_agent", "role": "agent", "title": "Code Agent", "text": "构建和 Trace 渲染检查完成。", "status": "completed", "createdAt": "2026-06-17T09:37:02.000Z", "conversationId": "cnv_completed", "sessionId": "ses_completed", "threadId": "thr_completed", "traceId": "trc_completed", "turnId": "turn_completed", "runnerTrace": { "traceId": "trc_completed", "status": "completed", "sessionId": "ses_completed", "threadId": "thr_completed", "events": [], "eventCount": 5, "fullTraceLoaded": true, "hasMore": false, "finalResponse": { "text": "构建和 Trace 渲染检查完成。" } } }
]
},
{
"conversationId": "cnv_failed",
"projectId": "prj_hwpod_workbench",
"sessionId": "ses_failed",
"threadId": "thr_failed",
"status": "failed",
"updatedAt": "2026-06-17T09:36:00.000Z",
"lastTraceId": "trc_failed",
"messageCount": 2,
"firstUserMessagePreview": "失败态 Trace 诊断",
"messages": [
{ "id": "msg_failed_user", "messageId": "msg_failed_user", "role": "user", "title": "用户", "text": "失败态 Trace 诊断", "status": "sent", "createdAt": "2026-06-17T09:35:00.000Z", "conversationId": "cnv_failed", "sessionId": "ses_failed", "threadId": "thr_failed", "turnId": "turn_failed" },
{ "id": "msg_failed_agent", "messageId": "msg_failed_agent", "role": "agent", "title": "Code Agent", "text": "测试工程编译失败:缺少受控依赖。", "status": "failed", "createdAt": "2026-06-17T09:35:03.000Z", "conversationId": "cnv_failed", "sessionId": "ses_failed", "threadId": "thr_failed", "traceId": "trc_failed", "turnId": "turn_failed", "error": { "code": "controlled_dependency_missing", "message": "测试工程编译失败:缺少受控依赖。" }, "runnerTrace": { "traceId": "trc_failed", "status": "failed", "sessionId": "ses_failed", "threadId": "thr_failed", "events": [], "eventCount": 4, "fullTraceLoaded": true, "hasMore": false, "error": { "code": "controlled_dependency_missing", "message": "测试工程编译失败:缺少受控依赖。" } } }
]
},
{
"conversationId": "cnv_canceled",
"projectId": "prj_hwpod_workbench",
"sessionId": "ses_canceled",
"threadId": "thr_canceled",
"status": "canceled",
"updatedAt": "2026-06-17T09:34:00.000Z",
"lastTraceId": "trc_canceled",
"messageCount": 2,
"firstUserMessagePreview": "取消态验证",
"messages": [
{ "id": "msg_canceled_user", "messageId": "msg_canceled_user", "role": "user", "title": "用户", "text": "取消态验证", "status": "sent", "createdAt": "2026-06-17T09:33:00.000Z", "conversationId": "cnv_canceled", "sessionId": "ses_canceled", "threadId": "thr_canceled", "turnId": "turn_canceled" },
{ "id": "msg_canceled_agent", "messageId": "msg_canceled_agent", "role": "agent", "title": "Code Agent", "text": "运行已取消。", "status": "canceled", "createdAt": "2026-06-17T09:33:02.000Z", "conversationId": "cnv_canceled", "sessionId": "ses_canceled", "threadId": "thr_canceled", "traceId": "trc_canceled", "turnId": "turn_canceled", "runnerTrace": { "traceId": "trc_canceled", "status": "canceled", "sessionId": "ses_canceled", "threadId": "thr_canceled", "events": [{ "seq": 1, "createdAt": "2026-06-17T09:33:02.000Z", "label": "agentrun:terminal:canceled", "status": "canceled", "message": "用户取消运行。", "terminal": true }], "eventCount": 1, "fullTraceLoaded": true, "hasMore": false } }
]
}
],
"traces": {
"trc_running": { "traceId": "trc_running", "status": "running", "sessionId": "ses_running", "threadId": "thr_running", "conversationId": "cnv_running", "turnId": "turn_running", "events": [{ "seq": 1, "createdAt": "2026-06-17T09:39:42.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "running", "message": "正在检查测试工程。" }, { "seq": 2, "createdAt": "2026-06-17T09:39:45.000Z", "label": "agentrun:tool:commandExecution:started", "type": "tool_call", "status": "running", "itemId": "tool_running_check", "toolName": "commandExecution", "command": "bun run check:tsc" }], "eventCount": 2, "fullTraceLoaded": false, "hasMore": false, "agentRun": { "adapter": "agentrun-v02", "runId": "run_running", "commandId": "cmd_running", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "running", "valuesPrinted": false } },
"trc_completed": { "traceId": "trc_completed", "status": "completed", "sessionId": "ses_completed", "threadId": "thr_completed", "conversationId": "cnv_completed", "turnId": "turn_completed", "events": [{ "seq": 1, "createdAt": "2026-06-17T09:37:03.000Z", "label": "agentrun:backend:token_count", "type": "event", "status": "completed" }, { "seq": 2, "createdAt": "2026-06-17T09:37:04.000Z", "label": "agentrun:tool:commandExecution:completed", "type": "tool_call", "status": "completed", "itemId": "tool_completed_build", "toolName": "commandExecution", "command": "bun run build", "stdoutSummary": "build completed", "exitCode": 0 }, { "seq": 3, "createdAt": "2026-06-17T09:37:07.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "completed", "replyAuthority": true, "final": true, "message": "构建和 Trace 渲染检查完成。" }, { "seq": 4, "createdAt": "2026-06-17T09:37:08.000Z", "label": "agentrun:terminal:completed", "status": "completed", "terminal": true }], "eventCount": 4, "fullTraceLoaded": true, "hasMore": false, "finalResponse": { "text": "构建和 Trace 渲染检查完成。" }, "assistantText": "构建和 Trace 渲染检查完成。", "agentRun": { "adapter": "agentrun-v02", "runId": "run_completed", "commandId": "cmd_completed", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "completed", "valuesPrinted": false } },
"trc_failed": { "traceId": "trc_failed", "status": "failed", "sessionId": "ses_failed", "threadId": "thr_failed", "conversationId": "cnv_failed", "turnId": "turn_failed", "events": [{ "seq": 1, "createdAt": "2026-06-17T09:35:04.000Z", "label": "agentrun:tool:commandExecution:completed", "type": "tool_call", "status": "blocked", "itemId": "tool_failed_build", "toolName": "commandExecution", "command": "bun run build", "stderrSummary": "missing controlled dependency", "exitCode": 1 }, { "seq": 2, "createdAt": "2026-06-17T09:35:05.000Z", "label": "agentrun:terminal:failed", "status": "failed", "message": "测试工程编译失败:缺少受控依赖。", "terminal": true, "errorCode": "controlled_dependency_missing" }], "eventCount": 2, "fullTraceLoaded": true, "hasMore": false, "assistantText": "测试工程编译失败:缺少受控依赖。", "error": { "code": "controlled_dependency_missing", "message": "测试工程编译失败:缺少受控依赖。" }, "agentRun": { "adapter": "agentrun-v02", "runId": "run_failed", "commandId": "cmd_failed", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "failed", "valuesPrinted": false } },
"trc_canceled": { "traceId": "trc_canceled", "status": "canceled", "sessionId": "ses_canceled", "threadId": "thr_canceled", "conversationId": "cnv_canceled", "turnId": "turn_canceled", "events": [{ "seq": 1, "createdAt": "2026-06-17T09:33:02.000Z", "label": "agentrun:terminal:canceled", "status": "canceled", "message": "用户取消运行。", "terminal": true }], "eventCount": 1, "fullTraceLoaded": true, "hasMore": false, "assistantText": "运行已取消。", "agentRun": { "adapter": "agentrun-v02", "runId": "run_canceled", "commandId": "cmd_canceled", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "canceled", "valuesPrinted": false } }
}
}
}
@@ -0,0 +1,23 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0.
// Responsibility: Stable DOM selectors for Workbench browser regression tests.
export const selectors = {
workspace: "#workspace",
sessionTabs: "#session-tabs",
sessionStatus: "#session-status",
commandInput: "#command-input",
commandSend: "#command-send",
conversationList: "#conversation-list",
messageCard: ".message-card",
traceTimeline: ".trace-timeline",
traceRow: ".trace-render-row",
messageDetailDialog: "#message-detail-dialog"
} as const;
export function sessionTab(conversationId: string): string {
return `.session-tab[data-conversation-id="${cssEscape(conversationId)}"]`;
}
function cssEscape(value: string): string {
return value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"");
}
@@ -0,0 +1,33 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0.
// Responsibility: Playwright fixtures for isolated Workbench fake-runtime scenarios.
import { expect, test as base, type Page, type TestInfo } from "@playwright/test";
import { selectors } from "./selectors";
export { expect };
export const test = base.extend<{ scenarioId: string }>({
scenarioId: ["baseline", { option: true }],
page: async ({ page, request, scenarioId }, use) => {
const reset = await request.post("/__e2e/reset", { data: { scenarioId } });
expect(reset.ok()).toBeTruthy();
await expect.poll(async () => (await (await request.get("/__e2e/state")).json() as { scenarioId?: string }).scenarioId).toBe(scenarioId);
await use(page);
}
});
export async function gotoWorkbench(page: Page, path = "/workbench"): Promise<void> {
await page.goto(path);
await expect(page.locator(selectors.workspace)).toBeVisible();
await expect(page.locator(selectors.commandInput)).toBeVisible();
}
export async function saveScreenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true });
}
export async function fakeServerState(page: Page): Promise<Record<string, unknown>> {
const response = await page.request.get("/__e2e/state");
expect(response.ok()).toBeTruthy();
return await response.json() as Record<string, unknown>;
}
@@ -0,0 +1,13 @@
import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test.use({ scenarioId: "deep-link" });
test("deep link hydrates the same authority path as tab selection", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench");
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed");
await saveScreenshot(page, testInfo, "deep-link-failed");
});
@@ -0,0 +1,19 @@
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors } from "../fixtures/selectors";
test.use({ scenarioId: "event-replay" });
test("SSE terminal event and REST gap fill replay to the same terminal UI", async ({ page }, testInfo) => {
await gotoWorkbench(page);
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
await expect(page.locator(`${selectors.traceTimeline}[data-status="running"]`)).toBeVisible();
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("事件重放后完成");
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn");
await saveScreenshot(page, testInfo, "event-replay-terminal");
await page.reload();
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("事件重放后完成");
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
});
@@ -0,0 +1,12 @@
import { expect, test } from "../fixtures/test";
import { selectors } from "../fixtures/selectors";
test.use({ scenarioId: "loading" });
test("delayed session list resolves to captured tabs without fake entries", async ({ page }) => {
await page.goto("/workbench");
await expect(page.locator(selectors.workspace)).toBeVisible();
await expect(page.locator(selectors.sessionTabs)).toHaveAttribute("data-loading", "false");
await expect(page.locator(".session-tab")).toHaveCount(4);
await expect(page.locator(selectors.commandInput)).toBeVisible();
});
@@ -0,0 +1,21 @@
import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test("session switch persists active conversation after reload", async ({ page }, testInfo) => {
await gotoWorkbench(page);
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true");
await saveScreenshot(page, testInfo, "session-switch-before");
await page.locator(sessionTab("cnv_completed")).click();
await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_completed/u);
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_completed");
await saveScreenshot(page, testInfo, "session-switch-after");
await page.reload();
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(selectors.sessionStatus)).toContainText("ses_completed");
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
await saveScreenshot(page, testInfo, "session-switch-reload");
});
@@ -0,0 +1,23 @@
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test("session rail, messages, composer and trace status stay consistent", async ({ page }, testInfo) => {
await gotoWorkbench(page);
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-running", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
await page.locator(sessionTab("cnv_failed")).click();
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-status", "failed");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
await page.locator(sessionTab("cnv_canceled")).click();
await expect(page.locator(sessionTab("cnv_canceled"))).toHaveAttribute("data-status", "canceled");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="canceled"]`)).toContainText("运行已取消");
await page.locator(sessionTab("cnv_completed")).click();
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-running", "false");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
await saveScreenshot(page, testInfo, "status-consistency-completed");
});
@@ -0,0 +1,27 @@
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test("TraceTimeline renders readable rows and hides backend noise", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_completed");
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
const trace = page.locator(`${selectors.traceTimeline}[data-status="completed"]`);
await expect(trace).toBeVisible();
await trace.locator("summary.trace-disclosure-summary").click();
await expect(page.locator(`${selectors.traceRow}[data-row-kind="tool"]`)).toContainText("commandExecution");
await expect(page.locator(`${selectors.traceRow}[data-terminal="true"]`)).toContainText("构建和 Trace 渲染检查完成");
await expect(page.locator(selectors.conversationList)).not.toContainText("token_count");
await page.locator(".message-detail-button").first().click();
await expect(page.locator(selectors.messageDetailDialog)).toContainText("trc_completed");
await expect(page.locator(selectors.messageDetailDialog)).toContainText("ses_completed");
await saveScreenshot(page, testInfo, "trace-rendering-detail");
});
test("failed trace exposes readable failure row", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_failed");
const trace = page.locator(`${selectors.traceTimeline}[data-status="failed"]`);
await expect(trace).toBeVisible();
await trace.locator("summary.trace-disclosure-summary").click();
await expect(page.locator(`${selectors.traceRow}[data-event-status="blocked"][data-row-kind="tool"]`)).toContainText("missing controlled dependency");
await expect(page.locator(`${selectors.messageCard}[data-status="failed"]`)).toContainText("缺少受控依赖");
await saveScreenshot(page, testInfo, "trace-rendering-failed");
});
+3 -1
View File
@@ -33,7 +33,9 @@
"include": [ "include": [
"src/**/*.ts", "src/**/*.ts",
"src/**/*.vue", "src/**/*.vue",
"scripts/**/*.ts" "scripts/**/*.ts",
"tests/**/*.ts",
"playwright.workbench.config.ts"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",