diff --git a/internal/naming/panel_mirror_test.go b/internal/naming/panel_mirror_test.go new file mode 100644 index 0000000..fdda9c3 --- /dev/null +++ b/internal/naming/panel_mirror_test.go @@ -0,0 +1,43 @@ +package naming + +import ( + "os" + "regexp" + "slices" + "strings" + "testing" +) + +// The panel's create form checks names as they are typed with its own copy of +// this rule (panel/src/lib/naming.ts). A name reserved or a length changed here +// without the panel would let the form send what the API refuses, or refuse what +// it takes, so the copy is compared to the source. +func TestPanelMirrorsServerNameRule(t *testing.T) { + raw, err := os.ReadFile("../../panel/src/lib/naming.ts") + if err != nil { + t.Fatal(err) + } + src := string(raw) + + list := regexp.MustCompile(`(?s)RESERVED_SERVER_NAMES: readonly string\[\] = \[(.*?)\];`).FindStringSubmatch(src) + if list == nil { + t.Fatal("panel/src/lib/naming.ts: RESERVED_SERVER_NAMES not found") + } + var panel []string + for _, m := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(list[1], -1) { + panel = append(panel, m[1]) + } + var api []string + for name := range reserved { + api = append(api, name) + } + slices.Sort(panel) + slices.Sort(api) + if !slices.Equal(panel, api) { + t.Errorf("panel reserves %v, API reserves %v", panel, api) + } + + if want := "const SERVER_NAME_RE = /" + serverNameRE.String() + "/;"; !strings.Contains(src, want) { + t.Errorf("panel/src/lib/naming.ts lacks %q", want) + } +} diff --git a/panel/src/components/CreateServerDialog.test.tsx b/panel/src/components/CreateServerDialog.test.tsx new file mode 100644 index 0000000..53154e0 --- /dev/null +++ b/panel/src/components/CreateServerDialog.test.tsx @@ -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(); + 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(); + 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, 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) { + 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"); + }); +}); diff --git a/panel/src/components/CreateServerDialog.tsx b/panel/src/components/CreateServerDialog.tsx index 3be90ec..8f1e276 100644 --- a/panel/src/components/CreateServerDialog.tsx +++ b/panel/src/components/CreateServerDialog.tsx @@ -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 ( @@ -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" /> +

+ {form.name && nameIssue + ? issueText(nameIssue, form.name) + : t("create_server_name_hint")} +

@@ -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" /> -

- {form.subdomain && !subdomainValid - ? t("create_server_subdomain_invalid") +

+ {form.subdomain && subdomainIssue + ? issueText(subdomainIssue, form.subdomain) : t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}

diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 15230c3..300fee0 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 5d4b39b..efca840 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -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": "生存世界", diff --git a/panel/src/lib/naming.ts b/panel/src/lib/naming.ts new file mode 100644 index 0000000..f0d35bc --- /dev/null +++ b/panel/src/lib/naming.ts @@ -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; +} diff --git a/panel/src/pages/ServerConsole.test.tsx b/panel/src/pages/ServerConsole.test.tsx index 2ac0f79..41bb4ee 100644 --- a/panel/src/pages/ServerConsole.test.tsx +++ b/panel/src/pages/ServerConsole.test.tsx @@ -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: "admin@example.test", 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"]); + }); +}); diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 7d1d07c..124f506 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -134,6 +134,8 @@ function CommandInput({ name }: { name: string }) { const [last, setLast] = useState<{ cmd: string; out: string } | null>(null); const historyRef = useRef(loadHistory(name)); const cursorRef = useRef(-1); + // What was typed before stepping up into the history. + const draftRef = useRef(""); const inputRef = useRef(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}