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 | 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({ 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") }); });