415 lines
22 KiB
TypeScript
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
|
|
};
|
|
});
|
|
}
|