Files
next-tjwater-frontend/tests/browser/agent-acrylic.e2e.ts
T
jiang 73896a893f fix: unify Agent history panel framing
The regression came from applying history-open styling to the outer acrylic shell during iteration. Keep the shell frame unchanged, confine the bottom edge treatment to the expanded header, and cover desktop and mobile geometry in Playwright.
2026-07-28 17:24:32 +08:00

550 lines
21 KiB
TypeScript

import { expect, test, type Locator } from "@playwright/test";
type BoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
async function waitForAnimations(locator: Locator) {
await locator.evaluate(async (element) => {
const animations = element
.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
await Promise.all(
animations.map((animation) => animation.finished.catch(() => undefined))
);
});
}
test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const topBar = page.locator("header.acrylic-navigation");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
const agentComposer = agentPanel.locator(".agent-panel-composer");
await expect(topBar).toBeVisible();
await expect(agentPanel).toBeVisible();
await expect(agentHeader).toBeVisible();
await expect(agentComposer).toBeVisible();
await waitForAnimations(agentPanel);
await expectAcrylicSurface(topBar);
await expectAcrylicSurface(agentPanel, ".agent-panel-composer");
await expectIntegratedHeader(agentHeader);
await expectContextualAcrylicSurface(agentComposer, {
backdropFilter: "blur(",
maximumAlpha: 0.9,
minimumAlpha: 0.84
});
await expectLightweightMaterialContext(agentConversation);
await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control"));
await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
).toHaveCSS("color", "rgb(51, 65, 85)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("color", "rgb(30, 41, 59)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("font-size", "14px");
const panelBox = await agentPanel.boundingBox();
const conversationBox = await agentConversation.boundingBox();
const scrollBox = await agentConversationScroll.boundingBox();
const composerBox = await agentComposer.boundingBox();
expect(panelBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4))
).toBeLessThanOrEqual(1);
expect(
Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height))
).toBeLessThanOrEqual(1);
expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21);
expect(
Math.abs(
composerBox!.x -
panelBox!.x -
(panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width)
)
).toBeLessThanOrEqual(1);
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
await expect
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer))
.toEqual(beforeFocus);
});
async function expectIntegratedHeader(header: Locator) {
const composition = await header.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
borderRadius: style.borderRadius
};
});
expect(composition.backdropFilter).toBe("none");
expect(getAlpha(composition.backgroundColor)).toBe(0);
expect(composition.borderTopWidth).toBe("0px");
expect(composition.borderRadius).toBe("0px");
}
test("desktop Agent header expands into one attached acrylic history surface", async ({
page
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
const historyButton = agentPanel.getByRole("button", {
name: "打开 Agent 历史记录"
});
const headerBefore = await agentHeader.boundingBox();
const conversationBefore = await agentConversation.boundingBox();
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel);
await historyButton.click();
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toBeVisible();
await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await page.mouse.move(720, 320);
await expectAttachedHistoryExtension(history);
await expectTransparentExpandedHeader(agentHeader, agentPanel);
await expectUnifiedHistoryStructure(history);
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
const headerBox = await agentHeader.boundingBox();
const agentPanelBox = await agentPanel.boundingBox();
const historyBox = await history.boundingBox();
const conversationAfter = await agentConversation.boundingBox();
expect(headerBefore).not.toBeNull();
expect(headerBox).not.toBeNull();
expect(agentPanelBox).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(conversationBefore).not.toBeNull();
expect(conversationAfter).not.toBeNull();
expectRoundedValue(headerBox!.y, headerBefore!.y);
expect(headerBox!.height).toBeGreaterThan(headerBefore!.height);
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
expectRoundedValue(headerBox!.x, agentPanelBox!.x + 1);
expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1);
expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2);
expectRoundedValue(historyBox!.x, headerBox!.x);
expectRoundedValue(historyBox!.width, headerBox!.width);
expectRoundedBox(conversationAfter!, conversationBefore!);
await history.getByRole("button", { name: "重命名对话" }).first().click();
const renameInput = history.locator("input").first();
const editingItem = renameInput.locator(
"xpath=ancestor::*[contains(@class, 'agent-history-item')][1]"
);
await expect(renameInput).toBeFocused();
await expect(renameInput).toHaveCSS("box-shadow", "none");
await expect(renameInput).toHaveCSS("border-color", "rgb(203, 213, 225)");
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
await history.getByRole("button", { name: "取消重命名" }).click();
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
await historyButton.click();
await expect(history).toBeVisible();
await page.mouse.click(720, 320);
await expect(history).toBeHidden();
});
test("mobile Agent mirrors the desktop floating history surface", async ({
page
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const agentPanel = page
.locator('aside[aria-label="Agent 命令面板"]')
.filter({ visible: true });
await expect(agentPanel).toBeVisible();
await waitForAnimations(agentPanel);
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
const sheetHandle = mobileSheet.locator(".mobile-workbench-sheet-handle");
const agentHeader = agentPanel.locator(".agent-panel-header");
const agentConversation = agentPanel.getByRole("log");
const historyButton = agentPanel.getByRole("button", {
name: "打开 Agent 历史记录"
});
const headerBefore = await agentHeader.boundingBox();
const conversationBefore = await agentConversation.boundingBox();
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
const mobileSheetFrameBefore = await expectAcrylicOutline(mobileSheet);
await historyButton.click();
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toBeVisible();
await waitForAnimations(history);
await page.mouse.move(10, 100);
await expectAttachedHistoryExtension(history);
await expectTransparentExpandedHeader(agentHeader, mobileSheet);
await expectUnifiedHistoryStructure(history);
await expect.poll(() => getAcrylicFrame(mobileSheet)).toEqual(mobileSheetFrameBefore);
await expect(agentPanel).toHaveCSS("box-shadow", "none");
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
await expect(sheetHandle).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const panelBox = await agentPanel.boundingBox();
const headerAfter = await agentHeader.boundingBox();
const historyBox = await history.boundingBox();
const conversationAfter = await agentConversation.boundingBox();
expect(panelBox).not.toBeNull();
expect(headerBefore).not.toBeNull();
expect(headerAfter).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(conversationBefore).not.toBeNull();
expect(conversationAfter).not.toBeNull();
expectRoundedValue(headerAfter!.y, headerBefore!.y);
expectRoundedValue(headerAfter!.x, panelBox!.x);
expectRoundedValue(headerAfter!.y, panelBox!.y - 28);
expectRoundedValue(headerAfter!.width, panelBox!.width);
expect(headerAfter!.height).toBeGreaterThan(headerBefore!.height);
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
expectRoundedValue(historyBox!.width, headerAfter!.width);
expect(headerAfter!.height).toBeLessThan(panelBox!.height);
expectRoundedBox(conversationAfter!, conversationBefore!);
await expect(agentConversation).toHaveClass(/agent-panel-conversation-canvas/);
await expect(agentPanel.locator(".agent-panel-composer")).toBeVisible();
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
});
test("Agent floating acrylic respects forced color mode", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const composer = page.locator(".agent-panel-composer");
await expect(composer).toBeVisible();
await expect(composer).toHaveCSS("backdrop-filter", "none");
await expect(composer).toHaveCSS("background-image", "none");
await expect(composer).toHaveCSS("box-shadow", "none");
});
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
const composition = await shell.evaluate((element, allowedSelector) => {
const style = getComputedStyle(element);
const filteredDescendants: string[] = [];
for (const descendant of element.querySelectorAll("*")) {
if (
getComputedStyle(descendant).backdropFilter !== "none" &&
!(allowedSelector && descendant.matches(allowedSelector))
) {
filteredDescendants.push(descendant.className);
}
}
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
filteredDescendants,
transform: style.transform,
willChange: style.willChange
};
}, allowedFilteredDescendant);
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");
}
async function expectContextualAcrylicSurface(
context: Locator,
{
backdropFilter = "none",
maximumAlpha = 0.7,
minimumAlpha = 0.35
}: {
backdropFilter?: string;
maximumAlpha?: number;
minimumAlpha?: number;
} = {}
) {
const composition = await context.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
boxShadow: style.boxShadow
};
});
if (backdropFilter === "none") {
expect(composition.backdropFilter).toBe("none");
} else {
expect(composition.backdropFilter).toContain(backdropFilter);
}
expect(getAlpha(composition.backgroundColor)).toBeGreaterThan(minimumAlpha);
expect(getAlpha(composition.backgroundColor)).toBeLessThan(maximumAlpha);
expect(composition.backgroundImage).not.toBe("none");
expect(composition.boxShadow).not.toBe("none");
}
async function expectAttachedHistoryExtension(history: Locator) {
const presentation = await history.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderBottomLeftRadius: style.borderBottomLeftRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomWidth: style.borderBottomWidth,
borderLeftWidth: style.borderLeftWidth,
borderRightWidth: style.borderRightWidth,
borderTopWidth: style.borderTopWidth,
borderTopLeftRadius: style.borderTopLeftRadius,
borderTopRightRadius: style.borderTopRightRadius,
boxShadow: style.boxShadow
};
});
expect(presentation.backdropFilter).toContain("blur(");
expect(getAlpha(presentation.backgroundColor)).toBeGreaterThanOrEqual(0.78);
expect(getAlpha(presentation.backgroundColor)).toBeLessThanOrEqual(0.9);
expect(presentation.backgroundImage).not.toBe("none");
expect(presentation.borderBottomWidth).toBe("0px");
expect(presentation.borderLeftWidth).toBe("0px");
expect(presentation.borderRightWidth).toBe("0px");
expect(presentation.borderTopWidth).toBe("0px");
expect(presentation.borderTopLeftRadius).toBe("0px");
expect(presentation.borderTopRightRadius).toBe("0px");
expect(presentation.borderBottomLeftRadius).toBe("16px");
expect(presentation.borderBottomRightRadius).toBe("16px");
expect(presentation.boxShadow).toBe("none");
}
async function getAcrylicFrame(surface: Locator) {
return surface.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderBottomColor: style.borderBottomColor,
borderLeftColor: style.borderLeftColor,
borderRightColor: style.borderRightColor,
borderTopColor: style.borderTopColor,
boxShadow: style.boxShadow
};
});
}
async function expectAcrylicOutline(surface: Locator) {
const frame = await getAcrylicFrame(surface);
expect(frame.borderTopColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderRightColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderBottomColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderLeftColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.boxShadow).toContain("0px 1px 0px 0px inset");
expect(frame.boxShadow).toContain("0px 0px 0px 1px");
return frame;
}
async function expectUnifiedHistoryStructure(history: Locator) {
const structure = await history.evaluate((element) => {
const paragraphs = Array.from(element.querySelectorAll("p"));
const heading = paragraphs.find((paragraph) => paragraph.textContent?.trim() === "历史记录");
const count = paragraphs.find((paragraph) =>
/^(?:\d+ 个会话|暂无历史会话)$/.test(paragraph.textContent?.trim() ?? "")
);
return {
countTagName: count?.tagName ?? null,
headingTagName: heading?.tagName ?? null,
siblings: Boolean(heading && count && heading.parentElement === count.parentElement)
};
});
expect(structure).toEqual({
countTagName: "P",
headingTagName: "P",
siblings: true
});
await expect(history.getByRole("button", { name: "重命名对话" }).first()).toBeVisible();
await expect(history.getByRole("button", { name: "删除对话" }).first()).toBeVisible();
}
async function expectTransparentExpandedHeader(header: Locator, agentSurface: Locator) {
const [presentation, agentOutlineColor] = await Promise.all([
header.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
borderBottomColor: style.borderBottomColor,
borderBottomLeftRadius: style.borderBottomLeftRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomWidth: style.borderBottomWidth,
borderLeftWidth: style.borderLeftWidth,
borderRightWidth: style.borderRightWidth,
borderTopWidth: style.borderTopWidth,
boxShadow: style.boxShadow
};
}),
agentSurface.evaluate((element) =>
getComputedStyle(element).getPropertyValue("--acrylic-outline").trim()
)
]);
expect(getAlpha(presentation.backgroundColor)).toBe(0);
expect(presentation.borderBottomColor).toBe(agentOutlineColor);
expect(presentation.borderBottomLeftRadius).toBe("16px");
expect(presentation.borderBottomRightRadius).toBe("16px");
expect(presentation.borderBottomWidth).toBe("1px");
expect(presentation.borderLeftWidth).toBe("0px");
expect(presentation.borderRightWidth).toBe("0px");
expect(presentation.borderTopWidth).toBe("0px");
expect(presentation.boxShadow).toContain("0px 12px 12px -12px");
}
async function getSurfacePresentation(surface: Locator) {
return surface.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderColor: style.borderColor,
borderRadius: style.borderRadius,
boxShadow: style.boxShadow,
color: style.color
};
});
}
function getAlpha(color: string) {
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
return match ? Number(match[1]) : 1;
}
async function expectLightweightMaterialContext(context: Locator) {
const style = await context.evaluate((element) => {
const computedStyle = getComputedStyle(element);
return {
backdropFilter: computedStyle.backdropFilter,
backgroundColor: computedStyle.backgroundColor,
webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter")
};
});
expect(style.backdropFilter).toBe("none");
expect(getAlpha(style.backgroundColor)).toBeGreaterThan(0);
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.3);
expect(["", "none"]).toContain(style.webkitBackdropFilter);
}
async function expectUnfilteredInternalSurfaces(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-");
}
}
function expectRoundedBox(actual: BoundingBox, expected: BoundingBox) {
expect(Math.round(actual.x)).toBe(Math.round(expected.x));
expect(Math.round(actual.y)).toBe(Math.round(expected.y));
expect(Math.round(actual.width)).toBe(Math.round(expected.width));
expect(Math.round(actual.height)).toBe(Math.round(expected.height));
}
function expectRoundedValue(actual: number, expected: number) {
expect(Math.abs(Math.round(actual) - Math.round(expected))).toBeLessThanOrEqual(1);
}
async function getFloatingSurfaceStyles(header: Locator, composer: Locator) {
const control = composer.locator(".agent-panel-control");
const [headerStyles, composerStyles, controlStyles] = await Promise.all([
header.evaluate((element) => {
const style = getComputedStyle(element);
return {
shadow: style.boxShadow,
transform: style.transform
};
}),
composer.evaluate((element) => {
const style = getComputedStyle(element);
return {
shadow: style.boxShadow,
transform: style.transform
};
}),
control.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderColor: style.borderColor,
boxShadow: style.boxShadow
};
})
]);
return {
composerShadow: composerStyles.shadow,
composerTransform: composerStyles.transform,
controlBorderColor: controlStyles.borderColor,
controlBoxShadow: controlStyles.boxShadow,
headerShadow: headerStyles.shadow,
headerTransform: headerStyles.transform
};
}