Files
next-tjwater-frontend/tests/browser/project-switching.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

312 lines
18 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 { TEST_PROJECT_ID } from "./support/mock-scada-api";
const FENGYANG_ID = "b962c44e-8433-4a10-81e9-a64b70ac1152";
const projects = [
{ project_id: TEST_PROJECT_ID, name: "奉贤演示项目", code: "tjwater_v2", gs_workspace: "tjwater_next", status: "active" },
{ project_id: FENGYANG_ID, name: "凤阳(新版)", code: "fengyang_v2", gs_workspace: "fengyang_v2", status: "active" }
];
async function prepare(page: Page, denyFengyang = false, configureSearch = true) {
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("https://tjwater-api.invalid/**", async (route) => {
const request = route.request();
if (request.method() === "OPTIONS") return route.fallback();
const path = new URL(request.url()).pathname;
const projectId = request.headers()["x-project-id"];
const project = projects.find((item) => item.project_id === projectId);
const headers = { "Access-Control-Allow-Origin": "*" };
if (path === "/api/v1/projects") return route.fulfill({ headers, json: { items: projects, total: 2, limit: 1000, offset: 0 } });
if (!project) return route.fallback();
if (path === "/api/v1/projects/current/metadata") return route.fulfill({ headers,
status: denyFengyang && projectId === FENGYANG_ID ? 403 : 200,
json: { ...project, map_config: configureSearch ? { place_search: {
enabled: true, provider: "tianditu", admin_code: projectId === FENGYANG_ID ? "341126" : "310120",
region: projectId === FENGYANG_ID ? "凤阳县" : "奉贤区"
} } : {}, map_extent: { bbox: projectId === FENGYANG_ID
? [13043968, 3846034, 13127803, 3889110]
: [13508802, 3608164, 13555651, 3633686] }, project_role: "member" }
});
if (path === "/api/v1/access-context") return route.fulfill({ headers, json: {
user_id: "operator", username: "project.operator", system_role: "user", is_system_admin: false,
project_id: projectId, project_role: "member", permissions: ["webgis.view"]
} });
return route.fallback();
});
await page.emulateMedia({ reducedMotion: "reduce" });
}
async function openSwitcher(page: Page, input: "pointer" | "keyboard" = "pointer") {
const userMenu = page.getByRole("button", { name: "打开用户菜单" });
if (input === "keyboard") {
await userMenu.press("Enter");
await page.getByRole("menuitem", { name: "切换项目" }).press("Enter");
} else {
await userMenu.click();
await page.getByRole("menuitem", { name: "切换项目" }).click();
}
const dialog = page.getByRole("dialog", { name: "切换项目" });
await expect(dialog).toBeVisible();
return dialog;
}
async function chooseProject(page: Page, name: string) {
await page.getByRole("combobox", { name: "项目环境" }).click();
await page.getByRole("option").filter({ has: page.getByText(name, { exact: true }) }).click();
}
for (const viewport of [{ width: 1440, height: 900 }, { width: 375, height: 812 }, { width: 320, height: 740 }]) {
test(`switches projects through the dialog and select at ${viewport.width}px`, async ({ page }, testInfo) => {
// This scenario boots the map three times and checks both dialog states.
test.setTimeout(60_000);
// Empty mock sources emit unrelated degradation notices; exclude them from design captures.
const screenshotStyle = "[data-sonner-toaster] { visibility: hidden; }";
await page.setViewportSize(viewport);
await prepare(page);
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const requests: { url: string; project: string | undefined }[] = [];
page.on("request", (request) => requests.push({ url: request.url(), project: request.headers()["x-project-id"] }));
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page);
await expect(dialog.getByRole("combobox")).toContainText("奉贤演示项目");
await expect(dialog.getByRole("heading", { name: "切换项目" })).toBeVisible();
await expect(dialog.getByRole("button", { name: "进入项目", exact: true })).toBeDisabled();
await chooseProject(page, "凤阳(新版)");
expect(requests.some((request) => request.project === FENGYANG_ID)).toBe(false);
await dialog.screenshot({ path: testInfo.outputPath(`project-draft-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
await dialog.getByRole("button", { name: "进入项目", exact: true }).click();
await expect(dialog).toBeHidden();
await expect.poll(() => requests.some((request) => request.url.includes("/scada-devices") && request.project === FENGYANG_ID), { timeout: 10000 }).toBe(true);
await expect.poll(() => requests.some((request) => request.url.includes("/api/v1/sessions") && request.project === FENGYANG_ID)).toBe(true);
await expect.poll(() => page.evaluate(() => {
const source = window.__waterNetworkMap?.getSource("pipes") as { tiles?: string[] } | undefined;
return source?.tiles?.[0] ?? "";
})).toContain("fengyang_v2:pipes");
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getCenter().lng ?? 0), { timeout: 15_000 }).toBeGreaterThan(117);
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getCenter().lng ?? 999), { timeout: 15_000 }).toBeLessThan(119);
const currentDialog = await openSwitcher(page);
await expect(currentDialog.getByRole("combobox")).toContainText("凤阳(新版)");
const bounds = await currentDialog.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(15);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width - 15);
await page.screenshot({ path: testInfo.outputPath(`project-dialog-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
await currentDialog.screenshot({ path: testInfo.outputPath(`project-dialog-detail-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
await currentDialog.getByRole("combobox").click();
await expect(page.getByRole("listbox")).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`project-options-${viewport.width}.png`), animations: "disabled", style: screenshotStyle });
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await page.reload({ waitUntil: "domcontentloaded" });
const restoredDialog = await openSwitcher(page);
await expect(restoredDialog.getByRole("combobox")).toContainText("凤阳(新版)");
await chooseProject(page, "奉贤演示项目");
await restoredDialog.getByRole("button", { name: "进入项目", exact: true }).click();
await expect.poll(() => page.evaluate(() => {
const source = window.__waterNetworkMap?.getSource("pipes") as { tiles?: string[] } | undefined;
return source?.tiles?.[0] ?? "";
})).toContain("tjwater_next:pipes");
expect(pageErrors).toEqual([]);
});
}
test("keeps the dialog and current project when target access fails", async ({ page }) => {
await prepare(page, true);
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page);
await dialog.getByRole("combobox").press("ArrowDown");
await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).focus();
await page.keyboard.press("Enter");
await dialog.getByRole("button", { name: "进入项目", exact: true }).focus();
await page.keyboard.press("Enter");
await expect(dialog.getByRole("alert")).toContainText("目标项目暂时无法访问");
await expect(dialog.getByText("当前项目:奉贤演示项目")).toBeVisible();
await expect(dialog.getByRole("combobox")).toContainText("凤阳(新版)");
});
for (const reducedMotion of ["reduce", "no-preference"] as const) {
test(`discards a draft selection and restores focus with motion=${reducedMotion}`, async ({ page }) => {
await prepare(page);
await page.emulateMedia({ reducedMotion });
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page, reducedMotion === "reduce" ? "keyboard" : "pointer");
await expect(dialog.getByRole("combobox")).toBeFocused();
await expect(dialog.getByRole("button", { name: "关闭项目切换" })).not.toBeFocused();
await expect(page.getByRole("listbox")).toBeHidden();
await expect(dialog).toHaveCSS("opacity", "1");
await expect(dialog).toHaveCSS("animation-name", "none");
await page.keyboard.press("Shift+Tab");
await expect(dialog.getByRole("button", { name: "关闭项目切换" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(dialog.getByRole("combobox")).toBeFocused();
await dialog.getByRole("combobox").click();
await expect(page.getByRole("listbox")).toHaveCSS("animation-name", "none");
await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).click();
await dialog.getByRole("button", { name: "关闭项目切换" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "打开用户菜单" })).toBeFocused();
const reopened = await openSwitcher(page);
await expect(reopened.getByRole("combobox")).toContainText("奉贤演示项目");
await expect(reopened.getByRole("combobox")).toBeFocused();
await page.keyboard.press("Escape");
await expect(reopened).toBeHidden();
});
}
test("ignores a remembered project that is absent from the authorized list", async ({ page }) => {
await prepare(page);
await page.addInitScript(() => localStorage.setItem("tjwater:active-project", "not-authorized"));
const projectIds: string[] = [];
page.on("request", (request) => {
const project = request.headers()["x-project-id"];
if (project) projectIds.push(project);
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page);
await expect(dialog.getByRole("combobox")).toContainText("奉贤演示项目");
expect(projectIds).not.toContain("not-authorized");
expect(projectIds).toContain(TEST_PROJECT_ID);
});
test("opens the project list without a transient outer scrollbar", async ({ page }, testInfo) => {
await prepare(page);
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page);
await expect(dialog).toHaveCSS("opacity", "1");
const sampling = page.evaluate(() => new Promise<{
height: number; scrollHeight: number; overflow: string; viewportTransform: string;
}[]>((resolve, reject) => {
const frames: { height: number; scrollHeight: number; overflow: string; viewportTransform: string }[] = [];
const timeout = setTimeout(() => { observer.disconnect(); reject(new Error("Project list did not open")); }, 10_000);
const observer = new MutationObserver(() => {
const content = document.querySelector<HTMLElement>('[role="listbox"]');
if (!content) return;
observer.disconnect();
const start = performance.now();
const sample = () => {
const viewport = content.querySelector<HTMLElement>('[data-radix-select-viewport]');
frames.push({ height: content.clientHeight, scrollHeight: content.scrollHeight,
overflow: getComputedStyle(content).overflowY,
viewportTransform: viewport ? getComputedStyle(viewport).transform : "missing" });
if (performance.now() - start < 350) requestAnimationFrame(sample);
else { clearTimeout(timeout); resolve(frames); }
};
sample();
});
observer.observe(document.body, { childList: true, subtree: true });
}));
await dialog.getByRole("combobox").click();
const samples = await sampling;
await testInfo.attach("first-open-scroll-frames", { body: JSON.stringify(samples, null, 2), contentType: "application/json" });
expect(samples.length).toBeGreaterThan(1);
expect(samples.filter((sample) => ["auto", "scroll"].includes(sample.overflow) && sample.scrollHeight > sample.height)).toEqual([]);
await expect(page.getByRole("option")).toHaveCount(2);
await page.keyboard.press("Escape");
await expect(dialog.getByRole("combobox")).toBeFocused();
});
test("keeps long project lists scrollable with keyboard selection", async ({ page }) => {
await prepare(page);
await page.emulateMedia({ reducedMotion: "no-preference" });
const catalog = [...projects, ...Array.from({ length: 40 }, (_, index) => ({
project_id: `project-${index}`, name: `测试供水项目 ${index + 1}`,
code: `project_${index}`, gs_workspace: `project_${index}`, status: "active"
}))];
await page.route("https://tjwater-api.invalid/api/v1/projects?**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" },
json: { items: catalog, total: catalog.length, limit: 1000, offset: 0 } });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page);
await dialog.getByRole("combobox").click();
await expect(page.getByRole("option")).toHaveCount(catalog.length);
const viewport = page.locator("[data-radix-select-viewport]");
await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
await page.getByRole("option").first().press("End");
await expect(page.getByRole("option").last()).toBeFocused();
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await page.keyboard.press("Enter");
await expect(dialog.getByRole("combobox")).toContainText("测试供水项目 40");
});
for (const input of ["pointer", "keyboard"] as const) {
test(`selects a project without a dark dialog focus outline using ${input}`, async ({ page }, testInfo) => {
await prepare(page);
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page, input);
await expect(dialog).toHaveCSS("opacity", "1");
const trace = await dialog.evaluateHandle((element) => {
const samples: { isDialog: boolean; tag: string; outline: string; width: number; color: string }[] = [];
element.addEventListener("focusin", (event) => {
const target = event.target as HTMLElement;
const style = getComputedStyle(element);
samples.push({ isDialog: target === element, tag: target.tagName,
outline: style.outlineStyle, width: parseFloat(style.outlineWidth), color: style.outlineColor });
});
return samples;
});
const trigger = dialog.getByRole("combobox");
await expect(trigger).toBeFocused();
const focusedBorder = await trigger.evaluate((element) => getComputedStyle(element).borderTopColor);
if (input === "keyboard") {
await trigger.press("ArrowDown");
await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).focus();
await page.keyboard.press("Enter");
} else await chooseProject(page, "凤阳(新版)");
await expect(trigger).toContainText("凤阳(新版)");
await expect(trigger).toBeFocused();
await expect(trigger).toHaveCSS("border-top-color", focusedBorder);
const samples = await trace.jsonValue();
await trace.dispose();
await testInfo.attach("project-option-focus-trace", { body: JSON.stringify(samples, null, 2), contentType: "application/json" });
expect(samples.length).toBeGreaterThan(0);
expect(samples.filter((sample) => sample.isDialog && sample.width > 0 && sample.outline !== "none")).toEqual([]);
await expect(dialog.getByRole("button", { name: "进入项目", exact: true })).toBeEnabled();
});
}
test("place search follows project configuration after switching", async ({ page }, testInfo) => {
await prepare(page);
await page.route("**/api/v1/place-search-requests", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
expect(route.request().headers()["x-project-id"]).toBe(FENGYANG_ID);
expect(route.request().postDataJSON()).toEqual({ keyword: "府城镇" });
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
items: [{ id: "fengyang-place", name: "府城镇", address: "安徽省滁州市凤阳县府城镇", longitude: 117.56,
latitude: 32.86, category: "行政区", district: "凤阳县" }], total: 1, truncated: false, region: "凤阳县"
} });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const dialog = await openSwitcher(page);
await chooseProject(page, "凤阳(新版)");
await dialog.getByRole("button", { name: "进入项目", exact: true }).click();
await expect(dialog).toBeHidden();
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
const panel = page.getByRole("region", { name: "地址定位", exact: true });
await expect(panel).toContainText("凤阳县 · 天地图地名搜索");
await expect(panel).not.toContainText("奉贤区");
await panel.getByLabel("查询地址").fill("府城镇");
await panel.getByRole("button", { name: "搜索地名" }).click();
await panel.getByRole("button", { name: "定位 府城镇 安徽省滁州市凤阳县府城镇", exact: true }).click();
await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(1);
await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("117.560000, 32.860000");
await panel.screenshot({ path: testInfo.outputPath("fengyang-place-search.png"), animations: "disabled" });
});
test("unconfigured project disables place search", async ({ page }) => {
await prepare(page, false, false);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
const panel = page.getByRole("region", { name: "地址定位", exact: true });
await expect(panel).toContainText("当前项目暂未配置地名搜索");
await expect(panel.getByRole("button", { name: "搜索地名" })).toBeDisabled();
});