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.
96 lines
4.5 KiB
TypeScript
96 lines
4.5 KiB
TypeScript
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") });
|
|
});
|