Files
next-tjwater-frontend/tests/browser/agent-streaming-stability.e2e.ts
jiang f4318a9b9c feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
2026-07-28 16:39:36 +08:00

719 lines
25 KiB
TypeScript

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<string, unknown>) => {
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<typeof setInterval> | 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<void>((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<void>((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<HTMLElement>(".agent-panel-message");
const wrapper = message?.parentElement?.parentElement;
const scroll = document.querySelector<HTMLElement>(".agent-conversation-scroll");
const plan = Array.from(document.querySelectorAll<HTMLElement>(".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<HTMLOListElement>('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<HTMLElement>(".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<void>((resolve) => window.requestAnimationFrame(() => resolve()));
const deadline = performance.now() + 1_000;
let button: HTMLButtonElement | undefined;
while (!button && performance.now() < deadline) {
button = Array.from(document.querySelectorAll<HTMLButtonElement>("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();
}