Files
next-tjwater-frontend/tests/browser/agent-answer-state.e2e.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

142 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { expect, test, type Page } from "@playwright/test";
import { answerUiDemoBlock } from "./support/answer-ui-demo";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../../src/features/agent/api/answer-view-state";
const block = {
...answerUiDemoBlock,
spec: { ...answerUiDemoBlock.spec, elements: {
...answerUiDemoBlock.spec.elements,
root: { ...answerUiDemoBlock.spec.elements.root, children: ["metrics", "chart", "records", "evidence", "location", "form"] },
records: { type: "Table", props: { title: "演练监测记录", columns: ["位置", "压力"], rows: [["北侧", "0.30"], ["南侧", "0.28"]] }, children: [] }
} }
};
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (typeof value === "object" && value !== null) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`).join(",")}}`;
return JSON.stringify(value);
}
const digest = createHash("sha256").update(canonical(block.spec)).digest("hex");
const empty = (): AnswerViewStateRecord => ({ version: "answer-view-state@1", message_id: "state-answer", block_id: "pressure", revision: 0, schema_digest: digest, nodes: {}, block: { evidenceFilter: "all" }, updated_at: null });
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
async function setup(page: Page) {
const records = new Map<string, AnswerViewStateRecord>();
const writes: AnswerViewStateWrite[] = [];
const receipts = new Map<string, AnswerViewStateRecord>();
const controls = { failAfterCommit: false };
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page, { sessions: ["A", "B"].map((name) => ({ session_id: `state-${name}`, title: `交互状态演练 ${name}`, created_at: Date.now(), updated_at: Date.now() })) });
await page.route("**/api/v1/session/state-*?**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/ui-state")) return route.fallback();
const session = url.pathname.split("/").at(-1)!;
await route.fulfill({ headers, json: { session_id: session, title: `交互状态演练 ${session.slice(-1)}`, purpose: "ordinary_chat", status: "active", is_streaming: false,
messages: [{ id: "state-answer", role: "assistant", parts: [
{ type: "data-answer_ui", id: "pressure", data: block },
{ type: "data-final_answer", data: { content: "演练交互状态,不涉及业务写入。", ui_block_ids: ["pressure"] } }
] }] } });
});
await page.route("**/api/v1/session/state-*/ui-state**", async (route) => {
const method = route.request().method();
if (method === "OPTIONS") return route.fulfill({ status: 204, headers });
const session = new URL(route.request().url()).pathname.split("/").at(-2)!;
const current = records.get(session) ?? empty();
if (method === "GET") return route.fulfill({ headers, json: current });
const input = route.request().postDataJSON() as AnswerViewStateWrite;
writes.push(input);
const receipt = receipts.get(input.request_id);
if (receipt) return route.fulfill({ headers, json: receipt });
if (input.expected_revision !== current.revision) return route.fulfill({ status: 409, headers, json: { code: "ANSWER_VIEW_STATE_CONFLICT", detail: "演练并发修改", current } });
const next = { ...current, nodes: input.nodes, block: input.block, revision: current.revision + 1, updated_at: new Date().toISOString() };
records.set(session, next); receipts.set(input.request_id, next);
if (controls.failAfterCommit) { controls.failAfterCommit = false; return route.abort("failed"); }
await route.fulfill({ headers, json: next });
});
return { records, writes, controls };
}
async function openPanel(page: Page, width: number) {
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
}
async function openSession(page: Page, name: string) {
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: `交互状态演练 ${name}` }).click();
const ui = panel.locator('[data-answer-ui="pressure"]');
await expect(ui).toBeVisible();
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
return { panel, ui };
}
for (const width of [1440, 320]) {
test(`interactive state survives reload and isolates sessions at ${width}px`, async ({ page }) => {
test.setTimeout(60000);
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
const { records } = await setup(page);
await page.goto("/", { waitUntil: "domcontentloaded" }); await openPanel(page, width);
let { ui } = await openSession(page, "A");
await ui.getByLabel("现场位置(必填)").fill("演练北侧草稿");
const chart = ui.locator(".answer-ui-chart");
await chart.getByRole("button", { name: "参考压力(MPa)", exact: true }).click();
await chart.getByRole("tab", { name: "数据", exact: true }).click();
await chart.getByRole("textbox", { name: /搜索/ }).fill("09:00");
await ui.getByRole("textbox", { name: "搜索演练监测记录", exact: true }).fill("北侧");
await ui.getByRole("button", { name: /查看演练证据/ }).click();
await expect.poll(() => records.get("state-A")?.nodes).toMatchObject({
form: { values: { location: "演练北侧草稿" } }, chart: { view: "table", hidden: [1], table: { query: "09:00", selection: [] } },
records: { query: "北侧", selection: [] }, evidence: { open: true }
});
await page.reload({ waitUntil: "domcontentloaded" }); await openPanel(page, width);
({ ui } = await openSession(page, "A"));
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("演练北侧草稿");
await expect(ui.locator(".answer-ui-chart").getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(ui.getByRole("textbox", { name: "搜索演练监测记录", exact: true })).toHaveValue("北侧");
await expect(ui.getByText("仅用于组件验收,不代表已核验现场事实。", { exact: true })).toBeVisible();
({ ui } = await openSession(page, "B"));
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("");
await ui.getByLabel("现场位置(必填)").fill("演练南侧草稿");
await expect.poll(() => records.get("state-B")?.nodes.form).toMatchObject({ values: { location: "演练南侧草稿" } });
({ ui } = await openSession(page, "A"));
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("演练北侧草稿");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/answer-state-restored-${width}.png` });
});
}
test("failed writes retain drafts and retry idempotently; conflicts require an explicit choice", async ({ page }) => {
test.setTimeout(60000);
await page.emulateMedia({ reducedMotion: "reduce" });
const { records, writes, controls } = await setup(page);
await page.goto("/", { waitUntil: "domcontentloaded" }); await openPanel(page, 1440);
const { panel, ui } = await openSession(page, "A");
controls.failAfterCommit = true;
await ui.getByLabel("现场位置(必填)").fill("网络失败仍保留");
await expect(panel.getByRole("button", { name: "重试保存" })).toBeVisible();
await expect.poll(() => writes.length).toBe(1);
await panel.getByRole("button", { name: "重试保存" }).click();
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
expect(writes[1]).toEqual(writes[0]);
expect(records.get("state-A")?.revision).toBe(1);
const remote = records.get("state-A")!;
records.set("state-A", { ...remote, revision: 2, nodes: { ...remote.nodes, form: { type: "FollowUpForm", values: { location: "另一个窗口" } } } });
await ui.getByLabel("现场位置(必填)").fill("本地选择保留");
await expect(panel.locator('[data-answer-state="conflict"]')).toBeVisible();
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("本地选择保留");
await panel.getByRole("button", { name: "保留本地并重新保存" }).click();
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
expect(records.get("state-A")?.revision).toBe(3);
expect(records.get("state-A")?.nodes.form).toMatchObject({ values: { location: "本地选择保留" } });
const next = records.get("state-A")!;
records.set("state-A", { ...next, revision: 4, nodes: { ...next.nodes, form: { type: "FollowUpForm", values: { location: "远端已确认草稿" } } } });
await ui.getByLabel("现场位置(必填)").fill("待丢弃草稿");
await expect(panel.locator('[data-answer-state="conflict"]')).toBeVisible();
await panel.getByRole("button", { name: "重新加载远端" }).click();
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("待丢弃草稿");
await panel.getByRole("button", { name: "确认替换本地草稿" }).click();
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("远端已确认草稿");
expect(records.get("state-A")?.revision).toBe(4);
});