feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
+63 -33
View File
@@ -42,7 +42,6 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
const workspace = page.locator("#main-workspace");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-header");
const agentConversation = agentPanel.getByRole("log");
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
const agentControls = agentPanel.locator(".agent-panel-band").last();
@@ -63,28 +62,24 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
await expectThemeColor(agentPanel, "border-right-color", "--surface-divider");
await expect(agentPanel).toHaveCSS("box-shadow", "none");
await expect(agentControls).toHaveCSS("border-top-width", "1px");
await expectThemeColor(agentControls, "background-color", "--agent-dock-band");
await expectThemeColor(agentPanel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
const capabilities = agentPanel.getByRole("list", { name: "Agent 分析能力" });
await expect(capabilities.getByRole("listitem")).toHaveCount(4);
await expect(capabilities).toHaveCSS("box-shadow", "none");
for (const item of await capabilities.getByRole("listitem").all()) {
await expect(item).toHaveCSS("border-right-width", "0px");
await expect(item).toHaveCSS("border-bottom-width", "0px");
}
await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
).toHaveCSS("color", "rgb(51, 65, 85)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("color", "rgb(30, 41, 59)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("font-size", "14px");
await expect(agentPanel).toHaveAttribute("data-agent-starting", "true");
await expect(agentControls).toHaveCSS("border-top-width", "0px");
await expect(agentControls).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expectAcrylicSurface(agentPanel.locator("form.agent-panel-control"));
const inputBody = agentPanel.locator(".agent-panel-input-body");
const inputTools = agentPanel.locator(".agent-panel-input-tools");
await expectThemeColor(inputBody, "background-color", "--agent-dock-reading");
await expectThemeColor(inputBody, "border-color", "--surface-border");
await expect(inputBody).toHaveCSS("display", "block");
await expect(inputTools).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const startComposer = agentPanel.locator(".agent-panel-start-composer");
const suggestions = agentPanel.getByRole("group", { name: "推荐问题" });
await expect(suggestions.getByRole("button")).toHaveCount(3);
await expect(agentPanel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
await expect(agentPanel.locator(".agent-panel-main-content")).toBeHidden();
const panelBox = await agentPanel.boundingBox();
const conversationBox = await agentConversation.boundingBox();
const scrollBox = await agentConversationScroll.boundingBox();
expect(panelBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull();
expect(panelBox!.x).toBe(0);
const topBarBox = await topBar.boundingBox();
expect(panelBox!.y).toBe(topBarBox!.y + topBarBox!.height);
@@ -97,17 +92,27 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
expect(headerBox!.y).toBe(topBarBox!.y + topBarBox!.height);
expect(workspaceBox!.y).toBe(topBarBox!.y);
await expect(topBar).toHaveCSS("box-shadow", "none");
await expect(agentHeader).toHaveCSS("box-shadow", "none");
await expectAcrylicSurface(agentHeader);
await expect(agentHeader.locator(":scope > div").first()).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await topBar.evaluate((element) => getComputedStyle(element).borderBottomColor)).toBe(
await agentPanel.evaluate((element) => getComputedStyle(element).borderRightColor)
);
expect(Math.abs(conversationBox!.x - panelBox!.x)).toBeLessThanOrEqual(1);
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
const beforeFocus = await getSurfacePresentation(agentPanel);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
await page.getByPlaceholder("询问供水问题,或描述需要处理的任务").focus();
await expectThemeColor(inputBody, "border-color", "--field-focus-border");
await expect(inputBody).toHaveCSS("box-shadow", "none");
await expect(agentPanel.locator("form.agent-panel-control > [data-slot=input-group]")).toHaveCSS("border-width", "0px");
await expect.poll(() => getSurfacePresentation(agentPanel)).toEqual(beforeFocus);
await agentPanel.getByRole("button", { name: "选择对话归属" }).click();
const projectMenu = page.getByRole("menu");
await expect(projectMenu.getByRole("menuitem", { name: "个人会话" })).toBeVisible();
await expect(projectMenu.getByRole("menuitem", { name: "当前项目" })).toBeVisible();
await expect(projectMenu.getByRole("menuitem", { name: "新建项目" })).toBeVisible();
await expect(projectMenu.getByRole("menuitem", { name: "切换项目" })).toHaveCount(0);
await page.screenshot({ path: "test-results/agent-history-ui/project-menu.png" });
await page.keyboard.press("Escape");
await page.screenshot({ path: "test-results/agent-history-ui/desktop-dock.png" });
for (const viewport of [
@@ -125,6 +130,27 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
path: `test-results/agent-history-ui/desktop-dock-${viewport.width}.png`
});
}
await page.setViewportSize({ width: 1440, height: 640 });
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
for (const width of [400, 640]) {
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(width, box.y + box.height / 2);
await page.mouse.up();
await page.mouse.move(1000, 450);
await expect(agentPanel).toHaveCSS("width", `${width}px`);
expect(await startComposer.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
expect(await inputTools.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
const textarea = agentPanel.locator("textarea");
await textarea.fill("核对近期供水压力变化,说明监测证据和需要现场核验的事项。".repeat(20));
const composerBox = (await startComposer.boundingBox())!;
expect(composerBox.y).toBeGreaterThanOrEqual(topBarBox!.height * 2);
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(640);
await textarea.fill("");
await agentPanel.screenshot({ path: `test-results/agent-history-ui/desktop-material-${width}.png` });
}
});
test("desktop Agent portal menus keep local material and respect reduced transparency", async ({ page }) => {
@@ -137,11 +163,12 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
const trigger = panel.getByRole("button", { name });
await trigger.click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await waitForAnimations(menu);
await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock");
expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull();
await expectThemeColor(menu, "background-color", "--agent-dock-overlay-surface");
await expect(menu).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await expect(menu).toHaveCSS("background-image", "none");
await expectAcrylicSurface(menu);
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
}
@@ -158,7 +185,8 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
await page.keyboard.press("Escape");
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
const history = panel.getByRole("region", { name: "Agent 历史记录" });
await expectThemeColor(history, "background-color", "--palette-paper");
await expectThemeColor(panel.locator(".agent-panel-header"), "background-color", "--palette-paper");
await expect(panel.locator(".agent-panel-header")).toHaveCSS("backdrop-filter", "none");
await expect(history).toHaveCSS("backdrop-filter", "none");
});
@@ -178,8 +206,10 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await expectUnifiedHistoryStructure(history);
await expectThemeColor(history, "background-color", "--agent-dock-overlay-surface");
await expect(history).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await expectThemeColor(agentPanel.locator(".agent-panel-header"), "background-color", "--agent-dock-overlay-surface");
await expectAcrylicSurface(agentPanel.locator(".agent-panel-header"));
await expect(history).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(history).toHaveCSS("backdrop-filter", "none");
await history.getByRole("button", { name: "重命名对话" }).first().click();
const renameInput = history.locator("input").first();
@@ -339,12 +369,12 @@ test("long history titles keep rename and explicit delete usable across viewport
}
})
);
await page.route("**/api/v1/agent/chat/session/**", async (route) => {
await page.route("**/api/v1/session/**", async (route) => {
const request = route.request();
const method = request.method();
if (method === "OPTIONS") return route.fallback();
const path = new URL(request.url()).pathname;
if (method === "PATCH" && path === `/api/v1/agent/chat/session/${sessionId}/title`) {
if (method === "PATCH" && path === `/api/v1/session/${sessionId}/title`) {
const body = request.postDataJSON();
title = body.title;
writes.push({ method, path, body });
@@ -353,7 +383,7 @@ test("long history titles keep rename and explicit delete usable across viewport
json: {}
});
}
if (method === "DELETE" && path === `/api/v1/agent/chat/session/${sessionId}`) {
if (method === "DELETE" && path === `/api/v1/session/${sessionId}`) {
deleted = true;
writes.push({ method, path, body: null });
return route.fulfill({
@@ -444,7 +474,7 @@ test("long history titles keep rename and explicit delete usable across viewport
expect(writes).toEqual([
{
method: "PATCH",
path: `/api/v1/agent/chat/session/${sessionId}/title`,
path: `/api/v1/session/${sessionId}/title`,
body: { title: "城南压力复核(已复核)", is_title_manually_edited: true }
}
]);
@@ -459,7 +489,7 @@ test("long history titles keep rename and explicit delete usable across viewport
expect(writes).toHaveLength(2);
expect(writes[1]).toEqual({
method: "DELETE",
path: `/api/v1/agent/chat/session/${sessionId}`,
path: `/api/v1/session/${sessionId}`,
body: null
});
await page.keyboard.press("Escape");
+7 -7
View File
@@ -15,7 +15,7 @@ test("real model publishes local components, restores them and receives form inp
test.setTimeout(300_000);
const target = new URL(realAgentUrl!);
expect(["127.0.0.1", "localhost"]).toContain(target.hostname);
const base = `${target.origin}/api/v1/agent/chat`;
const base = `${target.origin}/api/v1`;
const health = await request.get(`${target.origin}/health`);
expect(health.ok()).toBe(true);
expect(await health.json()).toMatchObject({ ready: true, warmed_up: true });
@@ -26,7 +26,7 @@ test("real model publishes local components, restores them and receives form inp
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: target.origin,
TJWATER_AGENT_URL: target.origin,
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_ENABLE_MSW: "false"
@@ -66,7 +66,7 @@ test("real model publishes local components, restores them and receives form inp
await page.getByRole("menuitem", { name: new RegExp(requestedModelLabel) }).click();
}
const firstResponse = page.waitForResponse(
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
(response) => response.url() === `${base}/responses` && response.request().method() === "POST"
);
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
const stream = await firstResponse;
@@ -103,18 +103,18 @@ test("real model publishes local components, restores them and receives form inp
.getByLabel("现场位置(必填)")
.fill("演练:东侧阀门旁,未实地核验。本轮仍为功能验收,请不要写业务证据或记忆,只确认收到。");
const secondResponse = page.waitForResponse(
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
(response) => response.url() === `${base}/responses` && response.request().method() === "POST"
);
await panel.getByRole("button", { name: "发送补充资料" }).click();
const submitted = await secondResponse;
const submittedBody = submitted.request().postDataJSON() as {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
};
expect(
submittedBody.messages
submittedBody.input
.filter((message) => message.role === "user")
.at(-1)
?.parts.some((part) => part.text?.includes("现场位置:演练:东侧阀门旁"))
?.content.some((part) => part.text?.includes("现场位置:演练:东侧阀门旁"))
).toBe(true);
expect(submitted.ok()).toBe(true);
await expect(panel.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible({
+8 -7
View File
@@ -1,3 +1,4 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
@@ -99,7 +100,7 @@ for (const width of [1440, 375, 320]) {
data: { content: "仍需用户补充现场核验资料。", ui_block_ids: ["pressure"] }
}
];
await page.route("**/api/v1/agent/chat/session/browser-session-1", async (route) => {
await page.route("**/api/v1/session/browser-session-1", async (route) => {
if (route.request().method() !== "GET") return route.fallback();
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
@@ -121,16 +122,16 @@ for (const width of [1440, 375, 320]) {
});
});
const prompts: string[] = [];
await page.route("**/api/v1/agent/chat/stream", async (route) => {
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
const body = route.request().postDataJSON() as {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
};
const text =
body.messages
body.input
.filter((m) => m.role === "user")
.at(-1)
?.parts.filter((p) => p.type === "text")
?.content.filter((p) => p.type === "input_text")
.map((p) => p.text)
.join("") ?? "";
prompts.push(text);
@@ -160,9 +161,9 @@ for (const width of [1440, 375, 320]) {
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
"Cache-Control": "no-cache"
},
body: chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")
body: responsesFixture(chunks)
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
+49
View File
@@ -0,0 +1,49 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockScadaApi } from "./support/mock-scada-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("chart and history actions send success only after rendering, and final answer replaces process text", async ({ page }) => {
await mockWorkbenchContext(page);
await mockScadaApi(page, [{ device_id: "D1", device_type: "pressure", node_id: "J1", link_id: null, api_query_id: null, measurement_unit: "m", transmission_mode: "test", transmission_frequency: "1min", reliability: 1, x: null, y: null, longitude: 121.48, latitude: 31.1 }]);
await mockAgentApi(page);
const receipts: Array<{ actionId: string; status: string; output: unknown }> = [];
const chart = { title: "监测压力", chart_type: "line", x_data: ["t1", "t2"], series: [{ name: "D1 scada_raw", data: [20, 21] }] };
await page.route("**/api/v1/frontend-action-registry", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { schema_version: "frontend-action-registry@1", actions: ["show_chart", "view_history", "zoom_to_map", "locate_features"].map((id) => ({ id, version: "frontend-action@1" })) } }));
await page.route("**/api/v1/frontend-actions/*/result", async (route) => {
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "POST, OPTIONS" } }); return; }
receipts.push(route.request().postDataJSON());
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { accepted: true } });
});
await page.route("**/api/v1/responses", async (route) => {
const now = Date.now();
const action = (name: string, params: unknown, actionId: string) => ({ version: "frontend-action@1", actionId, toolCallId: actionId, sessionId: "migrated-session", name, params, issuedAt: now, expiresAt: now + 15000 });
const chunks = [
{ type: "start", messageId: "assistant-migrated" },
{ type: "data-progress", data: { session_id: "migrated-session", id: "business-data", phase: "data", status: "completed", title: "获取业务数据" } },
{ type: "text-start", id: "text" }, { type: "text-delta", id: "text", delta: "正在整理过程说明" }, { type: "text-end", id: "text" },
{ type: "data-frontend_action", data: action("show_chart", chart, "chart-action") },
{ type: "data-frontend_action", data: action("view_history", { element_type: "junction", element_id: "J1", start: "2026-09-01T00:00:00Z", end: "2026-09-01T01:00:00Z", chart }, "history-action") },
{ type: "data-frontend_action", data: action("locate_features", { layer: "scada", feature_ids: ["D1", "missing"] }, "locate-action") },
{ type: "data-frontend_action", data: action("zoom_to_map", { center: [121.48, 31.1], zoom: 14.5 }, "zoom-action") },
{ type: "data-final_answer", data: { session_id: "migrated-session", content: "最终答复:压力曲线已核实。" } },
{ type: "finish", finishReason: "stop" },
];
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, body: responsesFixture(chunks) });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const prompt = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).filter({ visible: true }).first();
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
await prompt.fill("查询历史并展示压力曲线");
await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click();
await expect(page.getByText("最终答复:压力曲线已核实。", { exact: true })).toBeVisible();
await expect(page.getByText("正在整理过程说明", { exact: true })).toBeHidden();
await expect(page.locator('[data-agent-ui-result="chart-action"] svg[role="img"]')).toBeVisible();
await expect(page.locator('[data-agent-ui-result="history-action"] svg[role="img"]')).toBeVisible();
await expect.poll(() => receipts.length).toBe(4);
expect(receipts.find((receipt) => receipt.actionId === "locate-action")?.output).toEqual({ located_ids: ["D1"], missing_ids: ["missing"], fitted: true });
expect(receipts.every((receipt) => receipt.status === "succeeded")).toBe(true);
});
+27 -4
View File
@@ -43,7 +43,7 @@ test("dragging the Agent edge keeps the workspace attached and preserves the dra
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const workspace = page.locator("#main-workspace");
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
const prompt = panel.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
const prompt = panel.getByPlaceholder("询问供水问题,或描述需要处理的任务");
await expect(panel.getByText("当前上下文", { exact: true })).toHaveCount(0);
await prompt.fill("保留尚未发送的诊断问题");
await expect(panel).toHaveCSS("width", "500px");
@@ -231,7 +231,7 @@ test("the floating condition feed coordinates with the docked Agent", async ({ p
.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0))
.toBeGreaterThan(400);
const expandedOffset = Math.round((await workspace.boundingBox())?.x ?? 0);
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
const prompt = page.getByPlaceholder("询问供水问题,或描述需要处理的任务");
await prompt.fill("保留尚未发送的诊断问题");
const conditionToggle = page
.locator("header.acrylic-navigation")
@@ -286,10 +286,33 @@ test.describe("mobile touch layout", () => {
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 });
await mockWorkbenchContext(page, {
availability: "ready",
source: "none",
scenario_availability: "not_configured",
artifact_availability: "not_configured",
schedule_availability: "not_configured",
work_order_availability: "not_configured",
alert_availability: "ready",
generated_at: "2026-07-21T02:40:00Z",
conditions: [],
alerts: [{
id: "test-alert",
condition_id: null,
source: { task_type: "other_task", task_id: "test-task", run_id: "test-run" },
title: "供水压力告警",
description: "待核对压力异常。",
occurred_at: "2026-07-21T02:30:00Z",
severity: "warning"
}],
scenarios: [],
latest_analysis: null,
work_orders: []
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
await expect(page.getByText("来源:工况演示回退", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "查看待办与告警,当前 1 条待处理事项" }).click();
await expect(page.getByText("业务告警来源:后端运行态", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "工况汇总" }).click();
await expect(
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const { scope, width } of [
{ scope: "project", width: 1440 },
{ scope: "personal", width: 1440 },
{ scope: "personal", width: 375 }
] as const) {
test(`authenticated ${scope} chat sends the matching context at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await mockWorkbenchContext(page);
await mockAgentApi(page);
if (scope === "personal") {
await page.route("**/api/v1/projects?*", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: { items: [], total: 0, limit: 1000, offset: 0 }
}));
}
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "https://authentication.invalid/realms/test",
TJWATER_KEYCLOAK_CLIENT_ID: "browser-test",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_ENABLE_MSW: "false"
})};`
}));
// Exercise the real authentication provider and chat transport without
// credentials, external authentication, or model inference.
await page.route("**/keycloak-js.js*", (route) => route.fulfill({
contentType: "application/javascript",
body: `export default class Keycloak {
token = "BROWSER_CONTEXT_TEST_ONLY";
idTokenParsed = { name: "Browser context test" };
async init() { return true; }
async updateToken() { return false; }
async login() {}
async logout() {}
}`
}));
const streamHeaders: Array<Record<string, string>> = [];
const expectedProject = scope === "project" ? TEST_PROJECT_ID : undefined;
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
const headers = await route.request().allHeaders();
streamHeaders.push(headers);
if (headers.authorization !== "Bearer BROWSER_CONTEXT_TEST_ONLY" || headers["x-project-id"] !== expectedProject) {
await route.fulfill({
status: 401,
headers: { "Access-Control-Allow-Origin": "*" },
json: {
code: "PRODUCT_CONTEXT_UNAVAILABLE",
message: "valid Bearer authorization and X-Project-Id are required"
}
});
return;
}
await route.fallback();
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
if (scope === "personal") {
await panel.getByRole("button", { name: "选择对话归属" }).click();
await page.getByRole("menuitem", { name: "个人会话", exact: true }).click();
await expect(panel.getByRole("button", { name: "选择对话归属" })).toHaveText("个人会话");
}
await expect(panel.getByRole("button", { name: "选择 Agent 模型" })).toContainText("测试模型");
await panel.getByRole("textbox").fill("检查会话项目上下文");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect.poll(() => streamHeaders.length).toBe(1);
expect(streamHeaders[0].authorization).toBe("Bearer BROWSER_CONTEXT_TEST_ONLY");
expect(streamHeaders[0]["x-project-id"]).toBe(expectedProject);
await expect(panel.getByText("已收到工况汇总请求。", { exact: true })).toBeVisible();
await expect(page.getByText("Agent 请求失败", { exact: true })).toHaveCount(0);
const box = await panel.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
});
}
+16 -9
View File
@@ -1,3 +1,4 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
@@ -10,7 +11,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const prompt = page
.getByPlaceholder(/输入调度问题/)
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
.filter({ visible: true })
.first();
await prompt.fill("测试 question 自定义回答");
@@ -29,7 +30,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
.poll(() => replies)
.toEqual([
{
pathname: "/api/v1/agent/chat/question/question-1/reply",
pathname: "/api/v1/question/question-1/reply",
body: {
session_id: "question-session",
answers: [["分析高峰时段压力"]]
@@ -48,7 +49,7 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page
await page.getByRole("button", { name: /Agent 工作台抽屉高度/ }).click();
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
const prompt = panel.getByPlaceholder(/输入调度问题/);
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("测试窄屏 question");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await panel.getByRole("radio", { name: "自定义回答" }).click();
@@ -69,7 +70,7 @@ async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; bo
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://question-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
@@ -78,17 +79,23 @@ async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; bo
})
);
await page.route("https://question-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS"
} });
return;
}
if (pathname.endsWith("/stream")) {
if (pathname.endsWith("/responses")) {
await route.fulfill({
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
"Content-Type": "text/event-stream"
},
body: createQuestionStream()
});
@@ -176,5 +183,5 @@ function createQuestionStream() {
{ type: "finish", finishReason: "stop" }
];
return parts.map((part) => `data: ${JSON.stringify(part)}\n\n`).join("");
return responsesFixture(parts);
}
+136
View File
@@ -0,0 +1,136 @@
import { createServer, type ServerResponse } from "node:http";
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responseFixtureWriter } from "./support/responses-fixture";
import { expectThemeColor } from "./support/theme-colors";
for (const width of [1440, 375, 320]) {
test(`steers and queues without stopping, and edits unconsumed messages at ${width}px`, async ({ page }, testInfo) => {
const pending = new Map<string, { id: string; text: string; delivery: string; status: string }>();
const admitted: Array<{ id: string; text: string; delivery: string }> = [];
let response: ServerResponse | undefined;
let encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response!.write(encode(chunk));
const server = createServer((_request, output) => {
response = output;
encode = responseFixtureWriter();
output.writeHead(200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache", "Content-Type": "text/event-stream" });
send({ type: "start", messageId: "input-answer" });
send({ type: "data-progress", data: { session_id: "input-session", title: "正在核对压力", status: "running" } });
send({ type: "text-start", id: "answer" });
send({ type: "text-delta", id: "answer", delta: "已开始核对压力。" });
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing fixture address");
try {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let primaryRequests = 0;
let abortRequests = 0;
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
const request = route.request();
if (request.method() === "OPTIONS") { await route.fallback(); return; }
const path = new URL(request.url()).pathname;
const headers = { "Access-Control-Allow-Origin": "*" };
if (path.endsWith("/responses")) {
const body = request.postDataJSON();
if (!body.tjwater?.delivery) {
primaryRequests++;
await route.continue({ url: `http://127.0.0.1:${address.port}/responses` });
} else {
const item = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
admitted.push(item); pending.set(item.id, item);
await route.fulfill({ status: 202, headers, json: item });
}
return;
}
if (path.endsWith("/inputs")) { await route.fulfill({ headers, json: { items: [...pending.values()] } }); return; }
if (path.includes("/inputs/") && request.method() === "PATCH") {
const item = pending.get(path.split("/").at(-1)!);
if (!item) { await route.fulfill({ status: 409, headers, json: { message: "Consumed" } }); return; }
item.delivery = request.postDataJSON().delivery;
await route.fulfill({ headers, json: item });
return;
}
if (path.endsWith("/abort")) {
abortRequests++; pending.clear(); response?.end();
await route.fulfill({ headers, json: { aborted: true } });
return;
}
await route.fallback();
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await composer.fill("核对压力变化");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" });
await expect(deliveryMode).toHaveText("引导当前任务");
await deliveryMode.focus();
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("menuitemradio", { name: "引导当前任务" })).toHaveAttribute("aria-checked", "true");
const queueOption = page.getByRole("menuitemradio", { name: "排队处理" });
await queueOption.hover();
await expectThemeColor(queueOption, "background-color", "--action-selection-soft-hover");
await expectThemeColor(queueOption, "border-color", "--action-border-hover");
await page.getByRole("menu").screenshot({ path: testInfo.outputPath("delivery-menu.png"), animations: "disabled" });
await page.keyboard.press("Escape");
await expect(deliveryMode).toBeFocused();
await expect(deliveryMode).toHaveText("引导当前任务");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
await composer.fill("补充当前任务");
await page.getByRole("button", { name: "引导当前 Agent 任务" }).click();
const pendingMode = page.getByRole("button", { name: "消息发送方式:补充当前任务" });
await expect(pendingMode).toHaveText("引导当前任务");
await pendingMode.click();
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
await expect(pendingMode).toHaveText("排队处理");
await pendingMode.click();
await expect(page.getByRole("menuitemradio", { name: "排队处理" })).toHaveAttribute("aria-checked", "true");
const steerOption = page.getByRole("menuitemradio", { name: "引导当前任务" });
await steerOption.hover();
await expectThemeColor(steerOption, "background-color", "--action-selection-soft-hover");
await page.screenshot({ path: testInfo.outputPath("pending-delivery-menu.png"), animations: "disabled" });
await page.keyboard.press("Home");
await page.keyboard.press("Enter");
await expect(pendingMode).toHaveText("引导当前任务");
await expect(pendingMode).toBeFocused();
await deliveryMode.click();
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
await composer.fill("下一轮核对流量");
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
await expect(page.getByRole("button", { name: "消息发送方式:下一轮核对流量" })).toHaveText("排队处理");
expect(admitted.map((item) => item.delivery)).toEqual(["steer", "queue"]);
expect(primaryRequests).toBe(1);
expect(abortRequests).toBe(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
for (const item of pending.values()) {
send({ type: "data-user_input", data: { ...item, session_id: "input-session", status: "delivered" } });
}
pending.clear();
await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0);
send({ type: "text-delta", id: "answer", delta: "已结合补充信息完成核对。" });
send({ type: "text-end", id: "answer" });
send({ type: "finish" }); response!.end();
await expect(page.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible();
await expect(page.getByText("补充当前任务", { exact: true })).toBeVisible();
await expect(page.getByText("下一轮核对流量", { exact: true })).toBeVisible();
await composer.fill("继续核对压力");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
await composer.fill("等待处理的后续问题");
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
await expect(page.getByRole("button", { name: "消息发送方式:等待处理的后续问题" })).toBeVisible();
await page.getByRole("button", { name: "停止 Agent 指令" }).click();
await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0);
expect(abortRequests).toBe(1);
} finally {
response?.end(); server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
}
+78 -3
View File
@@ -7,7 +7,7 @@ test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/api/v1/agent/chat/models", (route) => route.fulfill({
await page.route("**/api/v1/models", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
default_model: "test/fast",
@@ -59,8 +59,8 @@ for (const width of [1440, 375]) {
const trigger = page.locator('button[aria-label="权限批准模式"]');
await trigger.click();
await waitForMenuAnimations(page.getByRole("menu"));
const request = page.getByRole("menuitem", { name: /工具权限逐次确认/ });
const always = page.getByRole("menuitem", { name: /自动允许本轮权限请求/ });
const request = page.getByRole("menuitem", { name: /需要审批的请求逐次确认/ });
const always = page.getByRole("menuitem", { name: /自动批准本轮工具权限请求/ });
for (const item of [request, always]) {
await item.hover();
await expectThemeColor(item, "background-color", "--action-selection-soft-hover");
@@ -85,6 +85,81 @@ for (const width of [1440, 375]) {
});
}
test("sends the selected approval mode and can return to manual approval", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const trigger = page.getByRole("button", { name: "权限批准模式" });
for (const [label, mode] of [
["自动批准本轮工具权限请求", "always"],
["需要审批的请求逐次确认", "request"],
] as const) {
await expect(trigger).toBeEnabled();
await trigger.click();
await page.getByRole("menuitem", { name: new RegExp(label) }).click();
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(`验证权限模式 ${mode}`);
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
expect((await sent).postDataJSON().tjwater.approval_mode).toBe(mode);
}
await expect(trigger).toBeEnabled();
});
test("replaces a removed model before sending and shows the model actually submitted", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const trigger = page.getByRole("button", { name: "选择 Agent 模型" });
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
await trigger.click();
await page.getByRole("menuitem", { name: /深入分析和复杂任务处理/ }).click();
await expect(trigger).toHaveAccessibleDescription("当前模型:专家");
// Simulate an Agent restart with a new catalog while this page stays open.
await page.route("**/api/v1/models", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
default_model: "waternetwork/gpt-6-luna",
models: [{ id: "waternetwork/gpt-6-luna", label: "快速", description: "当前可用模型", icon: "bolt" }]
}
}));
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("检查更新后的快速模型");
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
expect((await sent).postDataJSON().model).toBe("waternetwork/gpt-6-luna");
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
});
test("keeps an explicitly selected model when it remains available", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const trigger = page.getByRole("button", { name: "选择 Agent 模型" });
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
await trigger.click();
await page.getByRole("menuitem", { name: /深入分析和复杂任务处理/ }).click();
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("保留专家模型选择");
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
expect((await sent).postDataJSON().model).toBe("test/expert");
await expect(trigger).toHaveAccessibleDescription("当前模型:专家");
});
test("does not send a generation request when the refreshed catalog is empty", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByRole("button", { name: "选择 Agent 模型" })).toHaveAccessibleDescription("当前模型:快速");
await page.route("**/api/v1/models", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" }, json: { default_model: "test/fast", models: [] }
}));
const requests: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && request.url().endsWith("/api/v1/responses")) requests.push(request.url());
});
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("检查空模型目录");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(page.getByText("Agent 后端当前没有可用模型,请检查模型配置。").first()).toBeVisible();
expect(requests).toEqual([]);
});
async function waitForMenuAnimations(menu: Locator) {
await expect(menu).toBeVisible();
await menu.evaluate((element) => Promise.all(
+153
View File
@@ -0,0 +1,153 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page, { sessions: [{
session_id: "recent-pressure", title: "城南供水压力复核", created_at: Date.now() - 3_600_000, updated_at: Date.now() - 60_000,
}] });
});
test("empty conversation centers one composer and directly sends an Agent-generated recent activity question", async ({ page }) => {
let requests = 0;
await page.route("**/api/v1/suggestions", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
requests += 1;
const body = route.request().postDataJSON() as { model: string; activities: Array<{ id: string; title: string; kind: string }> };
expect(body.model).toBe("test/model");
const recent = body.activities.find((item) => item.title === "城南供水压力复核");
expect(recent).toBeDefined();
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { suggestions: [{
question: "重新核对城南供水压力复核涉及的监测证据", activity_id: recent!.id,
}] } });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel).toHaveAttribute("data-agent-starting", "true");
await expect(panel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
const question = panel.getByRole("button", { name: /重新核对城南供水压力复核涉及的监测证据/ });
await expect(question).toBeVisible();
await expect(question).toContainText("近期会话");
const composer = (await panel.locator(".agent-panel-start-composer").boundingBox())!;
const panelBox = (await panel.boundingBox())!;
expect(Math.abs(composer.y + composer.height / 2 - (panelBox.y + 56 + (panelBox.height - 56) / 2))).toBeLessThan(1);
await expect(panel.locator("form")).toHaveCount(1);
await panel.screenshot({ path: "test-results/agent-history-ui/agent-start-generated.png" });
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
for (const width of [400, 640]) {
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(width, box.y + box.height / 2);
await page.mouse.up();
await expect(panel).toHaveCSS("width", `${width}px`);
}
expect(requests).toBe(1);
await question.click();
await expect(panel).toHaveAttribute("data-agent-starting", "false");
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
await expect(panel.locator("textarea")).toHaveCSS("min-height", "96px");
expect((await panel.locator(".agent-panel-band").last().boundingBox())!.height).toBeLessThanOrEqual(160);
await expect(panel.locator(".agent-panel-user-message")).toHaveText("重新核对城南供水压力复核涉及的监测证据");
await expect(panel.locator("form")).toHaveCount(1);
await expect.poll(async () => {
const form = (await panel.locator("form").boundingBox())!;
const box = (await panel.boundingBox())!;
return Math.abs(form.y + form.height - (box.y + box.height - 8));
}).toBeLessThan(1);
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-compact.png" });
const prompt = panel.locator("textarea");
await prompt.focus();
await page.keyboard.type("核对监测点");
await page.keyboard.press("Shift+Enter");
await page.keyboard.type("补充现场记录");
await expect(prompt).toHaveValue("核对监测点\n补充现场记录");
const initialHeight = (await prompt.boundingBox())!.height;
expect(initialHeight).toBe(96);
await prompt.fill("监测点、异常时间及现场核验记录。\n".repeat(30));
for (const height of [900, 640]) {
await page.setViewportSize({ width: 1280, height });
const promptBox = (await prompt.boundingBox())!;
expect(promptBox.height).toBeGreaterThan(initialHeight);
expect(promptBox.height).toBeLessThanOrEqual(Math.min(height * 0.24, 192));
expect(promptBox.x).toBeGreaterThanOrEqual(0);
await expect.poll(() => prompt.evaluate((element) => {
const input = element.getBoundingClientRect();
const container = element.closest("aside")!.getBoundingClientRect();
return input.left >= container.left && input.right <= container.right;
})).toBe(true);
expect(await prompt.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
const send = panel.getByRole("button", { name: "发送 Agent 指令" });
await expect(send).toBeVisible();
const sendBox = (await send.boundingBox())!;
expect(sendBox.y + sendBox.height).toBeLessThanOrEqual(height);
expect((await panel.getByRole("log").boundingBox())!.height).toBeGreaterThan(100);
}
await prompt.fill("");
await expect(prompt).toHaveCSS("height", "96px");
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-short-viewport.png" });
});
test("failed recommendation generation leaves the centered input ready for a normal conversation", async ({ page }) => {
await page.route("**/api/v1/suggestions", (route) => route.request().method() === "OPTIONS" ? route.fallback() : route.fulfill({
status: 503, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "unavailable" },
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByText("推荐问题暂不可用,可直接输入任务。")).toBeVisible();
const prompt = panel.getByPlaceholder("询问供水问题,或描述需要处理的任务");
await prompt.fill("核对供水运行情况");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel.locator(".agent-panel-user-message")).toHaveText("核对供水运行情况");
await expect(panel).toHaveAttribute("data-agent-starting", "false");
});
test("first send smoothly moves the existing composer to the bottom", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByRole("group", { name: "推荐问题" }).getByRole("button")).toHaveCount(3);
await panel.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
.map((animation) => animation.finished.catch(() => undefined)));
});
const form = panel.locator("form");
await form.locator("textarea").fill("核对近期供水压力变化");
const beforeTop = (await form.boundingBox())!.y;
await page.evaluate(() => {
const panel = document.querySelector('aside[aria-label="Agent 命令面板"]')!;
const form = panel.querySelector("form")!;
const state = { form, positions: [] as Array<{ active: boolean; y: number }>, done: false };
Object.assign(window, { __agentComposerTransition: state });
const start = performance.now();
const sample = () => {
state.positions.push({ active: panel.getAttribute("data-agent-starting") === "false", y: form.getBoundingClientRect().y });
if (performance.now() - start < 1000) requestAnimationFrame(sample);
else state.done = true;
};
requestAnimationFrame(sample);
});
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel).toHaveAttribute("data-agent-starting", "false");
await expect.poll(() => page.evaluate(() => (window as typeof window & {
__agentComposerTransition: { done: boolean }
}).__agentComposerTransition.done)).toBe(true);
const afterTop = (await form.boundingBox())!.y;
const result = await page.evaluate(() => {
const state = (window as typeof window & { __agentComposerTransition: {
form: HTMLFormElement; positions: Array<{ active: boolean; y: number }>
} }).__agentComposerTransition;
return { sameForm: state.form === document.querySelector('aside[aria-label="Agent 命令面板"] form'), positions: state.positions };
});
expect(result.sameForm).toBe(true);
expect(afterTop).toBeGreaterThan(beforeTop);
expect(result.positions.some((position) => position.active && position.y > beforeTop + 4 && position.y < afterTop - 4)).toBe(true);
await expect(panel.locator("form")).toHaveCount(1);
});
+19 -37
View File
@@ -1,7 +1,9 @@
import { responseFixtureWriter } from "./support/responses-fixture";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
import { expectThemeColor } from "./support/theme-colors";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
@@ -17,16 +19,14 @@ test.beforeAll(async () => {
streamServer = createServer((_request, response) => {
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
"Cache-Control": "no-cache, no-transform",
"Content-Type": "text/event-stream"
});
const content = createLongMarkdownResponse();
const chunks = splitIntoCodePointChunks(content, STREAM_CHUNK_SIZE);
const send = (chunk: Record<string, unknown>) => {
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
};
const encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => { response.write(encode(chunk)); };
send({ type: "start", messageId: "assistant-stream-stability" });
send({
@@ -186,7 +186,7 @@ test("stream completion and the plan card remain positionally stable", async ({
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://stream-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
@@ -195,7 +195,7 @@ test("stream completion and the plan card remain positionally stable", async ({
})
);
await page.route("https://stream-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
@@ -210,7 +210,7 @@ test("stream completion and the plan card remain positionally stable", async ({
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/stream")) {
if (path.endsWith("/responses")) {
await route.continue({ url: streamServerUrl });
return;
}
@@ -244,7 +244,7 @@ test("stream completion and the plan card remain positionally stable", async ({
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
).toContainText("测试模型", { timeout: 10_000 });
const prompt = page
.getByPlaceholder(/输入调度问题/)
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
.filter({ visible: true })
.first();
await expect(prompt).toBeVisible();
@@ -311,7 +311,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await sendButton.click();
const userMessage = page.locator(".agent-panel-user-message");
const assistantMessage = page.locator(".agent-panel-message").first();
await expect(userMessage).toHaveCSS("background-color", "oklch(0.965 0.018 255)");
await expectThemeColor(userMessage, "background-color", "--agent-user-message-surface");
expect(
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
).not.toBe(
@@ -323,26 +323,10 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect
.poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow));
const operationalBrief = page.locator(".agent-panel-control").filter({
has: page.getByRole("button", { name: "预览影响", exact: true })
});
await expect(operationalBrief).toBeVisible();
await expect(operationalBrief).toContainText("Agent 分析中");
await expect(operationalBrief.getByRole("button", { name: "预览影响" })).toBeDisabled();
await expect(operationalBrief.getByRole("button", { name: "检查证据" })).toBeDisabled();
await expect(operationalBrief).toHaveCSS("position", "static");
await expect(operationalBrief).toHaveCSS("border-radius", "12px");
// Desktop uses an inline brief above the conversation, not the legacy floating overlay.
await expect(page.locator(".agent-panel-operational-brief")).toHaveCount(0);
await expectThemeColor(page.locator("[data-agent-conversation-state=active]"), "background-color", "--agent-dock-reading");
await expect(page.locator(".agent-panel-shell")).toHaveClass(/surface-dock/);
await expect(page.locator(".agent-panel-operational-float")).toHaveCount(0);
await expect
.poll(() =>
operationalBrief.evaluate((element) => {
const conversation = document.querySelector(".agent-panel-conversation")!;
return conversation.getBoundingClientRect().top - element.getBoundingClientRect().bottom;
})
)
.toBeGreaterThanOrEqual(0);
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
@@ -470,9 +454,9 @@ test("stream completion and the plan card remain positionally stable", async ({
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
await expect(expandedLiveProgress.locator(".status-badge")).toHaveCount(0);
await expect(expandedLiveProgress.locator('[data-progress-status="running"]').first()).toHaveCSS(
"color",
"oklch(0.546 0.245 262.881)"
await expectThemeColor(
expandedLiveProgress.locator('[data-progress-status="running"]').first(),
"color", "--action-blue-hover"
);
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(
@@ -494,10 +478,8 @@ test("stream completion and the plan card remain positionally stable", async ({
resumeStream();
await expect(
page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()
).toBeVisible({
timeout: 10_000
});
page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first()
).toBeVisible({ timeout: 10_000 });
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect(
@@ -618,7 +600,7 @@ test("stream completion and the plan card remain positionally stable", async ({
expect(pressedScrollButtonBox.y).toBe(scrollButtonBox.y + 1);
await page.mouse.move(scrollButtonBox.x - 20, scrollButtonBox.y);
await page.mouse.up();
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
await expectThemeColor(scrollToLatestButton, "background-color", "--agent-dock-overlay-surface");
await expect
.poll(() =>
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
+1 -1
View File
@@ -97,7 +97,7 @@ test("speech recognition appends every final transcript in one result batch", as
await page.getByRole("button", { name: "语音输入" }).click();
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站 水位");
await expect(page.getByPlaceholder("询问供水问题,或描述需要处理的任务")).toHaveValue("检查城南泵站 水位");
});
test("speech recognition pulse stays transparent across its loop boundary", async ({ page }) => {
+1 -1
View File
@@ -217,7 +217,7 @@ async function prepareWorkbench(page: Page) {
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`
+46 -44
View File
@@ -1,3 +1,4 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import type { OperationalTask, OperationalTaskNotification, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
@@ -89,8 +90,8 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
const firstReport = task.latest_report!;
let notifications: OperationalTaskNotification[] = [];
const summary = (item: OperationalTask): OperationalTaskSummary => {
const { latest_plan, latest_report, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
void latest_plan; void latest_report; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot;
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
void latest_plan; void latest_report; void latest_progress; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot;
return value;
};
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
@@ -194,7 +195,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
};
const inlineControls = await captureDiagramControls(diagram);
const assertControlGeometry = (controls: typeof inlineControls) => {
expect(controls).toHaveLength(4);
expect(controls).toHaveLength(3);
for (const control of controls) {
expect({ width: control.width, height: control.height, radius: control.radius,
iconWidth: control.iconWidth, iconHeight: control.iconHeight }).toEqual({
@@ -205,27 +206,20 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
const assertInlineControlRail = async (surface: typeof diagram) => {
const mermaidBlock = surface.locator('[data-streamdown="mermaid-block"]');
const graph = surface.locator('[data-streamdown="mermaid"]');
const fullscreenButton = surface.getByRole("button", { name: "全屏查看", exact: true });
const fullscreenButton = surface.getByRole("button", { name: "放大查看", exact: true });
const zoomInButton = surface.getByRole("button", { name: "Zoom in", exact: true });
const fullscreenGroup = surface.locator('[data-streamdown="mermaid-block-actions"]');
const zoomGroup = zoomInButton.locator("..");
const [blockBox, graphBox, fullscreenBox, zoomInBox, fullscreenGroupBox, zoomGroupBox] = await Promise.all([
const [blockBox, graphBox, fullscreenBox, zoomInBox, zoomGroupBox] = await Promise.all([
mermaidBlock.boundingBox(), graph.boundingBox(), fullscreenButton.boundingBox(), zoomInButton.boundingBox(),
fullscreenGroup.boundingBox(), zoomGroup.boundingBox()
zoomGroup.boundingBox()
]);
expect(blockBox).not.toBeNull();
expect(graphBox).not.toBeNull();
expect(fullscreenBox).not.toBeNull();
expect(zoomInBox).not.toBeNull();
expect(fullscreenGroupBox).not.toBeNull();
expect(zoomGroupBox).not.toBeNull();
expect(Math.round(graphBox!.y - blockBox!.y)).toBeLessThanOrEqual(10);
expect(Math.round(fullscreenBox!.x)).toBe(Math.round(zoomInBox!.x));
expect(Math.round(zoomInBox!.y - fullscreenBox!.y - fullscreenBox!.height)).toBe(18);
expect(Math.round(fullscreenGroupBox!.x)).toBe(Math.round(zoomGroupBox!.x));
expect(Math.round(fullscreenGroupBox!.width)).toBe(Math.round(zoomGroupBox!.width));
expect(Math.round(zoomGroupBox!.y - fullscreenGroupBox!.y - fullscreenGroupBox!.height)).toBe(8);
expect(Math.round(blockBox!.x + blockBox!.width - fullscreenGroupBox!.x - fullscreenGroupBox!.width)).toBe(18);
expect(fullscreenBox!.y + fullscreenBox!.height).toBeLessThanOrEqual(blockBox!.y);
expect(Math.round(graphBox!.x + graphBox!.width - zoomGroupBox!.x - zoomGroupBox!.width)).toBe(8);
};
assertControlGeometry(inlineControls);
@@ -259,31 +253,30 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
await expectThemeColor(zoomIn, "outline-color", "--action-focus-ring");
await zoomIn.locator("..").screenshot({ path: testInfo.outputPath("diagram-controls-keyboard-focus.png") });
await testInfo.attach("styled-investigation-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("styled-investigation-diagram.png") }), contentType: "image/png" });
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
const fullscreen = page.locator('body > div[role="button"]').filter({ has: page.getByRole("button", { name: "退出全屏", exact: true }) });
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
const fullscreen = page.getByRole("dialog", { name: "调查脉络", exact: true });
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
const fullscreenControls = await captureDiagramControls(fullscreen);
assertControlGeometry(fullscreenControls);
const assertFullscreenCloseFrame = async () => {
const close = fullscreen.getByRole("button", { name: "退出全屏", exact: true });
const close = fullscreen.getByRole("button", { name: "关闭详情", exact: true });
const frame = await close.evaluate((button) => {
const rect = button.getBoundingClientRect();
const panel = button.closest('[role="dialog"]')!.getBoundingClientRect();
return {
top: rect.top,
right: window.innerWidth - rect.right,
boxShadow: getComputedStyle(button).boxShadow
top: rect.top - panel.top,
right: panel.right - rect.right
};
});
expect(Math.round(frame.top)).toBe(21);
expect(Math.round(frame.right)).toBe(21);
expect(frame.boxShadow).not.toBe("none");
expect(frame.top).toBeGreaterThanOrEqual(12);
expect(frame.right).toBeGreaterThanOrEqual(12);
};
await assertFullscreenCloseFrame();
await writeFile(testInfo.outputPath("diagram-control-geometry.json"), JSON.stringify({ viewport: page.viewportSize(), inline: inlineControls, fullscreen: fullscreenControls }, null, 2));
await testInfo.attach("styled-investigation-fullscreen", { body: await page.screenshot({ path: testInfo.outputPath("styled-investigation-fullscreen.png") }), contentType: "image/png" });
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click();
await fullscreen.getByRole("button", { name: "关闭详情", exact: true }).click();
await expect(fullscreen).toHaveCount(0);
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
await expect(fullscreen).toBeVisible();
await page.keyboard.press("Escape");
await expect(fullscreen).toHaveCount(0);
@@ -347,14 +340,18 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
report_reference: { execution_generation: 1, revision: 2 }, resolved_at: null, resolution_reason: null,
status: "unread", version: 1, required_permission: null, created_at: now, read_at: null, read_by: null }];
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(panel.getByRole("button", { name: "查看新报告" })).toBeVisible();
await expect(panel.getByText("新报告", { exact: true })).toBeVisible();
expect(notifications[0].status).toBe("unread");
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "查看新报告" }).click();
// Live is the default; only explicitly choosing a historical version freezes it.
await expect(panel.getByRole("button", { name: "查看新报告" })).toHaveCount(0);
await expect.poll(() => notifications[0].status).toBe("read");
await expect(panel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible();
// Existing investigations may only have a same-round saved plan, not an embedded diagram.
await expect(diagram).toBeAttached();
await diagram.scrollIntoViewIfNeeded();
await expect(diagram.locator("svg[id]")).toHaveCount(1);
await expect(diagram).toContainText("多源数据核对");
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
await testInfo.attach("legacy-investigation-diagram", { body: await diagram.screenshot({
path: testInfo.outputPath("legacy-investigation-diagram.png") }), contentType: "image/png" });
await expect(panel.getByLabel("数据缺口")).toContainText("无法区分管网与用户端异常");
await expect(panel.getByText("已结束 · 证据不足", { exact: true }).first()).toHaveAttribute("data-status-tone", "warning");
await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
@@ -364,12 +361,12 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
await panel.getByRole("heading", { name: "当前结果", exact: true }).scrollIntoViewIfNeeded();
await testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({
path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" });
await panel.getByText("任务时间线", { exact: true }).click();
const timeline = panel.getByLabel("任务时间线记录");
await panel.getByRole("button", { name: "展开任务时间线" }).click();
const timeline = page.getByRole("dialog", { name: "任务时间线" }).getByLabel("任务时间线记录");
await expect(timeline).toContainText("采样工单已创建");
await testInfo.attach("investigation-timeline-desktop", { body: await timeline.screenshot({
path: testInfo.outputPath("investigation-timeline-desktop.png") }), contentType: "image/png" });
await panel.getByText("任务时间线", { exact: true }).click();
await page.getByRole("dialog", { name: "任务时间线" }).getByRole("button", { name: "关闭详情" }).click();
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
@@ -389,12 +386,12 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
assertControlGeometry(await captureDiagramControls(mobileDiagram));
await assertInlineControlRail(mobileDiagram);
if (width === 320) {
await mobileDiagram.getByRole("button", { name: "全屏查看", exact: true }).click();
await mobileDiagram.getByRole("button", { name: "放大查看", exact: true }).click();
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
assertControlGeometry(await captureDiagramControls(fullscreen));
await assertFullscreenCloseFrame();
await testInfo.attach("diagram-fullscreen-320", { body: await page.screenshot({ path: testInfo.outputPath("diagram-fullscreen-320.png") }), contentType: "image/png" });
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).evaluate((button) => button.click());
await fullscreen.getByRole("button", { name: "关闭详情", exact: true }).evaluate((button) => button.click());
await expect(fullscreen).toHaveCount(0);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
@@ -405,21 +402,26 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
});
}
await page.setViewportSize({ width: 1280, height: 720 });
await panel.getByText("报告版本", { exact: true }).click();
await expect(panel.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible();
await panel.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
await panel.getByRole("button", { name: "展开报告版本" }).click();
const versions = page.getByRole("dialog", { name: "报告版本" });
await expect(versions.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible();
await versions.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
await page.route("**/api/v1/agent/chat/stream", async (route) => {
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
expect(route.request().postDataJSON().task_context).toEqual({ report_revision: 1, evidence_id: "monitor-1" });
expect(route.request().postDataJSON().tjwater.task_context).toEqual({ report_revision: 1, evidence_id: "monitor-1" });
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "X-Vercel-AI-UI-Message-Stream": "v1" },
body: [{ type: "start", messageId: "selected-report-answer" },
body: responsesFixture([{ type: "start", messageId: "selected-report-answer" },
{ type: "data-task_context", data: { task_id: taskId, execution_generation: 1, report_revision: 1 } },
{ type: "text-start", id: "answer" }, { type: "text-delta", id: "answer", delta: "这条依据仅覆盖上游监测范围。" },
{ type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" }].map((part) => `data: ${JSON.stringify(part)}\n\n`).join("") });
{ type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" }]) });
});
await panel.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click();
await expect(agentPanel.getByRole("group", { name: "对话引用" })).toContainText("报告第 1 版");
await expect(agentPanel.locator("textarea")).toBeFocused();
await expect(agentPanel.locator("textarea")).toHaveValue(/请解释.*这条证据的范围、来源和判断影响/);
await agentPanel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(agentPanel).toContainText("依据:第 1 轮 · 报告第 1 版");
task = { ...task, revision: 9, execution_generation: 2, current_report_revision: 3,
latest_report: { ...firstReport, revision: 3, execution_generation: 2, report: {
@@ -430,7 +432,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
const reopen = panel.getByRole("button", { name: "展开工况任务" });
if (await reopen.isVisible()) await reopen.click();
const viewNew = panel.getByRole("button", { name: "查看新报告" });
const viewNew = panel.getByRole("button", { name: "返回实时内容" });
if (await viewNew.isVisible()) await viewNew.click();
await panel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
await expect(panel.getByText("图示暂不可用,仍可查看结论、证据和待审核动作。", { exact: true })).toBeVisible();
+1 -1
View File
@@ -119,7 +119,7 @@ async function mockFeaturePanelRuntime(page: Page) {
TJWATER_MAP_URL: "https://feature-panel-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_WORKBENCH_RUNTIME_MODE: "demo",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
+231
View File
@@ -0,0 +1,231 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
import { expectThemeColor } from "./support/theme-colors";
const first = { id: "place-nanqiao-north", name: "南桥路", address: "上海市奉贤区南桥路1号", longitude: 121.458891, latitude: 30.916188, category: "道路名", district: "奉贤区" };
const second = { id: "place-nanqiao-south", name: "南桥路", address: "上海市奉贤区南桥路100号", longitude: 121.459301, latitude: 30.914195, category: "道路名", district: "奉贤区" };
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id", "Access-Control-Allow-Methods": "POST, OPTIONS" };
async function setup(page: Page) {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
}
async function openSearch(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
const trigger = page.getByRole("button", { name: "地址定位:查询地址并定位到地图" });
await expect(trigger).toBeEnabled({ timeout: 20_000 });
await trigger.click();
return page.getByRole("region", { name: "地址定位", exact: true });
}
for (const width of [1280, 1023, 375, 320]) {
test(`place search selects and switches candidates at ${width}px`, async ({ page }) => {
test.setTimeout(45_000);
await page.setViewportSize({ width, height: 900 });
await setup(page);
let calls = 0;
await page.route("**/api/v1/place-search-requests", async (route) => {
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
expect(route.request().postDataJSON()).toEqual({ keyword: "南桥路" });
expect(route.request().headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
calls += 1;
await route.fulfill({ headers, json: { items: [first, second], total: 2, truncated: false, region: "奉贤区" } });
});
const panel = await openSearch(page);
await expect(panel).toContainText("奉贤区 · 天地图地名搜索");
const input = panel.getByLabel("查询地址");
const query = panel.getByRole("button", { name: "搜索地名" });
await expectThemeColor(input, "background-color", "--surface-reading");
const [inputBox, queryBox] = await Promise.all([input.boundingBox(), query.boundingBox()]);
expect(inputBox!.height).toBe(40);
expect(queryBox!.height).toBe(40);
expect(inputBox!.y).toBe(queryBox!.y);
await expect(query).toBeDisabled();
await input.fill(" 南桥路 ");
await input.press("Enter");
const candidates = panel.getByRole("region", { name: "搜索结果" });
await expect(candidates.getByRole("button")).toHaveCount(2);
const marker = page.locator('[data-geocoding-marker="true"]');
await expect(marker).toHaveCount(0);
await expect(panel.getByRole("status")).toContainText("选择一个候选位置");
const resultHeader = candidates.locator(":scope > div").first();
const measureFrame = () => Promise.all([panel.boundingBox(), input.boundingBox(), resultHeader.boundingBox()]);
const listFrame = await measureFrame();
const listViewport = await candidates.locator("ul").boundingBox();
const listName = candidates.locator("span.font-semibold").first();
const nameFontSize = await listName.evaluate((element) => getComputedStyle(element).fontSize);
await page.screenshot({ path: test.info().outputPath(`place-candidates-${width}.png`) });
await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路1号", exact: true }).click();
const selected = panel.getByRole("region", { name: "定位结果" });
await expect(selected).toContainText("上海市奉贤区南桥路1号");
await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.458891, 30.916188");
await expect(marker).toHaveCount(1);
await expect(candidates.getByRole("button", { name: "更换位置" })).toBeVisible();
await expect(candidates.locator("ul")).toHaveCount(0);
const detailFrame = await measureFrame();
const detailViewport = await selected.boundingBox();
for (let index = 0; index < listFrame.length; index += 1) {
expect(detailFrame[index]).toEqual(listFrame[index]);
}
expect(detailViewport).toEqual(listViewport);
await expect(selected.locator("span.font-semibold")).toHaveCSS("font-size", nameFontSize);
await candidates.getByRole("button", { name: "更换位置" }).click();
await expect(selected).toHaveCount(0);
expect(await measureFrame()).toEqual(listFrame);
await test.info().attach("list-detail-frame", { body: JSON.stringify({ width, listFrame, detailFrame, listViewport, detailViewport }), contentType: "application/json" });
await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路100号", exact: true }).click();
await expect(selected).toContainText("上海市奉贤区南桥路100号");
await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.459301, 30.914195");
await expect(marker).toHaveCount(1);
await expect.poll(() => page.evaluate(() => {
const center = window.__waterNetworkMap?.getCenter();
return center ? [Number(center.lng.toFixed(6)), Number(center.lat.toFixed(6))] : null;
})).toEqual([121.459301, 30.914195]);
await selected.getByRole("button", { name: "重新定位" }).scrollIntoViewIfNeeded();
await page.screenshot({ path: test.info().outputPath(`place-selected-${width}.png`) });
await selected.getByRole("button", { name: "重新定位" }).click();
expect(calls).toBe(1);
await selected.getByRole("button", { name: "清除结果" }).click();
await expect(marker).toHaveCount(0);
await expect(candidates).toHaveCount(0);
await expect(input).toHaveValue("");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
});
}
for (const width of [1280, 320]) {
test(`ten candidates have no paging or checkmark and preserve keyboard focus at ${width}px`, async ({ page }) => {
test.setTimeout(45_000);
await page.setViewportSize({ width, height: width === 320 ? 812 : 900 });
await setup(page);
const names = ["南桥饭店", "南桥饭店南楼", "南桥饭店东苑"];
const items = Array.from({ length: 10 }, (_, index) => ({
...first, id: `hotel-test-${index}`, name: names[index] ?? `南桥饭店测试入口${index + 1}`,
address: "南桥路111号", district: "奉贤区", category: "宾馆"
}));
await page.route("**/api/v1/place-search-requests", async (route) => {
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
await route.fulfill({ headers, json: { items, total: 10, truncated: true, region: "奉贤区" } });
});
const panel = await openSearch(page);
await panel.getByLabel("查询地址").fill("南桥饭店");
await panel.getByLabel("查询地址").press("Enter");
const results = panel.getByRole("region", { name: "搜索结果" });
await expect(results).toContainText("前 10 个结果");
await expect(results).toContainText("最多显示 10 个位置,请细化关键词");
await expect(results.getByLabel("搜索结果分页")).toHaveCount(0);
await expect(results.getByRole("button", { name: "上一页" })).toHaveCount(0);
await expect(results.getByRole("button", { name: "下一页" })).toHaveCount(0);
const list = results.locator("ul");
await expect(list.locator("li")).toHaveCount(10);
await expect(list).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(list).toHaveCSS("border-left-width", "0px");
await expect(list).toHaveCSS("border-right-width", "0px");
const geometry = await list.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const rows = Array.from(element.querySelectorAll("li")).map((row) => row.getBoundingClientRect());
return { visibleRows: rows.filter((row) => row.top >= viewport.top && row.bottom <= viewport.bottom).length,
rowHeight: rows[0].height, height: viewport.height, scrollable: element.scrollHeight > element.clientHeight };
});
expect(geometry.visibleRows).toBeGreaterThanOrEqual(width === 320 ? 2 : 4);
expect(geometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(geometry.scrollable).toBe(true);
const [panelBox, listBox] = await Promise.all([panel.boundingBox(), list.boundingBox()]);
expect(listBox!.y + listBox!.height).toBeLessThanOrEqual(panelBox!.y + panelBox!.height - 10);
await test.info().attach("candidate-geometry", { body: JSON.stringify(geometry), contentType: "application/json" });
await page.screenshot({ path: test.info().outputPath(`place-ten-candidates-${width}.png`) });
const target = results.getByRole("button", { name: "定位 南桥饭店 南桥路111号", exact: true });
await target.focus();
await page.keyboard.press("Enter");
const change = results.getByRole("button", { name: "更换位置" });
await expect(change).toBeFocused();
await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("121.458891, 30.916188");
const detail = panel.getByRole("region", { name: "定位结果" });
await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink");
await expect(detail.locator("span.font-semibold").locator("xpath=../../.."))
.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.keyboard.press("Enter");
await expect(target).toBeFocused();
await expect(target).toHaveAttribute("aria-pressed", "true");
await expectThemeColor(target, "background-color", "--action-selection-soft");
await expectThemeColor(target.locator("span.font-semibold"), "color", "--action-blue-hover");
await expect(target.locator("svg")).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath(`place-color-selected-${width}.png`) });
const eighth = results.getByRole("button", { name: `定位 ${items[7].name} ${items[7].address}`, exact: true });
await eighth.scrollIntoViewIfNeeded();
await eighth.click();
await expect(detail).toContainText(items[7].name);
await expect(detail.getByLabel("经纬度坐标")).toBeInViewport({ ratio: 1 });
const detailBox = await detail.boundingBox();
const actionBox = await detail.getByRole("button", { name: "重新定位" }).boundingBox();
expect(actionBox!.y + actionBox!.height).toBeLessThanOrEqual(detailBox!.y + detailBox!.height);
await expect(detail.getByText("08", { exact: true })).toHaveCount(0);
await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink");
await page.screenshot({ path: test.info().outputPath(`place-neutral-detail-${width}.png`) });
await change.click();
await expect(eighth).toHaveAttribute("aria-pressed", "true");
const isSelectedVisible = () => eighth.evaluate((element) => {
const viewport = element.closest("ul")!.getBoundingClientRect();
const row = element.getBoundingClientRect();
return row.top >= viewport.top && row.bottom <= viewport.bottom;
});
await expect.poll(isSelectedVisible).toBe(true);
expect(await list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await eighth.focus();
await page.keyboard.press("Enter");
await expect(change).toBeFocused();
await page.keyboard.press("Enter");
await expect(eighth).toBeFocused();
await expect.poll(isSelectedVisible).toBe(true);
await page.screenshot({ path: test.info().outputPath(`place-return-to-selection-${width}.png`) });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
});
}
test("place search reports errors and distinguishes an empty Fengxian search", async ({ page }) => {
test.setTimeout(45_000);
await page.setViewportSize({ width: 320, height: 700 });
await setup(page);
let fail = true;
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
const queries: string[] = [];
await page.route("**/api/v1/place-search-requests", async (route) => {
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
const body = route.request().postDataJSON() as { keyword: string };
queries.push(body.keyword);
if (fail) {
await pending;
await route.fulfill({ status: 503, headers, json: { detail: "地名搜索服务暂不可用" } });
return;
}
const empty = body.keyword === "未知地名";
await route.fulfill({ headers, json: {
items: empty ? [] : [first], total: empty ? 0 : 1, truncated: false, region: "奉贤区"
} });
});
const panel = await openSearch(page);
const input = panel.getByLabel("查询地址");
const query = panel.getByRole("button", { name: "搜索地名" });
await input.fill("南桥路");
await query.click();
await expect(panel.getByRole("status")).toContainText("正在搜索地名");
await expect(query).toBeDisabled();
release();
await expect(panel.getByRole("alert")).toContainText("地名搜索服务暂不可用");
fail = false;
await query.click();
await expect(panel.getByRole("region", { name: "搜索结果" }).locator("li")).toHaveCount(1);
expect(queries).toEqual(["南桥路", "南桥路"]);
await input.fill("未知地名");
await query.click();
await expect(panel.getByRole("status")).toContainText("奉贤区内未找到匹配位置");
await expect(panel.getByRole("region", { name: "搜索结果" })).toHaveCount(0);
await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320);
await page.screenshot({ path: test.info().outputPath("place-empty-320.png") });
});
@@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test("调查 SVG 兼容 HTML 换行并保留节点交互与错误兜底", async ({ page }, testInfo) => {
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="diagrams" style="max-width:640px;margin:auto;padding:16px"></main></body></html>');
await page.evaluate(async () => {
// Reuse Vite's exact module identities, including its per-port cache and
// version query; importing a second React breaks the component's hooks.
const entry = await (await fetch("/src/main.tsx")).text();
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
const reactModule = await import(reactUrl);
const React = reactModule.default ?? reactModule;
const clientModule = await import(clientUrl);
const { createRoot } = clientModule.default ?? clientModule;
const { InvestigationDiagramView } = await import("/src/features/workbench/components/investigation-diagram-view.tsx");
const { investigationDiagramPlugins } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const getMermaid = investigationDiagramPlugins.mermaid.getMermaid.bind(investigationDiagramPlugins.mermaid);
Object.assign(window, { diagramRenderCount: 0 });
investigationDiagramPlugins.mermaid.getMermaid = (config: unknown) => {
const renderer = getMermaid(config);
return { ...renderer, render(id: string, source: string) {
if (source.includes("读取任务上下文")) (window as unknown as { diagramRenderCount: number }).diagramRenderCount++;
return renderer.render(id, source);
} };
};
let diagram = {
title: "换行回归检查",
mermaid: 'flowchart TD\nsubgraph Evidence["证据核对"]\nA["读取任务上下文"] --> B["上游监测<br>读取SCADA"]\nB --> C["形成边界<br/>结论"]\nend',
node_refs: [{ node_id: "B", label: "上游监测", evidence_refs: ["e1"], action_refs: [], status: "running" }]
};
const root = createRoot(document.getElementById("diagrams"));
const render = () => root.render(React.createElement(React.Fragment, null,
React.createElement(InvestigationDiagramView, { diagram, running: true,
renderDetails: () => React.createElement("p", null, "已保存的 SCADA 引用依据") }),
React.createElement(InvestigationDiagramView, { diagram: { title: "错误兜底检查", mermaid: "flowchart TD\nA[未闭合", node_refs: [] },
renderDetails: () => null })
));
Object.assign(window, { updateDiagram: () => { diagram = { ...diagram, mermaid: `${diagram.mermaid}\nC --> D[补齐现场资料]` }; render(); } });
render();
});
const graph = page.getByRole("region", { name: "调查脉络", exact: true }).first();
await expect(graph.locator("svg .node")).toHaveCount(3);
await expect(graph.locator("svg foreignObject br")).toHaveCount(2);
await expect(graph.locator("svg .cluster-label")).toContainText("证据核对");
await expect(graph.locator("svg .tjRunning")).toHaveCount(1);
await expect(graph).not.toContainText("This page contains the following errors");
await graph.locator(".node").filter({ hasText: "读取SCADA" }).click();
await expect(graph.getByLabel("节点详情 上游监测")).toContainText("已保存的 SCADA 引用依据");
await graph.getByRole("button", { name: "Zoom in", exact: true }).click();
await graph.getByRole("button", { name: "Reset zoom and pan", exact: true }).click();
await graph.locator("svg.flowchart").evaluate((element) => { Object.assign(window, { originalDiagram: element }); });
await graph.getByRole("button", { name: "放大查看", exact: true }).click();
const detail = page.getByRole("dialog", { name: "换行回归检查", exact: true });
await expect(detail.locator("svg .node")).toHaveCount(3);
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(1);
expect(await detail.locator("svg.flowchart").evaluate((element) => element === (window as unknown as { originalDiagram: Element }).originalDiagram)).toBe(true);
await expect(detail.getByLabel("节点详情 上游监测")).toContainText("已保存的 SCADA 引用依据");
await detail.getByRole("button", { name: "Zoom in", exact: true }).click();
const box = (await detail.boundingBox())!;
expect(box.width).toBeLessThan(1440 - 32);
expect(box.height).toBeLessThan(900);
expect(box.x).toBeGreaterThan(16);
expect(await page.evaluate(() => document.fullscreenElement === null)).toBe(true);
const close = page.getByRole("button", { name: "关闭详情", exact: true });
await expect(close).toBeVisible();
await page.keyboard.press("Escape");
await expect(close).toHaveCount(0);
await expect(graph.getByRole("button", { name: "放大查看", exact: true })).toBeFocused();
await expect(graph.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
// Streamdown renders diagrams lazily when their block enters the viewport.
await page.getByRole("region", { name: "调查脉络", exact: true }).nth(1).scrollIntoViewIfNeeded();
await expect(page.getByText("图示暂不可用,仍可查看结论、证据和待审核动作。", { exact: true })).toBeVisible();
await graph.screenshot({ path: testInfo.outputPath("line-breaks-desktop.png") });
await page.setViewportSize({ width: 375, height: 800 });
await expect(graph.locator("svg .node")).toHaveCount(3);
await graph.getByRole("button", { name: "放大查看", exact: true }).click();
await expect(detail.locator("svg .node")).toHaveCount(3);
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(1);
const narrowBox = (await detail.boundingBox())!;
expect(narrowBox.width).toBeLessThanOrEqual(343);
expect(narrowBox.height).toBeLessThanOrEqual(688);
expect(narrowBox.x).toBeGreaterThanOrEqual(15);
await detail.screenshot({ path: testInfo.outputPath("diagram-expanded-narrow.png") });
await page.evaluate(() => (window as unknown as { updateDiagram: () => void }).updateDiagram());
await expect(detail.locator("svg .node")).toHaveCount(4);
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(2);
await close.click();
await expect(detail).toHaveCount(0);
await expect(graph.locator("svg .node")).toHaveCount(4);
await graph.getByRole("button", { name: "放大查看", exact: true }).click();
await expect(detail.locator("svg .node")).toHaveCount(4);
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(2);
await page.keyboard.press("Escape");
await expect(detail).toHaveCount(0);
await graph.screenshot({ path: testInfo.outputPath("line-breaks-mobile.png") });
});
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import { mockAgentApi } from "./support/mock-agent-api";
test("调查图样式对照保留中文标题、分工和真实 SVG 关系", async ({ page }, testInfo) => {
await page.route("**/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setViewportSize({ width: 1536, height: 1250 });
@@ -0,0 +1,89 @@
import { expect, test } from "@playwright/test";
import type { OperationalTask } from "../../src/features/workbench/api/operational-task-client";
const now = "2026-09-23T09:00:00Z";
const sources = ["peripheral_test_data", "scada_devices", "workbench_runtime", "scada_readings", "work_orders", "other_saved_data"];
const sourceRef = (source: string) => `${source}:sha256:${"7d5852b69d8e3d4e127800882683c3319e58".repeat(2)}`;
const snapshotKey = "query_scope_with_a_long_unbroken_field_name";
const task: OperationalTask = {
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "completed", active_run: null, status: "completed", priority: "normal",
revision: 1, execution_generation: 1, execution_request_id: null,
title: "水质投诉调查", summary: "居民反映异味",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
trigger: { kind: "manual", occurred_at: now, detail: "演示小区" }, scheduled_for: null,
agent_session_id: null, agent_lease_expires_at: null, current_plan_revision: null, current_report_revision: 1,
created_at: now, updated_at: now, latest_plan: null,
latest_report: {
revision: 1, execution_generation: 1, source_session_id: "evidence-layout-session", published_at: now, published_by: "agent",
report: {
title: "调查报告", current_conclusion: "需现场核查用户端。", conclusion_boundary: "仅覆盖已有记录。",
evidence_refs: sources.slice(0, 5), recommendations: [], pending_items: [],
stage: "final", outcome: "concluded", stop_reason: null, data_gaps: []
}
},
evidence: sources.map((source) => ({
evidence_id: source, kind: "system_data", summary: `${source} 数据快照`, source_label: source,
source_ref: sourceRef(source), observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: true
})),
actions: [], access_requests: [], work_order_snapshot: null,
permissions: {
can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false,
can_execute_commands: false, can_decide_access: false
}
};
test("调查证据与数据快照的长引用在桌面和窄屏完整换行", async ({ page }, testInfo) => {
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="report" style="max-width:480px;margin:auto;padding:16px"></main></body></html>');
await page.evaluate(async ({ task, snapshotKey }) => {
const reactModule = await import("/node_modules/.vite/deps/react.js");
const React = reactModule.default ?? reactModule;
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
const { createRoot } = clientModule.default ?? clientModule;
const { AgentExecutionPlanPanel } = await import("/src/features/workbench/components/agent-execution-plan-panel.tsx");
const noop = async () => undefined;
createRoot(document.getElementById("report")).render(React.createElement(AgentExecutionPlanPanel, {
task, onContinue: noop, onDecision: noop, onExecute: noop, onReconcile: noop, onRefresh: noop,
onLoadEvidenceSnapshot: async (evidenceId: string) => ({
source: "workbench_runtime", origin: "system_data",
source_ref: task.evidence.find((item) => item.evidence_id === evidenceId)!.source_ref,
queried_at: task.updated_at, truncated: false, data: { [snapshotKey]: "long_snapshot_value_".repeat(8) }
})
}));
}, { task, snapshotKey });
const evidence = page.getByRole("region", { name: "主要依据", exact: true });
await expect(evidence).toBeVisible();
for (const name of ["更多引用依据 · 2 条", "其他已保存资料"]) {
const summary = evidence.locator("summary").filter({ hasText: name });
await summary.focus();
await page.keyboard.press("Enter");
await expect(summary.locator("..")).toHaveAttribute("open", "");
}
for (const source of sources) {
const card = evidence.locator("details").filter({ has: page.locator("summary", { hasText: `${source} 数据快照` }) }).last();
await card.locator("summary").first().click();
await expect(card.locator("dd.font-mono")).toHaveText(sourceRef(source));
await card.getByRole("button", { name: "查看数据快照", exact: true }).click();
await expect(card.getByRole("region", { name: "数据快照" })).toContainText(snapshotKey);
}
for (const width of [1440, 705, 375, 320]) {
await page.setViewportSize({ width, height: 900 });
const overflow = await evidence.evaluate((section) => {
return [...section.querySelectorAll("dd, dt, [aria-label='数据快照'] p")].flatMap((element) => {
const rect = element.getBoundingClientRect();
const card = element.closest("details")!.getBoundingClientRect();
return element.scrollWidth > element.clientWidth + 1 || rect.right > card.right + 1 || rect.left < card.left - 1
? [{ text: element.textContent, width: element.clientWidth, scrollWidth: element.scrollWidth, right: rect.right, cardRight: card.right }]
: [];
});
});
expect(overflow, `证据字段在 ${width}px 下不应撑出卡片`).toEqual([]);
await evidence.screenshot({ path: testInfo.outputPath(`evidence-${width}.png`) });
}
});
@@ -0,0 +1,276 @@
import { expect, test } from "@playwright/test";
import type { OperationalTask } from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const taskId = "92000000-0000-4000-8000-000000000001";
const runId = "93000000-0000-4000-8000-000000000001";
const requestId = "94000000-0000-4000-8000-000000000001";
const now = "2026-10-10T08:00:00Z";
test("调查进度替换整图,跟随只展示新指令并在关闭时清理覆盖层", async ({ page }, testInfo) => {
test.setTimeout(90_000);
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("https://tjwater-api.invalid/api/v1/scada-devices**", (route) => route.fulfill({ json: {
items: ["P1", "P2"].map((device_id, index) => ({ device_id, device_type: "pressure", node_id: null,
link_id: null, api_query_id: null, measurement_unit: "MPa", transmission_mode: "test",
transmission_frequency: "1min", reliability: 1, x: null, y: null,
longitude: 121.48 + index * 0.001, latitude: 31.1 + index * 0.001 })),
total: 2, limit: 1000, offset: 0
} }));
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "", TJWATER_ENABLE_MSW: "false"
})};`
}));
// Keep the stream genuinely open: a fulfilled static SSE body is a disconnect.
await page.addInitScript(() => {
const animate = Element.prototype.animate;
const reportAnimations: string[] = [];
Object.assign(window, { reportAnimations });
Element.prototype.animate = function (...args) {
const label = this.getAttribute("aria-label");
if (label === "当前调查进展" || label === "任务报告") reportAnimations.push(label);
return animate.apply(this, args);
};
const original = window.fetch.bind(window);
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
Object.assign(window, {
emitInvestigationEvent: (event: { event_seq: number }) => {
const bytes = new TextEncoder().encode(`id: ${event.event_seq}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n`);
for (const stream of streams) stream.enqueue(bytes);
}
});
window.fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/v1/workbench/events")) return original(input, init);
let controller: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(value) { controller = value; streams.add(value); },
cancel() { streams.delete(controller); }
});
init?.signal?.addEventListener("abort", () => {
if (streams.delete(controller)) controller.close();
}, { once: true });
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
};
});
let task: OperationalTask = {
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
execution_request_id: requestId, title: "合成投诉联动调查", summary: "浏览器验证用合成投诉,不是真实工单",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
trigger: { kind: "manual", occurred_at: now, detail: "合成数据" }, scheduled_for: null,
agent_session_id: "investigation-test", agent_lease_expires_at: now, current_plan_revision: null,
current_report_revision: null, created_at: now, updated_at: now,
active_run: { run_id: runId, task_id: taskId, generation: 1, execution_request_id: requestId,
status: "running", revision: 1, agent_session_id: "investigation-test", lease_expires_at: now,
heartbeat_at: now, started_at: now, finished_at: null, failure_code: null, failure_detail: null,
dispatch_attempts: 1, next_dispatch_at: now, created_at: now, updated_at: now },
latest_plan: null, latest_report: null,
latest_progress: { revision: 1, run_id: runId, execution_generation: 1, published_at: now,
source_session_id: "investigation-test", progress: { summary: "正在核对投诉点位",
diagram: { title: "实时调查脉络", mermaid: 'flowchart TD\nA["投诉点位核对"] --> B["人工采样"]',
node_refs: [
{ node_id: "A", label: "投诉点位核对", status: "running", evidence_refs: [], action_refs: [] },
{ node_id: "B", label: "人工采样", status: "waiting_human", evidence_refs: [], action_refs: [] }
] } } },
evidence: [], actions: [], access_requests: [], work_order_snapshot: null,
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
can_decide_work_orders: true, can_execute_work_orders: true, can_decide_commands: false,
can_execute_commands: false, can_decide_access: false }
};
let eventCursor = 10;
let cursorReads = 0;
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => {
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, work_order_snapshot, permissions, ...summary } = task;
void latest_plan; void latest_report; void latest_progress; void evidence; void actions;
void access_requests; void work_order_snapshot; void permissions;
return route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: eventCursor, generated_at: now,
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
artifact_availability: "not_configured", schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] },
tasks: [summary], notifications: []
} });
});
await page.route(`https://tjwater-api.invalid/api/v1/operational-tasks/${taskId}**`, (route) => {
if (route.request().url().endsWith("/presentation-cursor")) {
cursorReads += 1;
return route.fulfill({ json: { task_id: taskId, execution_generation: 1,
active_run: { run_id: runId, generation: 1, status: "running" }, event_cursor: eventCursor } });
}
if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } });
return route.fulfill({ json: task });
});
const emit = async (command: Record<string, unknown>, seq = ++eventCursor) => {
await page.evaluate((event) => {
(window as unknown as { emitInvestigationEvent: (value: unknown) => void }).emitInvestigationEvent(event);
}, { schema: "workbench-event@1", event_seq: seq,
event_id: `95000000-0000-4000-8000-${String(seq).padStart(12, "0")}`, project_id: projectId,
topic: "operational.presentation.requested", aggregate_type: "operational_task", aggregate_id: taskId,
aggregate_revision: 1, occurred_at: now,
metadata: { task_id: taskId, run_id: runId, execution_generation: 1, command } });
};
await page.goto("/");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
const expand = panel.getByRole("button", { name: "展开工况任务" });
if (await expand.isVisible()) await expand.click();
await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText("合成投诉联动调查", { exact: true }).first().click();
await expect(panel.getByText("正在核对投诉点位", { exact: true }).first()).toBeVisible();
const diagram = panel.getByLabel("调查脉络", { exact: true });
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1);
const animations = () => page.evaluate(() => (window as unknown as { reportAnimations: string[] }).reportAnimations);
expect(await animations()).toEqual([]);
await diagram.evaluate((element) => { element.setAttribute("data-retained-reader", "true"); });
task = { ...task, revision: 2, latest_progress: { ...task.latest_progress!, revision: 2,
progress: { summary: "新增支路复核,等待现场反馈", diagram: { title: "重规划后的调查",
mermaid: 'flowchart TD\nC["支路复核"] --> D["现场反馈"]', node_refs: [
{ node_id: "C", label: "支路复核", status: "completed", evidence_refs: [], action_refs: [] },
{ node_id: "D", label: "现场反馈", status: "waiting_human", evidence_refs: [], action_refs: [] }
] } } } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(diagram).toContainText("支路复核");
await expect(diagram).not.toContainText("投诉点位核对");
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
await expect(diagram).toHaveAttribute("data-retained-reader", "true");
await expect.poll(animations).toEqual(["当前调查进展"]);
task = { ...task, revision: 3, latest_report: { revision: 1, execution_generation: 1,
source_session_id: "investigation-test", published_at: now, published_by: "agent", report: {
title: "合成调查报告", current_conclusion: "合成结论:等待现场核查", conclusion_boundary: "浏览器动画测试",
evidence_refs: [], recommendations: ["核对现场记录"], pending_items: [], stage: "interim",
outcome: "concluded", stop_reason: null, data_gaps: [], diagram: null
} } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
const report = panel.getByRole("region", { name: "任务报告", exact: true });
await expect(report).toContainText("合成结论:等待现场核查");
await expect.poll(animations).toEqual(["当前调查进展", "任务报告"]);
await report.getByText("判断说明与建议", { exact: true }).click();
await report.locator("details").evaluate((element) => element.setAttribute("data-retained-reader", "true"));
task = { ...task, revision: 4, latest_report: { ...task.latest_report!, revision: 2,
report: { ...task.latest_report!.report, current_conclusion: "合成结论:现场核查已更新" } } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(report).toContainText("合成结论:现场核查已更新");
await expect(report.locator("details")).toHaveAttribute("open", "");
await expect(report.locator("details")).toHaveAttribute("data-retained-reader", "true");
await expect.poll(animations).toEqual(["当前调查进展", "任务报告", "任务报告"]);
await testInfo.attach("report-update", { body: await report.screenshot(), contentType: "image/png" });
await page.emulateMedia({ reducedMotion: "reduce" });
task = { ...task, revision: 5, latest_report: { ...task.latest_report!, revision: 3,
report: { ...task.latest_report!.report, current_conclusion: "合成结论:减少动态效果下更新" } } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(report).toContainText("合成结论:减少动态效果下更新");
expect(await animations()).toEqual(["当前调查进展", "任务报告", "任务报告"]);
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const follow = page.getByRole("checkbox", { name: "跟随所选调查" });
await expect(follow).toBeChecked();
const checkboxAlignment = await follow.evaluate((input) => {
const box = input.getBoundingClientRect();
const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect();
return { width: box.width, height: box.height,
offsetX: mark.x + mark.width / 2 - box.x - box.width / 2,
offsetY: mark.y + mark.height / 2 - box.y - box.height / 2 };
});
expect(checkboxAlignment).toEqual({ width: 20, height: 20, offsetX: 0, offsetY: 0 });
await expect.poll(() => cursorReads).toBeGreaterThan(0);
const dev = page.getByTestId("map-dev-panel");
const sections = dev.locator("details");
await expect(sections).toHaveCount(5);
await expect(sections.filter({ hasText: "调查联动" })).toHaveAttribute("open", "");
await expect(sections.filter({ hasText: "要素定位" })).toHaveAttribute("open", "");
await expect(sections.filter({ hasText: "水流调试" })).not.toHaveAttribute("open", "");
await testInfo.attach("dev-panel-default", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-default.png") }), contentType: "image/png" });
const flowSection = sections.filter({ hasText: "水流调试" });
const flowToggle = flowSection.locator("summary");
await flowToggle.click();
await dev.getByLabel("管道 ID", { exact: true }).fill("P-9");
await flowToggle.focus();
await page.keyboard.press("Enter");
await expect(flowSection).not.toHaveAttribute("open", "");
await page.keyboard.press("Enter");
await expect(dev.getByLabel("管道 ID", { exact: true })).toHaveValue("P-9");
await flowToggle.click();
await sections.filter({ hasText: "要素定位" }).locator("summary").click();
await sections.filter({ hasText: "图层样式" }).locator("summary").click();
await sections.filter({ hasText: "数值标注" }).locator("summary").click();
await page.setViewportSize({ width: 1024, height: 640 });
await dev.getByRole("button", { name: "应用标注", exact: true }).scrollIntoViewIfNeeded();
for (const name of ["数值", "设施 ID"]) {
const control = dev.getByRole("checkbox", { name, exact: true });
await expect(control).toHaveCSS("width", "20px");
expect(await control.evaluate((input) => {
const box = input.getBoundingClientRect();
const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect();
return [mark.x + mark.width / 2 - box.x - box.width / 2,
mark.y + mark.height / 2 - box.y - box.height / 2];
})).toEqual([0, 0]);
}
expect(await dev.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expect(dev.getByRole("button", { name: "重置地图调试效果" })).toBeVisible();
await testInfo.attach("dev-panel-narrow", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-narrow.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await expect(page.getByLabel("调查实时展示")).toBeVisible();
await emit({ action: "highlight_features", layer: "scada", feature_ids: ["P1", "P2"], effect: "pulse" });
const highlightedCount = () => page.evaluate(async () => {
const map = (window as unknown as { __waterNetworkMap?: {
getSource: (id: string) => { getData: () => Promise<{ features: unknown[] }> } | undefined
} }).__waterNetworkMap;
return (await map?.getSource("investigation-presentation-highlights")?.getData())?.features.length ?? 0;
});
await expect.poll(highlightedCount).toBe(2);
await emit({ action: "clear_highlights" });
await expect.poll(highlightedCount).toBe(0);
const chart = { chart_id: "pressure", chart: { title: "合成压力趋势", chart_type: "line",
x_data: ["08:00", "08:05"], series: [{ name: "压力", data: [0.3, 0.28] }] }, evidence_refs: ["pressure-1"] };
await emit({ action: "open_chart", ...chart }, 9);
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
await emit({ action: "open_chart", ...chart });
await expect(page.locator('[data-investigation-chart="pressure"]')).toBeVisible();
await emit({ action: "close_chart", chart_id: "pressure" });
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
const data = { type: "FeatureCollection", features: [
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.48, 31.10] } },
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.481, 31.101] } }
] };
await emit({ action: "set_overlay", overlay_id: "complaints", title: "合成投诉点热力", mode: "heatmap", evidence_id: "spatial-1", data });
const layerType = () => page.evaluate(() => {
const map = (window as unknown as { __waterNetworkMap?: { getStyle: () => { layers?: { id: string; type: string }[] } } }).__waterNetworkMap;
return map?.getStyle().layers?.find((layer) => layer.id === "investigation-presentation-overlay-heatmap")?.type;
});
await expect.poll(layerType).toBe("heatmap");
await expect(page.getByLabel("调查实时展示")).toContainText("合成投诉点热力");
await page.mouse.move(300, 180);
await page.mouse.wheel(0, 120);
await expect(page.getByLabel("调查实时展示")).toContainText("镜头跟随已暂停");
await page.getByRole("button", { name: "恢复跟随", exact: true }).click();
await expect(page.getByLabel("调查实时展示")).not.toContainText("镜头跟随已暂停");
await testInfo.attach("live-investigation-map", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-map.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 375, height: 720 });
await expect(page.getByLabel("调查实时展示")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await testInfo.attach("live-investigation-mobile", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-mobile.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await follow.uncheck();
await expect(follow).not.toBeChecked();
await follow.focus();
await expect(follow).toBeFocused();
await testInfo.attach("dev-panel-checkbox-off", { body: await dev.screenshot({ path: testInfo.outputPath("dev-panel-checkbox-off.png") }), contentType: "image/png" });
await expect.poll(() => page.evaluate(() => {
const map = (window as unknown as { __waterNetworkMap?: { getLayoutProperty: (id: string, key: string) => unknown } }).__waterNetworkMap;
return map?.getLayoutProperty("investigation-presentation-overlay-heatmap", "visibility");
})).toBe("none");
await expect(page.locator('[data-investigation-chart]')).toHaveCount(0);
});
@@ -0,0 +1,208 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
// Product display stays UTC+8 even when the browser itself is configured for UTC.
test.use({ timezoneId: "UTC" });
const taskId = "22222222-2222-4222-8222-222222222222";
const runId = "33333333-3333-4333-8333-333333333333";
const projectId = "11111111-1111-4111-8111-111111111111";
const now = "2026-10-13T08:00:00Z";
const run = { run_id: runId, task_id: taskId, generation: 2, execution_request_id: runId, status: "succeeded", revision: 1,
agent_session_id: "investigation-run", lease_expires_at: null, heartbeat_at: null, started_at: now, finished_at: now,
failure_code: null, failure_detail: null, dispatch_attempts: 1, next_dispatch_at: now, created_at: now, updated_at: now };
const task = { project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "completed", active_run: null, latest_run: run, status: "completed", priority: "normal", revision: 1,
execution_generation: 2, execution_request_id: runId, title: "居民异味投诉调查", summary: "现场记录待复核",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
trigger: { kind: "manual", occurred_at: now, detail: "居民异味投诉" }, scheduled_for: null, agent_session_id: "investigation-run",
agent_lease_expires_at: null, current_plan_revision: null, current_report_revision: null, created_at: now, updated_at: now,
latest_plan: null, latest_report: {
revision: 3, execution_generation: 1, source_session_id: "investigation-run", published_at: now, published_by: "agent",
report: { title: "居民异味投诉调查报告", current_conclusion: "现场记录待复核", conclusion_boundary: "仅覆盖已有监测记录",
evidence_refs: ["monitor-1"], recommendations: [], pending_items: [], stage: "final", outcome: "concluded",
stop_reason: null, data_gaps: [], diagram: null }
}, latest_progress: null,
evidence: [{ evidence_id: "monitor-1", kind: "system_data", summary: "上游监测数据质量已核对", source_label: "SCADA 水质监测",
source_ref: "WQ-01", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }],
actions: [], access_requests: [], work_order_snapshot: null,
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false, can_decide_access: false } };
test("证据提问在预览、展开及更多依据中使用统一实体按钮", async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="report" style="max-width:640px;margin:auto;padding:16px"></main></body></html>');
await page.evaluate(async ({ fixture, cachePath }) => {
const reactModule = await import(`${cachePath}/react.js`);
const React = reactModule.default ?? reactModule;
const clientModule = await import(`${cachePath}/react-dom_client.js`);
const { createRoot } = clientModule.default ?? clientModule;
const { InvestigationTaskReport } = await import("/src/features/workbench/components/investigation-task-report.tsx");
const evidence = Array.from({ length: 4 }, (_, index) => ({ ...fixture.evidence[0], evidence_id: `monitor-${index + 1}` }));
createRoot(document.getElementById("report")).render(React.createElement(InvestigationTaskReport, {
task: { ...fixture, evidence, latest_report: { ...fixture.latest_report, report: { ...fixture.latest_report.report, evidence_refs: evidence.map((item) => item.evidence_id) } } },
onContinue: () => undefined, renderAction: () => null,
renderEvidence: (item: { summary: string }) => React.createElement("p", null, item.summary)
}));
}, { fixture: task, cachePath: `/node_modules/.vite-playwright-${process.env.PLAYWRIGHT_PORT || "5191"}/deps` });
const preview = page.getByRole("region", { name: "主要依据", exact: true });
await expect(page.getByText("任务更新于 2026/10/13 16:00:00", { exact: true })).toBeVisible();
await expect(preview).toContainText("2026/10/13 16:00:00");
for (const width of [1440, 320]) {
await page.setViewportSize({ width, height: 900 });
const buttons = preview.getByRole("button", { name: "围绕此证据提问", exact: true });
await expect(buttons).toHaveCount(3);
for (const button of await buttons.all()) {
await expect(button).toHaveAttribute("data-slot", "button");
await expect(button).toHaveCSS("border-top-width", "1px");
await expect(button).toHaveCSS("height", "32px");
}
await preview.screenshot({ path: testInfo.outputPath(`evidence-buttons-${width}.png`) });
await preview.getByRole("button", { name: "展开主要依据" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByText("更多引用依据 · 1 条", { exact: true }).click();
const expandedButtons = dialog.getByRole("button", { name: "围绕此证据提问", exact: true });
await expect(expandedButtons).toHaveCount(4);
for (const button of await expandedButtons.all()) {
await expect(button).toBeVisible();
await expect(button).toHaveAttribute("data-slot", "button");
await expect(button).toHaveCSS("border-top-width", "1px");
expect(await button.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true);
}
await dialog.screenshot({ path: testInfo.outputPath(`evidence-buttons-expanded-${width}.png`) });
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
}
});
async function expectCenteredReference(panel: Locator) {
await expect(panel).toHaveAttribute("data-agent-starting", "true");
await expect(panel.getByRole("group", { name: "对话引用" })).toBeVisible();
await expect(panel.getByRole("group", { name: "推荐问题" })).toHaveCount(0);
await expect(panel.locator("form")).toHaveCount(1);
await expect.poll(async () => {
const panelBox = (await panel.boundingBox())!;
const header = (await panel.locator("header").boundingBox())!;
const composer = (await panel.locator("form").boundingBox())!;
// The mobile header extends above the panel to cover the drawer handle.
const center = (header.y + header.height + panelBox.y + panelBox.height) / 2;
return Math.abs(composer.y + composer.height / 2 - center);
}).toBeLessThan(2);
}
test("单一协作入口展示任务引用,保留草稿且仅手动发送,并适配窄屏", async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page, { sessions: [{ session_id: "ordinary-main", title: "供水核查主聊天", purpose: "ordinary_chat", created_at: now, updated_at: now }] });
let submitted = 0;
let inboxReads = 0;
let incoming = false;
page.on("request", (request) => {
if (request.method() === "POST" && (new URL(request.url()).pathname.endsWith("/responses") || new URL(request.url()).pathname.endsWith("/investigations"))) submitted += 1;
});
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, work_order_snapshot, permissions, ...summary } = task;
void latest_plan; void latest_report; void latest_progress; void evidence; void actions; void access_requests; void work_order_snapshot; void permissions;
await page.route("**/workbench/sync-snapshot", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
artifact_availability: "not_configured", schedule_availability: "not_configured", work_order_availability: "not_configured", alert_availability: "not_configured",
generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }, tasks: [summary], notifications: [], generated_at: now
} }));
await page.route("**/operational-tasks/**", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: task }));
await page.route("**/agent-messages?**", (route) => {
inboxReads += 1;
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { items: incoming ? [{ message_id: "msg1", task_id: taskId, run_id: runId,
source_session_id: "investigation-run", target_session_id: "ordinary-main", direction: "to_chat", text: "已核对上游监测,请补充现场采样记录。",
status: "pending", in_reply_to: null, created_at: now, failure_detail: null }] : [], next_cursor: null } });
});
await page.route("**/agent-messages/stream", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, contentType: "text/event-stream", body: 'event: agent_message\ndata: {"session_id":"ordinary-main"}\n\n' }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const agent = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(agent.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
await agent.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await agent.getByRole("button", { name: /供水核查主聊天/ }).click();
const prompt = agent.locator("textarea");
await prompt.fill("保留的现场补充草稿");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const feed = page.getByRole("region", { name: "工况任务", exact: true });
const expand = feed.getByRole("button", { name: "展开工况任务" });
if (await expand.isVisible()) await expand.click();
await feed.getByRole("button", { name: /调查任务/ }).click();
await feed.getByText("居民异味投诉调查", { exact: true }).first().click();
await expect(feed.getByText("16:00", { exact: true })).toBeVisible();
await expect(feed.getByRole("region", { name: "调查任务标识" })).toHaveCount(0);
await expect(feed.getByRole("button", { name: "在主聊天中编辑" })).toHaveCount(0);
await expect(feed).not.toContainText(taskId);
await expect(feed).not.toContainText(runId);
await expect(feed.getByRole("button", { name: "Agent 协作", exact: true })).toHaveCount(1);
await expect(feed.getByRole("button", { name: "Agent 协作", exact: true })).toHaveAttribute("title", "打开调查协作对话,引用此任务,不会直接发送。");
await feed.screenshot({ path: testInfo.outputPath("investigation-entry-desktop.png") });
await feed.getByRole("button", { name: "Agent 协作", exact: true }).click();
await expect(agent.getByText("任务协作问答", { exact: true }).first()).toBeVisible();
const reference = agent.getByRole("group", { name: "对话引用" });
await expect(reference).toContainText("居民异味投诉调查");
await expect(reference).toContainText("第 1 轮 · 报告第 3 版");
await expect(reference).not.toContainText(/当前|第 2 轮|已结束|调查完成|正在调查/);
await expect(prompt).toHaveAttribute("placeholder", "可询问调查进展、讨论报告或补充信息");
await expect(prompt).toHaveValue("保留的现场补充草稿");
await expect(prompt).toBeFocused();
expect(submitted).toBe(0);
await feed.getByRole("button", { name: "Agent 协作", exact: true }).click();
await expect(prompt).toBeFocused();
await expect(prompt).toHaveValue("保留的现场补充草稿");
await feed.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click();
await expect(prompt).toHaveValue("保留的现场补充草稿\n\n请解释“上游监测数据质量已核对”这条证据的范围、来源和判断影响。");
await expect(prompt).toBeFocused();
await expect(reference).toContainText("已引用证据");
await expectCenteredReference(agent);
expect(submitted).toBe(0);
await agent.screenshot({ path: testInfo.outputPath("investigation-reference-desktop.png") });
await feed.getByRole("button", { name: /定时任务/ }).click();
await page.setViewportSize({ width: 375, height: 900 });
await page.getByRole("button", { name: "打开 Agent 面板", exact: true }).click();
const mobileAgent = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(mobileAgent.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
await expect(mobileAgent.getByRole("group", { name: "对话引用" })).toContainText("居民异味投诉调查");
await expectCenteredReference(mobileAgent);
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-reference-narrow.png") });
await page.setViewportSize({ width: 320, height: 900 });
await expectCenteredReference(mobileAgent);
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-reference-320.png") });
expect(await mobileAgent.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
await page.setViewportSize({ width: 375, height: 900 });
let sentContext: unknown;
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
sentContext = route.request().postDataJSON().tjwater.task_context;
expect(route.request().postDataJSON().conversation).toBe(`collaboration-${taskId}`);
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "X-Vercel-AI-UI-Message-Stream": "v1" }, contentType: "text/event-stream",
body: responsesFixture([{ type: "start", messageId: "reply" }, { type: "finish", finishReason: "stop" }]) });
});
expect(submitted).toBe(0);
await mobileAgent.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect.poll(() => sentContext).toEqual({ report_revision: 3, evidence_id: "monitor-1" });
expect(submitted).toBe(1);
await expect(mobileAgent).toHaveAttribute("data-agent-starting", "false");
await expect(mobileAgent.locator(".agent-panel-user-message")).toContainText("保留的现场补充草稿");
await expect.poll(async () => {
const panelBox = (await mobileAgent.boundingBox())!;
const composer = (await mobileAgent.locator("form").boundingBox())!;
return panelBox.y + panelBox.height - composer.y - composer.height;
}).toBeLessThan(32);
await expect(mobileAgent.getByRole("button", { name: "停止 Agent 分析" })).toHaveCount(0);
await mobileAgent.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await mobileAgent.getByRole("button", { name: /供水核查主聊天/ }).click();
await expect(mobileAgent.getByRole("group", { name: "对话引用" })).toHaveCount(0);
incoming = true;
await expect(mobileAgent.getByText("已核对上游监测,请补充现场采样记录。")).toBeVisible();
await expect(mobileAgent.getByText("待接收", { exact: true })).toBeVisible();
await expect(mobileAgent.getByRole("button", { name: "停止 Agent 分析" })).toHaveCount(0);
expect(inboxReads).toBeGreaterThan(1);
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-inbox-narrow.png") });
expect(await mobileAgent.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
expect(submitted).toBe(1);
});
@@ -0,0 +1,129 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test("调查图加载与尺寸变化保持阅读位置,刷新后仍可缩放和查看节点", async ({ page }, testInfo) => {
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" style="max-width:640px;margin:auto;padding:16px;height:100vh;overflow:auto"></main></body></html>');
await page.evaluate(async () => {
const entry = await (await fetch("/src/main.tsx")).text();
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
const reactModule = await import(reactUrl);
const React = reactModule.default ?? reactModule;
const clientModule = await import(clientUrl);
const { createRoot } = clientModule.default ?? clientModule;
const { InvestigationDiagramView } = await import("/src/features/workbench/components/investigation-diagram-view.tsx");
const { investigationDiagramPlugins } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const renderer = investigationDiagramPlugins.mermaid.getMermaid.bind(investigationDiagramPlugins.mermaid);
let release: (() => void) | undefined;
let renderPending = false;
investigationDiagramPlugins.mermaid.getMermaid = (config: unknown) => {
const instance = renderer(config);
return { ...instance, async render(id: string, source: string) {
renderPending = true;
await new Promise<void>((resolve) => { release = resolve; });
renderPending = false;
return instance.render(id, source);
} };
};
const root = createRoot(document.getElementById("reader"));
const documentHeights: number[] = [];
new MutationObserver(() => {
documentHeights.push(document.documentElement.scrollHeight);
}).observe(document.body, { childList: true, subtree: true, attributes: true });
let source = "flowchart TD\nA[读取工单] --> B[核对监测]\nB --> C[现场采样]\nC --> D[关联证据]\nD --> E[发布报告]";
const render = () => root.render(React.createElement(React.Fragment, null,
React.createElement(InvestigationDiagramView, {
diagram: { title: "调查刷新检查", mermaid: source,
node_refs: [{ node_id: "B", label: "核对监测", evidence_refs: ["e1"], action_refs: [] }] },
renderDetails: () => React.createElement("p", null, "监测记录的引用依据")
}),
React.createElement("section", { id: "reading-anchor", style: { height: 1200 } }, "当前正在阅读的证据")
));
Object.assign(window, { investigationFixture: {
release: () => release?.(), pending: () => renderPending,
update: (next: string) => { source = next; render(); }
}, documentHeights });
render();
});
const fixture = (action: "release" | "pending") => page.evaluate((key) => {
const api = (window as unknown as { investigationFixture: { release: () => void; pending: () => boolean } }).investigationFixture;
return api[key]();
}, action);
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
const viewport = diagram.locator(".investigation-diagram");
await expect.poll(() => fixture("pending")).toBe(true);
const loadingHeight = (await viewport.boundingBox())!.height;
await fixture("release");
await expect(diagram.locator("svg .node")).toHaveCount(5);
const renderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
expect(Math.max(...renderHeights) - Math.min(...renderHeights)).toBeLessThanOrEqual(1);
const loadedHeight = (await viewport.boundingBox())!.height;
testInfo.annotations.push({ type: "geometry", description: `loading=${loadingHeight}, loaded=${loadedHeight}` });
expect(loadedHeight).toBeGreaterThan(loadingHeight);
// Read below the graph: native scroll anchoring must preserve that content,
// while the graph is free to take its real (different) height.
await page.locator("#reading-anchor").evaluate((element) => element.scrollIntoView({ block: "start" }));
const anchorTop = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
await page.evaluate(() => (window as unknown as { investigationFixture: { update: (source: string) => void } }).investigationFixture
.update("flowchart LR\nA[读取工单] --> B[核对监测]"));
await expect.poll(() => fixture("pending")).toBe(true);
await expect(diagram.locator("svg .node")).toHaveCount(5);
await fixture("release");
await expect(diagram.locator("svg .node")).toHaveCount(2);
expect((await viewport.boundingBox())!.height).toBeLessThan(loadedHeight - 100);
expect(Math.abs(await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top) - anchorTop)).toBeLessThanOrEqual(1);
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
await diagram.locator(".node").filter({ hasText: "核对监测" }).click();
await expect(diagram.getByLabel("节点详情 核对监测")).toContainText("监测记录的引用依据");
await diagram.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
await diagram.getByRole("button", { name: "Reset zoom and pan", exact: true }).click();
const checkCollapse = async (keyboard: boolean) => {
const before = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
const detail = page.getByRole("dialog", { name: "调查刷新检查", exact: true });
await expect(detail.locator("svg .node")).toHaveCount(2);
await page.evaluate(() => {
const reader = document.getElementById("reader")!;
const anchor = document.getElementById("reading-anchor")!;
const frames: { top: number; scroll: number; height: number }[] = [];
Object.assign(window, { collapseFrames: frames, collapseDone: false });
const start = performance.now();
const sample = () => {
frames.push({ top: anchor.getBoundingClientRect().top, scroll: reader.scrollTop, height: reader.scrollHeight });
if (performance.now() - start < 800) requestAnimationFrame(sample);
else Object.assign(window, { collapseDone: true });
};
sample();
});
if (keyboard) await page.keyboard.press("Escape");
else await detail.getByRole("button", { name: "关闭详情", exact: true }).click();
await expect(detail).toHaveCount(0);
await page.waitForFunction(() => (window as unknown as { collapseDone: boolean }).collapseDone);
const frames = await page.evaluate(() => (window as unknown as { collapseFrames: { top: number; scroll: number; height: number }[] }).collapseFrames);
const range = (key: "top" | "scroll" | "height") => Math.max(...frames.map((frame) => frame[key])) - Math.min(...frames.map((frame) => frame[key]));
testInfo.annotations.push({ type: "collapse-geometry", description: `keyboard=${keyboard}, anchorShift=${range("top")}, scrollShift=${range("scroll")}, heightShift=${range("height")}` });
expect(Math.abs(frames[0].top - before)).toBeLessThanOrEqual(1);
expect(range("top")).toBeLessThanOrEqual(1);
expect(range("scroll")).toBeLessThanOrEqual(1);
expect(range("height")).toBeLessThanOrEqual(1);
expect(await diagram.locator(":scope > .flow-root").evaluate((element) => (element as HTMLElement).style.height)).toBe("");
};
await checkCollapse(false);
const allRenderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
expect(Math.max(...allRenderHeights) - Math.min(...allRenderHeights)).toBeLessThanOrEqual(1);
testInfo.annotations.push({ type: "page-height", description: `min=${Math.min(...allRenderHeights)}, max=${Math.max(...allRenderHeights)}` });
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-desktop.png") });
await page.setViewportSize({ width: 375, height: 800 });
await expect(diagram.locator("svg .node")).toHaveCount(2);
await expect(diagram.getByRole("button", { name: "放大查看", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-mobile.png") });
await checkCollapse(true);
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(diagram.locator('[role="img"] > svg')).toHaveCSS("animation-name", "none");
});
@@ -0,0 +1,237 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.beforeEach(async ({ page }) => {
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" class="scheduled-feed-scroll" style="max-width:680px;margin:auto;padding:16px;height:100vh;overflow:auto"></main></body></html>');
await page.evaluate(async () => {
const entry = await (await fetch("/src/main.tsx")).text();
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
const reactModule = await import(reactUrl);
const React = reactModule.default ?? reactModule;
const clientModule = await import(clientUrl);
const { createRoot } = clientModule.default ?? clientModule;
const { InvestigationTaskReport } = await import("/src/features/workbench/components/investigation-task-report.tsx");
const now = "2026-10-13T08:00:00Z";
const report = { revision: 1, execution_generation: 1, published_at: now, report: {
title: "居民异味投诉调查报告", current_conclusion: "上游监测未见明显异常,仍需现场核查。",
conclusion_boundary: "仅覆盖现有上游监测,不代表末梢水质。", stage: "interim", outcome: "concluded",
recommendations: ["建议安排现场采样。"], pending_items: ["核实楼栋二次供水记录。"], evidence_refs: ["e1"],
data_gaps: [{ gap_id: "g1", missing_data: "缺少末梢采样记录", impact: "尚不能确定异常来源", scope: "投诉时段",
required_data: "末梢采样结果", evidence_refs: [], blocking: false }],
diagram: { title: "现场核查路线", mermaid: "flowchart LR\nA[核对监测] --> B[现场采样]", node_refs: [] }
} };
let task = { task_id: "task-cards", title: "居民异味投诉调查", summary: "上游监测已核对,等待现场资料",
execution_generation: 1, lifecycle_status: "open", status: "running", updated_at: now,
latest_report: report, latest_progress: null, latest_plan: null,
actions: [{ action_id: "a1", kind: "create_work_order", payload: { title: "安排现场采样" }, reason: "补齐现场资料",
approval_status: "pending", execution_status: "not_submitted", proposed_at: now }],
evidence: [{ evidence_id: "e1", kind: "system_data", summary: "上游监测数据已核对", source_label: "SCADA 水质监测",
observed_at: now, recorded_at: now }] };
const root = createRoot(document.getElementById("reader"));
const render = () => root.render(React.createElement(InvestigationTaskReport, {
task, onContinue: () => undefined,
renderAction: () => React.createElement("p", null, "人工审核后才会创建工单"),
renderEvidence: () => React.createElement("p", null, "原始监测记录与来源详情"),
onLoadReports: async () => ({ items: [report], next_offset: null })
}));
Object.assign(window, { updateInvestigationReport: () => {
task = { ...task, latest_report: { ...report, revision: task.latest_report.revision + 1,
report: { ...report.report, current_conclusion: "新增现场记录已接收,等待复核。" } } };
render();
}, showHistoricalUtcReport: () => {
task = { ...task, latest_report: { ...report, revision: 2, report: { ...report.report,
current_conclusion: "上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。",
conclusion_boundary: "覆盖2026-12-31 15:30–17:00Z。",
recommendations: ["复核2026-10-08T05:00:00Z的数据。"],
data_gaps: [{ ...report.report.data_gaps[0], scope: "2026-10-08 04:00–05:00Z" }]
} } };
Object.assign(window, { storedReportConclusion: task.latest_report.report.current_conclusion });
render();
}, startNextInvestigationRound: () => {
task = { ...task, execution_generation: 2, status: "running", lifecycle_status: "open" };
Object.assign(task, {
active_run: { run_id: "run-2", status: "running" },
latest_progress: { revision: 1, run_id: "run-2", execution_generation: 2, published_at: now,
progress: { summary: "正在复核本轮现场反馈", diagram: {
title: "本轮调查进展图", mermaid: "flowchart LR\nC[现场反馈复核] --> D[复测记录]",
node_refs: [{ node_id: "C", label: "现场反馈复核", status: "running", evidence_refs: [], action_refs: [] }]
} }
}
});
render();
}, finishInvestigationRound: (clearProgressDiagram: boolean) => {
task = { ...task, status: "completed", lifecycle_status: "completed", latest_report: {
...report, revision: 2, execution_generation: 2, report: { ...report.report, stage: "final",
current_conclusion: "本轮现场反馈已复核,形成新的调查结论。",
diagram: { title: "本轮最终报告图", mermaid: "flowchart LR\nE[复核结果] --> F[恢复条件]", node_refs: [] }
}
} };
Object.assign(task, { active_run: null });
if (clearProgressDiagram) Object.assign(task, {
latest_progress: { revision: 2, run_id: "run-2", execution_generation: 2, published_at: now,
progress: { summary: "调查完成", diagram: null } }
});
render();
} });
render();
});
});
for (const clearProgressDiagram of [false, true]) {
test(`新一轮跟随最新进展,最终报告保留自己的图(过程图${clearProgressDiagram ? "为空" : "仍存在"})`, async ({ page }, testInfo) => {
const report = page.getByRole("region", { name: "任务报告", exact: true });
await page.evaluate(() => (window as unknown as { startNextInvestigationRound: () => void }).startNextInvestigationRound());
await expect(report).toContainText("上一轮报告,本轮分析中");
await expect(page.getByRole("region", { name: "当前调查进展", exact: true })).toContainText("正在复核本轮现场反馈");
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).toContainText("现场反馈复核");
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1);
await page.evaluate((clear) => (window as unknown as { finishInvestigationRound: (value: boolean) => void }).finishInvestigationRound(clear), clearProgressDiagram);
await expect(report).toContainText("本轮现场反馈已复核,形成新的调查结论。");
await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).toContainText("恢复条件");
await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).not.toContainText("现场反馈复核");
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
await page.getByRole("article", { name: "调查任务报告" }).evaluate(async (element) => {
await Promise.allSettled(element.getAnimations({ subtree: true })
.filter((animation) => Number.isFinite(Number(animation.effect?.getComputedTiming().endTime)))
.map((animation) => animation.finished));
});
await testInfo.attach("final-report-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("final-report-diagram.png"), animations: "disabled" }), contentType: "image/png" });
});
}
test.describe("报告正文时间展示", () => {
test.use({ timezoneId: "UTC" });
test("历史 UTC 正文和查询范围按 UTC+8 展示并保留原始报告", async ({ page }) => {
await page.evaluate(() => (window as unknown as { showHistoricalUtcReport: () => void }).showHistoricalUtcReport());
const report = page.getByRole("region", { name: "任务报告", exact: true });
await expect(report).toContainText("上报时间2026/10/8 12:00:00(UTC+8),查询12:00–13:00(UTC+8)。");
await expect(report).toContainText("覆盖2026/12/31 23:30–2027/1/1 01:00(UTC+8)。");
await expect(report).not.toContainText("04:00:00Z");
await page.getByRole("button", { name: "展开当前结果", exact: true }).click();
const detail = page.getByRole("dialog", { name: "当前结果", exact: true });
await expect(detail).toContainText("复核2026/10/8 13:00:00(UTC+8)的数据。");
expect(await page.evaluate(() => (window as unknown as { storedReportConclusion: string }).storedReportConclusion))
.toBe("上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。");
});
});
test("报告摘要与有界详情分层,保留人工事项、阅读版本和返回位置", async ({ page }, testInfo) => {
const progress = page.getByRole("region", { name: "当前调查进展", exact: true });
await expect(progress).toContainText("尚未发布本轮调查进展。");
await expect(progress.getByRole("button")).toHaveCount(0);
await page.getByRole("button", { name: "展开任务时间线", exact: true }).click();
const timeline = page.getByRole("dialog", { name: "任务时间线", exact: true });
await expect(timeline.getByLabel("任务时间线记录").locator("li")).toHaveCount(3);
await page.keyboard.press("Escape");
await expect(timeline).toHaveCount(0);
await expect(page.getByLabel("当前需处理", { exact: true })).toContainText("安排现场采样");
await expect(page.getByLabel("数据缺口", { exact: true })).toContainText("缺少末梢采样记录");
await expect(page.getByText("建议安排现场采样。", { exact: true })).toHaveCount(0);
await expect(page.getByText("原始监测记录与来源详情", { exact: true })).toHaveCount(0);
await expect(page.locator(".investigation-diagram svg .node")).toHaveCount(2);
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
await page.locator("#reader").screenshot({ path: testInfo.outputPath("report-cards-desktop.png") });
const trigger = page.getByRole("button", { name: "展开当前结果", exact: true });
await trigger.click();
const detail = page.getByRole("dialog", { name: "当前结果", exact: true });
await expect(detail.getByText("建议安排现场采样。", { exact: true })).toBeVisible();
await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(704);
await detail.screenshot({ path: testInfo.outputPath("report-expanded-desktop.png") });
await page.evaluate(() => (window as unknown as { updateInvestigationReport: () => void }).updateInvestigationReport());
await expect(detail).toContainText("上游监测未见明显异常,仍需现场核查。");
await expect(detail).not.toContainText("新增现场记录已接收");
await expect(detail.getByRole("status")).toContainText("已有更新");
await detail.getByRole("button", { name: "查看最新内容" }).click();
await expect(detail).toContainText("新增现场记录已接收");
await page.keyboard.press("Escape");
await expect(detail).toHaveCount(0);
await expect(trigger).toBeFocused();
const historyButton = page.getByRole("button", { name: "展开报告版本" });
await historyButton.click();
await page.getByRole("dialog", { name: "报告版本" }).getByRole("button", { name: /报告第 1 版/ }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByRole("region", { name: "任务报告" })).toContainText("上游监测未见明显异常");
await expect(page.getByRole("status")).toContainText("正在阅读历史报告");
await page.getByRole("button", { name: "返回实时内容" }).click();
await page.setViewportSize({ width: 375, height: 800 });
await page.getByRole("button", { name: "展开主要依据" }).click();
const evidence = page.getByRole("dialog", { name: "主要依据", exact: true });
await expect(evidence).toContainText("原始监测记录与来源详情");
await expect.poll(async () => Math.round((await evidence.boundingBox())!.width)).toBe(343);
expect((await evidence.boundingBox())!.height).toBeLessThanOrEqual(688);
await evidence.screenshot({ path: testInfo.outputPath("report-expanded-narrow.png") });
await evidence.getByRole("button", { name: "关闭详情" }).click();
await expect(evidence).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
});
test("数据刷新按整份报告错峰揭示,保留图表与滚动位置并支持减少动态效果", async ({ page }, testInfo) => {
const report = page.getByRole("article", { name: "调查任务报告" });
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
await expect(diagram.locator("svg .node")).toHaveCount(2);
const before = await report.evaluate((element) => {
const graph = element.querySelector(".investigation-diagram svg");
const reader = document.getElementById("reader")!;
Object.assign(window, { originalReportGraph: graph });
return { height: element.clientHeight, scrollHeight: reader.scrollHeight };
});
const update = () => page.evaluate(() => (window as unknown as { updateInvestigationReport: () => void }).updateInvestigationReport());
await update();
await expect.poll(() => report.evaluate((element) => element.getAnimations({ subtree: true })
.filter((animation) => (animation.effect as KeyframeEffect).getKeyframes().some((frame) => "translate" in frame)).length)).toBeGreaterThan(3);
const sequence = await report.evaluate((element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) =>
(animation.effect as KeyframeEffect).getKeyframes().some((frame) => "translate" in frame));
// Hold the actual browser animation at a common time to inspect the stagger.
element.getAnimations({ subtree: true }).forEach((animation) => { animation.pause(); animation.currentTime = 160; });
return animations.map((animation) => ({
label: ((animation.effect as KeyframeEffect).target as HTMLElement).getAttribute("aria-label"),
delay: animation.effect!.getTiming().delay,
frames: (animation.effect as KeyframeEffect).getKeyframes().map((frame) => frame.translate)
}));
});
expect(sequence.map((item) => item.label)).toEqual(expect.arrayContaining(["任务报告", "当前调查进展", "调查脉络", "主要依据", "任务时间线"]));
expect(sequence[0].delay).toBe(0);
expect(sequence[1].delay).toBe(45);
expect(sequence.every((item) => item.frames[0] === "0px 18px")).toBe(true);
await page.screenshot({ path: testInfo.outputPath("report-refresh-during.png") });
await report.evaluate((element) => element.getAnimations({ subtree: true }).forEach((animation) => animation.finish()));
await expect(report.getByText("新增现场记录已接收,等待复核。", { exact: true })).toBeVisible();
const after = await report.evaluate((element) => ({
sameGraph: element.querySelector(".investigation-diagram svg") === (window as unknown as { originalReportGraph: Element }).originalReportGraph,
height: element.clientHeight, scrollHeight: document.getElementById("reader")!.scrollHeight
}));
expect(after).toEqual({ ...before, sameGraph: true });
await diagram.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
const scrollTop = await page.locator("#reader").evaluate((element) => element.scrollTop);
await update();
await expect(report).toContainText("报告第 3 版");
expect(await page.locator("#reader").evaluate((element) => element.scrollTop)).toBe(scrollTop);
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
await page.emulateMedia({ reducedMotion: "reduce" });
await update();
await expect(report).toContainText("报告第 4 版");
expect(await report.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0);
await page.setViewportSize({ width: 375, height: 800 });
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
await update();
await expect(report).toContainText("报告第 5 版");
// Pointer interaction must end the refresh before shared-layout expansion starts.
await page.getByRole("button", { name: "展开当前结果", exact: true }).dispatchEvent("pointerdown");
await page.getByRole("button", { name: "展开当前结果", exact: true }).click();
const detail = page.getByRole("dialog", { name: "当前结果", exact: true });
await expect(detail).toBeVisible();
await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(343);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await page.screenshot({ path: testInfo.outputPath("report-refresh-narrow-detail.png") });
});
+4 -2
View File
@@ -23,8 +23,9 @@ test("Dev Panel opens as a floating desktop panel and closes with Escape", async
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(884);
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden();
await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible();
await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible();
await expect(panel.getByRole("button", { name: "重置地图调试效果" })).toBeVisible();
await panel.locator("summary").filter({ hasText: "水流调试" }).click();
await panel.getByLabel("管道 ID").fill("P-9");
await panel.getByLabel("有符号流速").fill("-2.75");
await panel.getByRole("button", { name: "更新该管道方向" }).click();
@@ -61,9 +62,10 @@ test("labels remain active per business group and clear globally", async ({ page
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByTestId("map-dev-panel");
await panel.locator("summary").filter({ hasText: "数值标注" }).click();
const apply = panel.getByRole("button", { name: "应用标注", exact: true });
await apply.click();
await panel.getByRole("combobox", { name: "业务图层组" }).click();
await panel.getByRole("combobox", { name: "标注图层组" }).click();
await page.getByRole("option", { name: "节点", exact: true }).click();
await apply.click();
+5 -13
View File
@@ -173,6 +173,7 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
const devPanel = page.getByRole("complementary", { name: "地图开发面板" });
await expectContained(devPanel, 1280);
await expect(devPanel.locator("select")).toHaveCount(0);
await devPanel.locator("summary").filter({ hasText: "图层样式" }).click();
const layerGroup = devPanel.getByRole("combobox", { name: "业务图层组" });
await layerGroup.focus();
await layerGroup.press("Enter");
@@ -199,18 +200,9 @@ for (const width of [375, 320]) {
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const agentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
await expectContained(agentSheet, width);
const welcomeTitle = agentSheet.getByRole("heading", { name: "我已就绪,请描述任务" });
await expect(welcomeTitle).toBeVisible();
const [welcomeBox, conversationBox] = await Promise.all([
welcomeTitle.boundingBox(),
agentSheet.getByRole("log").boundingBox()
]);
expect(welcomeBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(welcomeBox!.y).toBeGreaterThanOrEqual(conversationBox!.y);
expect(welcomeBox!.y + welcomeBox!.height).toBeLessThanOrEqual(
conversationBox!.y + conversationBox!.height
);
await expect(agentSheet.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
await expect(agentSheet.getByRole("log")).toBeVisible();
await expect(agentSheet.getByRole("textbox")).toBeVisible();
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { timeout: 15_000 });
await page.screenshot({
path: `test-results/non-map-panels/${width}-agent.png`,
@@ -364,7 +356,7 @@ async function prepareWorkbench(page: Page) {
TJWATER_MAP_URL: "https://non-map-panels.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false"
})}`
@@ -266,12 +266,12 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
await page.goto("/");
const alertTrigger = page.getByRole("button", {
name: "查看异常处置面板,当前 0 条待复核工况"
name: "查看待办与告警,当前 0 条待处理事项"
}).first();
await expect(alertTrigger).toBeVisible();
await alertTrigger.click();
await expect(page.getByText("当前无待复核工况", { exact: true }).first()).toBeVisible();
await expect(page.getByText("来源:未配置告警源", { exact: true })).toBeVisible();
await expect(page.getByText("当前无待处理事项", { exact: true }).first()).toBeVisible();
await expect(page.getByText("业务告警来源:未配置", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
const reconnectNotice = page.locator("[data-sonner-toast]").filter({
hasText: "业务同步正在重连"
+1 -3
View File
@@ -25,11 +25,9 @@ test("gem-blue actions and semantic labels remain readable on the new surfaces",
expect(tint[2]).toBeGreaterThan(tint[1]);
expect(tint[2]).toBeGreaterThan(tint[0]);
await expectThemeColor(panel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
const body = panel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
expect(await readRenderedContrast(panel.getByText("对话主题"))).toBeGreaterThanOrEqual(4.5);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").fill("检查压力变化");
await page.getByPlaceholder("询问供水问题,或描述需要处理的任务").fill("检查压力变化");
const send = panel.getByRole("button", { name: "发送 Agent 指令", exact: true });
await expect(send).toBeEnabled();
await expectThemeColor(send, "background-color", "--action-blue");
+82 -18
View File
@@ -102,16 +102,28 @@ test.describe("外围工单测试", () => {
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
await expect(panel.getByText("测试数据", { exact: true })).toHaveCount(0);
const workOrderList = page.getByLabel("工单列表");
await expect(workOrderList).toContainText("合成通信核查");
await expect(workOrderList).toContainText("合成压力偏差复核");
await expect(workOrderList).toContainText("合成零流量样本确认");
await expect(workOrderList).toContainText("待处理");
await expect(workOrderList).toContainText("处理中");
await expect(workOrderList).toContainText("已完成");
await expect(workOrderList.getByText("工单报告", { exact: true })).toHaveCount(3);
await expect(workOrderList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
await expect(workOrderList.getByText("活动记录", { exact: true })).toHaveCount(0);
const appointmentList = panel.getByLabel("预约工单列表");
const complaintList = panel.getByLabel("投诉工单列表");
const burstList = panel.getByLabel("爆管上报工单列表");
await expect(appointmentList).toContainText("合成通信核查");
await expect(appointmentList).toContainText("合成压力偏差复核");
await expect(appointmentList).toContainText("合成零流量样本确认");
await expect(appointmentList).toContainText("待处理");
await expect(appointmentList).toContainText("处理中");
await expect(appointmentList).toContainText("已完成");
await expect(appointmentList.getByText("工单报告", { exact: true })).toHaveCount(3);
await expect(appointmentList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
await expect(complaintList).toContainText("水质投诉:演示小区");
await expect(complaintList.getByRole("region", { name: "投诉工单信息" })).toContainText("发生时间");
await expect(complaintList.getByText("工单报告", { exact: true })).toHaveCount(0);
await expect(burstList).toContainText("爆管上报:演示路口");
await expect(burstList.getByRole("region", { name: "爆管上报工单信息" })).toContainText("发生时间");
await expect(complaintList).toHaveAttribute("open", "");
await complaintList.locator("summary").click();
await expect(complaintList).not.toHaveAttribute("open", "");
await expect(complaintList.getByText("水质投诉:演示小区")).toBeHidden();
await complaintList.locator("summary").click();
await expect(complaintList.getByText("水质投诉:演示小区")).toBeVisible();
expect(requestedPaths).toEqual(expect.arrayContaining([
"/api/sample/v1/context",
"/api/sample/v1/work-orders"
@@ -257,7 +269,12 @@ test.describe("外围工单测试", () => {
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON();
writes.push(body);
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester", activity: [{ sequence: 1, occurred_at: now, actor: "tester", kind: "created", title: "工单创建", detail: "预约工单已创建。" }] };
const createdDetail = body.work_order_type === "water_quality_complaint"
? "投诉工单已创建。"
: body.work_order_type === "burst_report"
? "爆管上报工单已创建。"
: "预约工单已创建。";
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester", activity: [{ sequence: 1, occurred_at: now, actor: "tester", kind: "created", title: "工单创建", detail: createdDetail }] };
delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item });
}
if (path.includes("/work-orders/") && request.method() === "PATCH") {
@@ -280,7 +297,8 @@ test.describe("外围工单测试", () => {
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
await expect(panel.getByLabel("工单列表")).toContainText("暂无测试工单");
await expect(panel.getByLabel("预约工单列表")).toContainText("暂无预约工单");
await expect(panel.getByLabel("投诉工单列表")).toContainText("暂无投诉工单");
await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden();
await panel.getByRole("button", { name: "新增预约工单" }).click();
let editor = panel.getByRole("region", { name: "新增预约工单" });
@@ -302,7 +320,7 @@ test.describe("外围工单测试", () => {
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
await expect(panel.getByLabel("工单列表").getByRole("region", { name: "编辑工单 合成预约维修" })).toBeVisible();
await expect(panel.getByLabel("预约工单列表").getByRole("region", { name: "编辑工单 合成预约维修" })).toBeVisible();
await expect(editor.locator("xpath=ancestor::article")).toContainText("合成预约维修");
await expect(editor.getByRole("textbox", { name: "标题" })).toBeFocused();
await expect(panel.getByRole("button", { name: "编辑工单 合成预约维修" })).toHaveCount(0);
@@ -387,7 +405,9 @@ test.describe("外围工单测试", () => {
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示路与样板路交叉口");
await panel.getByRole("textbox", { name: "现场描述" }).fill("路面积水并伴随压力突降");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel.getByText("爆管上报", { exact: true }).last()).toBeVisible();
await expect(panel.getByText("爆管上报工单", { exact: true }).last()).toBeVisible();
await expect(panel.getByLabel("爆管上报工单列表")).toContainText("爆管上报:演示路与样板路交叉口");
await expect(panel.getByLabel("爆管上报工单列表")).toContainText("爆管上报工单已创建。");
expect(workOrders[0]).toMatchObject({
work_order_type: "burst_report",
scenario_code: "BURST-REPORT-001",
@@ -439,7 +459,7 @@ test.describe("外围工单测试", () => {
page.on("request", (request) => {
if (
request.method() === "POST" &&
request.url().includes("/api/v1/agent/chat/") &&
request.url().includes("/api/v1/") &&
request.postData()
) {
agentRequests.push(request.postDataJSON() as Record<string, unknown>);
@@ -450,7 +470,7 @@ test.describe("外围工单测试", () => {
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test"
})}`
}));
@@ -643,7 +663,7 @@ test.describe("外围工单测试", () => {
status: "open"
});
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
await page.route("**/api/v1/agent/chat/sessions", (route) => route.fulfill({ json: { sessions: [{
await page.route("**/api/v1/sessions", (route) => route.fulfill({ json: { sessions: [{
id: "browser-session-1", title: "水质投诉调查", created_at: "2026-09-23T01:00:00Z", updated_at: "2026-09-23T01:00:00Z",
trigger: { subject_id: workOrderId, workflow: "water_quality_complaint", occurred_at: "2026-09-23T01:00:00Z" }
}] } }));
@@ -735,7 +755,7 @@ function createDemoDataset(projectId: string) {
}
function createSourceWorkOrders(projectId: string) {
return [
const appointments = [
["合成通信核查", "核查监测点通信和数据上送状态", "open", "high"],
["合成压力偏差复核", "复核压力偏差与历史回放证据", "in_progress", "urgent"],
["合成零流量样本确认", "确认零流量样本及质量标记", "completed", "normal"]
@@ -760,4 +780,48 @@ function createSourceWorkOrders(projectId: string) {
created_by: "synthetic-generator",
updated_by: "synthetic-generator"
}));
const events = [
{
work_order_id: "50000000-0000-4000-8000-000000000004",
title: "水质投诉:演示小区",
summary: "居民反映晨间用水有异味",
priority: "high",
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: "WQ-SECONDARY-SUPPLY-001",
activity_detail: "投诉工单已创建。"
},
{
work_order_id: "50000000-0000-4000-8000-000000000005",
title: "爆管上报:演示路口",
summary: "路面积水并伴随压力突降",
priority: "urgent",
work_order_type: "burst_report",
location: "演示路口",
scenario_code: "BURST-REPORT-001",
activity_detail: "爆管上报工单已创建。"
}
].map(({ activity_detail, ...event }) => ({
...event,
project_id: projectId,
revision: 1,
status: "open",
assignee: null,
scheduled_at: "2026-09-17T08:30:00Z",
feedback_requirements: [],
activity: [{
sequence: 1,
occurred_at: "2026-09-17T08:30:00Z",
actor: "synthetic-generator",
kind: "created",
title: "工单创建",
detail: activity_detail
}],
origin: "synthetic",
created_at: "2026-09-17T08:30:00Z",
updated_at: "2026-09-17T08:30:00Z",
created_by: "synthetic-generator",
updated_by: "synthetic-generator"
}));
return [...appointments, ...events];
}
+1 -1
View File
@@ -155,7 +155,7 @@ async function mockRuntimeAndMap(
TJWATER_MAP_URL: "https://scada-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`
@@ -1,6 +1,31 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order";
test("keeps the open scheduled report when the rolling history refreshes", async ({ page }) => {
await page.clock.install({ time: new Date("2026-09-24T09:20:53Z") });
await page.goto("/", { waitUntil: "domcontentloaded" });
await openConditionFeed(page);
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
const rows = panel.getByTestId("scheduled-condition-timeline").locator("button[aria-pressed]");
const oldestReport = rows.last();
const reportTitle = await oldestReport.textContent();
expect(reportTitle).toContain("11:20SCADA 数据诊断");
await oldestReport.click();
await expect(oldestReport).toHaveAttribute("aria-pressed", "true");
await page.clock.runFor(12_000);
await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveAttribute("aria-pressed", "true");
await expect(panel.getByTestId("scheduled-condition-detail-scroll"))
.toContainText("11:20 的SCADA 数据诊断");
await panel.getByRole("button", { name: /预约任务/ }).click();
await panel.getByRole("button", { name: /定时任务/ }).click();
await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveCount(0);
});
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await openConditionFeed(page);
+19 -9
View File
@@ -1,3 +1,4 @@
import { responsesFixture } from "./responses-fixture";
import type { Page } from "@playwright/test";
type MockAgentApiOptions = {
@@ -19,7 +20,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
icon: "bolt" as const
};
const triggeredSessions = new Map<string, { sessionId: string; triggerId: string }>();
await page.route("**/api/v1/agent/chat/**", async (route) => {
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
@@ -34,6 +35,18 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
const pathname = new URL(route.request().url()).pathname;
if (pathname.endsWith("/suggestions") && route.request().method() === "POST") {
await route.fulfill({ headers: corsHeaders, json: {
suggestions: [
{ question: "分析当前管网需要优先核对的压力监测点。", activity_id: null },
{ question: "检查近期供水任务的证据与待确认事项。", activity_id: null },
{ question: "解读近期分析成果并列出进一步验证的问题。", activity_id: null },
],
generated_at: "2026-10-09T07:00:00Z",
} });
return;
}
const collaborationMatch = pathname.match(/\/task\/([^/]+)\/collaboration\/session$/);
if (collaborationMatch && route.request().method() === "POST") {
await route.fulfill({
@@ -99,24 +112,21 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
return;
}
if (pathname.endsWith("/stream")) {
if (pathname.endsWith("/responses")) {
await route.fulfill({
status: 200,
headers: {
...corsHeaders,
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
"Content-Type": "text/event-stream"
},
body: [
body: responsesFixture([
{ type: "start", messageId: "assistant-browser-test" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: "已收到工况汇总请求。" },
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
]
.map((part) => `data: ${JSON.stringify(part)}\n\n`)
.join("")
])
});
return;
}
@@ -161,6 +171,6 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
return;
}
await route.fulfill({ headers: corsHeaders, json: {} });
await route.fallback();
});
}
@@ -28,7 +28,7 @@ export async function mockWorkbenchContext(
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
@@ -0,0 +1,42 @@
// Translate existing UI scenarios into Responses wire fixtures. Production
// clients accept Responses only; this helper belongs exclusively to tests.
export function responseFixtureWriter() {
let sequence = 0;
let id = "resp_browser_fixture";
let messageId = "msg_browser_fixture";
const texts = new Map<string, string>();
const contentIndices = new Map<string, number>();
const closed = new Set<string>();
let error: string | undefined;
const event = (type: string, fields: Record<string, unknown>) => `event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: sequence++, ...fields })}\n\n`;
const textFields = (key: string) => ({ item_id: messageId, output_index: 0, content_index: contentIndices.get(key) ?? 0 });
return (chunk: Record<string, unknown>) => {
if (chunk.type === "start") {
messageId = String(chunk.messageId); id = `resp_${messageId}`;
return event("response.created", { response: { id, object: "response", status: "in_progress" } })
+ event("response.output_item.added", { output_index: 0, item: { type: "message", role: "assistant", id: messageId, content: [] } });
}
if (chunk.type === "text-start") {
const key = String(chunk.id);
contentIndices.set(key, contentIndices.size); texts.set(key, "");
return event("response.content_part.added", { ...textFields(key), part: { type: "output_text", text: "" } });
}
if (chunk.type === "text-delta") {
const key = String(chunk.id); texts.set(key, `${texts.get(key) ?? ""}${String(chunk.delta)}`);
return event("response.output_text.delta", { ...textFields(key), delta: chunk.delta });
}
if (chunk.type === "text-end") { const key = String(chunk.id); closed.add(key); return event("response.output_text.done", { ...textFields(key), text: texts.get(key) ?? "" }); }
if (typeof chunk.type === "string" && chunk.type.startsWith("data-")) return event(`tjwater.${chunk.type.slice(5)}`, { response_id: id, data: chunk.data, ...(chunk.id ? { id: chunk.id } : {}) });
if (chunk.type === "error") { error = String(chunk.errorText); return ""; }
if (chunk.type === "finish") {
const rest = [...texts.keys()].filter((key) => !closed.has(key)).map((key) => event("response.output_text.done", { ...textFields(key), text: texts.get(key) })).join("");
return rest + event(error ? "response.failed" : "response.completed", { response: { id, object: "response", status: error ? "failed" : "completed", ...(error ? { error: { message: error } } : {}) } });
}
return "";
};
}
export function responsesFixture(chunks: Record<string, unknown>[]) {
const write = responseFixtureWriter();
return chunks.map(write).join("");
}
@@ -123,7 +123,7 @@ test("keeps mock conditions while retaining explicit backend alerts", async ({ p
await expect(panel).not.toContainText("演示 合成低压调查");
const alertButton = page.getByRole("button", {
name: "查看异常处置面板,当前 1 条待复核工况"
name: "查看待办与告警,当前 1 条待处理事项"
});
await alertButton.focus();
await page.keyboard.press("Enter");
@@ -462,11 +462,11 @@ test("keeps mock conditions while using backend work orders and alerts", async (
await expect(detail.locator("details")).toHaveCount(0);
const alertButton = page.getByRole("button", {
name: "查看异常处置面板,当前 1 条待复核工况"
name: "查看待办与告警,当前 1 条待处理事项"
});
await alertButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByText("来源:后端运行态", { exact: true })).toBeVisible();
await expect(page.getByText("业务告警来源:后端运行态", { exact: true })).toBeVisible();
await expect(page.getByText("后端告警:泵站通信中断")).toBeVisible();
});
@@ -0,0 +1,95 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
type SyncProbe = {
available: boolean;
connections: number;
cursors: string[];
warnings: number;
disconnect: (scheduled: boolean) => void;
};
declare global {
interface Window { syncProbe: SyncProbe }
}
test("scheduled renewals stay silent while a sustained outage warns and recovers", async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 41, generated_at: "2026-10-12T02:00:00Z",
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
artifact_availability: "not_configured", schedule_availability: "not_configured",
work_order_availability: "not_configured", alert_availability: "not_configured",
generated_at: "2026-10-12T02:00:00Z", conditions: [], alerts: [], scenarios: [],
latest_analysis: null, work_orders: [] }, tasks: [], notifications: []
} }));
await page.addInitScript(() => {
const originalFetch = window.fetch.bind(window);
let stream: ReadableStreamDefaultController<Uint8Array> | undefined;
window.syncProbe = {
available: true, connections: 0, cursors: [], warnings: 0,
disconnect: (scheduled) => {
if (!stream) throw new Error("No active event stream");
if (scheduled) {
stream.enqueue(new TextEncoder().encode("event: reconnect\ndata: {}\n\n"));
stream.close();
} else {
stream.error(new Error("Synthetic connection loss"));
}
stream = undefined;
}
};
new MutationObserver(() => {
if (document.body?.textContent?.includes("业务同步正在重连")) window.syncProbe.warnings += 1;
}).observe(document, { subtree: true, childList: true, characterData: true });
window.fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/v1/workbench/events")) return originalFetch(input, init);
window.syncProbe.cursors.push(new Headers(init?.headers).get("Last-Event-ID") ?? "");
if (!window.syncProbe.available) return new Response("{}", { status: 503 });
window.syncProbe.connections += 1;
const body = new ReadableStream<Uint8Array>({
start(controller) { stream = controller; },
cancel() { stream = undefined; }
});
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
};
});
const now = new Date("2026-10-12T02:00:00Z");
await page.clock.install({ time: now });
await page.clock.pauseAt(now);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect.poll(() => page.evaluate(() => window.syncProbe.connections), { timeout: 15_000 }).toBe(1);
for (let cycle = 0; cycle < 3; cycle += 1) {
await page.evaluate(() => window.syncProbe.disconnect(true));
await page.clock.runFor(1_100);
await expect.poll(() => page.evaluate(() => window.syncProbe.connections)).toBe(cycle + 2);
expect(await page.evaluate(() => window.syncProbe.warnings)).toBe(0);
}
await page.evaluate(() => {
window.syncProbe.available = false;
window.syncProbe.disconnect(false);
});
await page.clock.runFor(14_000);
await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0);
await page.clock.runFor(1_100);
await expect(page.getByText("业务同步正在重连", { exact: true })).toBeVisible();
await page.clock.runFor(500);
expect(await page.evaluate(() => window.syncProbe.warnings)).toBeGreaterThan(0);
const notice = page.getByText("业务同步正在重连", { exact: true });
await expect(notice.locator("xpath=ancestor::li[@data-sonner-toast]")).toHaveCSS("opacity", "1");
await expect(notice).toBeInViewport();
await page.screenshot({ path: test.info().outputPath("sustained-outage.png") });
await page.evaluate(() => { window.syncProbe.available = true; });
await page.clock.runFor(16_000);
await expect.poll(() => page.evaluate(() => window.syncProbe.connections)).toBe(5);
await page.clock.runFor(500);
await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0);
expect(await page.evaluate(() => window.syncProbe.cursors.every((cursor) => cursor === "41"))).toBe(true);
await page.screenshot({ path: test.info().outputPath("recovered.png") });
});
@@ -10,7 +10,7 @@ test("top bar uses compact controls on narrow mobile viewports", async ({ page }
);
await expect(page.getByRole("button", { name: "切换模拟方案" })).toBeHidden();
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
const alertButton = page.getByRole("button", { name: /查看异常处置面板/ });
const alertButton = page.getByRole("button", { name: /查看待办与告警/ });
const userButton = page.getByRole("button", { name: "打开用户菜单" });
await expect(alertButton).toBeVisible();
await expect(userButton).toBeVisible();
@@ -55,6 +55,6 @@ test("top bar keeps text horizontal at the desktop breakpoint", async ({ page })
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
})
.map((span) => span.textContent?.trim())
.filter((text) => text === "任务浮条" || text === "工况任务" || text === "异常处置").length;
.filter((text) => text === "任务浮条" || text === "工况任务" || text === "待办与告警").length;
})).toBe(0);
});
+6 -2
View File
@@ -186,7 +186,7 @@ async function prepareDeterministicWorkbench(page: Page) {
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`
@@ -248,6 +248,9 @@ async function openWorkbench(page: Page) {
await page.waitForFunction(() =>
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))
);
await expect(page.getByRole("button", {
name: "查看待办与告警,当前 0 条待处理事项"
})).toBeVisible();
}
type BasemapLabel = "浅色" | "影像" | "天地图矢量" | "天地图影像";
@@ -331,6 +334,7 @@ async function expectLoadedChineseFont(page: Page) {
}
async function expectBodyContrast(page: Page) {
const body = page.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
const body = page.getByRole("group", { name: "推荐问题" }).getByRole("button").first();
await expect(body).toBeVisible();
expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+1 -1
View File
@@ -244,7 +244,7 @@ async function prepareMainFrame(page: Page, latestAnalysis: unknown = MOCK_SPATI
TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false"
};`