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.
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
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";
|
|
|
|
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
|
const taskId = "82000000-0000-4000-8000-000000000011";
|
|
const runId = "82500000-0000-4000-8000-000000000011";
|
|
const previousRunId = "82500000-0000-4000-8000-000000000010";
|
|
const sessionId = "investigation-execution-session";
|
|
const previousSessionId = "investigation-execution-session-previous";
|
|
const now = "2026-10-30T08:00:00Z";
|
|
|
|
const latestRun: NonNullable<OperationalTask["latest_run"]> = {
|
|
run_id: runId,
|
|
task_id: taskId,
|
|
generation: 2,
|
|
execution_request_id: "85000000-0000-4000-8000-000000000011",
|
|
status: "succeeded",
|
|
revision: 4,
|
|
agent_session_id: sessionId,
|
|
agent_instance_id: "agent-supply-1",
|
|
lease_expires_at: null,
|
|
heartbeat_at: now,
|
|
started_at: now,
|
|
finished_at: now,
|
|
failure_code: null,
|
|
failure_detail: null,
|
|
dispatch_attempts: 1,
|
|
next_dispatch_at: now,
|
|
created_at: now,
|
|
updated_at: now
|
|
};
|
|
|
|
const previousRun: NonNullable<OperationalTask["latest_run"]> = {
|
|
...latestRun,
|
|
run_id: previousRunId,
|
|
generation: 1,
|
|
execution_request_id: "85000000-0000-4000-8000-000000000010",
|
|
agent_session_id: previousSessionId,
|
|
created_at: "2026-10-29T08:00:00Z",
|
|
updated_at: "2026-10-29T08:05:00Z"
|
|
};
|
|
|
|
const latestReport: NonNullable<OperationalTask["latest_report"]> = {
|
|
revision: 2,
|
|
execution_generation: 2,
|
|
source_session_id: sessionId,
|
|
published_at: now,
|
|
published_by: "agent",
|
|
report: {
|
|
title: "南桥爆管调查报告",
|
|
current_conclusion: "第二轮已完成现场记录复核",
|
|
conclusion_boundary: "以第二轮调查数据为准",
|
|
evidence_refs: [],
|
|
recommendations: [],
|
|
pending_items: []
|
|
}
|
|
};
|
|
|
|
const previousReport: NonNullable<OperationalTask["latest_report"]> = {
|
|
...latestReport,
|
|
revision: 1,
|
|
execution_generation: 1,
|
|
source_session_id: previousSessionId,
|
|
published_at: "2026-10-29T08:05:00Z",
|
|
report: {
|
|
...latestReport.report,
|
|
current_conclusion: "第一轮完成压力与流量核对",
|
|
conclusion_boundary: "尚未取得现场阀门记录"
|
|
}
|
|
};
|
|
|
|
const task: OperationalTask = {
|
|
project_id: projectId,
|
|
task_id: taskId,
|
|
task_type: "event_investigation",
|
|
workflow: "burst-report",
|
|
lifecycle_status: "completed",
|
|
active_run: null,
|
|
latest_run: latestRun,
|
|
status: "completed",
|
|
priority: "normal",
|
|
revision: 2,
|
|
execution_generation: 2,
|
|
execution_request_id: "85000000-0000-4000-8000-000000000011",
|
|
title: "南桥爆管调查",
|
|
summary: "核对压力、流量和现场反馈",
|
|
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "burst" },
|
|
trigger: { kind: "manual", occurred_at: now, detail: "调试调查执行历史" },
|
|
scheduled_for: null,
|
|
agent_session_id: sessionId,
|
|
agent_lease_expires_at: null,
|
|
current_plan_revision: null,
|
|
current_report_revision: 2,
|
|
created_at: now,
|
|
updated_at: now,
|
|
latest_plan: null,
|
|
latest_report: latestReport,
|
|
latest_progress: null,
|
|
evidence: [],
|
|
actions: [],
|
|
access_requests: [],
|
|
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,
|
|
can_decide_access: false
|
|
},
|
|
work_order_snapshot: null
|
|
};
|
|
|
|
const summary = (item: OperationalTask): OperationalTaskSummary => {
|
|
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
|
|
void latest_plan; void latest_report; void latest_progress; void evidence;
|
|
void actions; void access_requests; void permissions; void work_order_snapshot;
|
|
return value;
|
|
};
|
|
|
|
test("调查任务执行记录自动刷新并可创建调试会话追加消息", async ({ page }, testInfo) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await mockAgentApi(page);
|
|
let executionHistoryReads = 0;
|
|
let debugBranchCreates = 0;
|
|
const openedBindings: Array<{ runId: string | null; sessionId: string | null }> = [];
|
|
let debugBranchPayload: unknown;
|
|
await page.route(`http://127.0.0.1:8787/api/v1/task/${taskId}/execution-session?**`, async (route) => {
|
|
executionHistoryReads += 1;
|
|
const refreshed = executionHistoryReads > 1;
|
|
const query = new URL(route.request().url()).searchParams;
|
|
openedBindings.push({ runId: query.get("run_id"), sessionId: query.get("session_id") });
|
|
await route.fulfill({
|
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
json: {
|
|
id: previousSessionId,
|
|
session_id: previousSessionId,
|
|
title: "南桥爆管调查 · 第一轮执行记录",
|
|
purpose: "task_execution",
|
|
task_id: taskId,
|
|
status: "active",
|
|
created_at: now,
|
|
updated_at: refreshed ? "2026-10-30T08:01:00Z" : now,
|
|
messages: [
|
|
{ id: "execution-user", role: "user", parts: [{ type: "text", text: "执行后台调查任务" }] },
|
|
{ id: "execution-assistant", role: "assistant", parts: [
|
|
{ type: "data-progress", data: { id: "pressure-flow", phase: "tool", title: "核对第一轮压力与流量", detail: "查询监测记录并校验时间范围。", status: "completed" } },
|
|
{ type: "text", text: "第一轮已完成压力与流量证据核对" }
|
|
] },
|
|
...(refreshed ? [{ id: "execution-refresh", role: "assistant", parts: [{ type: "text", text: "第一轮自动同步发现现场阀门记录" }] }] : [])
|
|
],
|
|
is_streaming: !refreshed,
|
|
run_status: refreshed ? "completed" : "running",
|
|
read_only: true
|
|
}
|
|
});
|
|
});
|
|
page.on("request", (request) => {
|
|
if (request.method() === "POST" && request.url().endsWith(`/task/${taskId}/execution-session/debug`)) {
|
|
debugBranchCreates += 1;
|
|
debugBranchPayload = request.postDataJSON();
|
|
}
|
|
});
|
|
await page.route("**/runtime-config.js", (route) => route.fulfill({
|
|
contentType: "text/javascript",
|
|
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
|
|
TJWATER_AUTH_MODE: "disabled",
|
|
TJWATER_SERVER_API_BASE_URL: "http://server.test",
|
|
TJWATER_MAP_URL: "http://map.test/geoserver",
|
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
|
|
})}`
|
|
}));
|
|
await page.route("http://server.test/api/v1/projects**", (route) => route.fulfill({ json: {
|
|
items: [{ project_id: projectId, gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0
|
|
} }));
|
|
await page.route("http://server.test/api/v1/scada-devices**", (route) => route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } }));
|
|
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
|
|
schema: "workbench-sync-snapshot@1",
|
|
event_cursor: 0,
|
|
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: []
|
|
},
|
|
tasks: [summary(task)],
|
|
notifications: [],
|
|
generated_at: now
|
|
} }));
|
|
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;
|
|
if (path.endsWith("/operational-tasks/schedules")) return route.fulfill({ json: { items: [] } });
|
|
if (path.endsWith(`/operational-tasks/${taskId}/reports`)) return route.fulfill({ json: {
|
|
items: [latestReport, previousReport], next_offset: null
|
|
} });
|
|
if (path.endsWith(`/operational-tasks/${taskId}/runs`)) return route.fulfill({ json: {
|
|
items: [latestRun, previousRun], next_cursor: null
|
|
} });
|
|
if (path.endsWith(`/operational-tasks/${taskId}`)) return route.fulfill({ json: task });
|
|
if (path.endsWith("/operational-tasks")) return route.fulfill({ json: { items: [summary(task)], limit: 50, offset: 0, next_offset: null } });
|
|
return route.fulfill({ status: 404, json: {} });
|
|
});
|
|
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
|
|
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
|
const taskPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
|
const expand = taskPanel.getByRole("button", { name: "展开工况任务" });
|
|
if (await expand.isVisible()) await expand.click();
|
|
await taskPanel.getByRole("button", { name: /调查任务/ }).click();
|
|
await taskPanel.getByText("南桥爆管调查", { exact: true }).first().click();
|
|
await expect(taskPanel.getByText("调查任务 · 第 2 轮", { exact: true })).toBeVisible();
|
|
await taskPanel.getByRole("button", { name: "展开报告版本" }).click();
|
|
await page.getByRole("dialog", { name: "报告版本" }).getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
|
|
await expect(taskPanel.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible();
|
|
|
|
const executionHistory = taskPanel.getByRole("button", { name: "执行记录" });
|
|
await expect(executionHistory).toBeVisible();
|
|
await executionHistory.click();
|
|
|
|
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
|
await expect(agentPanel.getByRole("note").getByText("执行记录自动同步", { exact: true })).toBeVisible();
|
|
await expect(agentPanel).toContainText("第一轮已完成压力与流量证据核对");
|
|
const processToggle = agentPanel.getByRole("button", { name: "查看处理过程", exact: true }).first();
|
|
await expect(processToggle).toBeVisible();
|
|
await processToggle.click();
|
|
await expect(agentPanel.getByText("核对第一轮压力与流量", { exact: true })).toBeVisible();
|
|
await expect(agentPanel.locator("textarea")).toBeVisible();
|
|
await expect(agentPanel).toContainText("第一轮自动同步发现现场阀门记录", { timeout: 5_000 });
|
|
expect(executionHistoryReads).toBeGreaterThanOrEqual(2);
|
|
expect(openedBindings).toEqual(expect.arrayContaining([
|
|
{ runId: previousRunId, sessionId: previousSessionId }
|
|
]));
|
|
|
|
await agentPanel.locator("textarea").fill("继续核对失败前最后一次工具调用");
|
|
await agentPanel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
|
await expect.poll(() => debugBranchCreates).toBe(1);
|
|
expect(debugBranchPayload).toEqual({ run_id: previousRunId, session_id: previousSessionId });
|
|
await expect(agentPanel).toContainText("继续核对失败前最后一次工具调用");
|
|
await expect(agentPanel.getByRole("note").getByText("执行记录自动同步", { exact: true })).toHaveCount(0);
|
|
await testInfo.attach("desktop-execution-history", {
|
|
body: await agentPanel.screenshot(), contentType: "image/png"
|
|
});
|
|
|
|
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
|
|
await page.setViewportSize({ width: 375, height: 720 });
|
|
const openTasks = page.getByRole("button", { name: "打开工况任务", exact: true });
|
|
if (await openTasks.isVisible()) await openTasks.click();
|
|
const mobilePanel = page.getByRole("region", { name: "工况任务", exact: true });
|
|
const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" });
|
|
if (await mobileExpand.isVisible()) await mobileExpand.click();
|
|
await mobilePanel.getByRole("button", { name: /调查任务/ }).click();
|
|
await expect(mobilePanel.getByRole("button", { name: "执行记录" })).toBeVisible();
|
|
await testInfo.attach("mobile-execution-history-button", {
|
|
body: await page.getByRole("region", { name: "工况任务抽屉" }).screenshot(), contentType: "image/png"
|
|
});
|
|
});
|