Files
next-tjwater-frontend/tests/browser/agent-answer-ui.e2e.ts
T
jiang ec23d44b02 feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
2026-10-19 18:46:58 +08:00

232 lines
9.1 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 { responsesFixture } from "./support/responses-fixture";
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/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/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
const body = route.request().postDataJSON() as {
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
};
const text =
body.input
.filter((m) => m.role === "user")
.at(-1)
?.content.filter((p) => p.type === "input_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",
"Cache-Control": "no-cache"
},
body: responsesFixture(chunks)
});
});
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();
});
}