import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { expect, test } from "@playwright/test"; const STREAM_CHUNK_SIZE = 18; const STREAM_CHUNK_DELAY_MS = 20; const PROGRESS_GEOMETRY_TOLERANCE_PX = 0.1; let streamServer: Server; let streamServerUrl: string; let appendProgress: (() => void) | null = null; let failProgress: (() => void) | null = null; let releaseStream: (() => void) | null = null; test.beforeAll(async () => { streamServer = createServer((_request, response) => { response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache", "Content-Type": "text/event-stream", "X-Vercel-AI-UI-Message-Stream": "v1" }); const content = createLongMarkdownResponse(); const chunks = splitIntoCodePointChunks(content, STREAM_CHUNK_SIZE); const send = (chunk: Record) => { response.write(`data: ${JSON.stringify(chunk)}\n\n`); }; send({ type: "start", messageId: "assistant-stream-stability" }); send({ type: "data-progress", id: "inspect-context", data: { id: "inspect-context", phase: "planning", status: "running", title: "正在分析运行上下文", detail: "核对泵站、液位与降雨过程。", started_at: Date.now() } }); send({ type: "data-progress", id: "inspect-rainfall", data: { id: "inspect-rainfall", phase: "evidence", status: "completed", title: "正在核对降雨过程与汇水区响应关系", detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。", started_at: Date.now() } }); send({ type: "data-progress", id: "inspect-level", data: { id: "inspect-level", phase: "evidence", status: "completed", title: "正在核对液位连续性", detail: "排除缺测与异常峰值。", started_at: Date.now() } }); send({ type: "data-todo_update", id: "stream-stability-plan", data: { session_id: "stream-stability-session", message_id: "assistant-stream-stability", created_at: Date.now(), todos: [ { id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" }, { id: "inspect-level", content: "核对液位计连续性", status: "pending" }, { id: "assess-overflow", content: "判断溢流风险", status: "pending" } ] } }); send({ type: "text-start", id: "answer" }); let index = 0; let timer: ReturnType | null = null; const sendNextTextChunk = () => { const delta = chunks[index]; if (delta !== undefined) { index += 1; send({ type: "text-delta", id: "answer", delta }); send({ type: "data-stream_token", data: { session_id: "stream-stability-session", message_id: "assistant-stream-stability", content: delta }, transient: true }); return; } if (timer) { clearInterval(timer); timer = null; } send({ type: "data-progress", id: "assess-risk", data: { id: "assess-risk", phase: "analysis", status: "completed", title: "溢流风险判断完成", detail: "已完成风险点位排序。", ended_at: Date.now() } }); send({ type: "text-end", id: "answer" }); send({ type: "finish", finishReason: "stop" }); response.end(); }; while (index < 3 && index < chunks.length) { sendNextTextChunk(); } appendProgress = () => { send({ type: "data-progress", id: "assess-risk", data: { id: "assess-risk", phase: "analysis", status: "running", title: "正在判断溢流风险", detail: "结合流量突变与液位趋势形成判断。", started_at: Date.now() } }); }; failProgress = () => { send({ type: "data-progress", id: "inspect-level", data: { id: "inspect-level", phase: "evidence", status: "error", title: "液位连续性核对失败", detail: "监测数据读取超时。", ended_at: Date.now() } }); }; releaseStream = () => { if (!timer) { timer = setInterval(sendNextTextChunk, STREAM_CHUNK_DELAY_MS); } }; response.on("close", () => { if (timer) { clearInterval(timer); } }); }); await new Promise((resolve) => streamServer.listen(0, "127.0.0.1", resolve)); const address = streamServer.address() as AddressInfo; streamServerUrl = `http://127.0.0.1:${address.port}/stream`; }); test.afterAll(async () => { await new Promise((resolve, reject) => { streamServer.close((error) => (error ? reject(error) : resolve())); }); }); test("stream completion and the plan card remain positionally stable", async ({ page }) => { await page.route("**/api/v1/agent/chat/**", async (route) => { const path = new URL(route.request().url()).pathname; if (path.endsWith("/stream")) { await route.continue({ url: streamServerUrl }); return; } const body = path.endsWith("/models") ? { default_model: "test/model", models: [ { id: "test/model", label: "测试模型", description: "流式稳定性测试", icon: "bolt" } ] } : path.endsWith("/sessions") ? { sessions: [] } : {}; await route.fulfill({ json: body }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await expect( page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first() ).toContainText("测试模型"); const prompt = page .getByPlaceholder(/输入调度问题/) .filter({ visible: true }) .first(); await expect(prompt).toBeVisible(); await prompt.fill("检查流式响应稳定性"); const sendButton = page .getByRole("button", { name: "发送 Agent 指令" }) .filter({ visible: true }) .first(); await expect(sendButton).toBeEnabled(); await page.evaluate(() => { type StreamSample = { clientHeight: number; planItemTransform: string | null; planSectionTransform: string | null; planTransform: string | null; scrollHeight: number; scrollTop: number; time: number; transform: string; }; const samples: StreamSample[] = []; const startedAt = performance.now(); const state = window as typeof window & { __agentStreamSamples?: StreamSample[]; __agentStreamSamplingDone?: boolean; }; state.__agentStreamSamples = samples; state.__agentStreamSamplingDone = false; const sample = () => { const message = document.querySelector(".agent-panel-message"); const wrapper = message?.parentElement?.parentElement; const scroll = document.querySelector(".agent-conversation-scroll"); const plan = Array.from(document.querySelectorAll(".agent-panel-nested")).find( (element) => element.textContent?.includes("计划") ); const planWrapper = plan?.parentElement; const planSection = planWrapper?.parentElement; const planItem = plan?.querySelector("li"); if (message && wrapper && scroll) { samples.push({ clientHeight: scroll.clientHeight, planItemTransform: planItem ? getComputedStyle(planItem).transform : null, planSectionTransform: planSection ? getComputedStyle(planSection).transform : null, planTransform: planWrapper ? getComputedStyle(planWrapper).transform : null, scrollHeight: scroll.scrollHeight, scrollTop: scroll.scrollTop, time: performance.now() - startedAt, transform: getComputedStyle(wrapper).transform }); } if (!state.__agentStreamSamplingDone) { requestAnimationFrame(sample); } }; requestAnimationFrame(sample); }); await sendButton.click(); const userMessage = page.locator(".agent-panel-user-message"); const assistantMessage = page.locator(".agent-panel-message").first(); await expect(userMessage).toHaveCSS("background-color", "rgba(224, 229, 249, 0.92)"); expect( await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor) ).not.toBe( await assistantMessage.evaluate((element) => getComputedStyle(element).backgroundColor) ); await expect .poll(() => userMessage.evaluate((element) => getComputedStyle(element).boxShadow)) .not.toBe("none"); await expect .poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow)) .toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow)); const operationalBrief = page.locator(".agent-panel-operational-float"); await expect(operationalBrief).toBeVisible(); await expect(operationalBrief).toHaveCSS("position", "absolute"); await expect(operationalBrief).not.toHaveClass(/agent-panel-floating-acrylic/); await expect(operationalBrief).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)"); await expect(operationalBrief).toHaveCSS("border-radius", "16px"); await expect(operationalBrief).toHaveCSS("background-image", "none"); await expect(operationalBrief).toHaveCSS("border-top-width", "0px"); await expect(operationalBrief).toHaveCSS( "box-shadow", "rgba(15, 33, 55, 0.14) 0px 12px 32px 0px, rgba(15, 33, 55, 0.08) 0px 3px 10px 0px" ); await expect .poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).boxShadow)) .not.toContain("inset"); await expect .poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).backdropFilter)) .toContain("blur("); const operationalBriefBox = await operationalBrief.boundingBox(); const conversationBox = await page.locator(".agent-panel-conversation-canvas").boundingBox(); expect(operationalBriefBox).not.toBeNull(); expect(conversationBox).not.toBeNull(); expect(operationalBriefBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height); await expect(page.locator(".agent-panel-conversation-content-has-brief")).toHaveCSS( "padding-top", "208px" ); const liveProgress = page.getByRole("list", { name: "最近执行进度" }); await expect(liveProgress.locator("li")).toHaveCount(3); await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible(); await expect(liveProgress).toHaveCSS("height", "156px"); await expect.poll(() => liveProgress.evaluate((list) => list.style.height)).toBe("auto"); await page.evaluate(() => { const state = window as typeof window & { __agentMinVisibleProgressRows?: number; __agentProgressGeometry?: { maxContainerHeight: number; maxListHeight: number; minContainerHeight: number; minListHeight: number; }; __agentProgressWindowSampleDone?: boolean; }; const startedAt = performance.now(); state.__agentMinVisibleProgressRows = Number.POSITIVE_INFINITY; state.__agentProgressGeometry = { maxContainerHeight: Number.NEGATIVE_INFINITY, maxListHeight: Number.NEGATIVE_INFINITY, minContainerHeight: Number.POSITIVE_INFINITY, minListHeight: Number.POSITIVE_INFINITY }; state.__agentProgressWindowSampleDone = false; const sample = () => { const list = document.querySelector('ol[aria-label="最近执行进度"]'); const rows = list?.children.length ?? 0; state.__agentMinVisibleProgressRows = Math.min( state.__agentMinVisibleProgressRows ?? rows, rows ); if (list && state.__agentProgressGeometry) { const listHeight = list.getBoundingClientRect().height; const containerHeight = list.parentElement?.getBoundingClientRect().height ?? 0; state.__agentProgressGeometry.minListHeight = Math.min( state.__agentProgressGeometry.minListHeight, listHeight ); state.__agentProgressGeometry.maxListHeight = Math.max( state.__agentProgressGeometry.maxListHeight, listHeight ); state.__agentProgressGeometry.minContainerHeight = Math.min( state.__agentProgressGeometry.minContainerHeight, containerHeight ); state.__agentProgressGeometry.maxContainerHeight = Math.max( state.__agentProgressGeometry.maxContainerHeight, containerHeight ); } if (performance.now() - startedAt < 400) { requestAnimationFrame(sample); } else { state.__agentProgressWindowSampleDone = true; } }; requestAnimationFrame(sample); }); triggerProgressAppend(); await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible(); await expect(liveProgress.locator("li")).toHaveCount(3); await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0); await expect .poll(() => page.evaluate( () => (window as typeof window & { __agentProgressWindowSampleDone?: boolean }) .__agentProgressWindowSampleDone ?? false ) ) .toBe(true); expect( await page.evaluate( () => (window as typeof window & { __agentMinVisibleProgressRows?: number }) .__agentMinVisibleProgressRows ) ).toBeGreaterThanOrEqual(3); const progressGeometry = await page.evaluate( () => ( window as typeof window & { __agentProgressGeometry?: { maxContainerHeight: number; maxListHeight: number; minContainerHeight: number; minListHeight: number; }; } ).__agentProgressGeometry ); expect(progressGeometry).toBeDefined(); expect(progressGeometry!.maxListHeight - progressGeometry!.minListHeight).toBeLessThanOrEqual( PROGRESS_GEOMETRY_TOLERANCE_PX ); expect( progressGeometry!.maxContainerHeight - progressGeometry!.minContainerHeight ).toBeLessThanOrEqual(PROGRESS_GEOMETRY_TOLERANCE_PX); const liveProgressRowHeights = await liveProgress .locator("li") .evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().height)); expect(liveProgressRowHeights.every((height) => Math.abs(height - 48) <= 0.01)).toBe(true); await expect(liveProgress.locator("li").first().locator("span.min-w-0 > span").first()).toHaveCSS( "white-space", "nowrap" ); const progressToggle = page .getByRole("button", { name: /执行进度/ }) .filter({ visible: true }) .first(); await expect(progressToggle).toBeEnabled(); await expect(progressToggle).toHaveAttribute("data-state", "closed"); await expect .poll(async () => (await progressToggle.boundingBox())?.height ?? 0) .toBeGreaterThanOrEqual(40); await progressToggle.click(); await expect(progressToggle).toHaveAttribute("data-state", "open"); const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" }); await expect(expandedLiveProgress.locator("li")).toHaveCount(4); await expect(expandedLiveProgress.locator(".status-badge")).toHaveCount(0); await expect(expandedLiveProgress.locator('[data-progress-status="running"]').first()).toHaveCSS( "color", "oklch(0.546 0.245 262.881)" ); await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible(); await expect( expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true }) ).toHaveCSS("white-space", "normal"); await progressToggle.click(); await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3); triggerProgressFailure(); await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible(); await expect(page.locator('[data-progress-status="error"]')).toHaveCSS("transform", "none"); resumeStream(); await expect( page.getByText("分析完成", { exact: true }).filter({ visible: true }).first() ).toBeVisible({ timeout: 10_000 }); await expect(progressToggle.locator("../..")).toContainText("步骤4/4"); await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0); await expect( page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }) ).toHaveCount(0); await expect .poll(async () => page.evaluate(() => { const scroll = document.querySelector(".agent-conversation-scroll"); return scroll ? Math.abs(scroll.scrollHeight - scroll.clientHeight - scroll.scrollTop) : Number.POSITIVE_INFINITY; }) ) .toBeLessThanOrEqual(2); await expect .poll(() => page.evaluate(() => { const samples = ( window as typeof window & { __agentStreamSamples?: Array<{ clientHeight: number; scrollHeight: number; scrollTop: number; }>; } ).__agentStreamSamples; const sample = samples?.at(-1); return sample ? Math.abs(sample.scrollHeight - sample.clientHeight - sample.scrollTop) : Number.POSITIVE_INFINITY; }) ) .toBeLessThanOrEqual(2); await page.evaluate(() => { (window as typeof window & { __agentStreamSamplingDone?: boolean }).__agentStreamSamplingDone = true; }); const samples = await page.evaluate( () => ( window as typeof window & { __agentStreamSamples?: Array<{ clientHeight: number; planItemTransform: string | null; planSectionTransform: string | null; planTransform: string | null; scrollHeight: number; scrollTop: number; time: number; transform: string; }>; } ).__agentStreamSamples ?? [] ); expect(samples.length).toBeGreaterThan(5); const finalSample = samples.at(-1); expect(finalSample).toBeDefined(); expect(finalSample).toMatchObject({ planItemTransform: "none", planSectionTransform: "none", planTransform: "none", transform: "none" }); expect( Math.abs(finalSample!.scrollHeight - finalSample!.clientHeight - finalSample!.scrollTop) ).toBeLessThanOrEqual(2); await progressToggle.click(); const expandedProgress = page.getByRole("list", { name: "全部执行进度" }); await expect(expandedProgress.locator("li")).toHaveCount(4); await expect( page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first() ).toBeVisible(); const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true }); await expect(expandedLongTitle).toHaveCSS("white-space", "normal"); await expect(expandedProgress.locator("li").nth(1)).not.toHaveCSS("height", "48px"); await progressToggle.click(); await prompt.fill("验证展开状态在流结束后保持"); await sendButton.click(); const latestProgressToggle = page .getByRole("button", { name: /执行进度/ }) .filter({ visible: true }) .last(); const latestProgressRegion = latestProgressToggle.locator("../.."); const latestExpandedProgress = latestProgressRegion.getByRole("list", { name: "全部执行进度" }); await expect(latestProgressRegion).toContainText("最近3/3"); triggerProgressAppend(); await expect( page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true }) ).toBeVisible(); await latestProgressToggle.click(); await expect(latestProgressRegion).toContainText("全部4/4"); resumeStream(); await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 }); await expect(latestExpandedProgress).toBeVisible(); await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible(); const conversationScroll = page.locator(".agent-conversation-scroll"); await conversationScroll.hover(); await page.mouse.wheel(0, -2400); const scrollToLatestButton = page.getByRole("button", { name: "滚动到最新消息" }); await expect(scrollToLatestButton).toBeVisible(); await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)"); await expect .poll(() => scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter) ) .toContain("blur("); await expect .poll(() => scrollToLatestButton.evaluate((element) => getComputedStyle(element).boxShadow)) .not.toBe("none"); const readAcrylicMaterial = (element: HTMLElement) => { const style = getComputedStyle(element); return { backdropFilter: style.backdropFilter, backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage, boxShadow: style.boxShadow }; }; expect(await scrollToLatestButton.evaluate(readAcrylicMaterial)).toEqual( await page.locator(".agent-panel-composer").evaluate(readAcrylicMaterial) ); const selectableAssistantMessage = page.getByRole("heading", { name: "诊断结果" }).first(); await selectableAssistantMessage.evaluate((element) => { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let textNode = walker.nextNode(); while (textNode && !textNode.textContent?.trim()) { textNode = walker.nextNode(); } if (!textNode?.textContent) { throw new Error("Assistant message does not contain selectable text"); } const range = document.createRange(); range.setStart(textNode, 0); range.setEnd(textNode, Math.min(8, textNode.textContent.length)); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); }); const speechSelectionIconMotion = await page.evaluate(async () => { const waitForAnimationFrame = () => new Promise((resolve) => window.requestAnimationFrame(() => resolve())); const deadline = performance.now() + 1_000; let button: HTMLButtonElement | undefined; while (!button && performance.now() < deadline) { button = Array.from(document.querySelectorAll("button")).find((element) => element.textContent?.includes("从这里开始朗读") ); if (!button) await waitForAnimationFrame(); } const icon = button?.querySelector("svg"); if (!button || !icon) { throw new Error("Speech selection action did not render with an icon"); } const samples: Array<{ x: number; y: number }> = []; const startedAt = performance.now(); while (performance.now() - startedAt < 220) { const rect = icon.getBoundingClientRect(); samples.push({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); await waitForAnimationFrame(); } const xValues = samples.map((sample) => sample.x); const yValues = samples.map((sample) => sample.y); return { xSpread: Math.max(...xValues) - Math.min(...xValues), ySpread: Math.max(...yValues) - Math.min(...yValues) }; }); expect(speechSelectionIconMotion.xSpread).toBeLessThanOrEqual(0.1); expect(speechSelectionIconMotion.ySpread).toBeLessThanOrEqual(0.1); const speechSelectionButton = page.getByRole("button", { name: "从这里开始朗读" }); await expect(speechSelectionButton).toBeVisible(); await expect(speechSelectionButton).toHaveCSS("border-top-width", "0px"); expect(await speechSelectionButton.evaluate(readAcrylicMaterial)).toEqual( await scrollToLatestButton.evaluate(readAcrylicMaterial) ); }); function createLongMarkdownResponse() { let content = "## 诊断结果\n\n"; for (let index = 1; index <= 6; index += 1) { content += `### 区域 ${index}\n\n`; content += "当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n"; content += "- 检查泵站运行状态与实时流量。\n"; content += "- 核对液位计连续性和异常峰值。\n"; content += "- 结合降雨过程判断溢流风险。\n\n"; } return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`; } function splitIntoCodePointChunks(content: string, chunkSize: number) { const codePoints = Array.from(content); const chunks: string[] = []; for (let index = 0; index < codePoints.length; index += chunkSize) { chunks.push(codePoints.slice(index, index + chunkSize).join("")); } return chunks; } function resumeStream() { if (!releaseStream) { throw new Error("Stream checkpoint was not reached"); } const resume = releaseStream; releaseStream = null; resume(); } function triggerProgressAppend() { if (!appendProgress) { throw new Error("Progress append checkpoint was not reached"); } const append = appendProgress; appendProgress = null; append(); } function triggerProgressFailure() { if (!failProgress) { throw new Error("Progress failure checkpoint was not reached"); } const fail = failProgress; failProgress = null; fail(); }