Files
next-tjwater-frontend/tests/browser/agent-process-disclosure.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

415 lines
22 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { responseFixtureWriter, responsesFixture } from "./support/responses-fixture";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const width of [1440, 320]) {
for (const reducedMotion of ["reduce", "no-preference"] as const) {
test(`completed process uses one disclosure and exposes every step at ${width}px with ${reducedMotion} motion`, async ({
page
}, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion });
await page.addInitScript(() => {
Object.defineProperty(window, "Audio", { configurable: true, value: class {
preload = ""; onended = null; onerror = null;
async play() {} pause() {} load() {} removeAttribute() {}
} });
});
let releaseSpeech!: () => void;
const speechReady = new Promise<void>((resolve) => { releaseSpeech = resolve; });
await page.route("**/api/tts/edge", async (route) => {
await speechReady;
await route.fulfill({ contentType: "audio/mpeg", body: "mock-audio" });
});
await mockWorkbenchContext(page);
await mockAgentApi(page);
const steps = [
{
id: "request",
title: "请求处理完成",
detail: "已核对本次请求中的管道编号。",
status: "completed"
},
{
id: "context",
title: "业务资料核对完成",
detail: "使用当前项目的查询结果。",
status: "completed"
},
{
id: "geocode",
phase: "tool",
title: "mcp_tjwater_geocode 已完成",
detail: "mcp_tjwater_geocode 已执行完成;关键参数:keyword=育秀路人民路路口",
status: "completed",
tool: {
name: "mcp_tjwater_geocode",
call_id: "geocode-call",
input: { keyword: "育秀路人民路路口" }
}
},
{
id: "nearby",
phase: "tool",
title: "mcp_tjwater_query_nearby_elements 已完成",
detail: "查询附近管道",
status: "completed",
tool: {
name: "mcp_tjwater_query_nearby_elements",
call_id: "nearby-call",
input: { center: [121.45688, 30.91065], element_type: "pipe", radius_m: 200, limit: 10 }
}
},
{
id: "missing",
title: "一处关联要素未找到",
detail: "未将缺失要素标记为定位成功。",
status: "error"
}
];
await page.route("**/api/v1/responses", (route) =>
route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "process-example" },
...steps.map((step) => ({
type: "data-progress",
data: { phase: "visualization", ...step }
})),
{
type: "data-final_answer",
data: { content: "已定位三根管道,另有一处关联要素未找到。" }
},
{ type: "finish", finishReason: "stop" }
])
})
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page
.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ })
.click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
.fill("核对管道定位结果");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(
panel.getByText("已定位三根管道,另有一处关联要素未找到。", { exact: true })
).toBeVisible();
const process = panel.locator("details[data-agent-process]");
await expect(process).not.toHaveAttribute("open");
const toggle = panel.getByRole("button", { name: "查看处理过程", exact: true });
await toggle.scrollIntoViewIfNeeded();
const toolbar = panel.locator("[data-agent-message-toolbar]").last();
const voice = toolbar.getByRole("button", { name: "语音播放", exact: true });
const copy = toolbar.getByRole("button", { name: "复制回复", exact: true });
await expect(voice).toBeVisible();
await expect(copy).toBeVisible();
const boxes = await Promise.all([voice, toggle, copy, toolbar].map((node) => node.boundingBox()));
expect(boxes[1]!.x).toBeLessThan(boxes[0]!.x);
expect(boxes[0]!.x).toBeLessThan(boxes[2]!.x);
expect(Math.abs(boxes[1]!.x - boxes[3]!.x)).toBeLessThanOrEqual(1);
const centers = boxes.slice(0, 3).map((box) => box!.y + box!.height / 2);
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThanOrEqual(1);
expect(Math.abs(boxes[2]!.x + boxes[2]!.width - boxes[3]!.x - boxes[3]!.width)).toBeLessThanOrEqual(1);
for (const control of [voice, toggle, copy]) {
await expect(control.locator("svg").first()).toHaveCSS("width", "16px");
await expect(control.locator("svg").first()).toHaveCSS("height", "16px");
}
console.log("reply-toolbar-geometry", width, JSON.stringify(boxes));
await toolbar.screenshot({ path: testInfo.outputPath(`reply-toolbar-${width}.png`) });
if (reducedMotion === "reduce") {
await voice.click();
await expect(toolbar.getByRole("button", { name: "正在生成语音", exact: true })).toBeVisible();
await expect(process).not.toHaveAttribute("open");
releaseSpeech();
await expect(toolbar.getByRole("button", { name: "暂停语音播放", exact: true })).toBeVisible();
expect(await toolbar.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await toolbar.screenshot({ path: testInfo.outputPath(`reply-toolbar-playing-${width}.png`) });
await toolbar.getByRole("button", { name: "暂停语音播放", exact: true }).click();
await toolbar.getByRole("button", { name: "继续语音播放", exact: true }).click();
await toolbar.getByRole("button", { name: "停止语音播放", exact: true }).click();
await expect(voice).toBeVisible();
await expect(copy).toBeVisible();
await expect(process).not.toHaveAttribute("open");
}
await expectStableToggle(page, toggle, process);
await expect(process).toHaveAttribute("open", "");
await expect(process).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(process.locator("[data-agent-progress]")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const successIcons = process.locator('.status-icon[data-status-tone="success"] svg');
await expect(successIcons).toHaveCount(4);
expect(new Set(await successIcons.evaluateAll((icons) => icons.map((icon) => icon.getAttribute("class")))).size).toBe(1);
await expect(successIcons.first()).toHaveCSS("width", "14px");
await toggle.screenshot({ path: testInfo.outputPath(`process-trigger-open-${width}.png`) });
await expect(process.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(
5
);
await expect(process.getByRole("button", { name: /执行进度/ })).toHaveCount(0);
await expect(process.getByText("执行进度", { exact: true })).toHaveCount(0);
await expect(process.locator("details[data-agent-tool]")).toHaveCount(2);
const geocode = process
.locator("details[data-agent-tool]")
.filter({ has: page.getByText("mcp_tjwater_geocode", { exact: true }) });
const nearby = process
.locator("details[data-agent-tool]")
.filter({ has: page.getByText("mcp_tjwater_query_nearby_elements", { exact: true }) });
await expect(geocode.locator("pre")).toBeHidden();
await expect(nearby.locator("pre")).toBeHidden();
for (const tool of [geocode, nearby]) {
await expect(tool).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(tool).toHaveCSS("border-top-width", "0px");
}
await expect(process.getByText("mcp_tjwater_geocode 已完成", { exact: true })).toHaveCount(0);
const toolToggle = geocode.locator(":scope > summary");
await toolToggle.scrollIntoViewIfNeeded();
await expectStableToggle(page, toolToggle, geocode);
await expect(geocode.locator("pre")).toContainText("育秀路人民路路口");
await expectStableToggle(page, toolToggle, geocode);
await expect(geocode.locator("pre")).toBeHidden();
await nearby.locator(":scope > summary").click();
await expect(nearby.locator("pre")).toContainText("121.45688");
await expect(nearby.locator("pre")).toContainText('"radius_m": 200');
await expect(nearby).toHaveAttribute("data-disclosure-motion", "idle");
await expect(process.getByText("一处关联要素未找到", { exact: true })).toBeVisible();
await expect(process.getByLabel("失败", { exact: true })).toBeVisible();
expect(
await process.evaluate((element) => element.scrollWidth - element.clientWidth)
).toBeLessThanOrEqual(1);
const alignment = await measureProcessAlignment(process);
console.log("process-icon-alignment", width, reducedMotion, JSON.stringify(alignment));
expect(
Math.max(...alignment.statusCentersX.map((x) => Math.abs(x - alignment.headerCenterX)))
).toBeLessThanOrEqual(1);
expect(Math.abs(alignment.headerTextLeft - alignment.stepTextLeft)).toBeLessThanOrEqual(1);
expect(Math.abs(alignment.parameterTextLeft - alignment.toolParameterAnchorLeft)).toBeLessThanOrEqual(1);
for (const row of alignment.toolTitles) {
for (const y of [row.statusCenterY, row.symbolCenterY, row.chevronCenterY])
expect(Math.abs(y - row.firstLineCenterY)).toBeLessThanOrEqual(1);
}
await process.screenshot({ path: testInfo.outputPath(`single-process-${width}.png`) });
await toggle.focus();
await page.keyboard.press("Enter");
await expect(process).not.toHaveAttribute("open");
await expect(process.getByText("mcp_tjwater_geocode", { exact: true })).toBeHidden();
await toggle.focus();
await page.keyboard.press("Enter");
await expect(process).toHaveAttribute("open", "");
await expect(process).toHaveAttribute("data-disclosure-motion", "idle");
await expect(process.locator(":scope > [data-agent-disclosure-content]")).toHaveCSS(
"opacity",
"1"
);
await page.keyboard.press("Enter");
await expect(process).not.toHaveAttribute("open");
await expect(
panel.getByText("已定位三根管道,另有一处关联要素未找到。", { exact: true })
).toBeVisible();
});
}
}
for (const width of [1440, 320]) {
test(`process stays below growing text and folds without final_answer at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let appendText!: () => void;
let finish!: () => void;
const server = createServer((_request, response) => {
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
const encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response.write(encode(chunk));
send({ type: "start", messageId: "legacy-process" });
send({ type: "data-progress", data: { id: "query", phase: "tool", status: "error", title: "查询超时", detail: "请稍后重试。" } });
send({ type: "data-todo_update", data: { session_id: "legacy-session", todos: [{ id: "verify", content: "核对模拟时段", status: "completed" }] } });
send({ type: "data-answer_ui", data: {
version: "answer-ui@1", block_id: "process-note", phase: "process", fallback_text: "已核对时段映射。",
spec: { root: "note", elements: { note: { type: "Text", props: { text: "已核对时段映射。", tone: "neutral" }, children: [] } } }
} });
send({ type: "text-start", id: "answer" });
send({ type: "text-delta", id: "answer", delta: "三根管道的模拟结果已整理。" });
appendText = () => send({ type: "text-delta", id: "answer", delta: "\n\n这些结果属于模型计算,仍需核查现场情况。" });
finish = () => {
send({ type: "text-end", id: "answer" });
send({ type: "finish", finishReason: "stop" });
response.end();
};
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/stream` }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("核对模拟结果");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const message = panel.locator('[data-agent-message-id="legacy-process"]');
const report = message.locator("[data-answer-parts]").first();
const progress = message.locator("[data-agent-progress]");
const note = message.getByText("已核对时段映射。", { exact: true });
const todo = message.getByText("核对模拟时段", { exact: true });
await expect(report).toContainText("三根管道的模拟结果已整理。");
for (const process of [progress, note, todo]) {
await expect(process).toBeVisible();
expect((await process.boundingBox())!.y).toBeGreaterThanOrEqual((await report.boundingBox())!.y + (await report.boundingBox())!.height);
}
appendText();
await expect(report).toContainText("仍需核查现场情况。");
expect((await progress.boundingBox())!.y).toBeGreaterThanOrEqual((await report.boundingBox())!.y + (await report.boundingBox())!.height);
await message.screenshot({ path: testInfo.outputPath(`process-streaming-below-${width}.png`) });
// A tall-message screenshot can move the narrow viewport away from the
// bottom. This case verifies automatic archival while following latest;
// reader-initiated expansion is covered by agent-answer-transition.
const latest = panel.getByRole("button", { name: "滚动到最新消息", exact: true });
if (await latest.isVisible()) await latest.click();
await expect(latest).toBeHidden();
finish();
const toggle = message.getByRole("button", { name: "查看处理过程", exact: true });
await expect(toggle).toBeVisible();
await expect(progress).toBeHidden();
await expect(note).toBeHidden();
await expect(todo).toBeHidden();
await expect(report).toContainText("三根管道的模拟结果已整理。");
await expect(message.getByText("部分查询或工具未完成,可展开处理过程查看原因。", { exact: true })).toBeVisible();
const toolbar = message.locator("[data-agent-message-toolbar]");
const speech = toolbar.getByRole("button", { name: "语音播放", exact: true });
expect((await toggle.boundingBox())!.x).toBeLessThan((await speech.boundingBox())!.x);
expect((await toolbar.boundingBox())!.y).toBeGreaterThanOrEqual((await report.boundingBox())!.y + (await report.boundingBox())!.height);
await toggle.click();
await expect(progress).toBeVisible();
await expect(note).toBeVisible();
await expect(todo).toBeVisible();
await message.screenshot({ path: testInfo.outputPath(`process-completed-below-${width}.png`) });
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
}
test("completed todos use the process footer while pending questions and approvals stay visible", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/api/v1/session/*/questions/question", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" }, json: { status: "pending" }
}));
let request = 0;
await page.route("**/api/v1/responses", (route) => {
const pending = request++ > 0;
return route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: pending ? "pending-input" : "todo-only" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: pending ? "请确认核查范围。" : "核对工作已完成。" },
{ type: "data-todo_update", data: { session_id: "details-session", todos: [{ id: "verify", content: "核对管道资料", status: "completed" }] } },
...(pending ? [
{ type: "data-question_request", data: { request_id: "question", session_id: "details-session", questions: [{ header: "核查范围", question: "要核查哪个片区?", options: [{ label: "片区 A", description: "供水片区" }] }] } },
{ type: "data-permission_request", data: { request_id: "permission", session_id: "details-session", permission: "model.simulate", patterns: ["test-model"] } }
] : []),
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
])
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
const send = panel.getByRole("button", { name: "发送 Agent 指令", exact: true });
await prompt.fill("核对资料");
await send.click();
const first = panel.locator('[data-agent-message-id="todo-only"]');
await expect(first.getByText("核对管道资料", { exact: true })).toBeHidden();
await first.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(first.getByText("核对管道资料", { exact: true })).toBeVisible();
await prompt.fill("补充核查范围");
await send.click();
const pending = panel.locator('[data-agent-message-id="pending-input"]');
await expect(pending.getByText("要核查哪个片区?", { exact: true })).toBeVisible();
await expect(pending.getByRole("button", { name: "允许一次", exact: true })).toBeVisible();
await expect(pending.getByRole("button", { name: "跳过", exact: true })).toBeVisible();
await expect(pending.getByRole("button", { name: "查看处理过程", exact: true })).toHaveCount(0);
});
async function expectStableToggle(
page: import("@playwright/test").Page,
toggle: import("@playwright/test").Locator,
disclosure: import("@playwright/test").Locator
) {
const before = await toggle.evaluate((element) => {
const message = element.closest<HTMLElement>("[data-agent-message-id]")!;
const scroll = element.closest<HTMLElement>(".agent-conversation-scroll")!;
const samples: number[] = [];
const start = performance.now();
(window as typeof window & { __processPositionSamples?: number[] }).__processPositionSamples =
samples;
const sample = () => {
samples.push(message.getBoundingClientRect().top);
if (performance.now() - start < 350 || samples.length < 12) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
return { messageTop: message.getBoundingClientRect().top, scrollTop: scroll.scrollTop };
});
await toggle.click();
await expect(disclosure).toHaveAttribute("data-disclosure-motion", "idle");
await page.waitForFunction(
() =>
((window as typeof window & { __processPositionSamples?: number[] }).__processPositionSamples
?.length ?? 0) >= 12
);
const samples = await page.evaluate(
() =>
(window as typeof window & { __processPositionSamples?: number[] })
.__processPositionSamples ?? []
);
expect(Math.max(...samples.map((top) => Math.abs(top - before.messageTop)))).toBeLessThanOrEqual(
2
);
}
async function measureProcessAlignment(process: import("@playwright/test").Locator) {
return process.evaluate((element) => {
const centerX = (node: Element) => {
const box = node.getBoundingClientRect();
return box.left + box.width / 2;
};
const centerY = (node: Element) => {
const box = node.getBoundingClientRect();
return box.top + box.height / 2;
};
const statuses = [...element.querySelectorAll("[data-agent-progress] .status-icon")];
const toolTitles = [...element.querySelectorAll("[data-agent-tool] summary")].map((row) => {
const text = row.querySelector("[data-tool-name]")!;
return {
statusCenterY: centerY(row.querySelector(".status-icon")!),
symbolCenterY: centerY(row.querySelector("[data-tool-symbol]")!),
chevronCenterY: centerY(row.querySelector("[data-tool-chevron]")!),
firstLineCenterY:
text.getBoundingClientRect().top +
Number.parseFloat(getComputedStyle(text).lineHeight) / 2
};
});
return {
headerCenterX: centerX(element.closest("[data-agent-message-id]")!.querySelector("[data-process-toggle-icon]")!),
headerTextLeft: element.closest("[data-agent-message-id]")!.querySelector("[data-process-toggle-label]")!.getBoundingClientRect().left,
statusCentersX: statuses.map(centerX),
toolTitles,
stepTextLeft: element
.querySelector("[data-agent-progress] li.grid > span.min-w-0")!
.getBoundingClientRect().left,
toolParameterAnchorLeft: element.querySelector("[data-agent-tool][open] [data-tool-symbol]")!.getBoundingClientRect().left,
parameterTextLeft: element
.querySelector("[data-agent-tool][open] [data-agent-disclosure-content] > div > p")!
.getBoundingClientRect().left
};
});
}