From 4ea27bda6a27f8395c48178c8c369e01ed08603c Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 15 Sep 2026 12:25:17 +0800 Subject: [PATCH] Isolate server search from the delete password prompt The delete confirmation adds a password field. Browsers and password managers paired it with the only other text field on the page and filled the account name into the server search box on the server list. The search input now stays out of the DOM while a delete confirmation is open, and the toolbar shows a suspended notice styled to match the field it replaces so the row does not jump. A jsdom test drives the real action menu and dialog instead of asserting page source strings. --- platform_web/pages/ServersPage.test.tsx | 145 ++++++++++++++++++++++++ platform_web/pages/ServersPage.tsx | 36 +++--- platform_web/theme/base-css.test.js | 7 ++ platform_web/theme/base.css | 1 + 4 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 platform_web/pages/ServersPage.test.tsx 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