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.
This commit is contained in:
npc0-hue
2026-09-15 12:25:17 +08:00
parent a822e9250a
commit 4ea27bda6a
4 changed files with 176 additions and 13 deletions
+145
View File
@@ -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<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();
});
});
+23 -13
View File
@@ -505,19 +505,29 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<div className="server-toolbar" role="search" data-form-type="other"> <div className="server-toolbar" role="search" data-form-type="other">
<Search size={16} aria-hidden="true" /> <Search size={16} aria-hidden="true" />
<input {/* The delete prompt adds a password field, and browsers/password managers
type="search" pair it with the only other text field on the page, filling the account
name="server-list-filter" name into the server search box. Keep the search field out of the DOM
autoComplete="new-password" while that prompt is open instead of relying on autofill hints. */}
data-1p-ignore="true" {deleteConfirmation === null ? (
data-lpignore="true" <input
data-bwignore="true" type="search"
data-protonpass-ignore="true" name="server-list-filter"
value={keyword} autoComplete="new-password"
placeholder="搜索服务器名称、ID 或插件" data-1p-ignore="true"
aria-label="搜索服务器" data-lpignore="true"
onChange={(event) => setKeyword(event.target.value)} data-bwignore="true"
/> data-protonpass-ignore="true"
value={keyword}
placeholder="搜索服务器名称、ID 或插件"
aria-label="搜索服务器"
onChange={(event) => setKeyword(event.target.value)}
/>
) : (
<span className="server-search-suspended" aria-hidden="true">
</span>
)}
{statusFilters.map((filter) => ( {statusFilters.map((filter) => (
<button <button
key={filter.id} key={filter.id}
+7
View File
@@ -224,6 +224,13 @@ describe("platform web shared theme CSS", () => {
expect(css).toContain(".guided-install-planol,.maintenance-node-item"); expect(css).toContain(".guided-install-planol,.maintenance-node-item");
}); });
it("keeps the suspended server search aligned with the search field it replaces", () => {
const css = compact(readThemeCss());
expect(css).toContain(".server-toolbar.server-search-suspended{flex:11220px;display:inline-flex;align-items:center;min-height:38px");
expect(css).toContain("border:1pxdashedvar(--line)");
});
it("keeps magical card decoration from becoming square background patches", () => { it("keeps magical card decoration from becoming square background patches", () => {
const css = compact(readThemeCss()); const css = compact(readThemeCss());
+1
View File
@@ -455,6 +455,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-toolbar select{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;min-width:180px} .server-toolbar select{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;min-width:180px}
.server-toolbar select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} .server-toolbar select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-toolbar input[type=search]:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} .server-toolbar input[type=search]:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-toolbar .server-search-suspended{flex:1 1 220px;display:inline-flex;align-items:center;min-height:38px;border:1px dashed var(--line);border-radius:8px;padding:0 12px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink-faint);font:inherit}
.server-file-manager{display:grid;gap:10px} .server-file-manager{display:grid;gap:10px}
.server-file-pathbar{display:flex;align-items:center;gap:8px;min-width:0;padding:0 0 10px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;box-shadow:none} .server-file-pathbar{display:flex;align-items:center;gap:8px;min-width:0;padding:0 0 10px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;box-shadow:none}
.server-file-back-icon{transform:rotate(180deg)} .server-file-back-icon{transform:rotate(180deg)}