From 78481903d7bed80b1b69998ca96a9d7707f675b6 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 2 Nov 2026 18:56:57 +0800 Subject: [PATCH] test: migrate workbench fixtures to persistent event streams Static fulfilled SSE bodies closed immediately and made ordinary UI cases reconnect. Serve persistent fixture streams, retain outage and cursor recovery coverage, and update legacy approval and investigation assertions for current disclosures and SVG diagrams. Validation: 12 related browser scenarios, 877 unit tests, type checking, scoped ESLint, and diff checks. Unrelated SCADA replay changes remain outside this commit. --- .../browser/dynamic-investigation-flow.e2e.ts | 10 ++-- .../investigation-execution-history.e2e.ts | 3 +- .../browser/operational-task-approval.e2e.ts | 44 ++++++++--------- tests/browser/peripheral-sample-panel.e2e.ts | 43 +++++++++-------- tests/browser/support/mock-scada-api.ts | 8 ++-- .../support/mock-workbench-event-stream.ts | 48 +++++++++++++++++++ tests/browser/workbench-sync-reconnect.e2e.ts | 22 +++++++++ 7 files changed, 125 insertions(+), 53 deletions(-) create mode 100644 tests/browser/support/mock-workbench-event-stream.ts 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);