fix(panel): 建服表单按后端规则即时校验名称和子域名,控制台命令历史的下键不再跳到最早一条,补请求体测试

This commit is contained in:
Lemon-miaow committed 2026-09-27 06:30:07 +08:00
1 parent 6943d9c4c6
commit aa73a8ca28
8 files changed
+405 -20

No files matched your search

@@ -0,0 +1,181 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CreateServerDialog } from "./CreateServerDialog";
import { humanizeError } from "@/lib/api";
import type { CreateServerRequest, WhitelistImage } from "@/lib/types";
const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() }));
vi.mock("@/lib/api", async (importActual) => {
const actual = await importActual<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
// Radix Select opens with pointer capture and scrolls the picked item into view,
// neither of which jsdom implements.
beforeAll(() => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.releasePointerCapture ??= () => {};
Element.prototype.scrollIntoView ??= () => {};
});
const PAPER = "registry.example.test/paper:1.21";
const RETIRED = "registry.example.test/paper:1.20";
const cfg = { apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 };
const onCreated = vi.fn();
function image(image_ref: string, enabled: boolean): WhitelistImage {
return { image_ref, enabled } as WhitelistImage;
}
beforeEach(() => {
calls.listImages.mockReset();
calls.createServer.mockReset();
onCreated.mockReset();
calls.listImages.mockResolvedValue([image(PAPER, true), image(RETIRED, false)]);
calls.createServer.mockResolvedValue({});
});
async function openDialog() {
const user = userEvent.setup();
render(<CreateServerDialog cfg={cfg} onCreated={onCreated} />);
await user.click(screen.getByRole("button", { name: "New server" }));
await screen.findByRole("dialog");
return user;
}
const nameBox = () => screen.getByLabelText("Name") as HTMLInputElement;
const subdomainBox = () => screen.getByLabelText("Subdomain") as HTMLInputElement;
const create = () => screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
async function pick(user: ReturnType<typeof userEvent.setup>, field: string, option: string) {
await user.click(screen.getByRole("combobox", { name: field }));
await user.click(await screen.findByRole("option", { name: option }));
}
/** fillValid fills every required field with what the API takes. */
async function fillValid(user: ReturnType<typeof userEvent.setup>) {
await user.type(nameBox(), "survival");
await user.type(subdomainBox(), "survival");
await pick(user, "Image", PAPER);
}
function sent(): CreateServerRequest {
expect(calls.createServer).toHaveBeenCalledTimes(1);
return calls.createServer.mock.calls[0][0];
}
describe("CreateServerDialog request body", () => {
it("sends every field as chosen, lowercased and trimmed", async () => {
const user = await openDialog();
await user.type(nameBox(), "Survival-2");
await user.type(subdomainBox(), "SMP");
await user.type(screen.getByLabelText("Display name (optional)"), " Survival World ");
await pick(user, "Image", PAPER);
await pick(user, "Memory", "8Gi");
await pick(user, "Storage", "20Gi");
await pick(user, "Autostart policy", "Public — any player join wakes it");
await user.click(create());
expect(sent()).toStrictEqual({
name: "survival-2",
subdomain: "smp",
displayName: "Survival World",
image: PAPER,
memory: "8Gi",
storage: "20Gi",
autostartPolicy: "public",
});
expect(onCreated).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("leaves a blank display name out, and sends the default sizes and policy", async () => {
const user = await openDialog();
await fillValid(user);
await user.type(screen.getByLabelText("Display name (optional)"), " ");
await user.click(create());
expect(sent()).toStrictEqual({
name: "survival",
subdomain: "survival",
displayName: undefined,
image: PAPER,
memory: "4Gi",
storage: "10Gi",
autostartPolicy: "ownerOnly",
});
});
it("offers only the enabled images", async () => {
const user = await openDialog();
await user.click(screen.getByRole("combobox", { name: "Image" }));
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual([PAPER]);
});
it("keeps the form and says why when the API refuses, then creates on the next try", async () => {
const refusal = { status: 409, code: "conflict", message: "server survival already exists" };
calls.createServer.mockRejectedValueOnce(refusal);
const user = await openDialog();
await fillValid(user);
await user.click(create());
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(humanizeError(refusal))).toBeTruthy();
expect(nameBox().value).toBe("survival");
expect(onCreated).not.toHaveBeenCalled();
await user.click(create());
expect(calls.createServer).toHaveBeenCalledTimes(2);
expect(onCreated).toHaveBeenCalledTimes(1);
});
});
describe("CreateServerDialog names", () => {
it.each([
["ab", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
["-survival", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
["survival-", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
["my_world", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
["a".repeat(33), "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
["lobby", "“lobby” is kept for the platform. Pick another."],
])("refuses the name %j before sending, and says why", async (name, why) => {
const user = await openDialog();
await fillValid(user);
expect(create().disabled).toBe(false);
await user.clear(nameBox());
await user.type(nameBox(), name);
expect(create().disabled).toBe(true);
expect(nameBox().getAttribute("aria-invalid")).toBe("true");
expect(screen.getByText(why)).toBeTruthy();
});
it("holds the subdomain to the same rule", async () => {
const user = await openDialog();
await fillValid(user);
await user.clear(subdomainBox());
await user.type(subdomainBox(), "api");
expect(create().disabled).toBe(true);
expect(screen.getByText("“api” is kept for the platform. Pick another.")).toBeTruthy();
expect(screen.queryByText(/Will be reachable at/)).toBeNull();
});
it("shows where a good subdomain will be reachable", async () => {
const user = await openDialog();
await user.type(subdomainBox(), "smp");
expect(screen.getByText(/Will be reachable at smp\.example\.test/)).toBeTruthy();
expect(subdomainBox().getAttribute("aria-invalid")).toBe("false");
});
});
+24 -9
View File
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import { serverNameIssue, type ServerNameIssue } from "@/lib/naming";
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
import { InlineError } from "@/components/MessageLine";
@@ -35,8 +36,6 @@ import { InlineError } from "@/components/MessageLine";
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
return [
{ value: "ownerOnly", label: t("create_server_policy_owner") },
@@ -72,10 +71,11 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
}
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
const subdomainValid = SUBDOMAIN_RE.test(form.subdomain);
const nameIssue = serverNameIssue(form.name);
const subdomainIssue = serverNameIssue(form.subdomain);
const canSubmit =
!!form.name &&
subdomainValid &&
nameIssue === null &&
subdomainIssue === null &&
!!form.image &&
!!form.memory &&
!!form.storage &&
@@ -102,6 +102,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
const policies = policyOptions(t);
// A field's hint turns into the reason it would be refused once something is typed.
const issueText = (issue: ServerNameIssue, value: string) =>
issue === "reserved" ? t("create_server_name_reserved", { name: value }) : t("create_server_name_invalid");
const hintClass = (value: string, issue: ServerNameIssue | null) =>
value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -125,8 +131,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
id="cs-name"
placeholder={t("create_server_name_placeholder")}
value={form.name}
onChange={(e) => set("name", e.target.value)}
onChange={(e) => set("name", e.target.value.toLowerCase())}
aria-invalid={!!form.name && nameIssue !== null}
aria-describedby="cs-name-hint"
/>
<p id="cs-name-hint" className={hintClass(form.name, nameIssue)}>
{form.name && nameIssue
? issueText(nameIssue, form.name)
: t("create_server_name_hint")}
</p>
</div>
<div className="grid gap-2">
@@ -136,10 +149,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
placeholder={t("create_server_subdomain_placeholder")}
value={form.subdomain}
onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
aria-invalid={!!form.subdomain && subdomainIssue !== null}
aria-describedby="cs-sub-hint"
/>
<p className="text-xs text-muted-foreground">
{form.subdomain && !subdomainValid
? t("create_server_subdomain_invalid")
<p id="cs-sub-hint" className={hintClass(form.subdomain, subdomainIssue)}>
{form.subdomain && subdomainIssue
? issueText(subdomainIssue, form.subdomain)
: t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
</p>
</div>
+4 -1
View File
@@ -41,6 +41,7 @@
"console_offline_title": "Console is offline",
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
"my_servers_breadcrumb": "My servers",
"command_label": "Server command",
"command_placeholder": "Type a command… e.g. list",
"log_connecting": "Connecting…",
"log_live": "Live",
@@ -132,9 +133,11 @@
"create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
"create_server_name": "Name",
"create_server_name_placeholder": "survival",
"create_server_name_hint": "3–32 lowercase letters, digits or hyphens.",
"create_server_name_invalid": "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end.",
"create_server_name_reserved": "“{{name}}” is kept for the platform. Pick another.",
"create_server_subdomain": "Subdomain",
"create_server_subdomain_placeholder": "survival",
"create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.",
"create_server_subdomain_hint": "Will be reachable at {{host}}",
"create_server_display_name": "Display name (optional)",
"create_server_display_name_placeholder": "Survival World",
+4 -1
View File
@@ -41,6 +41,7 @@
"console_offline_title": "控制台未连接",
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
"my_servers_breadcrumb": "我的服务器",
"command_label": "服务器命令",
"command_placeholder": "输入命令…如 list",
"log_connecting": "连接中…",
"log_live": "实时",
@@ -132,9 +133,11 @@
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
"create_server_name": "名称",
"create_server_name_placeholder": "survival",
"create_server_name_hint": "3–32 位小写字母、数字或连字符。",
"create_server_name_invalid": "请用 3–32 位小写字母、数字或连字符,首尾不能是连字符。",
"create_server_name_reserved": "“{{name}}” 是平台保留的名字,请换一个。",
"create_server_subdomain": "子域名",
"create_server_subdomain_placeholder": "survival",
"create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。",
"create_server_subdomain_hint": "访问地址:{{host}}",
"create_server_display_name": "显示名称(选填)",
"create_server_display_name_placeholder": "生存世界",
+31
View File
@@ -0,0 +1,31 @@
// The server name rule (spec §2, §22) as the API enforces it in internal/naming:
// 3–32 lowercase letters, digits or hyphens, no hyphen at either end, and none
// of the names the platform keeps for itself. The same rule covers subdomains.
// The create form checks it as the person types, so a name the API would
// answer with 400 never reaches it.
// RESERVED_SERVER_NAMES mirrors `reserved` in internal/naming/naming.go.
export const RESERVED_SERVER_NAMES: readonly string[] = [
"login",
"lobby",
"admin",
"panel",
"console",
"api",
"felis",
"velocity",
"registry",
"internal",
"www",
];
const SERVER_NAME_RE = /^[a-z0-9-]{3,32}$/;
export type ServerNameIssue = "shape" | "reserved";
/** serverNameIssue says why the API would refuse this name, or null if it takes it. */
export function serverNameIssue(name: string): ServerNameIssue | null {
if (!SERVER_NAME_RE.test(name) || name.startsWith("-") || name.endsWith("-")) return "shape";
if (RESERVED_SERVER_NAMES.includes(name)) return "reserved";
return null;
}
+104 -2
View File
@@ -1,12 +1,13 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServerConsole } from "./ServerConsole";
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
import { humanizeError } from "@/lib/api";
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() }));
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn(), sendCommand: vi.fn() }));
const tier = vi.hoisted(() => ({
loading: false,
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
@@ -35,6 +36,7 @@ beforeEach(() => {
calls.myServers.mockResolvedValue([]);
calls.listImages.mockReset();
calls.listImages.mockResolvedValue([]);
calls.sendCommand.mockReset();
});
function renderConsole() {
@@ -212,3 +214,103 @@ describe("ServerConsole retirement", () => {
expect(giveUp()).toBeNull();
});
});
describe("ServerConsole command line", () => {
const HISTORY = "felis:cmd:history:survival";
beforeEach(() => {
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
calls.sendCommand.mockResolvedValue({ output: "There are 0 of a max of 20 players online.\n" });
});
async function commandLine() {
renderConsole();
return (await screen.findByRole("textbox", { name: "Server command" })) as HTMLInputElement;
}
it("sends the typed command, trimmed, and shows the reply under it", async () => {
const user = userEvent.setup();
const box = await commandLine();
await user.type(box, " list {Enter}");
expect(calls.sendCommand).toHaveBeenCalledExactlyOnceWith("survival", "list");
expect(await screen.findByText("There are 0 of a max of 20 players online.")).toBeTruthy();
expect(screen.getByText(/^> list/)).toBeTruthy();
expect(box.value).toBe("");
});
it("sends nothing for a blank line", async () => {
const user = userEvent.setup();
const box = await commandLine();
await user.type(box, " {Enter}");
expect(calls.sendCommand).not.toHaveBeenCalled();
});
it("says why a command failed", async () => {
const refusal = { status: 503, code: "rcon_unavailable", message: "rcon: connection refused" };
calls.sendCommand.mockRejectedValue(refusal);
const user = userEvent.setup();
const box = await commandLine();
await user.type(box, "list{Enter}");
expect(await screen.findByText(humanizeError(refusal))).toBeTruthy();
});
it("sends nothing on the Enter that picks an input-method candidate", async () => {
const box = await commandLine();
fireEvent.compositionStart(box);
fireEvent.change(box, { target: { value: "你好" } });
fireEvent.keyDown(box, { key: "Enter" });
expect(calls.sendCommand).not.toHaveBeenCalled();
fireEvent.compositionEnd(box);
fireEvent.keyDown(box, { key: "Enter" });
await waitFor(() => expect(calls.sendCommand).toHaveBeenCalledExactlyOnceWith("survival", "你好"));
});
it("walks the history with Up and Down, and brings back the line being typed", async () => {
localStorage.setItem(HISTORY, JSON.stringify(["say hi", "list"]));
const user = userEvent.setup();
const box = await commandLine();
await user.type(box, "tim");
const seen: string[] = [];
for (const key of ["{ArrowUp}", "{ArrowUp}", "{ArrowUp}", "{ArrowDown}", "{ArrowDown}", "{ArrowDown}"]) {
await user.keyboard(key);
seen.push(box.value);
}
expect(seen).toEqual(["list", "say hi", "say hi", "list", "tim", "tim"]);
});
it("leaves a fresh line alone on Down", async () => {
localStorage.setItem(HISTORY, JSON.stringify(["say hi", "list"]));
const user = userEvent.setup();
const box = await commandLine();
await user.keyboard("{ArrowDown}");
expect(box.value).toBe("");
});
it("keeps each server's last 50 commands across visits, without repeats in a row", async () => {
localStorage.setItem(HISTORY, JSON.stringify(Array.from({ length: 50 }, (_, i) => `cmd ${i}`)));
const user = userEvent.setup();
const box = await commandLine();
await user.type(box, "list{Enter}");
await waitFor(() => expect(calls.sendCommand).toHaveBeenCalledTimes(1));
await user.type(box, "list{Enter}");
await waitFor(() => expect(calls.sendCommand).toHaveBeenCalledTimes(2));
const kept = JSON.parse(localStorage.getItem(HISTORY) ?? "[]") as string[];
expect(kept).toHaveLength(50);
expect(kept[0]).toBe("cmd 1");
expect(kept.slice(-2)).toEqual(["cmd 49", "list"]);
});
});
+14 -7
View File
@@ -134,6 +134,8 @@ function CommandInput({ name }: { name: string }) {
const [last, setLast] = useState<{ cmd: string; out: string } | null>(null);
const historyRef = useRef<string[]>(loadHistory(name));
const cursorRef = useRef(-1);
// What was typed before stepping up into the history.
const draftRef = useRef("");
const inputRef = useRef<HTMLInputElement>(null);
const composingRef = useRef(false);
@@ -172,21 +174,25 @@ function CommandInput({ name }: { name: string }) {
return;
}
if (h.length === 0) return;
// The cursor is -1 or h.length on the line being typed, else on a history entry.
const onLine = cursorRef.current === -1 || cursorRef.current >= h.length;
if (e.key === "ArrowUp") {
e.preventDefault();
const idx = Math.max(0, cursorRef.current === -1 ? h.length - 1 : cursorRef.current - 1);
if (onLine) draftRef.current = command;
const idx = Math.max(0, onLine ? h.length - 1 : cursorRef.current - 1);
cursorRef.current = idx;
setCommand(h[idx]);
setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
}
if (e.key === "ArrowDown") {
// Down walks back toward the line being typed, and brings it back as it was;
// on that line there is nothing below.
if (e.key === "ArrowDown" && !onLine) {
e.preventDefault();
const idx = Math.min(h.length, cursorRef.current + 1);
const idx = cursorRef.current + 1;
cursorRef.current = idx;
setCommand(idx < h.length ? h[idx] : "");
if (idx < h.length) {
setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
}
const next = idx < h.length ? h[idx] : draftRef.current;
setCommand(next);
setTimeout(() => { inputRef.current?.setSelectionRange(next.length, next.length); }, 0);
}
}, [command, send]);
@@ -207,6 +213,7 @@ function CommandInput({ name }: { name: string }) {
onKeyDown={onKeyDown}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={() => { composingRef.current = false; }}
aria-label={t("command_label")}
placeholder={t("command_placeholder")}
autoComplete="off"
spellCheck={false}