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

+29
View File
@@ -165,9 +165,38 @@ jobs:
- run: npm test - run: npm test
working-directory: panel working-directory: panel
# `tsc -b`: tsconfig.json is a solution file (files: [] plus references), so a plain
# `tsc --noEmit` checked nothing and passed with type errors in the tree.
- run: npm run typecheck - run: npm run typecheck
working-directory: panel working-directory: panel
# Rules of hooks and effect dependency lists, with --max-warnings 0.
- run: npm run lint
working-directory: panel
# openapi.gen.ts is generated from docs/openapi.yaml and checked in, so the
# compile-time parity in src/lib/types.parity.ts needs no generator in the build;
# a schema edit that was not regenerated fails here.
- name: openapi.gen.ts matches docs/openapi.yaml
working-directory: panel
run: |
npm run gen:api
git diff --exit-code -- src/lib/openapi.gen.ts
# Browser smoke over the mock-mode dev server, in the runner's installed Chrome
# (playwright.config.ts sets channel: chrome, so nothing is downloaded).
- run: npm run test:e2e
working-directory: panel
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: panel-playwright-report
path: |
panel/playwright-report
panel/test-results
retention-days: 7
plugins: plugins:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+52 -26
View File
@@ -303,7 +303,7 @@ components:
ServerInfo: ServerInfo:
type: object type: object
description: Status projection of one server (internal/api/cluster.go ServerInfo). description: Status projection of one server (internal/api/cluster.go ServerInfo).
required: [name, subdomain, phase, ready, playersOnline, playersMax] required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds]
properties: properties:
name: { type: string } name: { type: string }
subdomain: { type: string } subdomain: { type: string }
@@ -311,6 +311,7 @@ components:
ready: { type: boolean } ready: { type: boolean }
autostartPolicy: autostartPolicy:
type: string type: string
enum: [ownerOnly, public, allowlist]
description: Present only when set; who may wake the server via domain-autostart. description: Present only when set; who may wake the server via domain-autostart.
desiredState: desiredState:
type: string type: string
@@ -333,6 +334,26 @@ components:
type: boolean type: boolean
description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can.
FleetServer:
description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView).
allOf:
- $ref: '#/components/schemas/ServerInfo'
- type: object
properties:
owner:
type: string
description: >-
The owner's display identity (email, or username when the address is
absent). Absent for an unclaimed server or when the best-effort owner
lookup failed.
system:
type: boolean
description: >-
True for a platform-provisioned system service (the login gate, the
lobby). Their reserved names are rejected by every per-server route,
so the cockpit renders them read-only instead of offering actions
that would 400.
MyServerView: MyServerView:
type: object type: object
description: One row of the caller's server list (internal/api/repo.go MyServerView). description: One row of the caller's server list (internal/api/repo.go MyServerView).
@@ -350,6 +371,20 @@ components:
format: int32 format: int32
description: Best-effort from live CRD status; 0 when the cluster is unreachable. description: Best-effort from live CRD status; 0 when the cluster is unreachable.
playersMax: { type: integer, format: int32 } playersMax: { type: integer, format: int32 }
displayName:
type: string
description: From live CRD status; omitted when unset or the cluster is unreachable.
desiredState:
type: string
enum: [Running, Stopped]
description: Owned rows only, from live CRD status.
autostartPolicy:
type: string
enum: [ownerOnly, public, allowlist]
description: Owned rows only, from live CRD status.
playerCountUnknown:
type: boolean
description: Owned rows only. Present and true while the operator cannot read the player count, so a stop may disconnect players.
BackupView: BackupView:
type: object type: object
@@ -391,6 +426,9 @@ components:
enum: [pending, building, succeeded, failed, cancelled] enum: [pending, building, succeeded, failed, cancelled]
dockerfile: { type: string } dockerfile: { type: string }
context_ref: { type: string } context_ref: { type: string }
context_digest:
type: string
description: Lowercase hex sha256 of the context tarball the build was pinned to (the audit record). Omitted when the request named none.
base_image: { type: string } base_image: { type: string }
requested_by: { type: string } requested_by: { type: string }
job_name: { type: string } job_name: { type: string }
@@ -398,9 +436,9 @@ components:
error: { type: string } error: { type: string }
created_at: { type: string, format: date-time } created_at: { type: string, format: date-time }
finished_at: finished_at:
type: [string, 'null'] type: string
format: date-time format: date-time
description: Null until the build reaches a terminal status. description: Omitted until the build reaches a terminal status.
Image: Image:
type: object type: object
@@ -430,6 +468,9 @@ components:
context_ref: context_ref:
type: string type: string
description: Platform-derived pinned build context; not user-supplied. description: Platform-derived pinned build context; not user-supplied.
context_sha256:
type: string
description: Lowercase hex sha256 of the uploaded context tarball; omitted until one is uploaded. Approval must name it.
status: status:
type: string type: string
enum: [pending_review, approved, rejected] enum: [pending_review, approved, rejected]
@@ -456,9 +497,9 @@ components:
reject_reason: { type: string } reject_reason: { type: string }
created_at: { type: string, format: date-time } created_at: { type: string, format: date-time }
reviewed_at: reviewed_at:
type: [string, 'null'] type: string
format: date-time format: date-time
description: Null until an admin approves or rejects. description: Omitted until an admin approves or rejects.
UserView: UserView:
type: object type: object
@@ -478,7 +519,7 @@ components:
UserDetail: UserDetail:
type: object type: object
description: Full admin view of one user (internal/api/repo.go UserDetail). description: Full admin view of one user (internal/api/repo.go UserDetail).
required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at, linked_accounts] required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at]
properties: properties:
id: { type: string } id: { type: string }
username: { type: string } username: { type: string }
@@ -490,11 +531,12 @@ components:
created_at: { type: string, format: date-time } created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time } updated_at: { type: string, format: date-time }
deleted_at: deleted_at:
type: [string, 'null'] type: string
format: date-time format: date-time
description: Present only when soft-deleted. description: Present only when soft-deleted.
linked_accounts: linked_accounts:
type: array type: array
description: Omitted when the user has no linked Minecraft account.
items: items:
type: object type: object
required: [mc_uuid, auth_source, verified_at] required: [mc_uuid, auth_source, verified_at]
@@ -523,8 +565,9 @@ components:
created_at: { type: string, format: date-time } created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time } expires_at: { type: string, format: date-time }
revoked_at: revoked_at:
type: [string, 'null'] type: string
format: date-time format: date-time
description: Present only once the session is revoked.
paths: paths:
# ----------------------------------------------------------------- health --- # ----------------------------------------------------------------- health ---
@@ -2922,24 +2965,7 @@ paths:
properties: properties:
servers: servers:
type: array type: array
items: items: { $ref: '#/components/schemas/FleetServer' }
allOf:
- $ref: '#/components/schemas/ServerInfo'
- type: object
properties:
owner:
type: string
description: >-
The owner's display identity (email, or username when
the address is absent). Absent for an unclaimed server
or when the best-effort owner lookup failed.
system:
type: boolean
description: >-
True for a platform-provisioned system service (the login
gate, the lobby). Their reserved names are rejected by
every per-server route, so the cockpit renders them
read-only instead of offering actions that would 400.
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
+76 -3
View File
@@ -746,7 +746,9 @@ func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error {
return nil return nil
} }
func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) { func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) {
return f.mine[u], nil // Fresh rows per call, as PGRepo scans them: the handler joins live state
// into the slice it gets.
return append([]MyServerView(nil), f.mine[u]...), nil
} }
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) { func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
if f.ownersErr != nil { if f.ownersErr != nil {
@@ -1535,6 +1537,7 @@ type fakeCluster struct {
byName map[string]*ServerInfo byName map[string]*ServerInfo
bySub map[string]*ServerInfo bySub map[string]*ServerInfo
list []ServerInfo list []ServerInfo
listErr error
desired map[string]v1alpha1.DesiredState desired map[string]v1alpha1.DesiredState
created map[string]CreateServerInput // name -> the validated input it was created from created map[string]CreateServerInput // name -> the validated input it was created from
patched map[string]ServerSpecPatch // name -> the validated spec patch it received patched map[string]ServerSpecPatch // name -> the validated spec patch it received
@@ -1567,8 +1570,13 @@ func (c *fakeCluster) GetBySubdomain(_ context.Context, s string) (*ServerInfo,
} }
return nil, ErrNotFound return nil, ErrNotFound
} }
func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil } func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) {
func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr } if c.listErr != nil {
return nil, c.listErr
}
return c.list, nil
}
func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
// WorldVolumeExists models the world PVC: present unless the test named the // WorldVolumeExists models the world PVC: present unless the test named the
// server in noWorld (never started / already reaped). // server in noWorld (never started / already reaped).
@@ -1860,6 +1868,71 @@ func TestMeIdentity(t *testing.T) {
}) })
} }
// TestMyServersJoinsLiveState proves /me/servers carries the live CRD fields the
// panel renders: every row gets the display name, live phase and counts, and
// only the caller's own rows get owner detail (desired state, autostart policy,
// an unreadable player count), as the status route withholds them from others.
// A cluster read failure keeps the Postgres rows with the cached phase.
func TestMyServersJoinsLiveState(t *testing.T) {
repo := newFakeRepo()
repo.mine = map[string][]MyServerView{"u1": {
{Name: "mine", Subdomain: "mine", Owned: true, Phase: "Stopped"},
{Name: "open", Subdomain: "open", Claimable: true, Phase: "Stopped"},
}}
cl := newFakeCluster()
cl.list = []ServerInfo{
{Name: "mine", DisplayName: "My World", Phase: "Running", DesiredState: "Running",
AutostartPolicy: "ownerOnly", PlayersOnline: 2, PlayersMax: 20, PlayerCountUnknown: true},
{Name: "open", DisplayName: "Open World", Phase: "Running", DesiredState: "Running",
AutostartPolicy: "public", PlayersMax: 10, PlayerCountUnknown: true},
}
api := newTestAPI(repo, cl)
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
read := func() map[string]map[string]any {
t.Helper()
w := do(api.ExternalHandler(), "GET", "/api/v1/me/servers", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
var body struct {
Servers []map[string]any `json:"servers"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
out := map[string]map[string]any{}
for _, s := range body.Servers {
out[s["name"].(string)] = s
}
return out
}
got := read()
mine, open := got["mine"], got["open"]
for k, want := range map[string]any{"displayName": "My World", "phase": "Running",
"desiredState": "Running", "autostartPolicy": "ownerOnly", "playerCountUnknown": true,
"playersOnline": float64(2), "playersMax": float64(20)} {
if mine[k] != want {
t.Errorf("own row %s = %v, want %v", k, mine[k], want)
}
}
if open["displayName"] != "Open World" || open["phase"] != "Running" || open["playersMax"] != float64(10) {
t.Errorf("claimable row public fields = %v", open)
}
for _, k := range []string{"desiredState", "autostartPolicy", "playerCountUnknown"} {
if _, ok := open[k]; ok {
t.Errorf("claimable row carries owner detail %s = %v", k, open[k])
}
}
cl.listErr = errors.New("apiserver down")
got = read()
if got["mine"]["phase"] != "Stopped" || got["mine"]["playersOnline"] != float64(0) {
t.Errorf("cluster down: own row = %v, want cached phase and 0 players", got["mine"])
}
}
// ---- fleet (SysAdmin cockpit read) ---- // ---- fleet (SysAdmin cockpit read) ----
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND // TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
+21 -6
View File
@@ -239,19 +239,34 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return return
} }
// Player counts are presentational and best-effort, mirroring handleFleet's // The live fields are presentational and best-effort, mirroring handleFleet's
// owner join: the list exists for ownership/claim state, so a cluster hiccup // owner join: the list exists for ownership/claim state, so a cluster hiccup
// must degrade to 0/0 counts, never 500 the whole list. The CRD status is the // must degrade to 0/0 counts and the cached phase, never 500 the whole list.
// only source of live counts (spec §1) — Postgres never stores them. // The CRD status is the only source of live state (spec §1) — Postgres never
// stores counts. Owner detail (desired state, autostart policy, whether the
// count is readable) joins only onto rows the caller owns; the panel needs
// playerCountUnknown there to ask before a stop that may drop players.
if infos, err := a.Cluster.ListServers(r.Context()); err == nil { if infos, err := a.Cluster.ListServers(r.Context()); err == nil {
byName := make(map[string]ServerInfo, len(infos)) byName := make(map[string]ServerInfo, len(infos))
for _, s := range infos { for _, s := range infos {
byName[s.Name] = s byName[s.Name] = s
} }
for i := range servers { for i := range servers {
if info, ok := byName[servers[i].Name]; ok { info, ok := byName[servers[i].Name]
servers[i].PlayersOnline = info.PlayersOnline if !ok {
servers[i].PlayersMax = info.PlayersMax continue
}
v := &servers[i]
v.PlayersOnline = info.PlayersOnline
v.PlayersMax = info.PlayersMax
v.DisplayName = info.DisplayName
if info.Phase != "" {
v.Phase = info.Phase
}
if v.Owned {
v.DesiredState = info.DesiredState
v.AutostartPolicy = info.AutostartPolicy
v.PlayerCountUnknown = info.PlayerCountUnknown
} }
} }
} }
+190
View File
@@ -0,0 +1,190 @@
package api
import (
"os"
"reflect"
"sort"
"strings"
"testing"
"felis.lolicon.best/internal/build"
"sigs.k8s.io/yaml"
)
// TestOpenAPISchemasMatchWireStructs keeps docs/openapi.yaml honest about the
// response bodies. Each named schema is compared with the Go struct the handler
// actually encodes: the property set must equal the struct's JSON field set, and
// `required` must list exactly the fields that are always on the wire (no
// omitempty). The panel's types are checked against the same schemas at compile
// time (panel/src/lib/types.parity.ts), so a field added here without the docs
// fails in Go, and one added to the docs without the panel fails in tsc.
func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
raw, err := os.ReadFile("../../docs/openapi.yaml")
if err != nil {
t.Fatal(err)
}
var doc struct {
Components struct {
Schemas map[string]schemaDoc `json:"schemas"`
} `json:"components"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
t.Fatalf("parse openapi.yaml: %v", err)
}
pairs := map[string]any{
"ServerInfo": ServerInfo{},
"FleetServer": fleetServerView{},
"MyServerView": MyServerView{},
"BackupView": BackupView{},
"Build": build.Build{},
"Image": build.Image{},
"Submission": submissionView{},
"UserView": UserView{},
"UserDetail": UserDetail{},
"QuotaView": QuotaView{},
"SessionView": SessionView{},
"PasskeyCredential": passkeyCredentialView{},
"UpdateWindow": updateWindow{},
"DBBackupStatus": dbBackupView{},
}
for name, v := range pairs {
s, ok := doc.Components.Schemas[name]
if !ok {
t.Errorf("openapi.yaml has no components.schemas.%s", name)
continue
}
compareSchema(t, name, flatten(s, doc.Components.Schemas), reflect.TypeOf(v))
}
}
type schemaDoc struct {
Ref string `json:"$ref"`
AllOf []schemaDoc `json:"allOf"`
Type any `json:"type"`
Required []string `json:"required"`
Properties map[string]schemaDoc `json:"properties"`
Items *schemaDoc `json:"items"`
}
// flatten resolves a top-level $ref and merges allOf parts into one object
// schema, which is how a Go struct embedding another one is documented.
func flatten(s schemaDoc, all map[string]schemaDoc) schemaDoc {
if s.Ref != "" {
s = all[strings.TrimPrefix(s.Ref, "#/components/schemas/")]
}
if len(s.AllOf) == 0 {
return s
}
out := schemaDoc{Properties: map[string]schemaDoc{}}
for _, part := range append(s.AllOf, schemaDoc{Required: s.Required, Properties: s.Properties}) {
part = flatten(part, all)
out.Required = append(out.Required, part.Required...)
for k, v := range part.Properties {
out.Properties[k] = v
}
}
return out
}
type wireField struct {
omitempty bool
typ reflect.Type
}
// wireFields lists the JSON fields encoding/json emits for t, following
// embedded structs the way the encoder does.
func wireFields(t reflect.Type) map[string]wireField {
out := map[string]wireField{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
tag := f.Tag.Get("json")
if tag == "-" {
continue
}
name, opts, _ := strings.Cut(tag, ",")
if f.Anonymous && name == "" {
for k, v := range wireFields(f.Type) {
out[k] = v
}
continue
}
if !f.IsExported() {
continue
}
if name == "" {
name = f.Name
}
out[name] = wireField{omitempty: strings.Contains(","+opts+",", ",omitempty,"), typ: f.Type}
}
return out
}
func structOf(t reflect.Type) (reflect.Type, bool) {
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
t = t.Elem()
}
return t, t.Kind() == reflect.Struct && t.PkgPath() != "time"
}
func compareSchema(t *testing.T, path string, s schemaDoc, typ reflect.Type) {
t.Helper()
fields := wireFields(typ)
var missing, extra, notRequired, wronglyRequired []string
for name, f := range fields {
if _, ok := s.Properties[name]; !ok {
missing = append(missing, name)
}
if !f.omitempty && !contains(s.Required, name) {
notRequired = append(notRequired, name)
}
}
for name := range s.Properties {
if _, ok := fields[name]; !ok {
extra = append(extra, name)
}
}
for _, name := range s.Required {
if f, ok := fields[name]; ok && f.omitempty {
wronglyRequired = append(wronglyRequired, name)
}
}
report := func(what string, names []string) {
if len(names) > 0 {
sort.Strings(names)
t.Errorf("%s: %s: %s", path, what, strings.Join(names, ", "))
}
}
report("sent by Go but missing from openapi.yaml", missing)
report("documented but never sent by Go", extra)
report("always sent but not in required", notRequired)
report("required but omitted when empty", wronglyRequired)
// Nested objects (an object property, or an array of objects) are held to
// the same rule when the schema spells their properties out.
for name, f := range fields {
p, ok := s.Properties[name]
if !ok {
continue
}
inner, isStruct := structOf(f.typ)
if !isStruct {
continue
}
switch {
case len(p.Properties) > 0:
compareSchema(t, path+"."+name, p, inner)
case p.Items != nil && len(p.Items.Properties) > 0:
compareSchema(t, path+"."+name+"[]", *p.Items, inner)
}
}
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
+17 -11
View File
@@ -18,18 +18,24 @@ type ServerRecord struct {
} }
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns, // MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
// may auto-start, or may claim. PlayersOnline/PlayersMax are NOT stored in // may auto-start, or may claim. Everything after Claimable is NOT stored in
// Postgres — handleMyServers joins them best-effort from the CRD status // Postgres — handleMyServers joins it best-effort from the CRD status
// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0, never // (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0 and the
// a 500. // cached phase, never a 500. DesiredState, AutostartPolicy and
// PlayerCountUnknown are owner detail and stay empty on rows the caller does
// not own, the same split publicServerInfo makes on the status route.
type MyServerView struct { type MyServerView struct {
Name string `json:"name"` Name string `json:"name"`
Subdomain string `json:"subdomain"` Subdomain string `json:"subdomain"`
Owned bool `json:"owned"` Owned bool `json:"owned"`
Claimable bool `json:"claimable"` Claimable bool `json:"claimable"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
PlayersOnline int32 `json:"playersOnline"` PlayersOnline int32 `json:"playersOnline"`
PlayersMax int32 `json:"playersMax"` PlayersMax int32 `json:"playersMax"`
DisplayName string `json:"displayName,omitempty"`
DesiredState string `json:"desiredState,omitempty"`
AutostartPolicy string `json:"autostartPolicy,omitempty"`
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
} }
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text: // AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
+4
View File
@@ -13,3 +13,7 @@ dist/
# Logs # Logs
npm-debug.log* npm-debug.log*
# Playwright output
test-results/
playwright-report/
+58 -35
View File
@@ -9,8 +9,9 @@ import type {
CreateServerRequest, CreateServerRequest,
FleetServer, FleetServer,
Identity, Identity,
MyServerView,
Phase, Phase,
ServerInfo, ServerStatus,
WhitelistImage, WhitelistImage,
Submission, Submission,
UserView, UserView,
@@ -52,7 +53,7 @@ interface MockAccount {
sessions?: SessionView[]; sessions?: SessionView[];
} }
interface MockServer extends ServerInfo { interface MockServer extends ServerStatus {
owner: AccountID | null; owner: AccountID | null;
} }
@@ -220,13 +221,9 @@ function initialState(): MockState {
linked: account("linked", "user", true, true), linked: account("linked", "user", true, true),
}, },
images: [ images: [
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" }, whitelistImage("registry.felis.svc:5000/paper-1.21:demo", "recommended"),
{ image_ref: "registry.felis.svc:5000/fabric-1.20.1:demo", enabled: true, source: "demo" }, whitelistImage("registry.felis.svc:5000/fabric-1.20.1:demo", "recommended"),
{ { ...whitelistImage("registry.felis.svc:5000/forge-1.20.1:disabled", "external"), enabled: false },
image_ref: "registry.felis.svc:5000/forge-1.20.1:disabled",
enabled: false,
source: "demo",
},
], ],
servers: [ servers: [
server("survival", "Survival SMP", "Running", "owner", { 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( function server(
name: string, name: string,
displayName: string, displayName: string,
phase: Phase, phase: Phase,
owner: AccountID | null, owner: AccountID | null,
overrides: Partial<ServerInfo> = {}, overrides: Partial<ServerStatus> = {},
): MockServer { ): MockServer {
return { return {
name, name,
subdomain: name, subdomain: name,
displayName, displayName,
phase, phase,
ready: phase === "Running",
desiredState: phase === "Stopped" ? "Stopped" : "Running", desiredState: phase === "Stopped" ? "Stopped" : "Running",
playersOnline: phase === "Running" ? 1 : 0, playersOnline: phase === "Running" ? 1 : 0,
playersMax: 20, playersMax: 20,
autostartPolicy: "ownerOnly", autostartPolicy: "ownerOnly",
owned: false, idleStopSeconds: 900,
claimable: false,
owner, owner,
...overrides, ...overrides,
}; };
@@ -560,10 +561,10 @@ function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id; 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 return state.servers
.filter((serverInfo) => canSee(accountInfo, serverInfo)) .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 // 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. // gated on Running, exactly as the real cluster reports them.
function fleetView(state: MockState): FleetServer[] { function fleetView(state: MockState): FleetServer[] {
return state.servers.map((s, i) => { return state.servers.map((s, i) => {
const ready = s.phase === "Running"; const { owner, ...wire } = s;
return { return {
name: s.name, ...wire,
subdomain: s.subdomain,
phase: s.phase,
ready,
desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy,
endpointMode: "domain", endpointMode: "domain",
endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined, endpointAddress: s.ready ? `10.43.0.${10 + i}:25565` : undefined,
playersOnline: ready ? s.playersOnline ?? 0 : 0, playersOnline: s.ready ? s.playersOnline : 0,
playersMax: s.playersMax ?? 0, owner: owner ? state.accounts[owner].email : "",
owner: s.owner ? state.accounts[s.owner].email : "",
}; };
}); });
} }
function projectServer(serverInfo: MockServer, accountInfo: MockAccount): ServerInfo { // myServerView mirrors handleMyServers: ownership and claim state plus the live
const { owner: _owner, ...wire } = serverInfo; // 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); const owned = canManage(accountInfo, serverInfo);
return { return {
...wire, name: serverInfo.name,
subdomain: serverInfo.subdomain,
owned, owned,
claimable: serverInfo.owner === null && accountInfo.linked && !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 { function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase; serverInfo.phase = phase;
serverInfo.ready = phase === "Running";
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running"; serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.playersOnline = phase === "Running" ? Math.max(serverInfo.playersOnline ?? 0, 1) : 0; 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) => { const filtered = allUsers.filter((u) => {
return ( return (
u.username.toLowerCase().includes(search) || 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) { if (img) {
img.enabled = true; img.enabled = true;
} else { } else {
img = { image_ref: ref, enabled: true, source: "external" }; img = whitelistImage(ref, "external");
ctx.state.images.unshift(img); ctx.state.images.unshift(img);
} }
sendJSON(ctx.res, 201, img); sendJSON(ctx.res, 201, img);
@@ -1309,7 +1334,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
b.finished_at = new Date().toISOString(); b.finished_at = new Date().toISOString();
// Add to whitelist images // Add to whitelist images
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) { 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 }, 15000); // Succeeded after 15 seconds
@@ -1535,7 +1560,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
b.status = "succeeded"; b.status = "succeeded";
b.finished_at = new Date().toISOString(); b.finished_at = new Date().toISOString();
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) { 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); }, 15000);
@@ -1660,9 +1685,7 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
} }
if (is("GET", ctx) && ctx.parts[4] === "status") { if (is("GET", ctx) && ctx.parts[4] === "status") {
// The real status projection (api.ServerInfo) carries no owned/claimable. sendJSON(ctx.res, 200, statusView(serverInfo, ctx.account));
const { owned: _o, claimable: _c, ...status } = projectServer(serverInfo, ctx.account);
sendJSON(ctx.res, 200, status);
return true; return true;
} }
if (is("GET", ctx) && ctx.parts[4] === "console") { 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": "vite",
"dev:mock": "vite --mode mock", "dev:mock": "vite --mode mock",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc -b",
"test": "vitest run", "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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -30,16 +33,27 @@
"three": "^0.172.0" "three": "^0.172.0"
}, },
"devDependencies": { "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/node": "^22.10.5",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "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", "postcss": "^8.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.70.1",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^4.1.9" "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 } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
import { ThemeProvider } from "@/lib/theme"; import { ThemeProvider } from "@/lib/theme";
import { TierProvider } from "@/lib/tier"; import { TierProvider } from "@/lib/tier";
import { SETUP_REQUIRED_EVENT } from "@/lib/api";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth"; import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner"; import { RequireOwner } from "@/components/RequireOwner";
import { SetupRequiredRedirect } from "@/components/SetupRequiredRedirect";
import { ValidParam } from "@/components/ValidParam"; import { ValidParam } from "@/components/ValidParam";
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params"; import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params";
import { Login } from "@/pages/Login"; import { Login } from "@/pages/Login";
@@ -27,21 +26,6 @@ import { UserDetailPage } from "@/pages/admin/UserDetailPage";
import { MySubmissionsPage } from "@/pages/MySubmissionsPage"; import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
import { UpdatesPage } from "@/pages/admin/UpdatesPage"; 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): // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
// / User-Side — app-tier, every authenticated principal // / User-Side — app-tier, every authenticated principal
// /admin/* Admin-Side — admin-tier, server & content administration // /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 { useTranslation } from "react-i18next";
import { PHASE_KEY, phaseColor } from "@/components/PhaseBadge"; import { PHASE_KEY, phaseColor } from "@/components/PhaseBadge";
import { cn } from "@/lib/utils"; 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 // 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 // 3D chunk failed: the same square layout and phase palette seen from above, so
@@ -18,7 +18,7 @@ export function FleetGrid({
servers, servers,
reason, reason,
}: { }: {
servers: Pick<ServerInfo, "name" | "displayName" | "phase">[]; servers: Pick<MyServerView, "name" | "displayName" | "phase">[];
reason: FleetGridReason; reason: FleetGridReason;
}) { }) {
const { t } = useTranslation(["dashboard", "servers"]); const { t } = useTranslation(["dashboard", "servers"]);
@@ -36,8 +36,9 @@ export function FleetGrid({
style={{ gridTemplateColumns: `repeat(${cols}, ${tile})`, gridAutoRows: tile }} style={{ gridTemplateColumns: `repeat(${cols}, ${tile})`, gridAutoRows: tile }}
> >
{servers.map((s) => { {servers.map((s) => {
const color = phaseColor(s.phase); const phase = s.phase ?? "Unknown";
const label = `${s.displayName || s.name} · ${t(PHASE_KEY[s.phase] ?? PHASE_KEY.Unknown)}`; const color = phaseColor(phase);
const label = `${s.displayName || s.name} · ${t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}`;
return ( return (
<Link <Link
key={s.name} key={s.name}
@@ -46,7 +47,7 @@ export function FleetGrid({
aria-label={label} aria-label={label}
className={cn( 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", "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={{ style={{
backgroundColor: color, 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 { PowerButton } from "@/components/PowerButton";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { hostFor, type RuntimeConfig } from "@/lib/config"; import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { ServerInfo } from "@/lib/types"; import type { MyServerView } from "@/lib/types";
interface Props { interface Props {
server: ServerInfo; server: MyServerView;
cfg: RuntimeConfig; cfg: RuntimeConfig;
/** Called after a successful mutation so the parent can refetch. */ /** Called after a successful mutation so the parent can refetch. */
onChanged: () => void; onChanged: () => void;
@@ -49,7 +49,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
<span className="truncate text-sm font-semibold text-foreground"> <span className="truncate text-sm font-semibold text-foreground">
{server.displayName || server.name} {server.displayName || server.name}
</span> </span>
<PhaseBadge phase={server.phase} /> <PhaseBadge phase={server.phase ?? "Unknown"} />
</div> </div>
{/* Info Row */} {/* 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 { phaseColor } from "@/components/PhaseBadge";
import { FleetGrid } from "@/components/FleetGrid"; import { FleetGrid } from "@/components/FleetGrid";
import { webglAvailable } from "@/lib/webgl"; 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 // 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 // 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. // cannot be created at all, so the card falls back to the flat FleetGrid.
interface Props { interface Props {
servers: ServerInfo[]; servers: MyServerView[];
} }
const STARTING = "Starting"; const STARTING = "Starting";
@@ -86,7 +86,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
voxels = []; voxels = [];
} }
function build(list: ServerInfo[]) { function build(list: MyServerView[]) {
clearVoxels(); clearVoxels();
const n = Math.max(list.length, 1); const n = Math.max(list.length, 1);
const cols = Math.ceil(Math.sqrt(n)); const cols = Math.ceil(Math.sqrt(n));
@@ -96,7 +96,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
const offZ = ((rows - 1) * spacing) / 2; const offZ = ((rows - 1) * spacing) / 2;
list.forEach((s, i) => { 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({ const material = new THREE.MeshStandardMaterial({
color, color,
roughness: 0.45, 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 /** 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 * per index): name+phase pairs. Player counts don't change the voxels, so they
* don't trigger a rebuild. */ * don't trigger a rebuild. */
function sig(servers: ServerInfo[]): string { function sig(servers: MyServerView[]): string {
return servers.map((s) => `${s.name}:${s.phase}`).join("|"); return servers.map((s) => `${s.name}:${s.phase}`).join("|");
} }
+2 -2
View File
@@ -20,7 +20,7 @@ import type {
QuotaView, QuotaView,
ServerFileEntry, ServerFileEntry,
ServerJob, ServerJob,
ServerInfo, MyServerView,
ServerStatus, ServerStatus,
SessionView, SessionView,
UserDetail, UserDetail,
@@ -300,7 +300,7 @@ export const api = rejectingSync({
me: () => request<Identity>("GET", "/me"), me: () => request<Identity>("GET", "/me"),
myServers: () => 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 // 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 — // 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(() => { useEffect(() => {
reload(); reload();
// Bumping the ticket on cleanup drops any response still in flight.
const tickets = seq;
return () => { return () => {
seq.current++; tickets.current++;
}; };
}, [reload]); }, [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 { describe, it, expect } from "vitest";
import { canManage, ownershipPending } from "./ownership"; 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 // 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 // sends it, so owners lost the LuckPerms entry. These cases pin that ownership
// comes only from the /me/servers row for this exact server. // 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", () => { describe("canManage", () => {
it("lets an admin in without any /me/servers rows", () => { 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 // Owner-tier pages (console extras, players, files, backups, LuckPerms) decide
// who may act from GET /me/servers: the status projection never carries // 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. */ /** canManage reports whether the caller may use a server's owner-tier tools. */
export function canManage( export function canManage(
isAdmin: boolean, isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined, mine: readonly MyServerView[] | null | undefined,
name: string, name: string,
): boolean { ): boolean {
return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true); return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
@@ -18,7 +18,7 @@ export function canManage(
export function ownershipPending( export function ownershipPending(
tierLoading: boolean, tierLoading: boolean,
isAdmin: boolean, isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined, mine: readonly MyServerView[] | null | undefined,
mineError: unknown, mineError: unknown,
): boolean { ): boolean {
return tierLoading || (!isAdmin && mine == null && !mineError); 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"; export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
/** ServerInfo is the GET /me/servers row (wrapped under { servers: [...] }). /** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server. */ * Only this projection says whether the caller owns or may claim a server.
export interface ServerInfo { * 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; name: string;
subdomain: 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. */ /** 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; image?: string;
javaMemory?: string; javaMemory?: string;
storageSize?: string; storageSize?: string;
cpu?: string; cpu?: string;
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */ /** 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. */ /** True while the operator cannot read the player count; idle stop waits. */
playerCountUnknown?: boolean; 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). /** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
* `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done * `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 * 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 /** 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 * `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 * "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 * 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 * read (admin-tier). It mirrors the Go fleetServerView: the CRD lifecycle
* projection plus the owner joined read-only from Postgres for display. * 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 * 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. */ * Sharing one interface would blur which fields each face actually guarantees. */
export interface FleetServer { export interface FleetServer extends ServerStatus {
name: string;
subdomain: string;
phase: Phase;
ready: boolean;
desiredState?: "Running" | "Stopped";
autostartPolicy?: AutostartPolicy;
endpointMode?: string;
endpointAddress?: string;
playersOnline: number;
playersMax: number;
/** Owner's display identity (email, or username when the address is absent). /** 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 * Empty/absent for an unclaimed server or when the best-effort owner lookup
* failed — the cockpit renders that as "unclaimed". */ * failed — the cockpit renders that as "unclaimed". */
@@ -191,7 +199,11 @@ export interface ServerJob {
export interface WhitelistImage { export interface WhitelistImage {
image_ref: string; image_ref: string;
enabled: boolean; 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. */ /** CreateServerRequest is the §15 structured form — the ONLY create path. */
@@ -288,6 +300,8 @@ export interface Build {
error?: string; error?: string;
created_at: string; created_at: string;
finished_at?: string; finished_at?: string;
/** sha256 of the build context the Job actually fetched. */
context_digest?: string;
} }
export type SubmissionStatus = "pending_review" | "approved" | "rejected"; export type SubmissionStatus = "pending_review" | "approved" | "rejected";
@@ -343,7 +357,8 @@ export interface DBBackupStatus {
export interface UserView { export interface UserView {
id: string; id: string;
username: string; username: string;
email: string; /** Omitted for accounts created without one (bind-code / op-login only). */
email?: string;
role: "admin" | "user" | "owner"; role: "admin" | "user" | "owner";
disabled: boolean; disabled: boolean;
email_verified: boolean; email_verified: boolean;
@@ -359,8 +374,9 @@ export interface LinkedAccount {
} }
export interface UserDetail extends UserView { export interface UserDetail extends UserView {
deleted_at?: string | null; deleted_at?: string;
linked_accounts: LinkedAccount[]; /** Omitted when the user has no linked Minecraft account. */
linked_accounts?: LinkedAccount[];
} }
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless: /** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
@@ -398,12 +414,12 @@ export interface PasskeyCredential {
name: string; name: string;
aaguid?: string; aaguid?: string;
created_at: string; created_at: string;
last_used_at?: string | null; last_used_at?: string;
} }
export interface SessionView { export interface SessionView {
token_hash: string; token_hash: string;
created_at: string; created_at: string;
expires_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 { useAsync, useConfig } from "@/lib/hooks";
import { lazyWithReload } from "@/lib/chunk"; import { lazyWithReload } from "@/lib/chunk";
import { webglAvailable } from "@/lib/webgl"; 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 // 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. // async chunk so the rest of the panel doesn't pay for it on first load.
@@ -95,7 +95,7 @@ function FleetView({
images, images,
isAdmin, isAdmin,
}: { }: {
servers: ServerInfo[]; servers: MyServerView[];
counts: { total: number; running: number; players: number }; counts: { total: number; running: number; players: number };
linkStatus?: { linked: boolean }; linkStatus?: { linked: boolean };
images: WhitelistImage[]; images: WhitelistImage[];
+1 -1
View File
@@ -79,7 +79,7 @@ export function MySubmissionsPage() {
// Async API hook // Async API hook
const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []); const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []);
const submissions: Submission[] = data ?? []; const submissions = useMemo<Submission[]>(() => data ?? [], [data]);
// Dialog State // Dialog State
const [dialogOpen, setDialogOpen] = useState(false); 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 // 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. // 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(() => { useEffect(() => {
if (owned && stopped) void load(dir); if (owned && stopped) void load(dir);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [owned, stopped, load]); }, [owned, stopped, load]);
// While the server is not stopped, poll the phase so the first successful // 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. // stop flips the page from the notice to the listing without a manual reload.
const reloadStatus = statusQ.reload;
useEffect(() => { useEffect(() => {
if (stopped) return; if (stopped) return;
const id = setInterval(() => statusQ.reload(), 4000); const id = setInterval(reloadStatus, 4000);
return () => clearInterval(id); return () => clearInterval(id);
}, [stopped, statusQ.reload]); }, [stopped, reloadStatus]);
// Editor state. `editable` false marks a binary file (rendered read-only). // 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 // `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() { export function ImageAdmin() {
const { t } = useTranslation("admin"); const { t } = useTranslation("admin");
const { data, error, loading, reload } = useAsync(() => api.listImages(), []); const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
const images = data ?? []; const images = useMemo(() => data ?? [], [data]);
// Form & Dialog State // Form & Dialog State
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
+1 -1
View File
@@ -202,7 +202,7 @@ export function ImageBuildPage() {
}, [builds, search]); }, [builds, search]);
// Reset page when search changes // Reset page when search changes
useMemo(() => { useEffect(() => {
setPage(1); setPage(1);
}, [search]); }, [search]);
+1 -1
View File
@@ -34,7 +34,7 @@ export function SubmissionsPage() {
const now = Date.now(); const now = Date.now();
const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []); const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []);
const submissions: Submission[] = data ?? []; const submissions = useMemo<Submission[]>(() => data ?? [], [data]);
// Dialog State // Dialog State
const [rejectDialogOpen, setRejectDialogOpen] = useState(false); 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 { useTier } from "@/lib/tier";
import { hostFor, type RuntimeConfig } from "@/lib/config"; import { hostFor, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy"; 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"; import { cn } from "@/lib/utils";
const REFRESH_MS = 10_000; const REFRESH_MS = 10_000;
@@ -72,6 +72,8 @@ const PAGE_SIZE = 6;
interface UnifiedServer { interface UnifiedServer {
name: string; name: string;
subdomain: string; subdomain: string;
/** The owner-chosen label; the list leads with it and keeps the name beside. */
displayName?: string;
phase: Phase; phase: Phase;
ready: boolean; ready: boolean;
desiredState?: "Running" | "Stopped"; desiredState?: "Running" | "Stopped";
@@ -91,7 +93,10 @@ export function ServersPage() {
const { isAdmin, identity } = useTier(); const { isAdmin, identity } = useTier();
const cfg = useConfig(); 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 { data, error, loading, reload } = useAsync(fetchFn, [fetchFn]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -117,6 +122,7 @@ export function ServersPage() {
if (isAdmin) { if (isAdmin) {
return (data as FleetServer[]).map((s) => ({ return (data as FleetServer[]).map((s) => ({
name: s.name, name: s.name,
displayName: s.displayName,
subdomain: s.subdomain, subdomain: s.subdomain,
phase: s.phase, phase: s.phase,
ready: s.ready, ready: s.ready,
@@ -124,6 +130,7 @@ export function ServersPage() {
autostartPolicy: s.autostartPolicy, autostartPolicy: s.autostartPolicy,
playersOnline: s.playersOnline, playersOnline: s.playersOnline,
playersMax: s.playersMax, playersMax: s.playersMax,
playerCountUnknown: s.playerCountUnknown,
owner: s.owner, owner: s.owner,
endpointAddress: s.endpointAddress, endpointAddress: s.endpointAddress,
claimable: !s.owner, claimable: !s.owner,
@@ -131,15 +138,16 @@ export function ServersPage() {
system: s.system, system: s.system,
})); }));
} else { } else {
return (data as ServerInfo[]).map((s) => ({ return (data as MyServerView[]).map((s) => ({
name: s.name, name: s.name,
displayName: s.displayName,
subdomain: s.subdomain, subdomain: s.subdomain,
phase: s.phase, phase: s.phase ?? "Unknown",
ready: s.phase === "Running", ready: s.phase === "Running",
desiredState: s.desiredState, desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy, autostartPolicy: s.autostartPolicy,
playersOnline: s.playersOnline ?? 0, playersOnline: s.playersOnline,
playersMax: s.playersMax ?? 0, playersMax: s.playersMax,
playerCountUnknown: s.playerCountUnknown, playerCountUnknown: s.playerCountUnknown,
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined, owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
claimable: s.claimable, claimable: s.claimable,
@@ -175,7 +183,7 @@ export function ServersPage() {
const scored: { s: UnifiedServer; score: number }[] = []; const scored: { s: UnifiedServer; score: number }[] = [];
for (const s of servers) { for (const s of servers) {
if (!phaseOk(s)) continue; 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 }); if (score >= 0) scored.push({ s, score });
} }
scored.sort((a, b) => b.score - a.score); scored.sort((a, b) => b.score - a.score);
@@ -468,7 +476,7 @@ function ServerActions({
<DialogHeader> <DialogHeader>
<DialogTitle>{ts("claim_server_title")}</DialogTitle> <DialogTitle>{ts("claim_server_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{ts("claim_server_desc", { name: server.name })} {ts("claim_server_desc", { name: server.displayName || server.name })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ConfirmFooter <ConfirmFooter
@@ -593,7 +601,7 @@ function ServerRow({
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40"> <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"> <td className="px-4 py-3 align-middle text-left">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-foreground">{server.name}</span> <ServerName server={server} />
<PhaseBadge phase={server.phase} /> <PhaseBadge phase={server.phase} />
</div> </div>
{host && <HostLink host={host} />} {host && <HostLink host={host} />}
@@ -648,7 +656,9 @@ function ServerMobileCard({
<CardContent className="flex flex-1 flex-col gap-3 p-4"> <CardContent className="flex flex-1 flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <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} />} {host && <HostLink host={host} />}
</div> </div>
<PhaseBadge phase={server.phase} /> <PhaseBadge phase={server.phase} />
@@ -687,3 +697,17 @@ function ServerMobileCard({
} }
export default ServersPage; 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": [], "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: { test: {
setupFiles: ["./vitest.setup.ts"], 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 i18next from "i18next";
import "./src/i18n"; import "./src/i18n";
i18next.changeLanguage("en-US"); 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();
});