diff --git a/platform_web/pages/ServersPage.test.tsx b/platform_web/pages/ServersPage.test.tsx new file mode 100644 index 0000000..5e6c6ec --- /dev/null +++ b/platform_web/pages/ServersPage.test.tsx @@ -0,0 +1,145 @@ +/** @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ServerInstanceResponse } from "../api/types"; +import type { PageComponentProps } from "../contracts/page"; +import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace"; +import type { OperationTracker } from "../stores/operations"; +import { ServersPage } from "./ServersPage"; + +const apiMocks = vi.hoisted(() => ({ + listGamePlugins: vi.fn(), + listRunEndpoints: vi.fn(), + listServerInstances: vi.fn(), + listJobs: vi.fn(), + listServerMetrics: vi.fn(), + deleteServerInstance: vi.fn() +})); + +vi.mock("../api/client", () => ({ platformApiClient: apiMocks })); + +const adminUser: CurrentUserView = { + id: "user-admin", + displayName: "Operator", + status: "active", + roles: ["platformAdmin"], + capabilities: capabilitiesForRoles(["platformAdmin"]), + profile: {}, + source: "local" +}; + +const noopOperations = { + operations: [], + begin: () => "operation-1", + update: () => undefined, + succeed: () => undefined, + fail: () => undefined, + isPending: () => false +} as unknown as OperationTracker; + +const instance = { + id: "server-1", + name: "SCUM Alpha", + pluginId: "server.scum", + pluginVersion: "1.0.0", + runEndpointId: "run-local", + ownerUserId: "user-admin", + state: "ready", + configVersion: 1 +} as ServerInstanceResponse; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + apiMocks.listGamePlugins.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listRunEndpoints.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listServerInstances.mockResolvedValue({ items: [instance], count: 1 }); + apiMocks.listJobs.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listServerMetrics.mockResolvedValue({ items: [], count: 0 }); + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + root = null; + container?.remove(); + container = null; + document.body.innerHTML = ""; + vi.clearAllMocks(); +}); + +function pageProps(): PageComponentProps { + return { + session: adminUser, + params: {}, + operations: noopOperations, + onNavigate: () => undefined, + onLogout: async () => undefined, + onProfileSave: async () => adminUser, + onThemePreferenceSave: async () => ({ userId: adminUser.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-09-15T00:00:00Z" }) + }; +} + +async function renderServersPage(): Promise { + await act(async () => { + root = createRoot(container!); + root.render(); + }); + await act(async () => { + await Promise.resolve(); + }); +} + +function searchInput(): HTMLInputElement | null { + return container!.querySelector(".server-toolbar input[type='search']"); +} + +function findButton(text: string, scope: ParentNode = document.body): HTMLButtonElement { + const button = Array.from(scope.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes(text)); + if (!button) throw new Error(`button not found: ${text}`); + return button; +} + +describe("ServersPage server search isolation", () => { + it("keeps the search field out of the DOM while the delete password prompt is open", async () => { + await renderServersPage(); + + const input = searchInput(); + expect(input).not.toBeNull(); + expect(input!.getAttribute("autocomplete")).toBe("new-password"); + expect(input!.getAttribute("data-1p-ignore")).toBe("true"); + expect(input!.getAttribute("data-lpignore")).toBe("true"); + expect(input!.getAttribute("data-bwignore")).toBe("true"); + expect(input!.getAttribute("data-protonpass-ignore")).toBe("true"); + + const menuButton = container!.querySelector("button[aria-haspopup='menu']"); + expect(menuButton).not.toBeNull(); + await act(async () => { + menuButton!.click(); + }); + await act(async () => { + findButton("删除服务器").click(); + }); + + expect(document.querySelector(".confirm-panel[role='dialog']")).not.toBeNull(); + expect(document.querySelector("input[name='server-delete-password-confirmation']")).not.toBeNull(); + expect(searchInput()).toBeNull(); + expect(container!.querySelector(".server-search-suspended")?.textContent).toContain("搜索暂时停用"); + + await act(async () => { + findButton("取消", document.querySelector(".confirm-panel") ?? document.body).click(); + }); + expect(document.querySelector(".confirm-panel[role='dialog']")).toBeNull(); + expect(searchInput()).not.toBeNull(); + expect(apiMocks.deleteServerInstance).not.toHaveBeenCalled(); + }); +}); diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 180a758..4e8f66a 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -505,19 +505,29 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr