import { expect, test } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; const block = { version: "answer-ui@1", block_id: "pressure", fallback_text: "演练压力读数,仍待现场核验。", spec: { root: "root", elements: { root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["metrics", "chart", "evidence", "location", "form"] }, metrics: { type: "Stack", props: { columns: "two", gap: "compact" }, children: ["pressure", "flow"] }, pressure: { type: "Metric", props: { label: "压力", value: "0.30", unit: "MPa", source: "演练数据" }, children: [] }, flow: { type: "Metric", props: { label: "流量", value: "18", unit: "m³/h", source: "演练数据" }, children: [] }, chart: { type: "Chart", props: { title: "演练监测趋势", chart_type: "line", x_data: ["08:00", "09:00"], series: [{ name: "压力(MPa)", data: [0.3, 0.28] }], source: "浏览器验收演练数据" }, children: [] }, evidence: { type: "Evidence", props: { title: "查看演练证据", summary: "仅用于组件验收,不代表已核验现场事实。", source: "浏览器演练", evidence_id: "DEMO-001", observed_at: null, kind: "demo" }, children: [] }, location: { type: "MapLocation", props: { label: "定位演练监测点", layer: "scada", feature_ids: ["DEMO-P001"], source: "演练监测点" }, children: [] }, form: { type: "FollowUpForm", props: { title: "补充核验资料", description: "填写后由你主动发送给 Agent。", fields: [{ name: "location", label: "现场位置", kind: "text", required: true }] }, children: [] } } } }; for (const width of [1440, 375, 320]) { test(`local rich answers stream, restore and submit at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await page.emulateMedia({ reducedMotion: "reduce" }); await mockWorkbenchContext(page); await mockAgentApi(page, { sessions: [ { session_id: "browser-session-1", title: "演练结构化回答", created_at: Date.now(), updated_at: Date.now() } ] }); const parts = [ { type: "text", text: "以下资料均为演练数据。" }, { type: "data-answer_ui", id: "pressure", data: block }, { type: "text", text: "仍需用户补充现场核验资料。" }, { type: "data-final_answer", data: { content: "仍需用户补充现场核验资料。", ui_block_ids: ["pressure"] } } ]; await page.route("**/api/v1/agent/chat/session/browser-session-1", async (route) => { if (route.request().method() !== "GET") return route.fallback(); await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { session_id: "browser-session-1", title: "演练结构化回答", purpose: "ordinary_chat", status: "active", is_streaming: false, messages: [ { id: "user-fixture", role: "user", parts: [{ type: "text", text: "查看演练监测资料" }] }, { id: "rich-answer", role: "assistant", parts } ] } }); }); const prompts: string[] = []; await page.route("**/api/v1/agent/chat/stream", async (route) => { if (route.request().method() === "OPTIONS") return route.fallback(); const body = route.request().postDataJSON() as { messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>; }; const text = body.messages .filter((m) => m.role === "user") .at(-1) ?.parts.filter((p) => p.type === "text") .map((p) => p.text) .join("") ?? ""; prompts.push(text); const chunks = prompts.length === 1 ? [ { type: "start", messageId: "rich-answer" }, { type: "text-start", id: "answer" }, { type: "text-delta", id: "answer", delta: parts[0].text }, { type: "text-end", id: "answer" }, { ...parts[1], data: { ...block, fallback_text: "演练初始数据" } }, parts[1], { type: "text-start", id: "answer-1" }, { type: "text-delta", id: "answer-1", delta: parts[2].text }, { type: "text-end", id: "answer-1" }, parts[3], { type: "finish", finishReason: "stop" } ] : [ { type: "start", messageId: "form-answer" }, { type: "text-start", id: "answer" }, { type: "text-delta", id: "answer", delta: "已收到补充资料,尚未保存业务证据。" }, { type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" } ]; await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "X-Vercel-AI-UI-Message-Stream": "v1" }, body: chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await page .getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }) .click(); const panel = page.getByRole("complementary", { name: "Agent 命令面板" }); await panel.locator("textarea").fill("查看演练监测资料"); await panel.getByRole("button", { name: "发送 Agent 指令" }).click(); const ui = panel.locator('[data-answer-ui="pressure"]'); await expect(ui).toHaveCount(1); const process = panel .locator("details") .filter({ has: page.locator("summary", { hasText: "查看处理过程" }) }); await expect(process).not.toHaveAttribute("open"); await expect(panel.getByText("以下资料均为演练数据。", { exact: true })).toBeHidden(); await process.locator("summary").click(); await expect(panel.getByText("以下资料均为演练数据。", { exact: true })).toBeVisible(); await process.locator("summary").click(); if (width >= 1024) { const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" }); for (const panelWidth of [400, 640]) { const box = (await handle.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(panelWidth, box.y + box.height / 2); await page.mouse.up(); await expect(panel).toHaveCSS("width", `${panelWidth}px`); const columns = await ui .locator(".answer-ui-stack-comparison") .evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length); expect(columns).toBe(panelWidth === 640 ? 2 : 1); } } await expect(ui.getByText("0.30", { exact: false }).first()).toBeVisible(); await ui.getByRole("button", { name: "数据", exact: true }).click(); await expect(ui.getByRole("table")).toContainText("0.28"); const evidence = ui.locator("summary", { hasText: "查看演练证据" }); await evidence.focus(); await page.keyboard.press("Enter"); await expect(ui.getByText("仅用于组件验收,不代表已核验现场事实。")).toBeVisible(); await expect(panel.getByText("仍需用户补充现场核验资料。", { exact: true })).toHaveCount(1); const bounds = await ui.evaluate((element) => { const rect = element.getBoundingClientRect(); const panel = element.closest("aside")!.getBoundingClientRect(); return rect.left >= panel.left && rect.right <= panel.right; }); expect(bounds).toBe(true); await ui.getByRole("table").scrollIntoViewIfNeeded(); await panel.screenshot({ path: `test-results/answer-ui-${width}.png` }); // Restore from canonical persisted parts through the actual history selector. await panel.getByRole("button", { name: /历史/ }).click(); await page.getByRole("button", { name: /演练结构化回答/ }).click(); await expect(ui).toHaveCount(1); await expect(ui.getByLabel("现场位置(必填)")).toHaveValue(""); expect(prompts).toHaveLength(1); await ui.getByLabel("现场位置(必填)").fill("演练管段东侧"); await ui.getByRole("button", { name: "发送补充资料" }).click(); await expect.poll(() => prompts.length).toBe(2); expect(prompts[1]).toContain("现场位置:演练管段东侧"); await expect( panel.getByText("已收到补充资料,尚未保存业务证据。", { exact: true }) ).toBeVisible(); }); }