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((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) => 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((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((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("[data-agent-message-id]")!; const scroll = element.closest(".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 }; }); }