Files
next-tjwater-frontend/tests/browser/agent-history.e2e.ts
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

212 lines
13 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { createServer, type ServerResponse } from "node:http";
import { responseFixtureWriter } from "./support/responses-fixture";
import { expectThemeColor } from "./support/theme-colors";
async function prepare(page: Page) {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
const counts = { sessions: 0, suggestions: 0, projects: 0 };
page.on("request", (request) => {
if (request.method() === "OPTIONS") return;
const path = new URL(request.url()).pathname;
if (path.endsWith("/projects")) counts.projects++;
if (path.endsWith("/suggestions")) counts.suggestions++;
});
const sessions = Array.from({ length: 65 }, (_, index) => ({
id: `history-${index}`, session_id: `history-${index}`,
title: index === 64 ? "城南供水压力与流量证据核查" : `供水监测复核 ${index + 1}`,
updated_at: Date.now() - index * 60_000, created_at: Date.now() - index * 60_000, purpose: "ordinary_chat", run_status: "completed",
}));
await page.route("**/api/v1/sessions?**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
counts.sessions++;
const query = new URL(route.request().url()).searchParams;
const filtered = sessions.filter((item) => item.title.includes(query.get("q") ?? ""));
const start = Number(query.get("cursor") ?? 0);
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
sessions: filtered.slice(start, start + 30), has_more: start + 30 < filtered.length,
next_cursor: start + 30 < filtered.length ? String(start + 30) : null,
} });
});
const messages = Array.from({ length: 90 }, (_, index) => ({ id: `message-${index}`, role: index % 2 ? "assistant" : "user",
parts: [{ type: "text", text: `第 ${index + 1} 条:供水监测证据核对。${"压力、流量和监测时窗均需复核。".repeat(5)}` }] }));
await page.route(/\/api\/v1\/session\/history-[^?]+(?:\?.*)?$/, (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
const url = new URL(route.request().url());
if (url.pathname.endsWith("/messages")) {
const end = Number(url.searchParams.get("before"));
const start = Math.max(0, end - 40);
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
messages: messages.slice(start, end), message_page: { has_more: start > 0, before_cursor: start > 0 ? String(start) : null },
} });
}
const id = url.pathname.split("/").at(-1);
const session = sessions.find((item) => item.id === id);
if (!session) return route.fallback();
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
...session, messages: messages.slice(50), message_page: { has_more: true, before_cursor: "50" },
} });
});
return counts;
}
test("history searches all titles, loads pages and reuses lists and recommendations", async ({ page }, testInfo) => {
// Includes ten full focus cycles over 60 retained rows, plus pagination and search.
test.setTimeout(60_000);
// Keep the activity snapshot stable while testing cache hits; real status changes have unit coverage.
await page.clock.setFixedTime(new Date());
const counts = await prepare(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const trigger = page.getByRole("button", { name: "打开 Agent 历史记录" });
await expect(page.getByRole("group", { name: "推荐问题" })).toHaveAttribute("aria-busy", "false");
await expect(page.getByRole("button", { name: "选择 Agent 模型" }).locator("svg.lucide-zap")).toBeVisible();
const baseline = { ...counts };
await trigger.click();
const dialog = page.getByRole("dialog", { name: "历史对话" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("searchbox")).toBeFocused();
await expectThemeColor(dialog, "background-color", "--surface-reading");
await expect(dialog).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(dialog.locator("[data-agent-history-header]")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(dialog.getByRole("region", { name: "Agent 历史记录" })).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(page.locator(".agent-history-overlay")).toHaveCSS("backdrop-filter", "none");
await expect(dialog.getByRole("button", { name: "关闭历史对话" }).locator("svg.lucide-x")).toHaveCSS("width", "16px");
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(30);
await dialog.getByRole("button", { name: "加载更多", exact: true }).click();
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60);
await dialog.getByRole("searchbox").fill("城南");
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(1);
await expect(dialog.getByText("城南供水压力与流量证据核查", { exact: true })).toBeVisible();
await dialog.getByRole("searchbox").fill("");
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60);
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
const loadedCounts = { ...counts };
const cycleMs: number[] = [];
for (let i = 0; i < 10; i++) {
const started = Date.now();
await trigger.click();
await expect(dialog).toBeVisible();
await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60);
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
cycleMs.push(Date.now() - started);
}
await testInfo.attach("history-cache-metrics", { contentType: "application/json", body: JSON.stringify({ before: loadedCounts, after: counts, cycleMs }) });
expect(counts).toEqual(loadedCounts);
expect(counts.suggestions).toBe(baseline.suggestions);
expect(counts.projects).toBe(baseline.projects);
await trigger.click();
await dialog.screenshot({ path: "test-results/agent-history/desktop.png" });
});
for (const width of [320, 375]) test(`history is full screen and preserves the mobile sheet at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 812 });
await prepare(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
const dialog = page.getByRole("dialog", { name: "历史对话" });
await expect(dialog).toBeVisible();
await expect(dialog).toHaveCSS("background-color", "rgb(255, 255, 255)");
const box = (await dialog.boundingBox())!;
expect(box.x).toBe(0); expect(box.y).toBe(0); expect(box.width).toBe(width);
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
await dialog.screenshot({ path: `test-results/agent-history/mobile-${width}.png` });
await page.evaluate(() => {
Object.defineProperty(window.visualViewport!, "height", { configurable: true, value: 480 });
window.visualViewport!.dispatchEvent(new Event("resize"));
});
await expect.poll(async () => (await dialog.boundingBox())!.height).toBe(480);
await expect(dialog.getByRole("button", { name: "关闭历史对话" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toBeVisible();
});
test("older messages prepend without moving the visible reading anchor", async ({ page }) => {
await prepare(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.getByRole("dialog", { name: "历史对话" }).locator(".agent-history-session-trigger").first().click();
await expect(page.getByRole("dialog", { name: "历史对话" })).toBeHidden();
await expect(page.locator("[data-agent-message-id]")).toHaveCount(40);
const scroll = page.locator(".agent-conversation-scroll");
await scroll.evaluate((element) => { element.scrollTop = 1; element.dispatchEvent(new Event("scroll")); });
await expect(page.locator("[data-agent-message-id]")).toHaveCount(80);
expect(await scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
await expect(page.getByRole("button", { name: "滚动到最新消息" })).toBeVisible();
const position = await scroll.evaluate((element) => element.scrollTop);
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 2\s/ }).click();
await expect(page.locator("[data-agent-message-id]")).toHaveCount(40);
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click();
await expect(page.locator("[data-agent-message-id]")).toHaveCount(80);
await expect.poll(async () => Math.abs(await scroll.evaluate((element) => element.scrollTop) - position)).toBeLessThan(2);
});
test("switching conversations keeps the original generation and each draft isolated", async ({ page }) => {
await prepare(page);
let output: ServerResponse | undefined;
let aborts = 0;
let readsOfRunningSession = 0;
const write = responseFixtureWriter("background-a");
const server = createServer((_request, response) => {
output = response;
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
response.write(write({ type: "start", messageId: "background-answer" }));
response.write(write({ type: "data-progress", data: { session_id: "background-a", title: "正在复核后台压力", status: "running" } }));
response.write(write({ type: "data-session_title", data: { session_id: "background-a", title: "后台压力复核 A" } }));
response.write(write({ type: "text-start", id: "background-text" }));
response.write(write({ type: "text-delta", id: "background-text", delta: "已开始复核," }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing fixture server address");
try {
await page.route("**/api/v1/responses", (route) => route.request().method() === "OPTIONS" ? route.fallback()
: route.continue({ url: `http://127.0.0.1:${address.port}/responses` }));
page.on("request", (request) => {
if (request.method() === "OPTIONS") return;
const path = new URL(request.url()).pathname;
if (path.endsWith("/abort")) aborts++;
if (path.endsWith("/session/background-a")) readsOfRunningSession++;
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const prompt = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("核对后台压力");
await page.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(page.getByText("已开始复核,", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click();
await prompt.fill("保留在另一会话的草稿");
await expect(page.getByRole("button", { name: /会话正在后台处理/ })).toBeVisible();
output!.write(write({ type: "text-delta", id: "background-text", delta: "后台证据核对完成。" }));
output!.write(write({ type: "text-end", id: "background-text" }));
output!.write(write({ type: "finish" }));
output!.end();
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /后台压力复核 A\s/ }).click();
await expect(page.getByText("已开始复核,后台证据核对完成。", { exact: true })).toBeVisible();
await expect(prompt).toHaveValue("");
expect(aborts).toBe(0);
expect(readsOfRunningSession).toBe(0);
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click();
await expect(prompt).toHaveValue("保留在另一会话的草稿");
} finally {
output?.end();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});