diff --git a/tests/browser/dynamic-investigation-flow.e2e.ts b/tests/browser/dynamic-investigation-flow.e2e.ts index 94e2a21..7811d03 100644 --- a/tests/browser/dynamic-investigation-flow.e2e.ts +++ b/tests/browser/dynamic-investigation-flow.e2e.ts @@ -1,5 +1,6 @@ import { responsesFixture } from "./support/responses-fixture"; import { expect, test } from "@playwright/test"; +import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream"; import { writeFile } from "node:fs/promises"; import type { OperationalTask, OperationalTaskNotification, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client"; import { mockAgentApi } from "./support/mock-agent-api"; @@ -108,7 +109,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常 generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }, tasks: [summary(task)], notifications, generated_at: now } })); - await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" })); + await mockWorkbenchEventStream(page); await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => { if (route.request().method() === "POST") { expect(route.request().postDataJSON().expected_version).toBe(1); @@ -289,9 +290,12 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常 await expect(fullscreen).toHaveCount(0); await diagram.locator(".node").filter({ hasText: "上游监测" }).click({ timeout: 5000 }); await expect(panel.getByLabel("节点详情 上游监测")).toBeVisible(); - await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).click(); - await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).focus(); + const upstreamLink = diagram.getByRole("button", { name: /^上游监测 · .* · 1 条依据$/ }); + await upstreamLink.click(); + await expect(upstreamLink).toHaveAttribute("aria-pressed", "false"); + await upstreamLink.focus(); await page.keyboard.press("Enter"); + await expect(upstreamLink).toHaveAttribute("aria-pressed", "true"); await expect(panel.getByLabel("节点详情 上游监测")).toContainText("上游监测数据质量已核对"); await testInfo.attach("dynamic-investigation-desktop", { body: await panel.screenshot({ path: testInfo.outputPath("dynamic-investigation-desktop.png") }), contentType: "image/png" diff --git a/tests/browser/investigation-execution-history.e2e.ts b/tests/browser/investigation-execution-history.e2e.ts index 9e1a801..3c3f015 100644 --- a/tests/browser/investigation-execution-history.e2e.ts +++ b/tests/browser/investigation-execution-history.e2e.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream"; import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client"; import { mockAgentApi } from "./support/mock-agent-api"; @@ -189,7 +190,7 @@ test("调查任务执行记录自动刷新并可创建调试会话追加消息", notifications: [], generated_at: now } })); - await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" })); + await mockWorkbenchEventStream(page); await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })); await page.route("http://server.test/api/v1/operational-tasks**", (route) => { const path = new URL(route.request().url()).pathname; diff --git a/tests/browser/operational-task-approval.e2e.ts b/tests/browser/operational-task-approval.e2e.ts index b17c269..2152d44 100644 --- a/tests/browser/operational-task-approval.e2e.ts +++ b/tests/browser/operational-task-approval.e2e.ts @@ -1,4 +1,5 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Locator } from "@playwright/test"; +import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream"; import type { OperationalTask, OperationalTaskAction, @@ -207,9 +208,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后 generated_at: "2026-09-23T08:30:00Z" } }); }); - await page.route("http://server.test/api/v1/workbench/events**", (route) => - route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" }) - ); + const eventStream = await mockWorkbenchEventStream(page); await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } }) @@ -254,6 +253,10 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后 return route.fulfill({ status: 404, json: {} }); }); + const openAction = async (panel: Locator) => { + const disclosure = panel.locator(`[data-review-action="${actionId}"]`); + if (await disclosure.getAttribute("open") === null) await disclosure.locator("summary").first().click(); + }; const openTask = async () => { await page.getByRole("button", { name: "工况任务", exact: true }).click(); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); @@ -261,6 +264,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后 if (await expandButton.isVisible()) await expandButton.click(); await conditionPanel.getByRole("button", { name: /调查任务/ }).click(); await conditionPanel.getByText("阀门调节审批", { exact: true }).first().click(); + await openAction(conditionPanel); return conditionPanel; }; @@ -273,17 +277,8 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后 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: "业务同步正在重连" - }); - await expect(reconnectNotice).toBeVisible(); - await expect(reconnectNotice).toContainText("当前数据可能陈旧,连接恢复后将自动更新。"); - await expect(page.getByText("正在重连,业务数据可能陈旧", { exact: true })).toHaveCount(0); - await page.evaluate(() => document.fonts.ready); - await testInfo.attach("workbench-reconnect-notice", { - body: await page.screenshot({ path: testInfo.outputPath("workbench-reconnect-notice.png") }), - contentType: "image/png" - }); + await expect.poll(() => eventStream.cursors.length).toBe(1); + await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0); let conditionPanel = await openTask(); await expect(conditionPanel.getByRole("navigation", { name: "工况任务分类" })).toBeVisible(); await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据"); @@ -299,6 +294,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后 await refreshingButton.evaluate((button: HTMLButtonElement) => button.click()); expect(snapshotRequests).toBe(2); await expect(conditionRefreshButton).toBeEnabled({ timeout: 4_000 }); + expect(eventStream.cursors).toEqual(["0"]); expect(Date.now() - refreshStartedAt).toBeGreaterThanOrEqual(2_900); await expect(conditionPanel.getByRole("button", { name: "配置定时流程" })).toHaveCount(0); await expect(conditionPanel.getByRole("button", { name: "创建定时流程" })).toHaveCount(0); @@ -317,13 +313,15 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后 await expect(appointmentDetail.getByText("联调测试点东侧", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("预约 09/23 18:00 由北片区维修组处理", { exact: true })).toHaveCount(0); - await page.waitForTimeout(3_500); - await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" }); await page.evaluate(() => document.fonts.ready); - await page.screenshot({ path: "/tmp/tjwater-operational-ui-review/task-categories-desktop.png" }); + await testInfo.attach("task-categories-desktop", { + body: await conditionPanel.screenshot(), contentType: "image/png" + }); await conditionPanel.getByRole("button", { name: /调查任务/ }).click(); expect(scheduleRequests).toBe(0); - await expect(conditionPanel.getByText("上一轮报告,当前任务待人工", { exact: true })).toBeVisible(); + await expect(conditionPanel.getByRole("region", { name: "任务报告", exact: true }).getByText("上一轮报告", { exact: true })).toBeVisible(); + await expect(conditionPanel.getByText("待人工审核", { exact: true })).toBeVisible(); + await openAction(conditionPanel); await expect(conditionPanel.getByText(/第\s*[12]\s*代|generation/i)).toHaveCount(0); const actionCard = conditionPanel.locator(`[data-action-id="${actionId}"]`); await expect(actionCard.getByText("命令类型", { exact: true })).toBeVisible(); @@ -372,7 +370,9 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后 const reconcileButton = conditionPanel.getByRole("button", { name: "核对执行回执" }); await expect(reconcileButton).toBeVisible(); await reconcileButton.click(); - const receipt = conditionPanel.getByRole("region", { name: "执行回执" }); + const progress = conditionPanel.getByRole("region", { name: "处置进展", exact: true }); + await progress.locator("summary").filter({ hasText: "设备命令 · 执行成功" }).click(); + const receipt = progress.getByRole("region", { name: "执行回执" }); await expect(receipt).toContainText("SCADA-RECEIPT-20260923-01"); await expect(receipt).toContainText("已完成"); await expect(receipt).toContainText("V-102 开度已调整至 35%"); @@ -420,9 +420,7 @@ test("后端没有调查任务时显示空态,分类标签按压不缩放", as tasks: [], notifications: [], generated_at: "2026-09-23T08:30:00Z" } }) ); - await page.route("http://server.test/api/v1/workbench/events**", (route) => - route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" }) - ); + await mockWorkbenchEventStream(page); await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 })); await page.goto("/"); diff --git a/tests/browser/peripheral-sample-panel.e2e.ts b/tests/browser/peripheral-sample-panel.e2e.ts index 0ea0a37..48e9bd9 100644 --- a/tests/browser/peripheral-sample-panel.e2e.ts +++ b/tests/browser/peripheral-sample-panel.e2e.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream"; import { mockAgentApi } from "./support/mock-agent-api"; import { expectThemeColor, readRenderedContrast } from "./support/theme-colors"; @@ -755,9 +756,7 @@ test.describe("外围系统", () => { generated_at: "2026-09-23T08:30:00Z" } }) ); - await page.route("http://server.test/api/v1/workbench/events**", (route) => - route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" }) - ); + await mockWorkbenchEventStream(page); await page.route("http://server.test/api/v1/operational-tasks**", async (route) => { const request = route.request(); const url = new URL(request.url()); @@ -805,33 +804,37 @@ test.describe("外围系统", () => { await expect(conditionPanel).toBeVisible(); await conditionPanel.getByRole("button", { name: /调查任务/ }).click(); await expect(conditionPanel.getByRole("heading", { name: "水质投诉调查", exact: true })).toBeVisible(); - await expect(conditionPanel.getByText("调查处理流程", { exact: true })).toBeVisible(); - await expect(conditionPanel.getByText(/已完成 5 项 · 执行中 3 项 · 待人工或资料 3 项/)).toBeVisible(); - await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13); - await expect(conditionPanel.getByText(/历史流程按原始依赖展示/)).toBeVisible(); + const expectSavedPlan = async (taskPanel: typeof conditionPanel) => { + const diagram = taskPanel.getByRole("region", { name: "调查脉络", exact: true }); + await diagram.scrollIntoViewIfNeeded(); + await expect(diagram.locator("svg g.node")).toHaveCount(plan.nodes.length); + await expect(diagram.locator("svg .flowchart-link")).toHaveCount(plan.edges.length); + await expect(diagram.getByLabel("图示状态说明")).toContainText("未标注状态"); + await expect(diagram.locator("svg g.node").filter({ hasText: "形成调查结论" })).toBeVisible(); + return diagram; + }; + const diagram = await expectSavedPlan(conditionPanel); await expect(conditionPanel.getByText(/并行处理/)).toHaveCount(0); await expect(conditionPanel.getByRole("button", { name: "完整工程图" })).toHaveCount(0); await expect(conditionPanel.getByText("节点与执行记录", { exact: true })).toHaveCount(0); await expect(conditionPanel.getByLabel("计划版本", { exact: true })).toHaveCount(0); - await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13); - await expect(conditionPanel.getByText("等待水箱清洗记录", { exact: true }).first()).toBeVisible(); + await expect(diagram.locator("svg g.node").filter({ hasText: "核对水箱记录" })).toBeVisible(); const report = conditionPanel.getByRole("region", { name: "任务报告" }); await expect(report.getByText(/疑似二次供水环节问题,待现场核验/)).toBeVisible(); await expect(report.getByText("完整调查报告", { exact: true })).toHaveCount(0); await expect(report.getByText(/结论边界/)).toBeVisible(); await expect(conditionPanel.getByText("TJWater operational task", { exact: true })).toHaveCount(0); await expect(conditionPanel.getByText("browser-session-1", { exact: true })).toHaveCount(0); - await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded(); + await diagram.scrollIntoViewIfNeeded(); await expect(conditionPanel.getByText("前置工作与条件", { exact: true })).toHaveCount(0); - await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded(); + await diagram.scrollIntoViewIfNeeded(); await expect(page.getByText("已建立水质投诉调查", { exact: true })).toBeHidden({ timeout: 10_000 }); - await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" }); await page.evaluate(() => document.fonts.ready); await testInfo.attach("water-quality-condition-task-1280", { body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-desktop.png") }), contentType: "image/png" }); - await conditionPanel.locator('[data-execution-node="conclusion"]').scrollIntoViewIfNeeded(); + await diagram.locator("svg g.node").filter({ hasText: "形成调查结论" }).scrollIntoViewIfNeeded(); await testInfo.attach("water-quality-condition-task-second-fork-1280", { body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-second-fork-desktop.png") }), contentType: "image/png" @@ -849,20 +852,18 @@ test.describe("外围系统", () => { } await responsiveConditionPanel.getByRole("button", { name: /调查任务/ }).click(); await expect(responsiveConditionPanel.getByRole("heading", { name: "水质投诉调查" })).toBeVisible(); - const responsiveWorkflowStep = responsiveConditionPanel.locator('[data-execution-node="upstream"]'); + const responsiveDiagram = await expectSavedPlan(responsiveConditionPanel); + const responsiveWorkflowStep = responsiveDiagram.locator("svg g.node").filter({ hasText: "校验上游水质监测" }); await responsiveWorkflowStep.scrollIntoViewIfNeeded(); await expect(responsiveWorkflowStep).toBeVisible(); - await responsiveConditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded(); - await expect(responsiveConditionPanel.locator('[data-execution-node]')).toHaveCount(13); - await expect(responsiveConditionPanel.getByText(/历史流程按原始依赖展示/)).toBeVisible(); + await responsiveDiagram.scrollIntoViewIfNeeded(); await expect(responsiveConditionPanel.getByRole("button", { name: "放大流程图" })).toHaveCount(0); - await page.waitForTimeout(350); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); await testInfo.attach(`water-quality-condition-task-${width}`, { body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-${width}.png`) }), contentType: "image/png" }); - await responsiveConditionPanel.locator('[data-execution-node="conclusion"]').scrollIntoViewIfNeeded(); + await responsiveDiagram.locator("svg g.node").filter({ hasText: "形成调查结论" }).scrollIntoViewIfNeeded(); await page.evaluate(() => document.fonts.ready); await testInfo.attach(`water-quality-condition-task-second-fork-${width}`, { body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-second-fork-${width}.png`) }), @@ -900,7 +901,7 @@ test.describe("外围系统", () => { await recovered.getByRole("button", { name: /调查任务/ }).click(); const taskRow = recovered.getByRole("button", { name: /水质投诉调查/ }).first(); await taskRow.click(); - await expect(recovered.getByText(/历史流程按原始依赖展示/)).toBeVisible(); + await expectSavedPlan(recovered); expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0); }); diff --git a/tests/browser/support/mock-scada-api.ts b/tests/browser/support/mock-scada-api.ts index 8301258..1d8f0d3 100644 --- a/tests/browser/support/mock-scada-api.ts +++ b/tests/browser/support/mock-scada-api.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import { mockWorkbenchEventStream } from "./mock-workbench-event-stream"; const emptyRuntimeSnapshot = { availability: "not_configured", @@ -27,6 +28,7 @@ export async function mockScadaApi( analysisRuns: Record = {}, analysisHistory: unknown[] = [] ) { + await mockWorkbenchEventStream(page); const corsHeaders = { "Access-Control-Allow-Origin": "*" }; await page.route("https://tjwater-api.invalid/**", async (route) => { if (route.request().method() === "OPTIONS") { @@ -111,11 +113,7 @@ export async function mockScadaApi( return; } if (url.pathname === "/api/v1/workbench/events") { - await route.fulfill({ - contentType: "text/event-stream", - headers: { ...corsHeaders, "Cache-Control": "no-cache" }, - body: ": test keepalive\n\n" - }); + await route.fallback(); return; } if (url.pathname === "/api/v1/workbench/analysis-runs") { diff --git a/tests/browser/support/mock-workbench-event-stream.ts b/tests/browser/support/mock-workbench-event-stream.ts new file mode 100644 index 0000000..159a02c --- /dev/null +++ b/tests/browser/support/mock-workbench-event-stream.ts @@ -0,0 +1,48 @@ +import { createServer, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Page } from "@playwright/test"; + +const fixtures = new WeakMap(); + +// route.fulfill() closes an SSE response after its body. UI fixtures need a +// real open response so the application stays live until the page disconnects. +export async function mockWorkbenchEventStream(page: Page) { + const existing = fixtures.get(page); + if (existing) return existing; + const clients = new Set(); + const cursors: string[] = []; + const server = createServer((request, response) => { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID, X-Project-Id"); + response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + if (request.method === "OPTIONS") { + response.writeHead(204).end(); + return; + } + cursors.push(new URL(request.url!, "http://localhost").searchParams.get("after") ?? ""); + response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }); + response.write(": fixture connected\n\n"); + clients.add(response); + response.on("close", () => clients.delete(response)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address() as AddressInfo; + page.on("close", () => { + for (const client of clients) client.destroy(); + server.close(); + server.closeAllConnections(); + }); + await page.route("**/api/v1/workbench/events**", (route) => { + const query = new URL(route.request().url()).search; + return route.fulfill({ status: 307, headers: { + "Access-Control-Allow-Origin": "*", + "Location": `http://127.0.0.1:${port}/events${query}` + } }); + }); + const fixture = { cursors }; + fixtures.set(page, fixture); + return fixture; +} diff --git a/tests/browser/workbench-sync-reconnect.e2e.ts b/tests/browser/workbench-sync-reconnect.e2e.ts index d5e9842..8cc3063 100644 --- a/tests/browser/workbench-sync-reconnect.e2e.ts +++ b/tests/browser/workbench-sync-reconnect.e2e.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; +import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream"; type SyncProbe = { available: boolean; @@ -14,6 +15,27 @@ declare global { interface Window { syncProbe: SyncProbe } } +test("healthy UI fixtures keep one event connection open across the warning window and page reload", async ({ page }) => { + await mockWorkbenchContext(page); + await mockAgentApi(page); + const stream = await mockWorkbenchEventStream(page); + 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(() => stream.cursors.length).toBe(1); + + await page.clock.runFor(31_000); + expect(stream.cursors).toEqual(["0"]); + await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect.poll(() => stream.cursors.length).toBe(2); + await page.clock.runFor(16_000); + expect(stream.cursors).toEqual(["0", "0"]); + await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0); +}); + test("scheduled renewals stay silent while a sustained outage warns and recovers", async ({ page }) => { await mockWorkbenchContext(page); await mockAgentApi(page);