import { expect, test, type Page } from "@playwright/test"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { TEST_PROJECT_ID } from "./support/mock-scada-api"; async function expectPushedOrder(page: Page, failFirstDetail = false) { await mockWorkbenchContext(page); const base = "https://tjwater-api.invalid/api/v1"; const taskId = "72000000-0000-4000-8000-000000000041"; const orderId = "73000000-0000-4000-8000-000000000041"; const now = new Date().toISOString(); const title = "主动推送工单:现场阀门复核"; const 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: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }; const task = { project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order", workflow: "external-work-order", lifecycle_status: "open", active_run: null, status: "queued", priority: "normal", revision: 2, execution_generation: 1, execution_request_id: "74000000-0000-4000-8000-000000000041", title, summary: "收到新工单后自动同步,无需手动刷新", source: { kind: "external_system", external_id: `peripheral_sample:${orderId}`, external_revision: 1, event_kind: "general" }, trigger: { kind: "external_sync", occurred_at: now, detail: "来源工单事件" }, scheduled_for: now, agent_session_id: null, agent_lease_expires_at: null, current_plan_revision: null, current_report_revision: null, created_at: now, updated_at: now, latest_plan: null, latest_report: null, evidence: [], actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false }, work_order_snapshot: { work_order_id: orderId, revision: 1, title, summary: "现场阀门复核", status: "open", priority: "normal", assignee: "现场班组", scheduled_at: now, work_order_type: "general", location: "测试阀门", scenario_code: null, feedback_requirements: [], activity: [], origin: "synthetic", updated_at: now } }; let snapshotRequests = 0; let detailRequests = 0; let releaseProjection!: () => void; const projectionReady = new Promise((resolve) => { releaseProjection = resolve; }); const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" }; await page.route(`${base}/workbench/sync-snapshot`, (route) => { snapshotRequests += 1; return route.fulfill({ headers, json: { schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime, tasks: [], notifications: [], generated_at: now } }); }); await page.route(`${base}/operational-tasks/${taskId}`, (route) => { detailRequests += 1; if (failFirstDetail && detailRequests === 1) return route.fulfill({ headers, status: 503, json: { detail: "temporary" } }); return route.fulfill({ headers, json: task }); }); await page.route(`${base}/workbench/events**`, async (route) => { const after = new URL(route.request().url()).searchParams.get("after"); if (after !== "0" && after !== "1") { return route.fulfill({ headers, contentType: "text/event-stream", body: ": keepalive\n\n" }); } if (after === "1") await projectionReady; const sequence = after === "0" ? 1 : 2; const event = { schema: "workbench-event@1", event_seq: sequence, event_id: `75000000-0000-4000-8000-00000000000${sequence}`, project_id: TEST_PROJECT_ID, topic: sequence === 1 ? "workbench.work_order.changed" : "operational.task.changed", aggregate_type: sequence === 1 ? "peripheral_work_order" : "operational_task", aggregate_id: sequence === 1 ? orderId : taskId, aggregate_revision: sequence, occurred_at: now, metadata: sequence === 1 ? { source: "peripheral_sample", work_order_id: orderId } : { task_id: taskId } }; const projection = { ...event, event_seq: 3, topic: "workbench.work_order.changed", aggregate_type: "operational_task", event_id: "75000000-0000-4000-8000-000000000003" }; const body = `id: ${sequence}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n` + (sequence === 2 ? `id: 3\nevent: workbench_event\ndata: ${JSON.stringify(projection)}\n\n` : ""); return route.fulfill({ headers, contentType: "text/event-stream", body }); }); const waitingForProjection = page.waitForRequest((request) => request.url().includes("/workbench/events?after=1") ); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "工况任务", exact: true }).click(); const panel = page.getByRole("region", { name: "工况任务", exact: true }); await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: /^预约任务/ }).click(); await waitingForProjection; expect(snapshotRequests).toBe(1); await expect(panel).not.toContainText(title); const caughtUp = page.waitForRequest((request) => request.url().includes("/workbench/events?after=3")); releaseProjection(); await expect(panel).toContainText(title); await caughtUp; expect(detailRequests).toBe(failFirstDetail ? 2 : 1); expect(snapshotRequests).toBe(1); } test("源工单推送等待投影完成后自动进入预约任务,无需刷新快照", async ({ page }) => { await expectPushedOrder(page); }); test("事件详情短暂失败后定向重试,无需新的业务事件或全量快照", async ({ page }) => { await expectPushedOrder(page, true); }); test("事件日志清空后的合法游标重置会从零重连,不循环请求快照", async ({ page }) => { await mockWorkbenchContext(page); const base = "https://tjwater-api.invalid/api/v1"; const now = new Date().toISOString(); const 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: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }; let snapshots = 0; const cursors: string[] = []; const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" }; await page.route(`${base}/workbench/sync-snapshot`, (route) => { snapshots += 1; return route.fulfill({ headers, json: { schema: "workbench-sync-snapshot@1", event_cursor: snapshots === 1 ? 41 : 0, runtime, tasks: [], notifications: [], generated_at: now } }); }); await page.route(`${base}/workbench/events**`, (route) => { const cursor = new URL(route.request().url()).searchParams.get("after")!; cursors.push(cursor); return route.fulfill({ headers, contentType: "text/event-stream", body: cursor === "41" ? "event: reset_required\ndata: {}\n\n" : ": keepalive\n\n" }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await expect.poll(() => cursors.includes("0")).toBe(true); expect(cursors.slice(0, 2)).toEqual(["41", "0"]); expect(snapshots).toBe(2); await expect(page.getByRole("button", { name: "工况任务", exact: true })).toBeVisible(); }); test("运行态事件的快照请求短暂失败后重试,不等待其他事件", async ({ page }) => { await mockWorkbenchContext(page); const base = "https://tjwater-api.invalid/api/v1"; const now = new Date().toISOString(); const 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: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }; let snapshots = 0; const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" }; await page.route(`${base}/workbench/sync-snapshot`, (route) => { snapshots += 1; if (snapshots === 2) return route.fulfill({ headers, status: 503, json: { detail: "temporary" } }); return route.fulfill({ headers, json: { schema: "workbench-sync-snapshot@1", event_cursor: snapshots === 1 ? 41 : 42, runtime, tasks: [], notifications: [], generated_at: now } }); }); await page.route(`${base}/workbench/events**`, (route) => { const event = { schema: "workbench-event@1", event_seq: 42, event_id: "75000000-0000-4000-8000-000000000042", project_id: TEST_PROJECT_ID, topic: "analysis.run.changed", aggregate_type: "analysis_run", aggregate_id: "run-1", aggregate_revision: 1, occurred_at: now, metadata: {} }; return route.fulfill({ headers, contentType: "text/event-stream", body: new URL(route.request().url()).searchParams.get("after") === "41" ? `id: 42\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n` : ": keepalive\n\n" }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await expect.poll(() => snapshots).toBe(3); });