Files
browser/platform_web/pages/ServersPage.test.tsx
T
npc0-hue 4ea27bda6a 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.
2026-09-15 12:25:17 +08:00

146 lines
4.8 KiB
TypeScript

/** @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<void> {
await act(async () => {
root = createRoot(container!);
root.render(<ServersPage {...pageProps()} />);
});
await act(async () => {
await Promise.resolve();
});
}
function searchInput(): HTMLInputElement | null {
return container!.querySelector<HTMLInputElement>(".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<HTMLButtonElement>("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();
});
});