Files
next-tjwater-drainage-frontend/tests/browser/agent-streaming-stability.e2e.ts
T

492 lines
17 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;
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();
(window as typeof window & { __agentStreamSamples?: StreamSample[] }).__agentStreamSamples = samples;
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 (performance.now() - startedAt < 8_000) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
await sendButton.click();
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await page.evaluate(() => {
const state = window as typeof window & {
__agentMinVisibleProgressRows?: number;
__agentProgressWindowSampleDone?: boolean;
};
const startedAt = performance.now();
state.__agentMinVisibleProgressRows = Number.POSITIVE_INFINITY;
state.__agentProgressWindowSampleDone = false;
const sample = () => {
const rows = document.querySelectorAll('ol[aria-label="最近执行进度"] > li').length;
state.__agentMinVisibleProgressRows = Math.min(state.__agentMinVisibleProgressRows ?? rows, rows);
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 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 progressToggle.click();
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
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);
await page.evaluate(() => {
const state = window as typeof window & { __agentFailureMotionObserved?: boolean };
const startedAt = performance.now();
state.__agentFailureMotionObserved = false;
const sample = () => {
const badge = document.querySelector<HTMLElement>('[data-progress-status="error"]');
if (badge && getComputedStyle(badge).transform !== "none") {
state.__agentFailureMotionObserved = true;
}
if (!state.__agentFailureMotionObserved && performance.now() - startedAt < 1_000) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
triggerProgressFailure();
await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible();
await expect.poll(() =>
page.evaluate(() =>
(window as typeof window & { __agentFailureMotionObserved?: boolean }).__agentFailureMotionObserved ?? false
)
).toBe(true);
resumeStream();
await expect(page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()).toBeVisible({
timeout: 10_000
});
await expect(progressToggle).toContainText("共 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);
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 firstSampleTime = samples[0].time;
const settledEntranceSamples = samples.filter((sample) => sample.time - firstSampleTime >= 250);
expect(settledEntranceSamples.length).toBeGreaterThan(0);
expect(settledEntranceSamples.every((sample) => sample.transform === "none")).toBe(true);
const planSamples = samples.filter(
(sample) =>
sample.planItemTransform !== null &&
sample.planSectionTransform !== null &&
sample.planTransform !== null
);
expect(planSamples.length).toBeGreaterThan(0);
const firstPlanSampleTime = planSamples[0].time;
const settledPlanSamples = planSamples.filter((sample) => sample.time - firstPlanSampleTime >= 250);
expect(settledPlanSamples.length).toBeGreaterThan(0);
expect(
settledPlanSamples.every(
(sample) =>
sample.planItemTransform === "none" &&
sample.planSectionTransform === "none" &&
sample.planTransform === "none"
)
).toBe(true);
const finalSample = samples.at(-1);
expect(finalSample).toBeDefined();
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();
await expect(latestProgressToggle).toContainText("最近 3 条");
triggerProgressAppend();
await expect(
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true })
).toBeVisible();
await latestProgressToggle.click();
await expect(latestProgressToggle).toContainText("全部 4 条");
resumeStream();
await expect(latestProgressToggle).toContainText("全部 4 条", { timeout: 10_000 });
await expect(page.getByRole("list", { name: "全部执行进度" })).toBeVisible();
await expect(
page.getByRole("list", { name: "全部执行进度" }).getByText("溢流风险判断完成", { exact: true })
).toBeVisible();
});
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();
}