feat: align frontend runtime and Edge TTS
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Locator } from "playwright/test";
|
||||
|
||||
test("agent header reveals the shell acrylic without nesting another filter", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const topBar = page.locator("header.acrylic-navigation");
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||
|
||||
await expect(topBar).toBeVisible();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect(agentHeader).toBeVisible();
|
||||
|
||||
await expectAcrylicSurface(topBar);
|
||||
await expectAcrylicSurface(agentPanel);
|
||||
await expectTransparentAcrylicContext(agentHeader);
|
||||
await expectLightweightContextSurfaces(agentPanel.locator(".agent-panel-conversation"));
|
||||
await expectSolidInternalSurfaces(agentPanel.locator(".agent-panel-band"));
|
||||
});
|
||||
|
||||
async function expectAcrylicSurface(shell: Locator) {
|
||||
const composition = await shell.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const filteredDescendants: string[] = [];
|
||||
|
||||
for (const descendant of element.querySelectorAll("*")) {
|
||||
if (getComputedStyle(descendant).backdropFilter !== "none") {
|
||||
filteredDescendants.push(descendant.className);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor,
|
||||
filteredDescendants,
|
||||
transform: style.transform,
|
||||
willChange: style.willChange
|
||||
};
|
||||
});
|
||||
|
||||
expect(composition.backdropFilter).toContain("blur(");
|
||||
expect(getAlpha(composition.backgroundColor)).toBeLessThan(1);
|
||||
expect(composition.filteredDescendants).toEqual([]);
|
||||
expect(composition.transform).toBe("none");
|
||||
expect(composition.willChange).toBe("auto");
|
||||
}
|
||||
|
||||
function getAlpha(color: string) {
|
||||
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
|
||||
|
||||
return match ? Number(match[1]) : 1;
|
||||
}
|
||||
|
||||
async function expectTransparentAcrylicContext(header: Locator) {
|
||||
const style = await header.evaluate((element) => {
|
||||
const computedStyle = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: computedStyle.backdropFilter,
|
||||
backgroundColor: computedStyle.backgroundColor,
|
||||
className: element.className,
|
||||
webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter")
|
||||
};
|
||||
});
|
||||
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(style.backgroundColor).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(["", "none"]).toContain(style.webkitBackdropFilter);
|
||||
expect(String(style.className)).not.toContain("acrylic-");
|
||||
}
|
||||
|
||||
async function expectLightweightContextSurfaces(surfaces: Locator) {
|
||||
const surfaceStyles = await surfaces.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
expect(surfaceStyles.length).toBeGreaterThan(0);
|
||||
for (const style of surfaceStyles) {
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.12);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSolidInternalSurfaces(surfaces: Locator) {
|
||||
const surfaceStyles = await surfaces.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
className: element.className,
|
||||
webkitBackdropFilter: style.getPropertyValue("-webkit-backdrop-filter")
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
expect(surfaceStyles.length).toBeGreaterThan(0);
|
||||
for (const style of surfaceStyles) {
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(["", "none"]).toContain(style.webkitBackdropFilter);
|
||||
expect(String(style.className)).not.toContain("acrylic-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(resizeHandle).toBeVisible();
|
||||
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
expect(handleBox).not.toBeNull();
|
||||
|
||||
await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(720);
|
||||
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476);
|
||||
});
|
||||
|
||||
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
|
||||
const rail = page.locator('aside[aria-label="Agent 折叠栏"]');
|
||||
const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ });
|
||||
await expect(rail).toBeVisible();
|
||||
await expect(rail).toHaveCSS("width", "72px");
|
||||
await expect(expandButton).toHaveAttribute("title", /当前|正在/);
|
||||
|
||||
await expandButton.click();
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("separator", { name: "调整 Agent 面板宽度" })).toBeHidden();
|
||||
});
|
||||
|
||||
test.describe("mobile touch layout", () => {
|
||||
test.use({ hasTouch: true, isMobile: true, viewport: { width: 375, height: 812 } });
|
||||
|
||||
test("mobile Agent toggle opens the conversation panel by touch", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const openButton = page.getByRole("button", { name: "打开 Agent 面板" });
|
||||
const buttonBox = await openButton.boundingBox();
|
||||
expect(buttonBox).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(buttonBox!.x + buttonBox!.width / 2, buttonBox!.y + buttonBox!.height / 2);
|
||||
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
|
||||
await page.getByRole("button", { name: "工况汇总" }).click();
|
||||
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,601 @@
|
||||
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 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 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);
|
||||
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(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(latestExpandedProgress).toBeVisible();
|
||||
await expect(
|
||||
latestExpandedProgress.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();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("speech capability detection preserves the server hydration tree", async ({ page }) => {
|
||||
const hydrationErrors: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error" && /hydration|server rendered html/i.test(message.text())) {
|
||||
hydrationErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", {
|
||||
configurable: true,
|
||||
value: class SpeechRecognition {}
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible();
|
||||
expect(hydrationErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("speech recognition failure clears the active animation and explains the error", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", {
|
||||
configurable: true,
|
||||
value: class SpeechRecognition {
|
||||
onerror: ((event: { error: string }) => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
|
||||
start() {
|
||||
window.setTimeout(() => {
|
||||
this.onerror?.({ error: "not-allowed" });
|
||||
this.onend?.();
|
||||
}, 20);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
abort() {}
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "语音输入" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "语音输入" })).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator('[data-slot="voice-input-pulse"]')).toHaveCount(0);
|
||||
await expect(page.getByText("无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试")).toBeVisible();
|
||||
});
|
||||
|
||||
test("speech recognition writes final transcript into the prompt", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", {
|
||||
configurable: true,
|
||||
value: class SpeechRecognition {
|
||||
onresult: ((event: { resultIndex: number; results: Array<Array<{ transcript: string }> & { isFinal: boolean }> }) => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
|
||||
start() {
|
||||
window.setTimeout(() => {
|
||||
const result = Object.assign([{ transcript: "检查城南泵站水位" }], { isFinal: true });
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}, 20);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
abort() {}
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "语音输入" }).click();
|
||||
|
||||
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站水位");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("Dev Panel opens on desktop, owns the right workspace, and closes with Escape", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
|
||||
const trigger = page.getByRole("button", { name: "切换地图 Dev Panel" });
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeVisible();
|
||||
await trigger.click();
|
||||
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveCSS("width", "400px");
|
||||
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden();
|
||||
await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
});
|
||||
|
||||
test("Dev entry is absent below the desktop breakpoint", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
|
||||
await expect(page.getByTestId("map-dev-panel")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("map notice is centered in the mobile viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "打开用户菜单" }).click();
|
||||
await page.getByRole("menuitem", { name: /查看运行状态/ }).click();
|
||||
|
||||
const notice = page.locator('[role="status"]').filter({ hasText: "数据状态" }).first();
|
||||
await expect(notice).toBeVisible();
|
||||
|
||||
const box = await notice.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
|
||||
const leftInset = Math.round(box!.x);
|
||||
const rightInset = Math.round(375 - box!.x - box!.width);
|
||||
expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test, type Locator } from "playwright/test";
|
||||
|
||||
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await expect(panel).toBeVisible();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "432px");
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "880px");
|
||||
});
|
||||
|
||||
async function expectAcrylicComposition(panel: Locator) {
|
||||
const composition = await panel.evaluate((element) => {
|
||||
const rootStyle = getComputedStyle(element);
|
||||
const blockingAncestors: string[] = [];
|
||||
const filteredDescendants: string[] = [];
|
||||
|
||||
let ancestor = element.parentElement;
|
||||
while (ancestor && ancestor.tagName !== "MAIN") {
|
||||
const style = getComputedStyle(ancestor);
|
||||
if (
|
||||
style.transform !== "none" ||
|
||||
style.filter !== "none" ||
|
||||
style.opacity !== "1" ||
|
||||
style.isolation !== "auto" ||
|
||||
style.willChange.includes("transform")
|
||||
) {
|
||||
blockingAncestors.push(ancestor.className);
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
|
||||
for (const descendant of element.querySelectorAll("*")) {
|
||||
if (getComputedStyle(descendant).backdropFilter !== "none") {
|
||||
filteredDescendants.push(descendant.className);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backdropFilter: rootStyle.backdropFilter,
|
||||
blockingAncestors,
|
||||
filteredDescendants,
|
||||
transform: rootStyle.transform,
|
||||
willChange: rootStyle.willChange
|
||||
};
|
||||
});
|
||||
|
||||
expect(composition.backdropFilter).toContain("blur(");
|
||||
expect(composition.blockingAncestors).toEqual([]);
|
||||
expect(composition.filteredDescendants).toEqual([]);
|
||||
expect(composition.transform).toBe("none");
|
||||
expect(composition.willChange).toBe("auto");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("top bar uses compact controls on narrow mobile viewports", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.locator('meta[name="viewport"]')).toHaveAttribute(
|
||||
"content",
|
||||
/width=device-width/
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "切换模拟方案" })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /查看异常处置面板/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("top bar keeps text horizontal at the desktop breakpoint", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 844 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const header = page.locator("header").first();
|
||||
await expect(header).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await header.boundingBox())?.height ?? 0)).toBe(56);
|
||||
|
||||
await expect.poll(async () => header.evaluate((element) => {
|
||||
return Array.from(element.querySelectorAll("span"))
|
||||
.filter((span) => {
|
||||
const style = getComputedStyle(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
})
|
||||
.map((span) => span.textContent?.trim())
|
||||
.filter((text) => text === "任务浮条" || text === "工况任务" || text === "异常处置").length;
|
||||
})).toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user