Merge pull request #1473 from pikasTech/fix/issue-1467-workbench-fake-server
[1467-R4] Workbench fake-server session authority regression
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-4
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 渲染检查完成");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user