test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认
This commit is contained in:
44 files changed
+10578
-216
No files matched your search
@@ -13,3 +13,7 @@ dist/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
|
||||
# Playwright output
|
||||
test-results/
|
||||
playwright-report/
|
||||
+58
-35
@@ -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") {
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+2283
-39
File diff suppressed because it is too large.
Load diff
+15
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("|");
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -202,7 +202,7 @@ export function ImageBuildPage() {
|
||||
}, [builds, search]);
|
||||
|
||||
// Reset page when search changes
|
||||
useMemo(() => {
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}"],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user