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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -505,6 +505,11 @@ 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" />
|
||||||
|
{/* The delete prompt adds a password field, and browsers/password managers
|
||||||
|
pair it with the only other text field on the page, filling the account
|
||||||
|
name into the server search box. Keep the search field out of the DOM
|
||||||
|
while that prompt is open instead of relying on autofill hints. */}
|
||||||
|
{deleteConfirmation === null ? (
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
name="server-list-filter"
|
name="server-list-filter"
|
||||||
@@ -518,6 +523,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
aria-label="搜索服务器"
|
aria-label="搜索服务器"
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
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}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user