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)[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(); const writes: AnswerViewStateWrite[] = []; const receipts = new Map(); 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); });