diff --git a/web/hwlab-cloud-web/bun.lock b/web/hwlab-cloud-web/bun.lock index 0d5e693a..9575d99e 100644 --- a/web/hwlab-cloud-web/bun.lock +++ b/web/hwlab-cloud-web/bun.lock @@ -15,6 +15,7 @@ "vue-router": "^4.2.5", }, "devDependencies": { + "@playwright/test": "1.59.1", "@types/dompurify": "^3.0.5", "@types/node": "22.7.5", "@vitejs/plugin-vue": "^5.2.3", @@ -126,6 +127,8 @@ "@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-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=="], + "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-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=="], + "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/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 76ff8c54..5f1d30e3 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -10,7 +10,9 @@ "check:tsc-strict": "bun run scripts/tsc-check.ts --strict", "typecheck": "vue-tsc --noEmit", "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": { "@vueuse/core": "^10.7.0", @@ -23,6 +25,7 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "1.59.1", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.6", "@types/node": "22.7.5", diff --git a/web/hwlab-cloud-web/playwright.workbench.config.ts b/web/hwlab-cloud-web/playwright.workbench.config.ts new file mode 100644 index 00000000..23a7be78 --- /dev/null +++ b/web/hwlab-cloud-web/playwright.workbench.config.ts @@ -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"] } } + ] +}); diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-capture-fixtures.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-capture-fixtures.ts new file mode 100644 index 00000000..f0e2db6c --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-capture-fixtures.ts @@ -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 = { 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 = { + 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)[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 = {}; + for (const [key, raw] of Object.entries(value as Record)) { + if (/api[-_]?key|authorization|cookie|token|secret|password|dsn|credential/iu.test(key)) { + output[key] = ""; + continue; + } + output[key] = redact(raw); + } + return output; +} + +export {}; diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts new file mode 100644 index 00000000..308b5516 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -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; + +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; + selectRequests: JsonRecord[]; + listOmitSelected: boolean; + conversationDelayMs: number; + terminalScript: boolean; +} + +const cwd = process.cwd(); +const args = new Map(); +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 } }; + +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 { + 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) ? "" : item; + return output; +} + +async function readJson(request: IncomingMessage): Promise { + 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 { + return ms > 0 ? new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) : Promise.resolve(); +} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 2e01c843..97e70d2e 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -123,7 +123,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { void hydrateTraceEvents(messages.value); reattachRestoredActiveTrace(); } - loading.value = false; const conversationsResult = await conversationsPromise; let nextConversations = conversations.value; if (conversationsResult.ok) { @@ -141,6 +140,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { conversationsReady.value = conversations.value.length > 0; error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable"; } + loading.value = false; await refreshProviderOptions(); restartRealtime("hydrate"); } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json new file mode 100644 index 00000000..6fa8a277 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json @@ -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 } } + } + } +} diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts new file mode 100644 index 00000000..e5c628ca --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts @@ -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, "\\\""); +} diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/test.ts b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/test.ts new file mode 100644 index 00000000..7ca3aa0d --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/test.ts @@ -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 { + 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 { + await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true }); +} + +export async function fakeServerState(page: Page): Promise> { + const response = await page.request.get("/__e2e/state"); + expect(response.ok()).toBeTruthy(); + return await response.json() as Record; +} diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts new file mode 100644 index 00000000..0a52f317 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts @@ -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"); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts new file mode 100644 index 00000000..86548d30 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts @@ -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(); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/loading-state.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/loading-state.spec.ts new file mode 100644 index 00000000..1195f6b8 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/loading-state.spec.ts @@ -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(); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts new file mode 100644 index 00000000..be246490 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts @@ -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"); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts new file mode 100644 index 00000000..75184977 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts @@ -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"); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts new file mode 100644 index 00000000..78f312a3 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts @@ -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"); +}); diff --git a/web/hwlab-cloud-web/tsconfig.json b/web/hwlab-cloud-web/tsconfig.json index 7b50a91b..3280a411 100644 --- a/web/hwlab-cloud-web/tsconfig.json +++ b/web/hwlab-cloud-web/tsconfig.json @@ -33,7 +33,9 @@ "include": [ "src/**/*.ts", "src/**/*.vue", - "scripts/**/*.ts" + "scripts/**/*.ts", + "tests/**/*.ts", + "playwright.workbench.config.ts" ], "exclude": [ "node_modules",