test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认
This commit is contained in:
44 files changed
+10570
-208
No files matched your search
@@ -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
@@ -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':
|
||||||
|
|||||||
@@ -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,7 +1570,12 @@ 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) {
|
||||||
|
if c.listErr != nil {
|
||||||
|
return nil, c.listErr
|
||||||
|
}
|
||||||
|
return c.list, nil
|
||||||
|
}
|
||||||
func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+10
-4
@@ -18,10 +18,12 @@ 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"`
|
||||||
@@ -30,6 +32,10 @@ type MyServerView struct {
|
|||||||
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:
|
||||||
|
|||||||
@@ -13,3 +13,7 @@ dist/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Playwright output
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
+58
-35
@@ -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") {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// t reads the en-US strings the panel renders, so a copy change does not
|
||||||
|
// break the smoke and a missing key does ("ns:key", optional {{count}}).
|
||||||
|
const cache = new Map<string, Record<string, string>>();
|
||||||
|
export function t(key: string, vars: Record<string, string | number> = {}): string {
|
||||||
|
const [ns, k] = key.split(":");
|
||||||
|
if (!cache.has(ns)) {
|
||||||
|
const url = new URL(`../src/i18n/resources/en-US/${ns}.json`, import.meta.url);
|
||||||
|
cache.set(ns, JSON.parse(readFileSync(url, "utf8")));
|
||||||
|
}
|
||||||
|
const table = cache.get(ns)!;
|
||||||
|
const plural = typeof vars.count === "number" ? `${k}_${vars.count === 1 ? "one" : "other"}` : k;
|
||||||
|
const text = table[plural] ?? table[k];
|
||||||
|
if (text === undefined) throw new Error(`missing en-US string ${key}`);
|
||||||
|
return text.replace(/\{\{(\w+)\}\}/g, (_, v: string) => String(vars[v]));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Account = "owner" | "user" | "linked";
|
||||||
|
|
||||||
|
export const test = base.extend<{ signIn: (account: Account) => Promise<void> }>({
|
||||||
|
page: async ({ page, request }, use) => {
|
||||||
|
const res = await request.post("/api/v1/__mock/reset");
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
// signIn sets the mock session cookie directly; the sign-in form itself is
|
||||||
|
// covered by its own test.
|
||||||
|
signIn: async ({ context, baseURL }, use) => {
|
||||||
|
await use(async (account) => {
|
||||||
|
await context.addCookies([{ name: "felis_mock_session", value: account, url: baseURL! }]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect };
|
||||||
|
|
||||||
|
/** expectFitsScreen fails when anything scrolls sideways or a visible element
|
||||||
|
* pokes past the viewport edge (an overflow-hidden parent would just cut it
|
||||||
|
* off). The shell scrolls in an overflow-y-auto pane, whose computed
|
||||||
|
* overflow-x is auto as well, so a computed style cannot tell a deliberate
|
||||||
|
* horizontal scroller from the page pane: only a container that asks for one
|
||||||
|
* by class (overflow-x-auto, overflow-x-scroll, overflow-auto, e.g. a wide
|
||||||
|
* table) may be wider than the screen. */
|
||||||
|
export async function expectFitsScreen(page: Page) {
|
||||||
|
const report = await page.evaluate(() => {
|
||||||
|
const vw = document.documentElement.clientWidth;
|
||||||
|
const deliberate = (el: Element) =>
|
||||||
|
["overflow-x-auto", "overflow-x-scroll", "overflow-auto"].some((c) => el.classList.contains(c));
|
||||||
|
const inDeliberate = (el: Element) => {
|
||||||
|
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
||||||
|
if (deliberate(p)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const name = (el: Element) => {
|
||||||
|
const cls = typeof el.className === "string" ? el.className.split(/\s+/).slice(0, 3).join(".") : "";
|
||||||
|
return `${el.tagName.toLowerCase()}${cls ? "." + cls : ""}`;
|
||||||
|
};
|
||||||
|
const scrollers: string[] = [];
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const el of [document.documentElement, ...Array.from(document.body.querySelectorAll("*"))]) {
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
if (style.visibility === "hidden" || deliberate(el) || inDeliberate(el)) continue;
|
||||||
|
if (el.scrollWidth > el.clientWidth + 1 && ["auto", "scroll"].includes(style.overflowX)) {
|
||||||
|
scrollers.push(`${name(el)} scrolls ${el.scrollWidth - el.clientWidth}px sideways`);
|
||||||
|
}
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) continue;
|
||||||
|
if (r.left < -1 || r.right > vw + 1) offenders.push(`${name(el)} [${Math.round(r.left)}..${Math.round(r.right)}]`);
|
||||||
|
}
|
||||||
|
return { vw, scrollers: scrollers.slice(0, 5), offenders: offenders.slice(0, 5) };
|
||||||
|
});
|
||||||
|
expect(report.scrollers, "containers that scroll sideways").toEqual([]);
|
||||||
|
expect(report.offenders, `elements past the ${report.vw}px viewport`).toEqual([]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||||
|
|
||||||
|
test("the menu drawer reaches the other sections", async ({ page, signIn }) => {
|
||||||
|
await signIn("linked");
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: t("common:open_menu") }).click();
|
||||||
|
const drawer = page.getByRole("dialog");
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
await drawer.getByRole("link", { name: t("navigation:account") }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/account$/);
|
||||||
|
await expect(drawer).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [account, path] of [
|
||||||
|
["linked", "/"],
|
||||||
|
["linked", "/servers"],
|
||||||
|
["linked", "/servers/lobby"],
|
||||||
|
["linked", "/account"],
|
||||||
|
["owner", "/servers"],
|
||||||
|
["owner", "/admin/users"],
|
||||||
|
] as const) {
|
||||||
|
test(`${path} fits a 375px screen for ${account}`, async ({ page, signIn }) => {
|
||||||
|
await signIn(account);
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test, expect, t } from "./fixtures";
|
||||||
|
|
||||||
|
test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => {
|
||||||
|
await page.goto("/servers");
|
||||||
|
await expect(page).toHaveURL(/\/login\?next=%2Fservers$/);
|
||||||
|
|
||||||
|
await page.getByLabel(t("auth:email_address")).fill("[email protected]");
|
||||||
|
await page.getByRole("button", { name: t("auth:send_otp") }).click();
|
||||||
|
await page.getByLabel(t("auth:otp_code")).fill("123456");
|
||||||
|
await page.getByRole("button", { name: t("auth:otp_btn") }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/servers$/);
|
||||||
|
await expect(page.getByRole("heading", { name: t("ops:fleet_title") })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a player sees their own server, can claim an unowned one, and has no admin section", async ({ page, signIn }) => {
|
||||||
|
await signIn("linked");
|
||||||
|
await page.goto("/servers");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: t("servers:my_servers_title") })).toBeVisible();
|
||||||
|
// Display name first, the server name beside it.
|
||||||
|
const own = page.getByRole("row").filter({ hasText: "Hub Lobby" });
|
||||||
|
await expect(own).toContainText("lobby");
|
||||||
|
await expect(own.getByRole("link", { name: t("servers:console") })).toBeVisible();
|
||||||
|
await expect(own.getByRole("button", { name: t("servers:stop"), exact: true })).toBeVisible();
|
||||||
|
const unowned = page.getByRole("row").filter({ hasText: "Claimable Node" });
|
||||||
|
await expect(unowned.getByRole("button", { name: t("servers:claim") })).toBeVisible();
|
||||||
|
await expect(unowned.getByRole("button", { name: t("servers:stop"), exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("link", { name: t("navigation:admin_users") })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto("/admin/images");
|
||||||
|
await expect(page.getByText(t("common:not_authorized_title"))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stopping a server with players online asks first, and cancel keeps it running", async ({ page, signIn }) => {
|
||||||
|
await signIn("linked");
|
||||||
|
await page.goto("/servers/lobby");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: t("servers:stop"), exact: true }).click();
|
||||||
|
const question = page.getByText(t("servers:stop_confirm_players", { count: 28 }));
|
||||||
|
await expect(question).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: t("common:cancel") }).click();
|
||||||
|
await expect(question).toHaveCount(0);
|
||||||
|
|
||||||
|
const status = await page.request.get("/api/v1/servers/lobby/status");
|
||||||
|
expect((await status.json()).phase).toBe("Running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an admin reaches the user list", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/admin/users");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: t("admin:users_title") })).toBeVisible();
|
||||||
|
await expect(page.getByText("[email protected]")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
// Lint guards what tsc cannot: the rules of hooks (a hook behind a condition
|
||||||
|
// renders fine until the branch flips) and effect dependency lists (a stale
|
||||||
|
// closure, or a memo that recomputes every render). Types are tsc's job and
|
||||||
|
// style the formatter's, so nothing else is switched on. CI runs it with
|
||||||
|
// --max-warnings 0; a deliberate gap in a dependency list carries a disable
|
||||||
|
// comment that says why.
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist", "node_modules", "test-results", "playwright-report", "src/lib/openapi.gen.ts"] },
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}", "dev/**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: { ...globals.browser, ...globals.node },
|
||||||
|
},
|
||||||
|
linterOptions: { reportUnusedDisableDirectives: "error" },
|
||||||
|
plugins: { "react-hooks": reactHooks },
|
||||||
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+2283
-39
File diff suppressed because it is too large.
Load diff
+15
-1
@@ -8,8 +8,11 @@
|
|||||||
"dev": "vite",
|
"dev": "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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Browser smoke over the mock-mode dev server (dev/mockApi.ts): real routing,
|
||||||
|
// real fetches and cookies, a fake backend. It runs the installed Chrome, so
|
||||||
|
// no browser download is needed locally or on the GitHub runner. The mock's
|
||||||
|
// state is shared across requests, so tests run one at a time and each one
|
||||||
|
// resets it first. PW_BASE_URL points it at an already running mock server.
|
||||||
|
const port = 5298;
|
||||||
|
const external = process.env.PW_BASE_URL;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
|
||||||
|
use: {
|
||||||
|
baseURL: external ?? `http://localhost:${port}`,
|
||||||
|
channel: "chrome",
|
||||||
|
locale: "en-US",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "desktop", testMatch: /smoke\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } },
|
||||||
|
{
|
||||||
|
name: "mobile",
|
||||||
|
testMatch: /mobile\.spec\.ts/,
|
||||||
|
use: { channel: "chrome", viewport: { width: 375, height: 812 }, hasTouch: true, isMobile: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: external
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
command: `node node_modules/vite/bin/vite.js --mode mock --port ${port} --strictPort`,
|
||||||
|
url: `http://localhost:${port}/`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
+2
-18
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect } from "react";
|
import { BrowserRouter, Routes, Route, Navigate } 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
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
|
|
||||||
|
const { reloadForNewDeploy } = vi.hoisted(() => ({ reloadForNewDeploy: vi.fn() }));
|
||||||
|
vi.mock("@/lib/chunk", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/chunk")>();
|
||||||
|
return { ...actual, reloadForNewDeploy };
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = (key: string) => i18next.t(key);
|
||||||
|
|
||||||
|
const state = { broken: true, error: new Error("boom") as unknown };
|
||||||
|
function Page() {
|
||||||
|
if (state.broken) throw state.error;
|
||||||
|
return <p>page content</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// React and the boundary both log the caught render error, and React's dev
|
||||||
|
// build replays it as a window error event that jsdom would print too.
|
||||||
|
let quiet: ReturnType<typeof vi.spyOn>;
|
||||||
|
const swallow = (e: ErrorEvent) => e.preventDefault();
|
||||||
|
beforeEach(() => {
|
||||||
|
state.broken = true;
|
||||||
|
state.error = new Error("boom");
|
||||||
|
reloadForNewDeploy.mockReset();
|
||||||
|
quiet = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
window.addEventListener("error", swallow);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
quiet.mockRestore();
|
||||||
|
window.removeEventListener("error", swallow);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ErrorBoundary", () => {
|
||||||
|
it("shows the crash page with the error instead of a blank screen", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Page />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toBeTruthy();
|
||||||
|
expect(screen.getByText(t("common:crash_title"))).toBeTruthy();
|
||||||
|
expect(screen.getByText("Error: boom")).toBeTruthy();
|
||||||
|
expect(reloadForNewDeploy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("try again renders the page once it stops throwing", async () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Page />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
state.broken = false;
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("common:try_again") }));
|
||||||
|
|
||||||
|
expect(screen.getByText("page content")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the crash when the route changes", () => {
|
||||||
|
const view = render(
|
||||||
|
<ErrorBoundary resetKey="/servers/a">
|
||||||
|
<Page />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("alert")).toBeTruthy();
|
||||||
|
|
||||||
|
state.broken = false;
|
||||||
|
view.rerender(
|
||||||
|
<ErrorBoundary resetKey="/servers/b">
|
||||||
|
<Page />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("page content")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the caller's fallback when given one", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary fallback={() => <p>flat fleet</p>}>
|
||||||
|
<Page />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("flat fleet")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads once for a chunk from an older deploy and says the panel updated", () => {
|
||||||
|
state.error = new TypeError("Failed to fetch dynamically imported module: /assets/Files-abc.js");
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Page />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reloadForNewDeploy).toHaveBeenCalledOnce();
|
||||||
|
expect(screen.getByText(t("common:crash_updated_title"))).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { Info } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { 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,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { PowerButton } from "./PowerButton";
|
||||||
|
|
||||||
|
const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return { ...actual, api: { ...actual.api, wake, stop } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wake.mockReset();
|
||||||
|
stop.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PowerButton", () => {
|
||||||
|
it("wakes a stopped server and tells the parent", async () => {
|
||||||
|
wake.mockResolvedValue(undefined);
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
|
||||||
|
|
||||||
|
expect(wake).toHaveBeenCalledWith("lobby");
|
||||||
|
expect(onChanged).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays on the page with the reason when the wake is refused", async () => {
|
||||||
|
wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" });
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveProperty("textContent", t("errors:quota_exceeded"));
|
||||||
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops an empty server without asking", async () => {
|
||||||
|
stop.mockResolvedValue(undefined);
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
render(<PowerButton name="lobby" live playersOnline={0} onChanged={onChanged} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalledWith("lobby");
|
||||||
|
expect(onChanged).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks before disconnecting players, and cancel sends nothing", async () => {
|
||||||
|
render(<PowerButton name="lobby" live playersOnline={3} onChanged={vi.fn()} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||||
|
expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("common:cancel") }));
|
||||||
|
|
||||||
|
expect(stop).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText(t("servers:stop_confirm_players", { count: 3 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks when the player count cannot be read", async () => {
|
||||||
|
stop.mockResolvedValue(undefined);
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
render(<PowerButton name="lobby" live playersOnline={0} playerCountUnknown onChanged={onChanged} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||||
|
expect(stop).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(t("servers:stop_confirm_unknown"))).toBeTruthy();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalledWith("lobby");
|
||||||
|
expect(onChanged).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the confirmation open with the reason when the stop fails", async () => {
|
||||||
|
stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" });
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
render(<PowerButton name="lobby" live playersOnline={2} onChanged={onChanged} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(t("errors:cooldown"))).toBeTruthy();
|
||||||
|
expect(screen.getByText(t("servers:stop_confirm_players", { count: 2 }))).toBeTruthy();
|
||||||
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends one call however fast it is clicked", async () => {
|
||||||
|
let resolve!: () => void;
|
||||||
|
wake.mockReturnValue(new Promise<void>((r) => (resolve = r)));
|
||||||
|
render(<PowerButton name="lobby" live={false} onChanged={vi.fn()} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: t("servers:wake") });
|
||||||
|
await userEvent.click(button);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:waking") }));
|
||||||
|
resolve();
|
||||||
|
|
||||||
|
expect(wake).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { RequireAuth } from "./RequireAuth";
|
||||||
|
|
||||||
|
const tier = vi.hoisted(() => ({ loading: false, unauthenticated: false, sessionEnded: false }));
|
||||||
|
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
|
||||||
|
|
||||||
|
function LoginProbe() {
|
||||||
|
const location = useLocation();
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
login{location.search} ended={String((location.state as { sessionEnded?: boolean } | null)?.sessionEnded ?? false)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginProbe />} />
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route path="/" element={<p>dashboard</p>} />
|
||||||
|
<Route path="/servers/:name" element={<p>console</p>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.assign(tier, { loading: false, unauthenticated: false, sessionEnded: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RequireAuth", () => {
|
||||||
|
it("shows a spinner while /me is in flight, never the login page", () => {
|
||||||
|
tier.loading = true;
|
||||||
|
tier.unauthenticated = true;
|
||||||
|
renderAt("/servers/lobby");
|
||||||
|
|
||||||
|
expect(screen.getByText(i18next.t("common:loading"))).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/^login/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a 401 to /login with the page to come back to", () => {
|
||||||
|
tier.unauthenticated = true;
|
||||||
|
renderAt("/servers/lobby?tab=files");
|
||||||
|
|
||||||
|
expect(screen.getByText(`login?next=${encodeURIComponent("/servers/lobby?tab=files")} ended=false`)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops ?next= for the dashboard and says when the session ended", () => {
|
||||||
|
tier.unauthenticated = true;
|
||||||
|
tier.sessionEnded = true;
|
||||||
|
renderAt("/");
|
||||||
|
|
||||||
|
expect(screen.getByText("login ended=true")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the app for a session, including a degraded /me", () => {
|
||||||
|
renderAt("/servers/lobby");
|
||||||
|
|
||||||
|
expect(screen.getByText("console")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,10 +8,10 @@ import { PhaseBadge } from "@/components/PhaseBadge";
|
|||||||
import { PowerButton } from "@/components/PowerButton";
|
import { 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;
|
||||||
|
}
|
||||||
@@ -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("|");
|
||||||
}
|
}
|
||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Compile-time parity between the hand-written wire types (types.ts) and the
|
||||||
|
// schemas in docs/openapi.yaml, via the generated openapi.gen.ts (`npm run
|
||||||
|
// gen:api`; CI fails when the generated file is stale). The Go side is held to
|
||||||
|
// the same schemas by internal/api/openapi_parity_test.go, so a field that
|
||||||
|
// changes in one place and not the others breaks a build instead of rendering
|
||||||
|
// `undefined`.
|
||||||
|
//
|
||||||
|
// For each pair three things must hold:
|
||||||
|
// - the two key sets are equal;
|
||||||
|
// - a key the docs mark optional is optional here too (the panel must not
|
||||||
|
// count on a field the server may omit);
|
||||||
|
// - every documented value fits the panel type (enums included).
|
||||||
|
// A failure names the offending keys in the error's type.
|
||||||
|
import type { components } from "./openapi.gen";
|
||||||
|
import type * as T from "./types";
|
||||||
|
|
||||||
|
type S = components["schemas"];
|
||||||
|
|
||||||
|
type OptionalKeys<X> = { [K in keyof X]-?: object extends Pick<X, K> ? K : never }[keyof X];
|
||||||
|
|
||||||
|
type Parity<Panel, Docs> = [Exclude<keyof Panel, keyof Docs>, Exclude<keyof Docs, keyof Panel>] extends [
|
||||||
|
never,
|
||||||
|
never,
|
||||||
|
]
|
||||||
|
? [Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>>] extends [never]
|
||||||
|
? Docs extends Panel
|
||||||
|
? true
|
||||||
|
: { docsValueDoesNotFitPanel: { [K in keyof Docs & keyof Panel]: Docs[K] extends Panel[K] ? never : K }[keyof Docs & keyof Panel] }
|
||||||
|
: { optionalInDocsButRequiredInPanel: Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>> }
|
||||||
|
: { onlyInPanel: Exclude<keyof Panel, keyof Docs>; onlyInDocs: Exclude<keyof Docs, keyof Panel> };
|
||||||
|
|
||||||
|
type Holds<X extends true> = X;
|
||||||
|
|
||||||
|
export type WireParity = [
|
||||||
|
Holds<Parity<T.ServerStatus, S["ServerInfo"]>>,
|
||||||
|
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
|
||||||
|
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
|
||||||
|
Holds<Parity<T.BackupView, S["BackupView"]>>,
|
||||||
|
Holds<Parity<T.Build, S["Build"]>>,
|
||||||
|
Holds<Parity<T.WhitelistImage, S["Image"]>>,
|
||||||
|
Holds<Parity<T.Submission, S["Submission"]>>,
|
||||||
|
Holds<Parity<T.UserView, S["UserView"]>>,
|
||||||
|
Holds<Parity<T.UserDetail, S["UserDetail"]>>,
|
||||||
|
Holds<Parity<T.QuotaView, S["QuotaView"]>>,
|
||||||
|
Holds<Parity<T.SessionView, S["SessionView"]>>,
|
||||||
|
Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>,
|
||||||
|
Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>,
|
||||||
|
Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>,
|
||||||
|
];
|
||||||
+53
-37
@@ -17,35 +17,53 @@ export type Phase =
|
|||||||
|
|
||||||
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
|
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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import type { Identity, PasskeyCredential } from "@/lib/types";
|
||||||
|
import { Account } from "./Account";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
passkeyList: vi.fn(),
|
||||||
|
passkeyDelete: vi.fn(),
|
||||||
|
identity: null as Identity | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
linkStatus: () => Promise.resolve({ linked: true }),
|
||||||
|
migrateStatus: () => Promise.resolve({ active: false }),
|
||||||
|
passkeyList: mocks.passkeyList,
|
||||||
|
passkeyDelete: mocks.passkeyDelete,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("@/lib/tier", () => ({
|
||||||
|
useTier: () => ({ identity: mocks.identity, refresh: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
|
||||||
|
const deleteButton = (name: string) => ({ name: t("account:passkey_delete_aria", { name }) });
|
||||||
|
|
||||||
|
const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
|
||||||
|
const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
|
||||||
|
|
||||||
|
function identity(emailVerified: boolean): Identity {
|
||||||
|
return {
|
||||||
|
user_id: "u-1",
|
||||||
|
email: "[email protected]",
|
||||||
|
role: "user",
|
||||||
|
is_admin: false,
|
||||||
|
is_owner: false,
|
||||||
|
email_verified: emailVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccount() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Account />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.passkeyList.mockReset();
|
||||||
|
mocks.passkeyDelete.mockReset();
|
||||||
|
mocks.identity = identity(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Account passkey delete", () => {
|
||||||
|
it("names the passkey in the confirmation and deletes only on confirm", async () => {
|
||||||
|
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
|
||||||
|
mocks.passkeyDelete.mockResolvedValue(undefined);
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(within(dialog).getByText(t("account:passkey_delete_title"))).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText(/“Laptop”/)).toBeTruthy();
|
||||||
|
expect(mocks.passkeyDelete).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: t("account:passkey_delete_confirm") }));
|
||||||
|
|
||||||
|
expect(mocks.passkeyDelete).toHaveBeenCalledWith("pk-1");
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
await waitFor(() => expect(screen.queryByRole("button", deleteButton("Laptop"))).toBeNull());
|
||||||
|
expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel leaves the passkey alone", async () => {
|
||||||
|
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
|
||||||
|
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("common:cancel") }));
|
||||||
|
|
||||||
|
expect(mocks.passkeyDelete).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks the only passkey of an unverified account and says why", async () => {
|
||||||
|
mocks.identity = identity(false);
|
||||||
|
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
const button = await screen.findByRole("button", deleteButton("Laptop"));
|
||||||
|
expect(button).toHaveProperty("disabled", true);
|
||||||
|
expect(screen.getByText(t("account:passkey_last_hint"))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a verified account delete its only passkey (email-OTP still signs it in)", async () => {
|
||||||
|
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
const button = await screen.findByRole("button", deleteButton("Laptop"));
|
||||||
|
expect(button).toHaveProperty("disabled", false);
|
||||||
|
expect(screen.queryByText(t("account:passkey_last_hint"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the dialog open with the server's reason when the delete is refused", async () => {
|
||||||
|
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
|
||||||
|
mocks.passkeyDelete.mockRejectedValue({ status: 409, code: "last_passkey", message: "raw" });
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
|
||||||
|
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
|
||||||
|
|
||||||
|
expect(await within(screen.getByRole("dialog")).findByText(t("errors:last_passkey"))).toBeTruthy();
|
||||||
|
expect(mocks.passkeyList).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a passkey already gone as deleted", async () => {
|
||||||
|
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
|
||||||
|
mocks.passkeyDelete.mockRejectedValue({ status: 404, code: "not_found", message: "passkey not found" });
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
|
||||||
|
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
await waitFor(() => expect(screen.queryByRole("button", deleteButton("Laptop"))).toBeNull());
|
||||||
|
expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
|
||||||
|
expect(screen.queryByText("passkey not found")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ import { api } from "@/lib/api";
|
|||||||
import { useAsync, useConfig } from "@/lib/hooks";
|
import { 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[];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { ServerFiles } from "./ServerFiles";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
status: () => Promise.resolve({ name: "lobby", subdomain: "lobby", phase: "Stopped", ready: false }),
|
||||||
|
listServerFiles: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
path: "",
|
||||||
|
truncated: false,
|
||||||
|
entries: [{ name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }],
|
||||||
|
}),
|
||||||
|
readServerFile: () => Promise.resolve({ path: "server.properties", content: btoa("motd=hi\n"), sha256: "abc" }),
|
||||||
|
writeServerFile: mocks.writeServerFile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) }));
|
||||||
|
|
||||||
|
const t = (key: string) => i18next.t(key);
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/servers/lobby/files"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/servers/:name/files" element={<ServerFiles />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
await userEvent.click(await screen.findByText("server.properties"));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.writeServerFile.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ServerFiles editor", () => {
|
||||||
|
it("closes an unchanged file at once", async () => {
|
||||||
|
const { dialog } = await openEditor();
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks before dropping edits, and keep editing keeps the text", async () => {
|
||||||
|
const { editor } = await openEditor();
|
||||||
|
await userEvent.type(editor, "pvp=false");
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(within(dialog).getByText(t("files:discard_prompt"))).toBeTruthy();
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: t("files:keep_editing") }));
|
||||||
|
expect(within(dialog).queryByText(t("files:discard_prompt"))).toBeNull();
|
||||||
|
expect((within(dialog).getByRole("textbox") as HTMLTextAreaElement).value).toBe("motd=hi\npvp=false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the edits only after discard is confirmed", async () => {
|
||||||
|
const { dialog, editor } = await openEditor();
|
||||||
|
await userEvent.type(editor, "pvp=false");
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: t("files:discard") }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(mocks.writeServerFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards leaving the page while edits are unsaved", async () => {
|
||||||
|
const { editor } = await openEditor();
|
||||||
|
const clean = new Event("beforeunload", { cancelable: true });
|
||||||
|
window.dispatchEvent(clean);
|
||||||
|
expect(clean.defaultPrevented).toBe(false);
|
||||||
|
|
||||||
|
await userEvent.type(editor, "x");
|
||||||
|
const dirty = new Event("beforeunload", { cancelable: true });
|
||||||
|
window.dispatchEvent(dirty);
|
||||||
|
|
||||||
|
expect(dirty.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -128,17 +128,21 @@ export function ServerFiles() {
|
|||||||
|
|
||||||
// Load (and reload after a stop) only once the viewer is resolved as owner and
|
// 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["playwright.config.ts", "e2e/**/*.ts"]
|
||||||
|
}
|
||||||
+11
-1
@@ -1,4 +1,14 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.e2e.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,8 @@ export default defineConfig(async ({ mode }) => {
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
setupFiles: ["./vitest.setup.ts"],
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
// e2e/*.spec.ts belong to Playwright (npm run test:e2e).
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import { afterEach } from "vitest";
|
||||||
import i18next from "i18next";
|
import 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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user