test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认

This commit is contained in:
Lemon-miaow committed 2026-09-25 10:47:19 +08:00
1 parent ea425cffa4
commit 2f99874d5e
44 files changed
+10578 -216

No files matched your search

+4
View File
@@ -13,3 +13,7 @@ dist/
# Logs
npm-debug.log*
# Playwright output
test-results/
playwright-report/
+58 -35
View File
@@ -9,8 +9,9 @@ import type {
CreateServerRequest,
FleetServer,
Identity,
MyServerView,
Phase,
ServerInfo,
ServerStatus,
WhitelistImage,
Submission,
UserView,
@@ -52,7 +53,7 @@ interface MockAccount {
sessions?: SessionView[];
}
interface MockServer extends ServerInfo {
interface MockServer extends ServerStatus {
owner: AccountID | null;
}
@@ -220,13 +221,9 @@ function initialState(): MockState {
linked: account("linked", "user", true, true),
},
images: [
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
{ image_ref: "registry.felis.svc:5000/fabric-1.20.1:demo", enabled: true, source: "demo" },
{
image_ref: "registry.felis.svc:5000/forge-1.20.1:disabled",
enabled: false,
source: "demo",
},
whitelistImage("registry.felis.svc:5000/paper-1.21:demo", "recommended"),
whitelistImage("registry.felis.svc:5000/fabric-1.20.1:demo", "recommended"),
{ ...whitelistImage("registry.felis.svc:5000/forge-1.20.1:disabled", "external"), enabled: false },
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
@@ -467,24 +464,28 @@ function account(
};
}
function whitelistImage(image_ref: string, source: string): WhitelistImage {
return { image_ref, enabled: true, source, added_by: "owner", added_at: "2026-01-01T00:00:00Z" };
}
function server(
name: string,
displayName: string,
phase: Phase,
owner: AccountID | null,
overrides: Partial<ServerInfo> = {},
overrides: Partial<ServerStatus> = {},
): MockServer {
return {
name,
subdomain: name,
displayName,
phase,
ready: phase === "Running",
desiredState: phase === "Stopped" ? "Stopped" : "Running",
playersOnline: phase === "Running" ? 1 : 0,
playersMax: 20,
autostartPolicy: "ownerOnly",
owned: false,
claimable: false,
idleStopSeconds: 900,
owner,
...overrides,
};
@@ -560,10 +561,10 @@ function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id;
}
function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[] {
function visibleServers(state: MockState, accountInfo: MockAccount): MyServerView[] {
return state.servers
.filter((serverInfo) => canSee(accountInfo, serverInfo))
.map((serverInfo) => projectServer(serverInfo, accountInfo));
.map((serverInfo) => myServerView(serverInfo, accountInfo));
}
// fleetView projects the internal mock servers into the GET /fleet wire shape
@@ -574,35 +575,59 @@ function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[
// gated on Running, exactly as the real cluster reports them.
function fleetView(state: MockState): FleetServer[] {
return state.servers.map((s, i) => {
const ready = s.phase === "Running";
const { owner, ...wire } = s;
return {
name: s.name,
subdomain: s.subdomain,
phase: s.phase,
ready,
desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy,
...wire,
endpointMode: "domain",
endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined,
playersOnline: ready ? s.playersOnline ?? 0 : 0,
playersMax: s.playersMax ?? 0,
owner: s.owner ? state.accounts[s.owner].email : "",
endpointAddress: s.ready ? `10.43.0.${10 + i}:25565` : undefined,
playersOnline: s.ready ? s.playersOnline : 0,
owner: owner ? state.accounts[owner].email : "",
};
});
}
function projectServer(serverInfo: MockServer, accountInfo: MockAccount): ServerInfo {
const { owner: _owner, ...wire } = serverInfo;
// myServerView mirrors handleMyServers: ownership and claim state plus the live
// fields, with owner detail (desired state, policy, unreadable count) only on
// rows the caller owns.
function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServerView {
const owned = canManage(accountInfo, serverInfo);
return {
...wire,
name: serverInfo.name,
subdomain: serverInfo.subdomain,
owned,
claimable: serverInfo.owner === null && accountInfo.linked && !owned,
phase: serverInfo.phase,
playersOnline: serverInfo.playersOnline,
playersMax: serverInfo.playersMax,
displayName: serverInfo.displayName,
...(owned && {
desiredState: serverInfo.desiredState,
autostartPolicy: serverInfo.autostartPolicy,
playerCountUnknown: serverInfo.playerCountUnknown,
}),
};
}
// statusView mirrors handleServerStatus: the whole projection for the owner or
// an admin, the public subset (publicServerInfo) for anyone else.
function statusView(serverInfo: MockServer, accountInfo: MockAccount): ServerStatus {
const { owner: _owner, ...wire } = serverInfo;
if (canManage(accountInfo, serverInfo)) return wire;
return {
name: wire.name,
subdomain: wire.subdomain,
displayName: wire.displayName,
phase: wire.phase,
ready: wire.ready,
playersOnline: wire.playersOnline,
playersMax: wire.playersMax,
idleStopSeconds: 0,
};
}
function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase;
serverInfo.ready = phase === "Running";
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.playersOnline = phase === "Running" ? Math.max(serverInfo.playersOnline ?? 0, 1) : 0;
}
@@ -960,7 +985,7 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
const filtered = allUsers.filter((u) => {
return (
u.username.toLowerCase().includes(search) ||
u.email.toLowerCase().includes(search)
(u.email ?? "").toLowerCase().includes(search)
);
});
@@ -1248,7 +1273,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
if (img) {
img.enabled = true;
} else {
img = { image_ref: ref, enabled: true, source: "external" };
img = whitelistImage(ref, "external");
ctx.state.images.unshift(img);
}
sendJSON(ctx.res, 201, img);
@@ -1309,7 +1334,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
b.finished_at = new Date().toISOString();
// Add to whitelist images
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
ctx.state.images.unshift({ ...whitelistImage(b.image_ref, "built"), build_id: b.id });
}
}
}, 15000); // Succeeded after 15 seconds
@@ -1535,7 +1560,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
b.status = "succeeded";
b.finished_at = new Date().toISOString();
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
ctx.state.images.unshift({ ...whitelistImage(b.image_ref, "built"), build_id: b.id });
}
}
}, 15000);
@@ -1660,9 +1685,7 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
}
if (is("GET", ctx) && ctx.parts[4] === "status") {
// The real status projection (api.ServerInfo) carries no owned/claimable.
const { owned: _o, claimable: _c, ...status } = projectServer(serverInfo, ctx.account);
sendJSON(ctx.res, 200, status);
sendJSON(ctx.res, 200, statusView(serverInfo, ctx.account));
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "console") {
+77
View File
@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import { test as base, expect, type Page } from "@playwright/test";
// t reads the en-US strings the panel renders, so a copy change does not
// break the smoke and a missing key does ("ns:key", optional {{count}}).
const cache = new Map<string, Record<string, string>>();
export function t(key: string, vars: Record<string, string | number> = {}): string {
const [ns, k] = key.split(":");
if (!cache.has(ns)) {
const url = new URL(`../src/i18n/resources/en-US/${ns}.json`, import.meta.url);
cache.set(ns, JSON.parse(readFileSync(url, "utf8")));
}
const table = cache.get(ns)!;
const plural = typeof vars.count === "number" ? `${k}_${vars.count === 1 ? "one" : "other"}` : k;
const text = table[plural] ?? table[k];
if (text === undefined) throw new Error(`missing en-US string ${key}`);
return text.replace(/\{\{(\w+)\}\}/g, (_, v: string) => String(vars[v]));
}
type Account = "owner" | "user" | "linked";
export const test = base.extend<{ signIn: (account: Account) => Promise<void> }>({
page: async ({ page, request }, use) => {
const res = await request.post("/api/v1/__mock/reset");
expect(res.ok()).toBe(true);
await use(page);
},
// signIn sets the mock session cookie directly; the sign-in form itself is
// covered by its own test.
signIn: async ({ context, baseURL }, use) => {
await use(async (account) => {
await context.addCookies([{ name: "felis_mock_session", value: account, url: baseURL! }]);
});
},
});
export { expect };
/** expectFitsScreen fails when anything scrolls sideways or a visible element
* pokes past the viewport edge (an overflow-hidden parent would just cut it
* off). The shell scrolls in an overflow-y-auto pane, whose computed
* overflow-x is auto as well, so a computed style cannot tell a deliberate
* horizontal scroller from the page pane: only a container that asks for one
* by class (overflow-x-auto, overflow-x-scroll, overflow-auto, e.g. a wide
* table) may be wider than the screen. */
export async function expectFitsScreen(page: Page) {
const report = await page.evaluate(() => {
const vw = document.documentElement.clientWidth;
const deliberate = (el: Element) =>
["overflow-x-auto", "overflow-x-scroll", "overflow-auto"].some((c) => el.classList.contains(c));
const inDeliberate = (el: Element) => {
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
if (deliberate(p)) return true;
}
return false;
};
const name = (el: Element) => {
const cls = typeof el.className === "string" ? el.className.split(/\s+/).slice(0, 3).join(".") : "";
return `${el.tagName.toLowerCase()}${cls ? "." + cls : ""}`;
};
const scrollers: string[] = [];
const offenders: string[] = [];
for (const el of [document.documentElement, ...Array.from(document.body.querySelectorAll("*"))]) {
const style = getComputedStyle(el);
if (style.visibility === "hidden" || deliberate(el) || inDeliberate(el)) continue;
if (el.scrollWidth > el.clientWidth + 1 && ["auto", "scroll"].includes(style.overflowX)) {
scrollers.push(`${name(el)} scrolls ${el.scrollWidth - el.clientWidth}px sideways`);
}
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.left < -1 || r.right > vw + 1) offenders.push(`${name(el)} [${Math.round(r.left)}..${Math.round(r.right)}]`);
}
return { vw, scrollers: scrollers.slice(0, 5), offenders: offenders.slice(0, 5) };
});
expect(report.scrollers, "containers that scroll sideways").toEqual([]);
expect(report.offenders, `elements past the ${report.vw}px viewport`).toEqual([]);
}
+31
View File
@@ -0,0 +1,31 @@
import { test, expect, t, expectFitsScreen } from "./fixtures";
test("the menu drawer reaches the other sections", async ({ page, signIn }) => {
await signIn("linked");
await page.goto("/");
await page.getByRole("button", { name: t("common:open_menu") }).click();
const drawer = page.getByRole("dialog");
await expect(drawer).toBeVisible();
await drawer.getByRole("link", { name: t("navigation:account") }).click();
await expect(page).toHaveURL(/\/account$/);
await expect(drawer).toBeHidden();
});
for (const [account, path] of [
["linked", "/"],
["linked", "/servers"],
["linked", "/servers/lobby"],
["linked", "/account"],
["owner", "/servers"],
["owner", "/admin/users"],
] as const) {
test(`${path} fits a 375px screen for ${account}`, async ({ page, signIn }) => {
await signIn(account);
await page.goto(path);
await page.waitForLoadState("networkidle");
await expectFitsScreen(page);
});
}
+55
View File
@@ -0,0 +1,55 @@
import { test, expect, t } from "./fixtures";
test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => {
await page.goto("/servers");
await expect(page).toHaveURL(/\/login\?next=%2Fservers$/);
await page.getByLabel(t("auth:email_address")).fill("[email protected]");
await page.getByRole("button", { name: t("auth:send_otp") }).click();
await page.getByLabel(t("auth:otp_code")).fill("123456");
await page.getByRole("button", { name: t("auth:otp_btn") }).click();
await expect(page).toHaveURL(/\/servers$/);
await expect(page.getByRole("heading", { name: t("ops:fleet_title") })).toBeVisible();
});
test("a player sees their own server, can claim an unowned one, and has no admin section", async ({ page, signIn }) => {
await signIn("linked");
await page.goto("/servers");
await expect(page.getByRole("heading", { name: t("servers:my_servers_title") })).toBeVisible();
// Display name first, the server name beside it.
const own = page.getByRole("row").filter({ hasText: "Hub Lobby" });
await expect(own).toContainText("lobby");
await expect(own.getByRole("link", { name: t("servers:console") })).toBeVisible();
await expect(own.getByRole("button", { name: t("servers:stop"), exact: true })).toBeVisible();
const unowned = page.getByRole("row").filter({ hasText: "Claimable Node" });
await expect(unowned.getByRole("button", { name: t("servers:claim") })).toBeVisible();
await expect(unowned.getByRole("button", { name: t("servers:stop"), exact: true })).toHaveCount(0);
await expect(page.getByRole("link", { name: t("navigation:admin_users") })).toHaveCount(0);
await page.goto("/admin/images");
await expect(page.getByText(t("common:not_authorized_title"))).toBeVisible();
});
test("stopping a server with players online asks first, and cancel keeps it running", async ({ page, signIn }) => {
await signIn("linked");
await page.goto("/servers/lobby");
await page.getByRole("button", { name: t("servers:stop"), exact: true }).click();
const question = page.getByText(t("servers:stop_confirm_players", { count: 28 }));
await expect(question).toBeVisible();
await page.getByRole("button", { name: t("common:cancel") }).click();
await expect(question).toHaveCount(0);
const status = await page.request.get("/api/v1/servers/lobby/status");
expect((await status.json()).phase).toBe("Running");
});
test("an admin reaches the user list", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/users");
await expect(page.getByRole("heading", { name: t("admin:users_title") })).toBeVisible();
await expect(page.getByText("[email protected]")).toBeVisible();
});
+27
View File
@@ -0,0 +1,27 @@
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
// Lint guards what tsc cannot: the rules of hooks (a hook behind a condition
// renders fine until the branch flips) and effect dependency lists (a stale
// closure, or a memo that recomputes every render). Types are tsc's job and
// style the formatter's, so nothing else is switched on. CI runs it with
// --max-warnings 0; a deliberate gap in a dependency list carries a disable
// comment that says why.
export default tseslint.config(
{ ignores: ["dist", "node_modules", "test-results", "playwright-report", "src/lib/openapi.gen.ts"] },
{
files: ["src/**/*.{ts,tsx}", "dev/**/*.ts"],
languageOptions: {
parser: tseslint.parser,
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node },
},
linterOptions: { reportUnusedDisableDirectives: "error" },
plugins: { "react-hooks": reactHooks },
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
);
+2283 -39
View File
File diff suppressed because it is too large. Load diff
+15 -1
View File
@@ -8,8 +8,11 @@
"dev": "vite",
"dev:mock": "vite --mode mock",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"typecheck": "tsc -b",
"test": "vitest run",
"test:e2e": "playwright test",
"lint": "eslint . --max-warnings 0",
"gen:api": "openapi-typescript ../docs/openapi.yaml -o src/lib/openapi.gen.ts",
"preview": "vite preview"
},
"dependencies": {
@@ -30,16 +33,27 @@
"three": "^0.172.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^10.11.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"jsdom": "^30.1.1",
"openapi-typescript": "^7.13.0",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3",
"typescript-eslint": "^8.70.1",
"vite": "^6.0.7",
"vitest": "^4.1.9"
}
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, devices } from "@playwright/test";
// Browser smoke over the mock-mode dev server (dev/mockApi.ts): real routing,
// real fetches and cookies, a fake backend. It runs the installed Chrome, so
// no browser download is needed locally or on the GitHub runner. The mock's
// state is shared across requests, so tests run one at a time and each one
// resets it first. PW_BASE_URL points it at an already running mock server.
const port = 5298;
const external = process.env.PW_BASE_URL;
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL: external ?? `http://localhost:${port}`,
channel: "chrome",
locale: "en-US",
trace: "retain-on-failure",
},
projects: [
{ name: "desktop", testMatch: /smoke\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } },
{
name: "mobile",
testMatch: /mobile\.spec\.ts/,
use: { channel: "chrome", viewport: { width: 375, height: 812 }, hasTouch: true, isMobile: true },
},
],
webServer: external
? undefined
: {
command: `node node_modules/vite/bin/vite.js --mode mock --port ${port} --strictPort`,
url: `http://localhost:${port}/`,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});
+2 -18
View File
@@ -1,12 +1,11 @@
import { useEffect } from "react";
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "@/lib/theme";
import { TierProvider } from "@/lib/tier";
import { SETUP_REQUIRED_EVENT } from "@/lib/api";
import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner";
import { SetupRequiredRedirect } from "@/components/SetupRequiredRedirect";
import { ValidParam } from "@/components/ValidParam";
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params";
import { Login } from "@/pages/Login";
@@ -27,21 +26,6 @@ import { UserDetailPage } from "@/pages/admin/UserDetailPage";
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
// SetupRequiredRedirect listens for the `403 setup_required` signal api.ts emits
// when a session still owes forced onboarding (#8) and routes it to the wizard.
// It must live inside the Router (it navigates) and outside RequireAuth (/setup
// sits there too); the event fires from any protected call the app makes, so the
// listener is always mounted by the time one arrives.
function SetupRequiredRedirect() {
const navigate = useNavigate();
useEffect(() => {
const toSetup = () => navigate("/setup", { replace: true });
window.addEventListener(SETUP_REQUIRED_EVENT, toSetup);
return () => window.removeEventListener(SETUP_REQUIRED_EVENT, toSetup);
}, [navigate]);
return null;
}
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
// / User-Side — app-tier, every authenticated principal
// /admin/* Admin-Side — admin-tier, server & content administration
+104
View File
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { ErrorBoundary } from "./ErrorBoundary";
const { reloadForNewDeploy } = vi.hoisted(() => ({ reloadForNewDeploy: vi.fn() }));
vi.mock("@/lib/chunk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/chunk")>();
return { ...actual, reloadForNewDeploy };
});
const t = (key: string) => i18next.t(key);
const state = { broken: true, error: new Error("boom") as unknown };
function Page() {
if (state.broken) throw state.error;
return <p>page content</p>;
}
// React and the boundary both log the caught render error, and React's dev
// build replays it as a window error event that jsdom would print too.
let quiet: ReturnType<typeof vi.spyOn>;
const swallow = (e: ErrorEvent) => e.preventDefault();
beforeEach(() => {
state.broken = true;
state.error = new Error("boom");
reloadForNewDeploy.mockReset();
quiet = vi.spyOn(console, "error").mockImplementation(() => {});
window.addEventListener("error", swallow);
});
afterEach(() => {
quiet.mockRestore();
window.removeEventListener("error", swallow);
});
describe("ErrorBoundary", () => {
it("shows the crash page with the error instead of a blank screen", () => {
render(
<ErrorBoundary>
<Page />
</ErrorBoundary>,
);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(t("common:crash_title"))).toBeTruthy();
expect(screen.getByText("Error: boom")).toBeTruthy();
expect(reloadForNewDeploy).not.toHaveBeenCalled();
});
it("try again renders the page once it stops throwing", async () => {
render(
<ErrorBoundary>
<Page />
</ErrorBoundary>,
);
state.broken = false;
await userEvent.click(screen.getByRole("button", { name: t("common:try_again") }));
expect(screen.getByText("page content")).toBeTruthy();
});
it("clears the crash when the route changes", () => {
const view = render(
<ErrorBoundary resetKey="/servers/a">
<Page />
</ErrorBoundary>,
);
expect(screen.getByRole("alert")).toBeTruthy();
state.broken = false;
view.rerender(
<ErrorBoundary resetKey="/servers/b">
<Page />
</ErrorBoundary>,
);
expect(screen.getByText("page content")).toBeTruthy();
});
it("uses the caller's fallback when given one", () => {
render(
<ErrorBoundary fallback={() => <p>flat fleet</p>}>
<Page />
</ErrorBoundary>,
);
expect(screen.getByText("flat fleet")).toBeTruthy();
});
it("reloads once for a chunk from an older deploy and says the panel updated", () => {
state.error = new TypeError("Failed to fetch dynamically imported module: /assets/Files-abc.js");
render(
<ErrorBoundary>
<Page />
</ErrorBoundary>,
);
expect(reloadForNewDeploy).toHaveBeenCalledOnce();
expect(screen.getByText(t("common:crash_updated_title"))).toBeTruthy();
});
});
+6 -5
View File
@@ -3,7 +3,7 @@ import { Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PHASE_KEY, phaseColor } from "@/components/PhaseBadge";
import { cn } from "@/lib/utils";
import type { Phase, ServerInfo } from "@/lib/types";
import type { Phase, MyServerView } from "@/lib/types";
// FleetGrid is the flat stand-in for VoxelFleet when WebGL is unavailable or the
// 3D chunk failed: the same square layout and phase palette seen from above, so
@@ -18,7 +18,7 @@ export function FleetGrid({
servers,
reason,
}: {
servers: Pick<ServerInfo, "name" | "displayName" | "phase">[];
servers: Pick<MyServerView, "name" | "displayName" | "phase">[];
reason: FleetGridReason;
}) {
const { t } = useTranslation(["dashboard", "servers"]);
@@ -36,8 +36,9 @@ export function FleetGrid({
style={{ gridTemplateColumns: `repeat(${cols}, ${tile})`, gridAutoRows: tile }}
>
{servers.map((s) => {
const color = phaseColor(s.phase);
const label = `${s.displayName || s.name} · ${t(PHASE_KEY[s.phase] ?? PHASE_KEY.Unknown)}`;
const phase = s.phase ?? "Unknown";
const color = phaseColor(phase);
const label = `${s.displayName || s.name} · ${t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}`;
return (
<Link
key={s.name}
@@ -46,7 +47,7 @@ export function FleetGrid({
aria-label={label}
className={cn(
"rounded-md ring-1 ring-inset ring-white/10 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
TRANSIENT.has(s.phase) && "animate-pulse",
TRANSIENT.has(phase) && "animate-pulse",
)}
style={{
backgroundColor: color,
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { PowerButton } from "./PowerButton";
const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, wake, stop } };
});
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
beforeEach(() => {
wake.mockReset();
stop.mockReset();
});
describe("PowerButton", () => {
it("wakes a stopped server and tells the parent", async () => {
wake.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
expect(wake).toHaveBeenCalledWith("lobby");
expect(onChanged).toHaveBeenCalledOnce();
});
it("stays on the page with the reason when the wake is refused", async () => {
wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" });
const onChanged = vi.fn();
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
expect(await screen.findByRole("alert")).toHaveProperty("textContent", t("errors:quota_exceeded"));
expect(onChanged).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
});
it("stops an empty server without asking", async () => {
stop.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(<PowerButton name="lobby" live playersOnline={0} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(stop).toHaveBeenCalledWith("lobby");
expect(onChanged).toHaveBeenCalledOnce();
});
it("asks before disconnecting players, and cancel sends nothing", async () => {
render(<PowerButton name="lobby" live playersOnline={3} onChanged={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: t("common:cancel") }));
expect(stop).not.toHaveBeenCalled();
expect(screen.queryByText(t("servers:stop_confirm_players", { count: 3 }))).toBeNull();
});
it("asks when the player count cannot be read", async () => {
stop.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(<PowerButton name="lobby" live playersOnline={0} playerCountUnknown onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(stop).not.toHaveBeenCalled();
expect(screen.getByText(t("servers:stop_confirm_unknown"))).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(stop).toHaveBeenCalledWith("lobby");
expect(onChanged).toHaveBeenCalledOnce();
});
it("keeps the confirmation open with the reason when the stop fails", async () => {
stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" });
const onChanged = vi.fn();
render(<PowerButton name="lobby" live playersOnline={2} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(await screen.findByText(t("errors:cooldown"))).toBeTruthy();
expect(screen.getByText(t("servers:stop_confirm_players", { count: 2 }))).toBeTruthy();
expect(onChanged).not.toHaveBeenCalled();
});
it("sends one call however fast it is clicked", async () => {
let resolve!: () => void;
wake.mockReturnValue(new Promise<void>((r) => (resolve = r)));
render(<PowerButton name="lobby" live={false} onChanged={vi.fn()} />);
const button = screen.getByRole("button", { name: t("servers:wake") });
await userEvent.click(button);
await userEvent.click(screen.getByRole("button", { name: t("servers:waking") }));
resolve();
expect(wake).toHaveBeenCalledOnce();
});
});
+68
View File
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import i18next from "i18next";
import { RequireAuth } from "./RequireAuth";
const tier = vi.hoisted(() => ({ loading: false, unauthenticated: false, sessionEnded: false }));
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
function LoginProbe() {
const location = useLocation();
return (
<p>
login{location.search} ended={String((location.state as { sessionEnded?: boolean } | null)?.sessionEnded ?? false)}
</p>
);
}
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/login" element={<LoginProbe />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<p>dashboard</p>} />
<Route path="/servers/:name" element={<p>console</p>} />
</Route>
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
Object.assign(tier, { loading: false, unauthenticated: false, sessionEnded: false });
});
describe("RequireAuth", () => {
it("shows a spinner while /me is in flight, never the login page", () => {
tier.loading = true;
tier.unauthenticated = true;
renderAt("/servers/lobby");
expect(screen.getByText(i18next.t("common:loading"))).toBeTruthy();
expect(screen.queryByText(/^login/)).toBeNull();
});
it("sends a 401 to /login with the page to come back to", () => {
tier.unauthenticated = true;
renderAt("/servers/lobby?tab=files");
expect(screen.getByText(`login?next=${encodeURIComponent("/servers/lobby?tab=files")} ended=false`)).toBeTruthy();
});
it("drops ?next= for the dashboard and says when the session ended", () => {
tier.unauthenticated = true;
tier.sessionEnded = true;
renderAt("/");
expect(screen.getByText("login ended=true")).toBeTruthy();
});
it("renders the app for a session, including a degraded /me", () => {
renderAt("/servers/lobby");
expect(screen.getByText("console")).toBeTruthy();
});
});
+3 -3
View File
@@ -8,10 +8,10 @@ import { PhaseBadge } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { api, humanizeError } from "@/lib/api";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { ServerInfo } from "@/lib/types";
import type { MyServerView } from "@/lib/types";
interface Props {
server: ServerInfo;
server: MyServerView;
cfg: RuntimeConfig;
/** Called after a successful mutation so the parent can refetch. */
onChanged: () => void;
@@ -49,7 +49,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
<span className="truncate text-sm font-semibold text-foreground">
{server.displayName || server.name}
</span>
<PhaseBadge phase={server.phase} />
<PhaseBadge phase={server.phase ?? "Unknown"} />
</div>
{/* Info Row */}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { SETUP_REQUIRED_EVENT } from "@/lib/api";
import { SetupRequiredRedirect } from "./SetupRequiredRedirect";
function Tree({ listening }: { listening: boolean }) {
return (
<MemoryRouter initialEntries={["/servers"]}>
{listening && <SetupRequiredRedirect />}
<Routes>
<Route path="/setup" element={<p>setup wizard</p>} />
<Route path="/servers" element={<p>servers</p>} />
</Routes>
</MemoryRouter>
);
}
const announce = () =>
act(() => {
window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT));
});
describe("SetupRequiredRedirect", () => {
it("routes to the wizard when a call answers 403 setup_required", () => {
render(<Tree listening />);
expect(screen.getByText("servers")).toBeTruthy();
announce();
expect(screen.getByText("setup wizard")).toBeTruthy();
});
it("stops redirecting once it is unmounted", () => {
const view = render(<Tree listening />);
view.rerender(<Tree listening={false} />);
announce();
expect(screen.getByText("servers")).toBeTruthy();
});
});
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { SETUP_REQUIRED_EVENT } from "@/lib/api";
// SetupRequiredRedirect listens for the `403 setup_required` signal api.ts emits
// when a session still owes forced onboarding (#8) and routes it to the wizard.
// It must live inside the Router (it navigates) and outside RequireAuth (/setup
// sits there too); the event fires from any protected call the app makes, so the
// listener is always mounted by the time one arrives.
export function SetupRequiredRedirect() {
const navigate = useNavigate();
useEffect(() => {
const toSetup = () => navigate("/setup", { replace: true });
window.addEventListener(SETUP_REQUIRED_EVENT, toSetup);
return () => window.removeEventListener(SETUP_REQUIRED_EVENT, toSetup);
}, [navigate]);
return null;
}
+5 -5
View File
@@ -3,7 +3,7 @@ import * as THREE from "three";
import { phaseColor } from "@/components/PhaseBadge";
import { FleetGrid } from "@/components/FleetGrid";
import { webglAvailable } from "@/lib/webgl";
import type { ServerInfo } from "@/lib/types";
import type { MyServerView } from "@/lib/types";
// VoxelFleet renders the fleet as a grid of voxels, one per server, colored by
// lifecycle phase — the 3D view of the same state machine the list shows (spec
@@ -16,7 +16,7 @@ import type { ServerInfo } from "@/lib/types";
// cannot be created at all, so the card falls back to the flat FleetGrid.
interface Props {
servers: ServerInfo[];
servers: MyServerView[];
}
const STARTING = "Starting";
@@ -86,7 +86,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
voxels = [];
}
function build(list: ServerInfo[]) {
function build(list: MyServerView[]) {
clearVoxels();
const n = Math.max(list.length, 1);
const cols = Math.ceil(Math.sqrt(n));
@@ -96,7 +96,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
const offZ = ((rows - 1) * spacing) / 2;
list.forEach((s, i) => {
const color = new THREE.Color(phaseColor(s.phase));
const color = new THREE.Color(phaseColor(s.phase ?? "Unknown"));
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.45,
@@ -189,6 +189,6 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
/** sig is a cheap fingerprint of the fleet's renderable shape (order-independent
* per index): name+phase pairs. Player counts don't change the voxels, so they
* don't trigger a rebuild. */
function sig(servers: ServerInfo[]): string {
function sig(servers: MyServerView[]): string {
return servers.map((s) => `${s.name}:${s.phase}`).join("|");
}
+2 -2
View File
@@ -20,7 +20,7 @@ import type {
QuotaView,
ServerFileEntry,
ServerJob,
ServerInfo,
MyServerView,
ServerStatus,
SessionView,
UserDetail,
@@ -300,7 +300,7 @@ export const api = rejectingSync({
me: () => request<Identity>("GET", "/me"),
myServers: () =>
request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
request<{ servers: MyServerView[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
// fleet is the SysAdmin cockpit's fleet-wide read (admin-tier GET /fleet): every
// server's CRD lifecycle view plus its owner. It 403s for a non-admin principal —
+3 -1
View File
@@ -58,8 +58,10 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncSt
useEffect(() => {
reload();
// Bumping the ticket on cleanup drops any response still in flight.
const tickets = seq;
return () => {
seq.current++;
tickets.current++;
};
}, [reload]);
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -1,12 +1,20 @@
import { describe, it, expect } from "vitest";
import { canManage, ownershipPending } from "./ownership";
import type { ServerInfo } from "./types";
import type { MyServerView } from "./types";
// The owner-tier gate once read `owned` off /servers/{name}/status, which never
// sends it, so owners lost the LuckPerms entry. These cases pin that ownership
// comes only from the /me/servers row for this exact server.
const row = (name: string, owned?: boolean): ServerInfo => ({ name, subdomain: name, phase: "Running", owned });
const row = (name: string, owned = false): MyServerView => ({
name,
subdomain: name,
phase: "Running",
owned,
claimable: false,
playersOnline: 0,
playersMax: 0,
});
describe("canManage", () => {
it("lets an admin in without any /me/servers rows", () => {
+3 -3
View File
@@ -1,4 +1,4 @@
import type { ServerInfo } from "./types";
import type { MyServerView } from "./types";
// Owner-tier pages (console extras, players, files, backups, LuckPerms) decide
// who may act from GET /me/servers: the status projection never carries
@@ -7,7 +7,7 @@ import type { ServerInfo } from "./types";
/** canManage reports whether the caller may use a server's owner-tier tools. */
export function canManage(
isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined,
mine: readonly MyServerView[] | null | undefined,
name: string,
): boolean {
return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
@@ -18,7 +18,7 @@ export function canManage(
export function ownershipPending(
tierLoading: boolean,
isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined,
mine: readonly MyServerView[] | null | undefined,
mineError: unknown,
): boolean {
return tierLoading || (!isAdmin && mine == null && !mineError);
+49
View File
@@ -0,0 +1,49 @@
// Compile-time parity between the hand-written wire types (types.ts) and the
// schemas in docs/openapi.yaml, via the generated openapi.gen.ts (`npm run
// gen:api`; CI fails when the generated file is stale). The Go side is held to
// the same schemas by internal/api/openapi_parity_test.go, so a field that
// changes in one place and not the others breaks a build instead of rendering
// `undefined`.
//
// For each pair three things must hold:
// - the two key sets are equal;
// - a key the docs mark optional is optional here too (the panel must not
// count on a field the server may omit);
// - every documented value fits the panel type (enums included).
// A failure names the offending keys in the error's type.
import type { components } from "./openapi.gen";
import type * as T from "./types";
type S = components["schemas"];
type OptionalKeys<X> = { [K in keyof X]-?: object extends Pick<X, K> ? K : never }[keyof X];
type Parity<Panel, Docs> = [Exclude<keyof Panel, keyof Docs>, Exclude<keyof Docs, keyof Panel>] extends [
never,
never,
]
? [Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>>] extends [never]
? Docs extends Panel
? true
: { docsValueDoesNotFitPanel: { [K in keyof Docs & keyof Panel]: Docs[K] extends Panel[K] ? never : K }[keyof Docs & keyof Panel] }
: { optionalInDocsButRequiredInPanel: Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>> }
: { onlyInPanel: Exclude<keyof Panel, keyof Docs>; onlyInDocs: Exclude<keyof Docs, keyof Panel> };
type Holds<X extends true> = X;
export type WireParity = [
Holds<Parity<T.ServerStatus, S["ServerInfo"]>>,
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
Holds<Parity<T.BackupView, S["BackupView"]>>,
Holds<Parity<T.Build, S["Build"]>>,
Holds<Parity<T.WhitelistImage, S["Image"]>>,
Holds<Parity<T.Submission, S["Submission"]>>,
Holds<Parity<T.UserView, S["UserView"]>>,
Holds<Parity<T.UserDetail, S["UserDetail"]>>,
Holds<Parity<T.QuotaView, S["QuotaView"]>>,
Holds<Parity<T.SessionView, S["SessionView"]>>,
Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>,
Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>,
Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>,
];
+53 -37
View File
@@ -17,35 +17,53 @@ export type Phase =
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
/** ServerInfo is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server. */
export interface ServerInfo {
/** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server.
* The live fields come from the CRD best-effort; desiredState, autostartPolicy
* and playerCountUnknown are present on the caller's own rows only. */
export interface MyServerView {
name: string;
subdomain: string;
displayName?: string;
phase: Phase;
desiredState?: "Running" | "Stopped";
playersOnline?: number;
playersMax?: number;
autostartPolicy?: AutostartPolicy;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable?: boolean;
/** Whether the caller owns it. */
owned?: boolean;
owned: boolean;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable: boolean;
phase?: Phase;
playersOnline: number;
playersMax: number;
displayName?: string;
desiredState?: "Running" | "Stopped";
autostartPolicy?: AutostartPolicy;
/** True while the operator cannot read the player count; a stop may drop players. */
playerCountUnknown?: boolean;
}
/** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries
* `owned` or `claimable`; owner-tier gates read /me/servers via lib/ownership.
* A caller who does not own the server gets the public subset, so everything
* past the counts may be absent. */
export interface ServerStatus {
name: string;
subdomain: string;
phase: Phase;
ready: boolean;
autostartPolicy?: AutostartPolicy;
desiredState?: "Running" | "Stopped";
endpointMode?: string;
endpointAddress?: string;
playersOnline: number;
playersMax: number;
displayName?: string;
image?: string;
javaMemory?: string;
storageSize?: string;
cpu?: string;
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */
idleStopSeconds?: number;
idleStopSeconds: number;
/** True while the operator cannot read the player count; idle stop waits. */
playerCountUnknown?: boolean;
}
/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or
* `claimable`; owner-tier gates read /me/servers via lib/ownership. */
export type ServerStatus = Omit<ServerInfo, "owned" | "claimable">;
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
* `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done
* server-side (parseWhitelistOutput); `output` is the raw RCON text and is the
@@ -82,7 +100,7 @@ export interface AccessResult {
}
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
* ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
* ONLY source of WHO is online — MyServerView.playersOnline carries the count alone.
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
@@ -108,21 +126,11 @@ export interface KickResult {
* read (admin-tier). It mirrors the Go fleetServerView: the CRD lifecycle
* projection plus the owner joined read-only from Postgres for display.
*
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
* It is a DISTINCT type from MyServerView, not a reuse: /fleet emits the raw CRD
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
* required — whereas ServerInfo is the /me/servers projection with them optional.
* required — whereas MyServerView is the /me/servers projection.
* Sharing one interface would blur which fields each face actually guarantees. */
export interface FleetServer {
name: string;
subdomain: string;
phase: Phase;
ready: boolean;
desiredState?: "Running" | "Stopped";
autostartPolicy?: AutostartPolicy;
endpointMode?: string;
endpointAddress?: string;
playersOnline: number;
playersMax: number;
export interface FleetServer extends ServerStatus {
/** Owner's display identity (email, or username when the address is absent).
* Empty/absent for an unclaimed server or when the best-effort owner lookup
* failed — the cockpit renders that as "unclaimed". */
@@ -191,7 +199,11 @@ export interface ServerJob {
export interface WhitelistImage {
image_ref: string;
enabled: boolean;
source?: string;
source: string;
/** Set when the image came out of a panel build (build.Image BuildID). */
build_id?: string;
added_by: string;
added_at: string;
}
/** CreateServerRequest is the §15 structured form — the ONLY create path. */
@@ -288,6 +300,8 @@ export interface Build {
error?: string;
created_at: string;
finished_at?: string;
/** sha256 of the build context the Job actually fetched. */
context_digest?: string;
}
export type SubmissionStatus = "pending_review" | "approved" | "rejected";
@@ -343,7 +357,8 @@ export interface DBBackupStatus {
export interface UserView {
id: string;
username: string;
email: string;
/** Omitted for accounts created without one (bind-code / op-login only). */
email?: string;
role: "admin" | "user" | "owner";
disabled: boolean;
email_verified: boolean;
@@ -359,8 +374,9 @@ export interface LinkedAccount {
}
export interface UserDetail extends UserView {
deleted_at?: string | null;
linked_accounts: LinkedAccount[];
deleted_at?: string;
/** Omitted when the user has no linked Minecraft account. */
linked_accounts?: LinkedAccount[];
}
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
@@ -398,12 +414,12 @@ export interface PasskeyCredential {
name: string;
aaguid?: string;
created_at: string;
last_used_at?: string | null;
last_used_at?: string;
}
export interface SessionView {
token_hash: string;
created_at: string;
expires_at: string;
revoked_at?: string | null;
revoked_at?: string;
}
+139
View File
@@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import i18next from "i18next";
import type { Identity, PasskeyCredential } from "@/lib/types";
import { Account } from "./Account";
const mocks = vi.hoisted(() => ({
passkeyList: vi.fn(),
passkeyDelete: vi.fn(),
identity: null as Identity | null,
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
linkStatus: () => Promise.resolve({ linked: true }),
migrateStatus: () => Promise.resolve({ active: false }),
passkeyList: mocks.passkeyList,
passkeyDelete: mocks.passkeyDelete,
},
};
});
vi.mock("@/lib/tier", () => ({
useTier: () => ({ identity: mocks.identity, refresh: vi.fn() }),
}));
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
const deleteButton = (name: string) => ({ name: t("account:passkey_delete_aria", { name }) });
const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
function identity(emailVerified: boolean): Identity {
return {
user_id: "u-1",
email: "[email protected]",
role: "user",
is_admin: false,
is_owner: false,
email_verified: emailVerified,
};
}
function renderAccount() {
return render(
<MemoryRouter>
<Account />
</MemoryRouter>,
);
}
beforeEach(() => {
mocks.passkeyList.mockReset();
mocks.passkeyDelete.mockReset();
mocks.identity = identity(true);
});
describe("Account passkey delete", () => {
it("names the passkey in the confirmation and deletes only on confirm", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockResolvedValue(undefined);
renderAccount();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(t("account:passkey_delete_title"))).toBeTruthy();
expect(within(dialog).getByText(/“Laptop”/)).toBeTruthy();
expect(mocks.passkeyDelete).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: t("account:passkey_delete_confirm") }));
expect(mocks.passkeyDelete).toHaveBeenCalledWith("pk-1");
expect(screen.queryByRole("dialog")).toBeNull();
await waitFor(() => expect(screen.queryByRole("button", deleteButton("Laptop"))).toBeNull());
expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
});
it("cancel leaves the passkey alone", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
renderAccount();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("common:cancel") }));
expect(mocks.passkeyDelete).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("locks the only passkey of an unverified account and says why", async () => {
mocks.identity = identity(false);
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
renderAccount();
const button = await screen.findByRole("button", deleteButton("Laptop"));
expect(button).toHaveProperty("disabled", true);
expect(screen.getByText(t("account:passkey_last_hint"))).toBeTruthy();
});
it("lets a verified account delete its only passkey (email-OTP still signs it in)", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
renderAccount();
const button = await screen.findByRole("button", deleteButton("Laptop"));
expect(button).toHaveProperty("disabled", false);
expect(screen.queryByText(t("account:passkey_last_hint"))).toBeNull();
});
it("keeps the dialog open with the server's reason when the delete is refused", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue({ status: 409, code: "last_passkey", message: "raw" });
renderAccount();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
expect(await within(screen.getByRole("dialog")).findByText(t("errors:last_passkey"))).toBeTruthy();
expect(mocks.passkeyList).toHaveBeenCalledTimes(2);
});
it("treats a passkey already gone as deleted", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockRejectedValue({ status: 404, code: "not_found", message: "passkey not found" });
renderAccount();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
expect(screen.queryByRole("dialog")).toBeNull();
await waitFor(() => expect(screen.queryByRole("button", deleteButton("Laptop"))).toBeNull());
expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
expect(screen.queryByText("passkey not found")).toBeNull();
});
});
+2 -2
View File
@@ -26,7 +26,7 @@ import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { lazyWithReload } from "@/lib/chunk";
import { webglAvailable } from "@/lib/webgl";
import type { Phase, ServerInfo, WhitelistImage } from "@/lib/types";
import type { Phase, MyServerView, WhitelistImage } from "@/lib/types";
// three.js is heavy and only the Dashboard renders it — split it into its own
// async chunk so the rest of the panel doesn't pay for it on first load.
@@ -95,7 +95,7 @@ function FleetView({
images,
isAdmin,
}: {
servers: ServerInfo[];
servers: MyServerView[];
counts: { total: number; running: number; players: number };
linkStatus?: { linked: boolean };
images: WhitelistImage[];
+1 -1
View File
@@ -79,7 +79,7 @@ export function MySubmissionsPage() {
// Async API hook
const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []);
const submissions: Submission[] = data ?? [];
const submissions = useMemo<Submission[]>(() => data ?? [], [data]);
// Dialog State
const [dialogOpen, setDialogOpen] = useState(false);
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { ServerFiles } from "./ServerFiles";
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
status: () => Promise.resolve({ name: "lobby", subdomain: "lobby", phase: "Stopped", ready: false }),
listServerFiles: () =>
Promise.resolve({
path: "",
truncated: false,
entries: [{ name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }],
}),
readServerFile: () => Promise.resolve({ path: "server.properties", content: btoa("motd=hi\n"), sha256: "abc" }),
writeServerFile: mocks.writeServerFile,
},
};
});
vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) }));
const t = (key: string) => i18next.t(key);
async function openEditor() {
render(
<MemoryRouter initialEntries={["/servers/lobby/files"]}>
<Routes>
<Route path="/servers/:name/files" element={<ServerFiles />} />
</Routes>
</MemoryRouter>,
);
await userEvent.click(await screen.findByText("server.properties"));
const dialog = await screen.findByRole("dialog");
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
}
beforeEach(() => {
mocks.writeServerFile.mockReset();
});
describe("ServerFiles editor", () => {
it("closes an unchanged file at once", async () => {
const { dialog } = await openEditor();
await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("asks before dropping edits, and keep editing keeps the text", async () => {
const { editor } = await openEditor();
await userEvent.type(editor, "pvp=false");
await userEvent.keyboard("{Escape}");
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(t("files:discard_prompt"))).toBeTruthy();
await userEvent.click(within(dialog).getByRole("button", { name: t("files:keep_editing") }));
expect(within(dialog).queryByText(t("files:discard_prompt"))).toBeNull();
expect((within(dialog).getByRole("textbox") as HTMLTextAreaElement).value).toBe("motd=hi\npvp=false");
});
it("drops the edits only after discard is confirmed", async () => {
const { dialog, editor } = await openEditor();
await userEvent.type(editor, "pvp=false");
await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
await userEvent.click(within(dialog).getByRole("button", { name: t("files:discard") }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.writeServerFile).not.toHaveBeenCalled();
});
it("guards leaving the page while edits are unsaved", async () => {
const { editor } = await openEditor();
const clean = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(clean);
expect(clean.defaultPrevented).toBe(false);
await userEvent.type(editor, "x");
const dirty = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(dirty);
expect(dirty.defaultPrevented).toBe(true);
});
});
+6 -2
View File
@@ -128,17 +128,21 @@ export function ServerFiles() {
// Load (and reload after a stop) only once the viewer is resolved as owner and
// the server is fully stopped — both are hard server-side gates of every call.
// `dir` stays out of the list: load() itself moves it, and a navigation that
// re-ran this effect would fetch the same folder twice.
useEffect(() => {
if (owned && stopped) void load(dir);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [owned, stopped, load]);
// While the server is not stopped, poll the phase so the first successful
// stop flips the page from the notice to the listing without a manual reload.
const reloadStatus = statusQ.reload;
useEffect(() => {
if (stopped) return;
const id = setInterval(() => statusQ.reload(), 4000);
const id = setInterval(reloadStatus, 4000);
return () => clearInterval(id);
}, [stopped, statusQ.reload]);
}, [stopped, reloadStatus]);
// Editor state. `editable` false marks a binary file (rendered read-only).
// `sha256` is the hash the read returned: every save sends it back, so a file
+1 -1
View File
@@ -30,7 +30,7 @@ const PAGE_SIZE = 10;
export function ImageAdmin() {
const { t } = useTranslation("admin");
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
const images = data ?? [];
const images = useMemo(() => data ?? [], [data]);
// Form & Dialog State
const [dialogOpen, setDialogOpen] = useState(false);
+1 -1
View File
@@ -202,7 +202,7 @@ export function ImageBuildPage() {
}, [builds, search]);
// Reset page when search changes
useMemo(() => {
useEffect(() => {
setPage(1);
}, [search]);
+1 -1
View File
@@ -34,7 +34,7 @@ export function SubmissionsPage() {
const now = Date.now();
const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []);
const submissions: Submission[] = data ?? [];
const submissions = useMemo<Submission[]>(() => data ?? [], [data]);
// Dialog State
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
+34 -10
View File
@@ -43,7 +43,7 @@ import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy";
import type { AutostartPolicy, FleetServer, Phase, ServerInfo } from "@/lib/types";
import type { AutostartPolicy, FleetServer, Phase, MyServerView } from "@/lib/types";
import { cn } from "@/lib/utils";
const REFRESH_MS = 10_000;
@@ -72,6 +72,8 @@ const PAGE_SIZE = 6;
interface UnifiedServer {
name: string;
subdomain: string;
/** The owner-chosen label; the list leads with it and keeps the name beside. */
displayName?: string;
phase: Phase;
ready: boolean;
desiredState?: "Running" | "Stopped";
@@ -91,7 +93,10 @@ export function ServersPage() {
const { isAdmin, identity } = useTier();
const cfg = useConfig();
const fetchFn = useMemo(() => (isAdmin ? api.fleet : api.myServers), [isAdmin]);
const fetchFn = useMemo<() => Promise<FleetServer[] | MyServerView[]>>(
() => (isAdmin ? api.fleet : api.myServers),
[isAdmin],
);
const { data, error, loading, reload } = useAsync(fetchFn, [fetchFn]);
const [query, setQuery] = useState("");
@@ -117,6 +122,7 @@ export function ServersPage() {
if (isAdmin) {
return (data as FleetServer[]).map((s) => ({
name: s.name,
displayName: s.displayName,
subdomain: s.subdomain,
phase: s.phase,
ready: s.ready,
@@ -124,6 +130,7 @@ export function ServersPage() {
autostartPolicy: s.autostartPolicy,
playersOnline: s.playersOnline,
playersMax: s.playersMax,
playerCountUnknown: s.playerCountUnknown,
owner: s.owner,
endpointAddress: s.endpointAddress,
claimable: !s.owner,
@@ -131,15 +138,16 @@ export function ServersPage() {
system: s.system,
}));
} else {
return (data as ServerInfo[]).map((s) => ({
return (data as MyServerView[]).map((s) => ({
name: s.name,
displayName: s.displayName,
subdomain: s.subdomain,
phase: s.phase,
phase: s.phase ?? "Unknown",
ready: s.phase === "Running",
desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy,
playersOnline: s.playersOnline ?? 0,
playersMax: s.playersMax ?? 0,
playersOnline: s.playersOnline,
playersMax: s.playersMax,
playerCountUnknown: s.playerCountUnknown,
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
claimable: s.claimable,
@@ -175,7 +183,7 @@ export function ServersPage() {
const scored: { s: UnifiedServer; score: number }[] = [];
for (const s of servers) {
if (!phaseOk(s)) continue;
const score = matchScore([s.name, s.subdomain ?? "", s.owner ?? ""], terms);
const score = matchScore([s.name, s.displayName ?? "", s.subdomain ?? "", s.owner ?? ""], terms);
if (score >= 0) scored.push({ s, score });
}
scored.sort((a, b) => b.score - a.score);
@@ -468,7 +476,7 @@ function ServerActions({
<DialogHeader>
<DialogTitle>{ts("claim_server_title")}</DialogTitle>
<DialogDescription>
{ts("claim_server_desc", { name: server.name })}
{ts("claim_server_desc", { name: server.displayName || server.name })}
</DialogDescription>
</DialogHeader>
<ConfirmFooter
@@ -593,7 +601,7 @@ function ServerRow({
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
<td className="px-4 py-3 align-middle text-left">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{server.name}</span>
<ServerName server={server} />
<PhaseBadge phase={server.phase} />
</div>
{host && <HostLink host={host} />}
@@ -648,7 +656,9 @@ function ServerMobileCard({
<CardContent className="flex flex-1 flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{server.name}</div>
<div className="flex min-w-0 items-baseline gap-2">
<ServerName server={server} />
</div>
{host && <HostLink host={host} />}
</div>
<PhaseBadge phase={server.phase} />
@@ -687,3 +697,17 @@ function ServerMobileCard({
}
export default ServersPage;
/** ServerName leads with the display name and keeps the server name beside it,
* since the name is what the URL, the console and the subdomain use. */
function ServerName({ server }: { server: UnifiedServer }) {
const label = server.displayName || server.name;
return (
<>
<span className="truncate font-medium text-foreground">{label}</span>
{label !== server.name && (
<span className="truncate font-mono text-xs text-muted-foreground">{server.name}</span>
)}
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "e2e/**/*.ts"]
}
+11 -1
View File
@@ -1,4 +1,14 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.e2e.json"
}
]
}
+2
View File
@@ -23,6 +23,8 @@ export default defineConfig(async ({ mode }) => {
},
test: {
setupFiles: ["./vitest.setup.ts"],
// e2e/*.spec.ts belong to Playwright (npm run test:e2e).
include: ["src/**/*.test.{ts,tsx}"],
},
};
});
+10
View File
@@ -1,4 +1,14 @@
import { afterEach } from "vitest";
import i18next from "i18next";
import "./src/i18n";
i18next.changeLanguage("en-US");
// Component tests opt into jsdom per file (`// @vitest-environment jsdom`);
// the lib tests stay on node. Testing Library only unmounts between tests on
// its own when vitest globals are on, so do it here, and only where a DOM exists.
afterEach(async () => {
if (typeof document === "undefined") return;
const { cleanup } = await import("@testing-library/react");
cleanup();
});