fix(panel): 建服表单按后端规则即时校验名称和子域名,控制台命令历史的下键不再跳到最早一条,补请求体测试
This commit is contained in:
8 files changed
+405
-20
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { useAsync } from "@/lib/hooks";
|
import { useAsync } from "@/lib/hooks";
|
||||||
import { hostFor, type RuntimeConfig } from "@/lib/config";
|
import { hostFor, type RuntimeConfig } from "@/lib/config";
|
||||||
|
import { serverNameIssue, type ServerNameIssue } from "@/lib/naming";
|
||||||
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
|
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
|
||||||
import { InlineError } from "@/components/MessageLine";
|
import { InlineError } from "@/components/MessageLine";
|
||||||
|
|
||||||
@@ -35,8 +36,6 @@ import { InlineError } from "@/components/MessageLine";
|
|||||||
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
||||||
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
|
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 }[] {
|
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
||||||
return [
|
return [
|
||||||
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
{ 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 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 =
|
const canSubmit =
|
||||||
!!form.name &&
|
nameIssue === null &&
|
||||||
subdomainValid &&
|
subdomainIssue === null &&
|
||||||
!!form.image &&
|
!!form.image &&
|
||||||
!!form.memory &&
|
!!form.memory &&
|
||||||
!!form.storage &&
|
!!form.storage &&
|
||||||
@@ -102,6 +102,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
|
|
||||||
const policies = policyOptions(t);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -125,8 +131,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
id="cs-name"
|
id="cs-name"
|
||||||
placeholder={t("create_server_name_placeholder")}
|
placeholder={t("create_server_name_placeholder")}
|
||||||
value={form.name}
|
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>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -136,10 +149,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
placeholder={t("create_server_subdomain_placeholder")}
|
placeholder={t("create_server_subdomain_placeholder")}
|
||||||
value={form.subdomain}
|
value={form.subdomain}
|
||||||
onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
|
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">
|
<p id="cs-sub-hint" className={hintClass(form.subdomain, subdomainIssue)}>
|
||||||
{form.subdomain && !subdomainValid
|
{form.subdomain && subdomainIssue
|
||||||
? t("create_server_subdomain_invalid")
|
? issueText(subdomainIssue, form.subdomain)
|
||||||
: t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
|
: t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"console_offline_title": "Console is offline",
|
"console_offline_title": "Console is offline",
|
||||||
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
|
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
|
||||||
"my_servers_breadcrumb": "My servers",
|
"my_servers_breadcrumb": "My servers",
|
||||||
|
"command_label": "Server command",
|
||||||
"command_placeholder": "Type a command… e.g. list",
|
"command_placeholder": "Type a command… e.g. list",
|
||||||
"log_connecting": "Connecting…",
|
"log_connecting": "Connecting…",
|
||||||
"log_live": "Live",
|
"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_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": "Name",
|
||||||
"create_server_name_placeholder": "survival",
|
"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": "Subdomain",
|
||||||
"create_server_subdomain_placeholder": "survival",
|
"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_subdomain_hint": "Will be reachable at {{host}}",
|
||||||
"create_server_display_name": "Display name (optional)",
|
"create_server_display_name": "Display name (optional)",
|
||||||
"create_server_display_name_placeholder": "Survival World",
|
"create_server_display_name_placeholder": "Survival World",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"console_offline_title": "控制台未连接",
|
"console_offline_title": "控制台未连接",
|
||||||
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
|
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
|
||||||
"my_servers_breadcrumb": "我的服务器",
|
"my_servers_breadcrumb": "我的服务器",
|
||||||
|
"command_label": "服务器命令",
|
||||||
"command_placeholder": "输入命令…如 list",
|
"command_placeholder": "输入命令…如 list",
|
||||||
"log_connecting": "连接中…",
|
"log_connecting": "连接中…",
|
||||||
"log_live": "实时",
|
"log_live": "实时",
|
||||||
@@ -132,9 +133,11 @@
|
|||||||
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
|
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
|
||||||
"create_server_name": "名称",
|
"create_server_name": "名称",
|
||||||
"create_server_name_placeholder": "survival",
|
"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": "子域名",
|
||||||
"create_server_subdomain_placeholder": "survival",
|
"create_server_subdomain_placeholder": "survival",
|
||||||
"create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。",
|
|
||||||
"create_server_subdomain_hint": "访问地址:{{host}}",
|
"create_server_subdomain_hint": "访问地址:{{host}}",
|
||||||
"create_server_display_name": "显示名称(选填)",
|
"create_server_display_name": "显示名称(选填)",
|
||||||
"create_server_display_name_placeholder": "生存世界",
|
"create_server_display_name_placeholder": "生存世界",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { ServerConsole } from "./ServerConsole";
|
import { ServerConsole } from "./ServerConsole";
|
||||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
|
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(() => ({
|
const tier = vi.hoisted(() => ({
|
||||||
loading: false,
|
loading: false,
|
||||||
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
|
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
|
||||||
@@ -35,6 +36,7 @@ beforeEach(() => {
|
|||||||
calls.myServers.mockResolvedValue([]);
|
calls.myServers.mockResolvedValue([]);
|
||||||
calls.listImages.mockReset();
|
calls.listImages.mockReset();
|
||||||
calls.listImages.mockResolvedValue([]);
|
calls.listImages.mockResolvedValue([]);
|
||||||
|
calls.sendCommand.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderConsole() {
|
function renderConsole() {
|
||||||
@@ -212,3 +214,103 @@ describe("ServerConsole retirement", () => {
|
|||||||
expect(giveUp()).toBeNull();
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,6 +134,8 @@ function CommandInput({ name }: { name: string }) {
|
|||||||
const [last, setLast] = useState<{ cmd: string; out: string } | null>(null);
|
const [last, setLast] = useState<{ cmd: string; out: string } | null>(null);
|
||||||
const historyRef = useRef<string[]>(loadHistory(name));
|
const historyRef = useRef<string[]>(loadHistory(name));
|
||||||
const cursorRef = useRef(-1);
|
const cursorRef = useRef(-1);
|
||||||
|
// What was typed before stepping up into the history.
|
||||||
|
const draftRef = useRef("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const composingRef = useRef(false);
|
const composingRef = useRef(false);
|
||||||
|
|
||||||
@@ -172,21 +174,25 @@ function CommandInput({ name }: { name: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (h.length === 0) 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") {
|
if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
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;
|
cursorRef.current = idx;
|
||||||
setCommand(h[idx]);
|
setCommand(h[idx]);
|
||||||
setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
|
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();
|
e.preventDefault();
|
||||||
const idx = Math.min(h.length, cursorRef.current + 1);
|
const idx = cursorRef.current + 1;
|
||||||
cursorRef.current = idx;
|
cursorRef.current = idx;
|
||||||
setCommand(idx < h.length ? h[idx] : "");
|
const next = idx < h.length ? h[idx] : draftRef.current;
|
||||||
if (idx < h.length) {
|
setCommand(next);
|
||||||
setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
|
setTimeout(() => { inputRef.current?.setSelectionRange(next.length, next.length); }, 0);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [command, send]);
|
}, [command, send]);
|
||||||
|
|
||||||
@@ -207,6 +213,7 @@ function CommandInput({ name }: { name: string }) {
|
|||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onCompositionStart={() => { composingRef.current = true; }}
|
onCompositionStart={() => { composingRef.current = true; }}
|
||||||
onCompositionEnd={() => { composingRef.current = false; }}
|
onCompositionEnd={() => { composingRef.current = false; }}
|
||||||
|
aria-label={t("command_label")}
|
||||||
placeholder={t("command_placeholder")}
|
placeholder={t("command_placeholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|||||||
Reference in new issue
Block a user