Files
next-tjwater-frontend/tests/browser/workbench-sync-reconnect.e2e.ts
T
jiang 78481903d7 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.
2026-11-02 18:56:57 +08:00

118 lines
5.6 KiB
TypeScript

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;
connections: number;
cursors: string[];
warnings: number;
disconnect: (scheduled: boolean) => void;
};
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);
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") });
});