feat: add project-aware agent workspace
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user