From eb6d6c0f9c8b0da379e0256af544b814ceca2347 Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 18 Jun 2026 12:40:14 +0800 Subject: [PATCH] test: align workbench fake server with session authority --- .../scripts/workbench-e2e-server.ts | 51 +++++++++++++++++-- web/hwlab-cloud-web/src/stores/workbench.ts | 19 ++++++- .../real-captures/d601-v03-redacted.json | 9 ++-- .../workbench-e2e/specs/deep-link.spec.ts | 12 ++--- .../specs/deleted-session-deeplink.spec.ts | 18 ++----- .../workbench-e2e/specs/event-replay.spec.ts | 17 +++---- .../specs/new-session-thread.spec.ts | 2 +- .../specs/project-boundary.spec.ts | 32 ++++-------- .../specs/session-switch-reload.spec.ts | 25 ++++----- .../specs/stale-workspace-trace.spec.ts | 10 ++-- .../specs/status-consistency.spec.ts | 2 +- .../specs/trace-rendering.spec.ts | 6 +-- 12 files changed, 118 insertions(+), 85 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 1685c7aa..0f462227 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0. +// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-010403 API契约 draft-2026-06-18-r1. // Responsibility: Same-origin fake Workbench API and static server for Playwright browser regression tests. import { createReadStream, existsSync, statSync } from "node:fs"; @@ -79,7 +79,25 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload()); if (path === "/auth/login" && method === "POST") return authLoginResponse(response); - if (path === "/v1/workbench/events" && method === "GET") return sse(response); + if (path === "/v1/workbench/events" && method === "GET") return sse(response, url); + if (path === "/v1/workbench/sessions" && method === "GET") { + await delay(state.conversationDelayMs); + return json(response, 200, workbenchSessionListPayload(url)); + } + const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u); + if (sessionMessagesMatch && method === "GET") { + const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? ""); + const conversation = workbenchConversationBySessionId(sessionId); + return conversation ? json(response, 200, workbenchSessionMessagesPayload(conversation, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); + } + const workbenchSessionMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u); + if (workbenchSessionMatch && method === "GET") { + const sessionId = decodeURIComponent(workbenchSessionMatch[1] ?? ""); + if (state.scenarioId === "session-switch-detail-404-isolated" && sessionId === "ses_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); + if (state.scenarioId === "completed-replay-detail-404" && sessionId === "ses_completed") return json(response, 404, { ok: false, status: 404, error: { code: "session_replay_unavailable" } }); + const conversation = workbenchConversationBySessionId(sessionId); + return conversation ? json(response, 200, { session: conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); + } 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); @@ -413,10 +431,35 @@ function conversationSummary(conversation: ConversationRecord): ConversationReco return rest as ConversationRecord; } +function workbenchSessionListPayload(url: URL): JsonRecord { + const includeSessionId = url.searchParams.get("includeSessionId") || ""; + const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === state.projectId); + const conversations = state.listOmitSelected && includeSessionId ? visibleConversations.filter((item) => item.sessionId !== includeSessionId) : visibleConversations; + const summaries = conversations.map((conversation) => conversationSummary(conversation)); + return { projectId: null, sessions: summaries, conversations: summaries, count: summaries.length }; +} + +function workbenchSessionMessagesPayload(conversation: ConversationRecord, url: URL): JsonRecord { + const messages = Array.isArray(conversation.messages) ? conversation.messages : []; + const requestedLimit = Number(url.searchParams.get("limit") ?? messages.length); + const limit = Math.max(1, Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : messages.length || 100); + const page = messages.slice(0, limit); + return { sessionId: conversation.sessionId ?? null, conversationId: conversation.conversationId, messages: page, count: page.length, total: messages.length, hasMore: messages.length > page.length }; +} + function conversationById(id: string): ConversationRecord | null { return state.conversations.find((conversation) => conversation.conversationId === id) ?? null; } +function conversationBySessionId(id: string): ConversationRecord | null { + return state.conversations.find((conversation) => conversation.sessionId === id) ?? null; +} + +function workbenchConversationBySessionId(id: string): ConversationRecord | null { + const conversation = conversationBySessionId(id); + return conversation && conversationProjectId(conversation) === state.projectId ? conversation : null; +} + function deleteConversationById(id: string): void { const conversation = conversationById(id); if (!conversation) return; @@ -480,13 +523,13 @@ function tracePayload(traceId: string, url: URL): JsonRecord { 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 { +function sse(response: ServerResponse, url: URL): 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 }); + writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId"), workspaceId: state.workspaceId }); if (state.terminalScript) { const scenarioId = state.scenarioId; setTimeout(() => { diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index cb17b7b9..b965b708 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -377,6 +377,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { conversations.value = mergeConversationIntoList(conversations.value, persistedConversation); if (activeConversationId.value === conversation.conversationId || activeConversationId.value === authoritativeConversationId) { replaceActiveConversationSelection(authoritativeConversationId); + setActiveSessionSelection(sessionIdFromConversation(persistedConversation)); const persistedProjectId = conversationProjectId(persistedConversation, selectedProjectId); workspace.value = workspaceWithSelectedConversation(persistedWorkspace, persistedConversation, persistedProjectId); rememberWorkspaceSnapshot(persistedProjectId, workspace.value); @@ -387,7 +388,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { void hydrateTurnStatusAuthority(messages.value); void hydrateTraceEvents(messages.value); void refreshSelectedSessionStatus(persistedConversation); - void refreshConversations(conversation.conversationId); + void refreshConversations(authoritativeConversationId); restartRealtime(requestEpoch === workspaceSelectionEpoch.value ? "create-session" : "create-session-persisted"); } return { ok: true, error: null }; @@ -453,6 +454,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value; } + function replaceActiveSessionSelection(sessionId: string | null | undefined): void { + explicitSessionId.value = normalizeWorkbenchSessionId(sessionId); + } + function replaceActiveConversationSelection(conversationId: string | null | undefined): void { explicitConversationId.value = normalizeWorkbenchConversationId(conversationId); } @@ -1118,6 +1123,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { function applySelectedConversationDetail(conversation: ConversationRecord, baseWorkspace: WorkspaceRecord | null, selectedProjectId: string, previousConversationId: string | null, previousMessages: ChatMessage[]): void { setActiveConversationSelection(conversation.conversationId); + setActiveSessionSelection(sessionIdFromConversation(conversation)); workspace.value = workspaceWithSelectedConversation(baseWorkspace, conversation, selectedProjectId); rememberWorkspaceSnapshot(selectedProjectId, workspace.value); rememberConversationDetail(conversation); @@ -1139,13 +1145,21 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (previousId && previousId !== targetId) { const previousConversation = conversations.value.find((item) => item.conversationId === previousId) ?? null; replaceActiveConversationSelection(previousId); + replaceActiveSessionSelection(sessionIdFromConversation(previousConversation)); if (previousConversation) { const previousProjectId = conversationProjectId(previousConversation, activeProjectId.value); workspace.value = workspaceWithSelectedConversation(workspace.value, previousConversation, previousProjectId); rememberWorkspaceSnapshot(previousProjectId, workspace.value); + rememberConversationDetail(previousConversation); + messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(previousConversation, previousId, targetId, previousMessages), previousMessages); + void hydrateTurnStatusAuthority(messages.value); + void hydrateTraceEvents(messages.value); + reattachRestoredActiveTrace(); + } else { + messages.value = previousMessages; } - messages.value = previousMessages; } else { + replaceActiveSessionSelection(null); messages.value = []; } currentRequest.value = null; @@ -1285,6 +1299,7 @@ async function loadWorkbenchSessionConversation(sessionId: string, seed: Convers api.workbench.session(id), api.workbench.sessionMessages(id, { limit: 100 }) ]); + if (!detail.ok) return null; const detailConversation = detail.ok ? conversationFromWorkbenchSession(detail.data?.session) : null; const base = detailConversation ?? seed; if (!base) return null; 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 index 6fa8a277..b7d4fcbb 100644 --- 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 @@ -8,15 +8,16 @@ "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", + "capturedAt": "2026-06-18T04:20:00.000Z", "sourceProbe": { - "probedAt": "2026-06-17T10:24:00.000Z", + "probedAt": "2026-06-18T04:20: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"] } + { "path": "/v1/workbench/sessions", "status": 200, "topLevelKeys": ["conversations", "count", "ok", "projectId", "sessions", "status"] }, + { "path": "/v1/workbench/sessions/ses_running", "status": 200, "topLevelKeys": ["ok", "session", "status"] }, + { "path": "/v1/workbench/sessions/ses_running/messages?limit=100", "status": 200, "topLevelKeys": ["conversationId", "count", "hasMore", "messages", "ok", "sessionId", "status", "total"] } ] }, "schemaVersion": "workbench-e2e-capture.v1", 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 index 5219ed4f..4ea73fac 100644 --- 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 @@ -4,24 +4,24 @@ 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 gotoWorkbench(page, "/workbench/sessions/ses_failed"); 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 expect(page).toHaveURL(/\/workbench\/sessions\/ses_failed$/u); await saveScreenshot(page, testInfo, "deep-link-failed"); }); test.describe("deep link stale workspace authority", () => { test.use({ scenarioId: "deep-link-stale-workspace-authority" }); - test("deep link persists route conversation over stale workspace selection", async ({ page }) => { - await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench"); + test("deep link persists route session over stale workspace selection", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/ses_failed"); 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.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed"); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_failed$/u); const state = await fakeServerState(page); const selectRequests = state.selectRequests as Array<{ conversationId?: unknown }>; - expect(selectRequests.some((request) => request.conversationId === "cnv_failed")).toBeTruthy(); + expect(selectRequests.some((request) => request.conversationId === "cnv_failed")).toBeFalsy(); }); }); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deleted-session-deeplink.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deleted-session-deeplink.spec.ts index 54909c8f..20ee62df 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deleted-session-deeplink.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deleted-session-deeplink.spec.ts @@ -4,25 +4,15 @@ import { selectors, sessionTab } from "../fixtures/selectors"; test.use({ scenarioId: "deleted-session-deeplink" }); test("fresh deep link does not restore an archived session as active", async ({ page }, testInfo) => { - const projectId = "prj_hwpod_workbench"; - const workspace = await page.request.get(`/v1/workbench/workspace?projectId=${projectId}`); - expect(workspace.status()).toBe(200); - const workspaceBody = await workspace.json() as { workspace: { workspaceId: string } }; - - const detail = await page.request.get(`/v1/agent/conversations/cnv_deleted?projectId=${projectId}`); + const detail = await page.request.get("/v1/workbench/sessions/ses_deleted"); expect(detail.status()).toBe(200); - expect((await detail.json()).conversation.status).toBe("archived"); + expect((await detail.json()).session.status).toBe("archived"); - const list = await page.request.get(`/v1/agent/conversations?projectId=${projectId}&includeConversationId=cnv_deleted`); + const list = await page.request.get("/v1/workbench/sessions?includeSessionId=ses_deleted"); expect(list.status()).toBe(200); expect(((await list.json()).conversations as Array<{ conversationId: string; status?: string }>).some((item) => item.conversationId === "cnv_deleted" && item.status === "archived")).toBe(true); - const select = await page.request.post(`/v1/workbench/workspace/${workspaceBody.workspace.workspaceId}/select-conversation`, { - data: { projectId, conversationId: "cnv_deleted", sessionId: "ses_deleted", updatedByClient: "playwright" } - }); - expect(select.status()).toBe(404); - - await gotoWorkbench(page, `/workbench/sessions/cnv_deleted?projectId=${projectId}`); + await gotoWorkbench(page, "/workbench/sessions/ses_deleted"); await expect(page.locator(sessionTab("cnv_deleted"))).toHaveCount(0); await expect(page.locator(sessionTab("cnv_completed"))).toHaveCount(1); await expect(page.locator(".conversation-error-hint")).toContainText(/archived|归档|session/u); 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 index 60e22892..fb835181 100644 --- 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 @@ -18,22 +18,21 @@ test("SSE terminal event and REST gap fill replay to the same terminal UI", asyn await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible(); }); -test("fresh deep link replays completed conversation from the project-scoped authority", async ({ page }, testInfo) => { - const projectId = "prj_hwpod_workbench"; - const detail = await page.request.get(`/v1/agent/conversations/cnv_completed?projectId=${projectId}`); +test("fresh deep link replays completed conversation from the session authority", async ({ page }, testInfo) => { + const detail = await page.request.get("/v1/workbench/sessions/ses_completed"); expect(detail.status()).toBe(200); const detailBody = await detail.json(); - expect(detailBody.conversation.projectId).toBe(projectId); + expect(detailBody.session.sessionId).toBe("ses_completed"); - const turn = await page.request.get(`/v1/agent/turns/trc_completed?projectId=${projectId}`); + const turn = await page.request.get("/v1/agent/turns/trc_completed"); expect(turn.status()).toBe(200); expect((await turn.json()).status).toBe("completed"); - const trace = await page.request.get(`/v1/agent/traces/trc_completed?projectId=${projectId}`); + const trace = await page.request.get("/v1/agent/traces/trc_completed"); expect(trace.status()).toBe(200); expect((await trace.json()).status).toBe("completed"); - await gotoWorkbench(page, `/workbench/sessions/cnv_completed?projectId=${projectId}`); + await gotoWorkbench(page, "/workbench/sessions/ses_completed"); 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(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible(); @@ -45,8 +44,8 @@ test.describe("completed replay unavailable", () => { test.use({ scenarioId: "completed-replay-detail-404" }); test("detail or turn 404 renders diagnostic state instead of an empty conversation", async ({ page }) => { - await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench"); - await expect(page.locator(".conversation-error-hint")).toContainText(/conversation|replay|unavailable/u); + await gotoWorkbench(page, "/workbench/sessions/ses_completed"); + await expect(page.locator(".conversation-error-hint")).toContainText(/session URL not found|conversation|replay|unavailable/u); await expect(page.locator(selectors.messageCard)).toHaveCount(0); await expect(page.locator(selectors.sessionTabs)).not.toHaveAttribute("data-loading", "true"); }); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts index 26a0e1c7..87c8e4eb 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts @@ -29,7 +29,7 @@ test.describe("server authoritative session create", () => { await gotoWorkbench(page); await page.locator(selectors.sessionCreate).click(); - await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_server_created/u); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_server_created/u); await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-active", "true"); await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-running", "false"); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-boundary.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-boundary.spec.ts index 6396cabd..cb989650 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-boundary.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-boundary.spec.ts @@ -1,33 +1,19 @@ import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test"; import { selectors, sessionTab } from "../fixtures/selectors"; -test.describe("project-scoped conversation details", () => { +test.describe("session authority boundary", () => { test.use({ scenarioId: "cross-project-detail-boundary" }); - test("rejects conversation, turn and trace details outside the requested project", async ({ page }, testInfo) => { - const projectId = "prj_hwpod_workbench"; - const conversation = await page.request.get(`/v1/agent/conversations/cnv_cross_project?projectId=${projectId}`); - expect(conversation.status()).toBe(404); - await expect((await conversation.json()).error.code).toBe("conversation_project_mismatch"); + test("does not expose a cross-project session through Workbench session routes", async ({ page }, testInfo) => { + const session = await page.request.get("/v1/workbench/sessions/ses_cross_project"); + expect(session.status()).toBe(404); + await expect((await session.json()).error.code).toBe("session_not_found"); - const turn = await page.request.get(`/v1/agent/turns/trc_cross_project?projectId=${projectId}`); - expect(turn.status()).toBe(404); - await expect((await turn.json()).error.code).toBe("trace_project_mismatch"); + const messages = await page.request.get("/v1/workbench/sessions/ses_cross_project/messages"); + expect(messages.status()).toBe(404); + await expect((await messages.json()).error.code).toBe("session_not_found"); - const trace = await page.request.get(`/v1/agent/traces/trc_cross_project?projectId=${projectId}`); - expect(trace.status()).toBe(404); - await expect((await trace.json()).error.code).toBe("trace_project_mismatch"); - - const workspace = await page.request.get(`/v1/workbench/workspace?projectId=${projectId}`); - expect(workspace.status()).toBe(200); - const workspaceId = (await workspace.json()).workspace.workspaceId; - const selected = await page.request.post(`/v1/workbench/workspace/${workspaceId}/select-conversation`, { - data: { projectId, conversationId: "cnv_cross_project", sessionId: "ses_cross_project" } - }); - expect(selected.status()).toBe(404); - await expect((await selected.json()).error.code).toBe("agent_conversation_not_found"); - - await gotoWorkbench(page, `/workbench/sessions/cnv_cross_project?projectId=${projectId}`); + await gotoWorkbench(page, "/workbench/sessions/ses_cross_project"); await expect(page.locator(sessionTab("cnv_cross_project"))).toHaveCount(0); await expect(page.locator(`${selectors.messageCard}[data-conversation-id="cnv_cross_project"]`)).toHaveCount(0); await saveScreenshot(page, testInfo, "project-boundary-cross-detail"); 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 index d000623b..262b4dfa 100644 --- 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 @@ -7,7 +7,7 @@ test("session switch persists active conversation after reload", async ({ page } 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).toHaveURL(/\/workbench\/sessions\/ses_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"); @@ -24,12 +24,12 @@ test.describe("empty session switch target", () => { test.use({ scenarioId: "session-switch-empty-reload" }); test("switching from a filled session to an empty session updates URL and reload target", async ({ page }, testInfo) => { - await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench"); + await gotoWorkbench(page, "/workbench/sessions/ses_completed"); await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true"); await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toBeVisible(); await page.locator(sessionTab("cnv_empty")).click(); - await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty(?:\?projectId=prj_hwpod_workbench)?$/u); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty$/u); await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true"); await expect(page.locator(selectors.messageCard)).toHaveCount(0); await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_empty"); @@ -41,16 +41,16 @@ test.describe("empty session switch target", () => { }); test("switching back to a running session reflects URL before reload", async ({ page }) => { - await gotoWorkbench(page, "/workbench/sessions/cnv_running?projectId=prj_hwpod_workbench"); + await gotoWorkbench(page, "/workbench/sessions/ses_running"); await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true"); await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible(); await page.locator(sessionTab("cnv_empty")).click(); - await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty/u); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty/u); await expect(page.locator(selectors.messageCard)).toHaveCount(0); await page.locator(sessionTab("cnv_running")).click(); - await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_running/u); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_running/u); await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true"); await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible(); @@ -64,17 +64,18 @@ test.describe("stale session detail isolation", () => { test.use({ scenarioId: "session-switch-detail-404-isolated" }); test("a visible tab with detail 404 does not clear rail or block later switches", async ({ page }) => { - await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench"); + await gotoWorkbench(page, "/workbench/sessions/ses_completed"); await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true"); await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(1); - - await page.locator(sessionTab("cnv_stale_404")).click(); - await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true"); - await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(0); await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toBeVisible(); + await page.locator(sessionTab("cnv_stale_404")).click(); + await expect(page.locator(".conversation-error-hint")).toContainText(/session URL not found|unavailable/u); + await expect(page.locator(sessionTab("cnv_completed"))).toHaveCount(1); + await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(1); + await page.locator(sessionTab("cnv_empty")).click(); - await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty/u); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty/u); await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true"); await expect(page.locator(selectors.messageCard)).toHaveCount(0); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts index b3086ee5..66dae393 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts @@ -10,7 +10,7 @@ test.describe("stale nested workspace trace", () => { if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url()); }); - await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench"); + await gotoWorkbench(page, "/workbench/sessions/ses_failed"); 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(); @@ -28,17 +28,15 @@ test.describe("stale nested workspace trace after terminal restore", () => { if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url()); }); - await gotoWorkbench(page, "/workbench/sessions/cnv_running?projectId=prj_hwpod_workbench"); + await gotoWorkbench(page, "/workbench/sessions/ses_running"); await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-running", "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(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn"); - await expect.poll(async () => ((await fakeServerState(page)).workspacePatchRequests as unknown[]).length).toBeGreaterThan(0); - await expect.poll(async () => nestedWorkspaceLastTraceId(await fakeServerState(page))).toBeNull(); const state = await fakeServerState(page); - const patchRequests = state.workspacePatchRequests as Array<{ workspace?: { lastTraceId?: unknown } }>; - expect(patchRequests.at(-1)?.workspace?.lastTraceId ?? null).toBeNull(); + expect((state.workspacePatchRequests as unknown[]).length).toBe(0); + expect(nestedWorkspaceLastTraceId(state)).toBe("trc_stale_502"); expect(staleTraceRequests).toEqual([]); await page.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 index 18698cdf..ad31b036 100644 --- 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 @@ -26,7 +26,7 @@ test.describe("terminal turn with stale active session authority", () => { test.use({ scenarioId: "terminal-turn-stale-session-active" }); test("session rail shows terminal status instead of stale active", async ({ page }, testInfo) => { - await gotoWorkbench(page); + await gotoWorkbench(page, "/workbench/sessions/ses_completed"); await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-status", "completed"); 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 渲染检查完成"); 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 index b5c9587d..9225972a 100644 --- 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 @@ -2,7 +2,7 @@ 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 gotoWorkbench(page, "/workbench/sessions/ses_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(); @@ -17,7 +17,7 @@ test("TraceTimeline renders readable rows and hides backend noise", async ({ pag }); test("failed trace exposes readable failure row", async ({ page }, testInfo) => { - await gotoWorkbench(page, "/workbench/sessions/cnv_failed"); + await gotoWorkbench(page, "/workbench/sessions/ses_failed"); const trace = page.locator(`${selectors.traceTimeline}[data-status="failed"]`); await expect(trace).toBeVisible(); await trace.locator("summary.trace-disclosure-summary").click(); @@ -30,7 +30,7 @@ test.describe("terminal empty trace copy", () => { test.use({ scenarioId: "terminal-empty-trace" }); test("completed message details do not retain thinking placeholder", async ({ page }) => { - await gotoWorkbench(page, "/workbench/sessions/cnv_terminal_empty"); + await gotoWorkbench(page, "/workbench/sessions/ses_terminal_empty"); const card = page.locator(`${selectors.messageCard}[data-status="completed"]`).last(); await expect(card).toContainText("终态空 Trace 已完成。"); await expect(card.locator(".trace-empty")).toContainText("运行已完成,暂无可读 Trace");