Skip to content

test(e2e): expand Cypress coverage with deep journeys, setup wizard run + CI#1764

Merged
confuser merged 22 commits intofeat/easier-setup-web-installerfrom
test/expand-cypress-e2e-coverage
Apr 19, 2026
Merged

test(e2e): expand Cypress coverage with deep journeys, setup wizard run + CI#1764
confuser merged 22 commits intofeat/easier-setup-web-installerfrom
test/expand-cypress-e2e-coverage

Conversation

@confuser
Copy link
Copy Markdown
Member

Summary

Stacked on top of #feat/easier-setup-web-installer — please merge that PR first, then this one. GitHub will auto-retarget to master once the parent merges.

Expands Cypress e2e coverage from a handful of smoke specs to deep, end-to-end journey coverage for the pieces of the UI most likely to break:

  • 7 new application journey specs (admin server / role / webhook lifecycles, player moderation, appeal lifecycle, report lifecycle, registration / PIN forgotten-password, error pages)
  • 2 new setup wizard specs (full installer happy path + paste/path config import) running on a dedicated isolated server via a supervisor that exercises the real setup-mode -> normal-mode boot transition
  • New jest coverage for the API-level setup token gate
flowchart LR
    master[master] --> parent[\"feat/easier-setup-web-installer<br/>(PR open)\"]
    parent --> child[\"test/expand-cypress-e2e-coverage<br/>(this PR, base = parent)\"]
Loading

What's covered

Application journeys (`cypress/e2e/journeys/`)

  • `admin-server-lifecycle` — create / edit / delete a server.
  • `admin-role-lifecycle` — create role, assign globally and per-server (uses the new second seeded server so `AssignPlayersRoleForm`'s `ServerSelector` is meaningfully exercised), edit, delete.
  • `admin-webhook-lifecycle` — parameterised across Custom + Discord: add → save → Test webhook modal (using the example `APPEAL_CREATED` payload against `httpbin.org/post`) → deliveries page → assert status badge → edit → delete.
  • `player-moderation` — parameterised across all 4 punishment types (ban / mute / warn / note). Creates each on the unbanned seeded player, edits ban + mute, asserts profile lists, plus admin documents preview + delete-cancel.
  • `appeal-lifecycle` — parameterised across ban / mute / warning. Banned user → appeal flow → admin notification → assign → resolve → notification flips to read. Notifications coverage merged in here because `APPEAL_CREATED` is currently the only `NotificationType`.
  • `report-lifecycle` — admin walks an open report through Open → Assigned → Resolved → Closed, asserts comments are blocked once closed.
  • `registration` — PIN-as-login on `/appeal`, account register (with password mismatch branch), forgot-password, login as existing user via PIN, global player search smoke.

New page spec

  • `error-pages` — 404 (bad route) + 500 (`/500` direct visit) render with their Homepage links.

Setup wizard (`cypress/e2e-setup/`, isolated run)

  • `installer.spec.js` — UI rendering, bad DB credentials, password mismatch, invalid UUID, "Create database if missing", happy-path finalize. Uses a polling helper on `/api/setup/state` to wait for the supervisor's setup-mode → normal-mode restart, then asserts `/setup` returns 404.
  • `installer-config-import.spec.js` — paste-mode + path-mode of `config.yml` / `console.yml` (success + missing-section / missing-uuid / blank / non-existent-path errors).

Jest

  • `server/test/setup-mode-boot.test.js` — new `describe` for the `SETUP_TOKEN` gate (preflight no-leak, mutation rejection, wrong / right token).

Infrastructure / scaffolding

  • Seed: `cypress/setup.js` extended with extra players (one explicitly unbanned + one explicitly banned), a second BM server, active + historical punishments, reports + appeals at every state, comments, a bcrypt-hashed `bm_player_pins` row, a PIN-only player and an `APPEAL_CREATED` notification rule. `cypress/fixtures/e2e-data.json` exposes all the new ids. Existing `documents.spec.js` keeps working because `serverId` / `banId` are preserved.
  • Cypress config: explicit `retries` (1 in CI, 0 in open mode), `video: false`, `screenshotOnRunFailure` and new user / PIN env defaults.
  • Custom commands: `cy.loginAsAdmin`, `cy.loginAsUser`, `cy.loginAsPin`, `cy.logout`, `cy.gql`. Plus `cy.task('sleep')` for controlled waits.
  • Setup E2E target: new `cypress.setup.config.js`, supervisor at `cypress/scripts/setup-server.js` (spawns `server.js` from a temp sandbox cwd so dotenv can't pick up developer `.env`s, strips DB / encryption env vars, respawns child after clean exit), and `cypress/scripts/prepare-setup-db.js` for throwaway WebUI + BM databases (with proper `inetPton` IP encoding for the seeded Console player). New `npm run e2e:setup:server / e2e:setup:run / e2e:setup:open` scripts.
  • `data-cy` hooks: threaded through punishment forms, `PlayerActions`, all admin Server / Role / NotificationRule / Webhook (Custom + Discord) forms + list items + `WebhookTestForm` + `WebhookDeliveryItem`, admin `DocumentsTable` rows, report + appeal sidebars, `PunishmentPicker` (with type / id / server-id data attributes), notification list + container, dashboard widgets, the global `PlayerSelector` in `DefaultLayout`, and the entire installer (`installer.html` + `installer.js`). Also fixes the duplicate `data-cy=password` on `PlayerRegisterForm`.
  • CI: new `setup_e2e` job in `.github/workflows/build.yaml` running parallel to the existing `test` matrix. Boots MySQL on the runner, starts the supervisor, waits on `/health` and runs the e2e-setup specs.

Conventions

  • Conventional commits, split into 7 logical commits (one per todo group) for reviewable diffs.
  • Specs use re-queried selectors (`setText` / `setTextArea` helpers) instead of `.clear().type()` chains and `waitForState` polling instead of arbitrary `cy.wait()`.
  • Each spec creates with a `Date.now()` suffix and deletes at the end so reruns work without a re-seed.

Test plan

  • `npm run lint` — clean.
  • `npx jest server/test/setup-mode-boot.test.js` — 12 / 12 pass.
  • `cypress.setup.config.js` and `cypress/scripts/prepare-setup-db.js` load cleanly under Node.
  • CI: existing `test` matrix passes (additive seed only).
  • CI: new `setup_e2e` job passes.

Expands cypress/setup.js with the data shape the new journey specs need
without breaking the existing documents.spec.js fixture contract:

- Extra seeded players including an unbanned target for moderation
  flows and a banned target with active ban/mute/warning for appeal
  flows.
- A second BanManager server so AssignPlayersRoleForm's ServerSelector
  is meaningfully exercised by the role lifecycle journey.
- Pre-seeded reports and appeals at every state (open / assigned /
  resolved / closed) plus comments, scoped to the non-admin user so the
  admin ban + parameterised punishments stay unappealed.
- A bcrypt-hashed bm_player_pins row valid for 10 minutes plus a
  PIN-only player to cover both the registration journey and the
  forgotten-password / appeal-login PIN branches.
- An APPEAL_CREATED notification rule targeting the super-admin role so
  the appeal lifecycle journey can assert end-to-end notification
  delivery.

cypress/fixtures/e2e-data.json is extended with the new ids the specs
consume (secondServerId, unbannedPlayerId, bannedPlayer*, *ReportId,
*AppealId, notificationRuleId, pin* fields). Existing serverId / banId
keys are preserved for documents.spec.js.

cypress.config.js now ships explicit retries / video / screenshot
defaults so CI failures are debuggable, plus user/PIN env defaults
consumed by the new helpers.

cypress/support/commands.js gains loginAsAdmin / loginAsUser /
loginAsPin / logout / gql helpers so journey specs can short-circuit
authentication and warm state via the API instead of the UI.
Threads data-cy attributes through every component the new journey
specs target so selectors stay stable across refactors and aren't
coupled to translatable copy or react-select internals.

- Punishment forms (Ban / Mute / Warn / Note) and PlayerActions get
  per-input + submit hooks plus a canCreateNote permission check used
  by the moderation journey.
- Admin Server / Role / NotificationRule forms and list items get
  field, row, edit and delete hooks.
- Webhook (Custom + Discord) forms, list items, WebhookTestForm and
  WebhookDeliveryItem get hooks for the create / test / deliveries /
  edit / delete loop, including the status badge for delivery
  assertions.
- Admin DocumentsTable rows expose preview / delete hooks for the
  moderation journey's documents check.
- Report and appeal sidebars expose state and assignee controls for
  the lifecycle journeys.
- PunishmentPicker exposes per-row hooks (with type, id and server-id
  data attributes) plus filter and empty-state hooks for the appeal
  journey's picker filter coverage.
- NotificationContainer / NotificationList expose row and link hooks,
  the global PlayerSelector in DefaultLayout becomes
  data-cy=player-search, and dashboard widgets get container hooks.
- PlayerRegisterForm: the duplicate data-cy=password on the confirm
  field becomes confirm-password to match ResetPasswordForm.

No behavioural changes other than the canCreateNote gate and the
fixed register selector.
Three new journey specs covering deep admin flows that previously had
zero coverage. Each spec creates with a Date.now()-suffixed name and
deletes at the end so reruns work without a re-seed.

- admin-server-lifecycle: create, list, edit and delete a server,
  exercising the new ServerForm / ServerItem hooks.
- admin-role-lifecycle: create a custom role, assign it globally and
  per-server (using the second seeded server so AssignPlayersRoleForm's
  ServerSelector is meaningfully exercised), edit and delete.
- admin-webhook-lifecycle: parameterised across Custom and Discord
  variants. Walks add -> save -> Test webhook modal (with the example
  APPEAL_CREATED payload against httpbin.org/post) -> deliveries page
  -> edit URL -> delete, asserting the WebhookResponse status code and
  the WebhookDeliveryItem status badge.

All three specs use cy.loginAsAdmin() for setup and re-query elements
between steps to avoid Cypress unsafe chaining.
- player-moderation: parameterised across ban / mute / warn / note. An
  admin opens the unbanned seeded player's profile, creates each
  punishment type, edits ban + mute via /player/{ban|mute}/[id], and
  asserts each entry appears in the relevant profile list. Also walks
  the admin documents page (preview modal, modal-confirm delete cancel)
  to cover the new admin-doc-* hooks.
- appeal-lifecycle: parameterised across ban / mute / warning. The
  banned user submits an appeal via /appeal -> punishment picker (uses
  the seeded picker filters and clear-filters control) -> the admin
  receives the pre-seeded APPEAL_CREATED notification, opens the
  appeal, comments, assigns, marches state to resolved, then asserts
  the notification flips to read. Notifications coverage is fully
  merged in here because APPEAL_CREATED is currently the only
  NotificationType. Includes a dedicated case that opens the
  AnimatedDisclosure on the picker row before clicking Appeal.
- report-lifecycle: an admin walks an open report through the full
  state machine (Open -> Assigned -> Resolved -> Closed), comments and
  assigns along the way, and asserts that comments cannot be added
  once closed. Also smoke-checks the /reports list page.

All three specs delete what they create and use re-queried selectors
to avoid Cypress unsafe chaining.
…ourneys

- registration: covers PIN-as-login on /appeal for the seeded
  PIN-only player, account registration including the password
  mismatch branch, the forgotten-password page render, login via PIN
  for the existing seeded user through the appeal flow, and a smoke
  check of the global player search in the nav (data-cy=player-search
  in DefaultLayout) routing through to /player/{id}.
- error-pages: visits a deliberately bad route to assert the 404 page
  renders with the expected title and Homepage link, then visits /500
  directly with failOnStatusCode:false to assert the 500 page renders
  with its Homepage link too.

Specs are self-contained and use re-queried selectors throughout.
…t specs

The normal Cypress run boots against an already-set-up DB and can never
exercise /setup. This adds a dedicated setup-mode E2E target driven by
its own Cypress config + supervisor process so the wizard can be
walked end-to-end without touching any developer or CI .env file.

Infrastructure:

- cypress.setup.config.js: separate config rooted at cypress/e2e-setup
  with custom tasks (prepareSetupDb, dropSetupDbs, writeBmConfigFixture,
  readEnvFile, clearEnvFile, getSetupConsoleUuid, sleep) and a CYPRESS_
  / SETUP_ env-var pipeline that flows DB credentials and the dotenv
  target path through to the installer specs.
- cypress/scripts/setup-server.js: supervisor that spawns server.js
  from a temporary sandbox cwd (so dotenv.config() can't pick up the
  developer's .env), strips DB / encryption env vars to force
  setup-mode boot, and respawns the child after a clean exit so the
  spec can verify the post-finalize transition into normal-mode and
  the resulting /setup -> 404. Sets DISABLE_UI=true on the second boot
  so it doesn't try to load Next.js.
- cypress/scripts/prepare-setup-db.js: drops + recreates throwaway
  WebUI and BM databases, runs BM-side migrations and seeds a Console
  player with a deterministic UUID using inetPton for the IP column.
- npm run e2e:setup:server / e2e:setup:run / e2e:setup:open scripts.

Specs:

- cypress/e2e-setup/installer.spec.js covers progress rendering, bad
  DB credentials, password mismatch, invalid UUID validation, the
  "Create database if missing" flow, and the full happy-path finalize
  including a polling check on /api/setup/state for the supervisor
  restart and a post-completion 404 assertion on /setup.
- cypress/e2e-setup/installer-config-import.spec.js covers paste-mode
  and path-mode ingestion of config.yml + console.yml (success +
  missing-section / missing-uuid / blank / non-existent-path errors).

Hooks + supporting tests:

- server/setup/static/installer.html / installer.js gain data-cy
  attributes on banners, the progress indicator, every step input,
  navigation buttons and the success / continue-to-login screen so the
  specs can target stable selectors.
- server/test/setup-mode-boot.test.js gets a SETUP_TOKEN describe
  block that exercises the API token gate (preflight no-leak, mutation
  rejection, wrong / right token flows). Token coverage stays at the
  jest layer to keep the E2E supervisor simple.

Helpers (setText, setTextArea, waitForState polling) are used in both
specs to avoid Cypress unsafe chaining and arbitrary cy.wait() values.
New setup_e2e job that runs in parallel with the existing test matrix.
Boots MySQL on the runner, runs npm run e2e:setup:server (the
supervisor that handles setup-mode -> normal-mode restarts), waits on
http://127.0.0.1:3001/health and then runs the cypress/e2e-setup
specs.

CYPRESS_setup_* env vars are forwarded into the Cypress action so
prepareSetupDb / dropSetupDbs / readEnvFile / writeBmConfigFixture
tasks pick up the right host, port, user, password, dotenv path and
DB names.
@cypress
Copy link
Copy Markdown

cypress Bot commented Apr 18, 2026

BanManager-WebUI    Run #10201

Run Properties:  status check passed Passed #10201  •  git commit e00d8377c9 ℹ️: Merge 78a1887025f68371bc4dd8e56b396fc90887f165 into c6fe04c5d592b3f3e88e6eb2dc3b...
Project BanManager-WebUI
Branch Review test/expand-cypress-e2e-coverage
Run status status check passed Passed #10201
Run duration 02m 10s
Commit git commit e00d8377c9 ℹ️: Merge 78a1887025f68371bc4dd8e56b396fc90887f165 into c6fe04c5d592b3f3e88e6eb2dc3b...
Committer James Mortemore
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 1
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 49
View all changes introduced in this branch ↗︎

confuser added 15 commits April 18, 2026 21:02
- opengraph/player: resolve fonts dir absolutely so server boots in
  sandboxed CWDs (setup_e2e supervisor)
- cypress/setup: seed Console with deterministic UUID + expose via fixture
- pages/admin/roles: data-cy wrappers for assign-global/server-role panels
- admin-server spec: use fixture consoleUuid (was an invalid hex string)
- admin-role spec: target new data-cy wrappers and move portal selectors
  outside .within()
- admin-webhook spec: drop incorrect deliveries assertion, scope test
  modal to visible dialog only
- appeal-lifecycle spec: filter punishment picker by server-id, .first()
  on dual desktop/mobile sidebars
- report-lifecycle spec: .first() on dual sidebars, switch to existing
  /dashboard/reports listing page
- registration spec: combine register + mismatch tests so the PIN is only
  consumed once, simplify PIN appeal flow typing to use 6 inputmode=numeric
  inputs directly
- error-pages spec: failOnStatusCode:false on /500 visit
- setup rate-limit: read from env so e2e can disable it. The default
  10 req/60s easily exhausts during retries (~6 step submits per spec)
  which previously presented as the wizard "hanging" between steps. The
  setup-server supervisor now runs with a 10000/60 budget.
- installer.spec: open the create-database <details> wrapper before
  checking the checkbox so the click isn't intercepted by the card body.
- admin-server-lifecycle: import the canonical tables/{key} mapping
  instead of generating bm_{key}, otherwise createServer rejects with
  "missing tables" and the redirect assertion times out.
- admin-webhook-lifecycle: drop :visible filtering on modal selectors -
  Headless UI's transition wraps these in animating containers that
  briefly fail Cypress's visibility heuristic, and the response payload
  can push action buttons out of the visible scroll viewport. Use
  click({ force: true }) for modal interactions and assert the form is
  removed from the DOM after closing.
- appeal-lifecycle: intercept POST /graphql for createAppeal so a
  server-side rejection produces an actionable assertion instead of a
  silent "URL didn't change". Also use .first().find() for both the
  assignee and state widgets so we always interact with the visible
  desktop sidebar (the mobile copy is still mounted but hidden).
- report-lifecycle: same .first().find() pattern for the report
  sidebar so .react_select__input.last() doesn't accidentally pick the
  hidden mobile input.
- installer.spec: use a valid v4 admin UUID. The committed value
  aaaa-bbbb-cccc-dddd-... was rejected by validator.isUUID() (v4 only
  by default), causing the admin step to fail and the wizard to never
  reach the Review step.
- admin-webhook-lifecycle: cy.reload() after navigating back to the
  list page so SWR's cached listWebhooks response (no
  revalidateOnMount, 2s dedupe) doesn't race the assertion that the
  edited URL is rendered.
- admin-server-lifecycle: intercept POST /graphql for createServer
  so a server-side rejection (missing tables, console UUID lookup,
  etc.) produces an actionable assertion instead of a silent
  "URL didn't change" timeout.
- report-lifecycle: drop the brittle initial 'Open' state assertion
  and just confirm the sidebar widgets mounted, mirroring the
  appeal-lifecycle pattern. The state transitions later in the test
  (Open -> Assigned -> Resolved -> Closed) implicitly prove the
  starting value was correct.
Cypress requires task handlers to return a value, null, or a Promise
that resolves to a value/null. The original `sleep` task returned
`new Promise((resolve) => setTimeout(resolve, ms))`, which resolves
to `undefined` and fails the installer happy-path waitForState polling
loop with `cy.task('sleep') failed: returned undefined`.
The setup-mode child exits after finalize and is respawned by the
supervisor ~500ms later in normal mode. The waitForState polling loop
was using cy.request(), which surfaces ECONNREFUSED as a hard test
failure even though the gap is expected. Switched the poll to a new
fetchSetupState task that issues a plain Node http.request and
resolves to { ok: false } on connection errors so the loop can keep
retrying while the new process boots.
…ames

Cypress retries reuse the spec context, so describe-level random names get
reused across attempts. When the first attempt creates a row in the shared
test database and then fails on a later step, attempt 2 collides with that
row ("server with this name already exists" / stale webhook URL state).

- admin-server-lifecycle: move the random server name into the test body so
  every attempt mints a fresh Date.now()-based name.
- admin-webhook-lifecycle: derive the URLs from Date.now() per attempt and
  intercept create/update mutations so backend rejections produce actionable
  errors. Also assert the URL field hydrates the saved value before clearing
  so we never race react-hook-form's defaultValues sync into submitting the
  original URL.
The test's `MockDate.set(Date.now() - 5000)` was time-relative to
real wall-clock time, but the session cookie issued by the original
`getAuthPassword()` call carries `session.updated` derived from the
seeded user.updated timestamp. When total test runtime crossed the
5-second window the mocked time landed at >= the seeded value, so
the resolver's `session.updated = Math.floor(Date.now() / 1000)`
produced no change, koa-session detected nothing to commit, and the
response carried no `set-cookie` header. The follow-up
`header['set-cookie'].join(';')` then crashed with TypeError on
Node 22.x where the suite happens to run a few seconds slower.

Decode the session cookie, pluck its `updated` field, and pin the
mocked time precisely 5s before that so the new session always
strictly post-dates the existing one.
The main router's catch-all `(.*)` route was matching /setup and
/api/setup/* requests before the setup router had a chance to handle
them. With disableUI=true the catch-all would set ctx.respond=false and
never write a response (so the request hung); with disableUI=false it
would defer to Next.js, which has no /setup page.

Either way the post-setup lockdown middleware in server/routes/setup.js
never ran, meaning /setup stayed reachable after the WebUI database had
been provisioned. Re-ordering the middleware fixes both cases without
touching the catch-all itself.

Also covers the lockdown deterministically with a new jest test
(server/test/setup-lockdown-after-complete.test.js) and trims the e2e
installer happy-path to skip the post-finalize verification, which was
fragile against the supervisor's setup-mode -> normal-mode child
restart on Cypress retries.
Two related improvements after CI showed both lifecycle specs failing on
all 3 retry attempts:

- admin-server-lifecycle: alias the updateServer mutation the same way we
  already alias createServer, so a silent server-side rejection (e.g. the
  saved password not round-tripping through encrypt/decrypt) produces the
  actual GraphQL error instead of a "URL didn't change" timeout when the
  edit form fails to redirect.

- admin-webhook-lifecycle: capture the new webhook id directly from the
  createWebhook response and use it to scope all subsequent assertions,
  instead of reaching for `[data-cy-template=...].last()`. listWebhooks
  has no ORDER BY clause, so on Cypress retries (which reuse the shared
  test database) the .last() row could be an older webhook left over from
  a previous attempt with a stale URL. Also force a cy.reload() after the
  redirect back to /admin/webhooks so the SWR cache (no
  revalidate-on-mount, 2s default dedupe) picks up the brand-new row.
…imit

The intercept added in the previous commit surfaced the underlying
failure: \`Cypress\${Date.now()}Renamed\` is 27 characters but
CreateServerInput.name / UpdateServerInput.name both have
\`@constraint(maxLength: 20)\`, so updateServer was being rejected with
a BAD_USER_INPUT error (and the silent rejection is exactly what kept
the form on /admin/servers/[id]/edit instead of redirecting back).

Switch to a \`Cy\${Date.now()}\` / \`\${name}R\` pattern (15 / 16 chars)
so both create and rename stay within the schema limit.
After the rename redirects back to /admin/servers/[id], the test does a
\`cy.visit('/admin/servers')\` to assert the new name shows up in the
list. The list page reads servers via SWR with no
revalidate-on-mount and a default 2s dedupe window, so the cached
response from the very first \`cy.visit('/admin/servers')\` (with the
pre-rename label) was being served and the lookup by
\`data-cy-server="\${renamedServer}"\` timed out.

Mirror the webhook spec's pattern: \`cy.reload()\` immediately after
the visit to force SWR to refetch. Also bumps the lookup timeout to
10s for the same reason as the create case.
updateServer wrote to bm_web_servers but never touched
state.serversPool, so every resolver that reads from the pool (the
/admin/servers list, per-server scoped resolvers, etc.) kept serving
the pre-rename name and stale connection details until the 3-second
background sync in connections/servers-pool.js caught up.

createServer/deleteServer already keep the cache in sync inline; this
brings updateServer into the same shape:

- Refresh the cached config with the new name/host/port/user/db/tables.
- Rebuild the knex pool when any connection detail (or password) changed
  and destroy the previous pool, mirroring what the background sync does.

Also adds a jest test covering the refresh and drops the cy.reload()
workaround the cypress lifecycle test had been carrying to mask this
exact race.
The admin add/edit pages were calling router.push() back to the list page
without invalidating its SWR cache, so within SWR's 2s dedupe window the
list briefly showed pre-mutation data. The webhook lifecycle e2e papered
over this with cy.reload() — drop the workaround and fix it at the source
across webhooks, servers, notification-rules, and roles.

Also add an ORDER BY clause to listWebhooks: it was relying on MySQL's
implementation-defined row order, which makes LIMIT/OFFSET pagination
formally undefined and made the e2e test pick the wrong row with .last().
Follow-up cleanup pass on multi-line comments that just narrate what the
code does. No behaviour change.
The Docker compose smoke job was failing with curl "Connection reset by
peer" because the webui container was crash-looping. Reproduced locally
with the production compose stack.

Root cause: when DB credentials + keys are set in the environment but no
admin user has been created yet (i.e. fresh `docker compose up` before
the operator visits /setup), server.js was running the strict non-setup
env validator before consulting getSetupState. The validator hard-fails
on missing CONTACT_EMAIL and called process.exit(1), so the container
restarted forever and /health was never reachable.

Reorder: consult getSetupState first, and when setup is incomplete
validate in setup mode (warnings, not errors) so the wizard can run.

Also extract the boot decision into a pure `decideBoot` function on
server.js so jest can cover the regression directly without spawning
the full process.

Workflow side-fix: the smoke job's "Wait for /health" step was missing
the MYSQL_PASSWORD env, so when the wait timed out and tried to dump
container logs via `docker compose logs`, compose re-parsed
docker-compose.prod.yml and bailed on the required-var check — hiding
the actual webui container logs. Switch to `docker logs <container>`
and add the env block so future failures are debuggable.
@confuser confuser merged commit 3656f56 into feat/easier-setup-web-installer Apr 19, 2026
8 checks passed
@confuser confuser deleted the test/expand-cypress-e2e-coverage branch April 19, 2026 10:55
confuser added a commit that referenced this pull request Apr 19, 2026
…oy (#1763)

* feat: web installer, setup mode, doctor command and Docker-first deploy

Adds a first-run web installer at /setup so new operators can complete
configuration from the browser without hand-editing .env, plus a
companion `bmwebui doctor` command and Docker compose stacks for
production. The CLI setup wizard gains BanManager config auto-detection
and transactional admin creation, and the seed/update commands share
the same migration helper.

Highlights
- Setup mode: server boots without keys/DB and serves only /setup,
  /health and the installer assets. Refuses to expose the main UI
  until configuration is complete.
- Web installer: vanilla JS wizard (HTML/CSS/JS shipped from
  server/setup/static) walks through env vars, DB connection, schema
  migration, BanManager server registration and admin user creation.
  Atomic finalize wraps server + admin creation in a single Knex tx.
- bmwebui doctor: validates env, DB connection, migration status,
  admin presence, and pings each configured BanManager server (with
  encrypted password decryption).
- bmwebui setup: optional auto-detect from BanManager plugin folder
  (config.yml + console.yml), reuses WebUI database when desired,
  shared createAdminUser helper, transactional inserts.
- Apache + Caddy reverse-proxy generators (`bmwebui setup apache` /
  `bmwebui setup caddy`) with validated domain/subdirectory inputs.
- Docker: multi-arch image build, docker-entrypoint.js auto-generates
  missing keys, docker-compose.prod.yml + docker-compose.prod-no-db.yml,
  /health endpoint reports setup_required vs ok for orchestration.
- /health endpoint reuses the existing dbPool to avoid opening a
  new connection per request.

Security hardening
- Timing-safe comparison for SETUP_TOKEN.
- Same-origin (Origin/Referer) check on /api/setup/* writes.
- Strict regex validation for domain/subdirectory passed to shell.
- parseBanManagerConfig restricted to config.yml/console.yml only.
- BASE_PATH validated and HTML/JSON-escaped before injection.
- MySQL healthcheck password moved out of CLI args via MYSQL_PWD.
- docker-compose.prod.yml requires explicit DB credentials (no
  silent defaults).

Tests
- 8 new server-side suites covering setup state, finalize atomicity,
  env validation, key generation, parse-config, mode boot,
  basepath handling, and admin creation.
- New test/lib/setup-fresh.js harness applies WebUI + BanManager
  migrations against an isolated DB so installer code can run in
  realistic conditions without mocks.
- doctor positive/negative paths covered.

* test(e2e): expand Cypress coverage with deep journeys, setup wizard run + CI (#1764)

* chore(cypress): expand e2e seed and add login/gql helpers

Expands cypress/setup.js with the data shape the new journey specs need
without breaking the existing documents.spec.js fixture contract:

- Extra seeded players including an unbanned target for moderation
  flows and a banned target with active ban/mute/warning for appeal
  flows.
- A second BanManager server so AssignPlayersRoleForm's ServerSelector
  is meaningfully exercised by the role lifecycle journey.
- Pre-seeded reports and appeals at every state (open / assigned /
  resolved / closed) plus comments, scoped to the non-admin user so the
  admin ban + parameterised punishments stay unappealed.
- A bcrypt-hashed bm_player_pins row valid for 10 minutes plus a
  PIN-only player to cover both the registration journey and the
  forgotten-password / appeal-login PIN branches.
- An APPEAL_CREATED notification rule targeting the super-admin role so
  the appeal lifecycle journey can assert end-to-end notification
  delivery.

cypress/fixtures/e2e-data.json is extended with the new ids the specs
consume (secondServerId, unbannedPlayerId, bannedPlayer*, *ReportId,
*AppealId, notificationRuleId, pin* fields). Existing serverId / banId
keys are preserved for documents.spec.js.

cypress.config.js now ships explicit retries / video / screenshot
defaults so CI failures are debuggable, plus user/PIN env defaults
consumed by the new helpers.

cypress/support/commands.js gains loginAsAdmin / loginAsUser /
loginAsPin / logout / gql helpers so journey specs can short-circuit
authentication and warm state via the API instead of the UI.

* chore(ui): add data-cy hooks for e2e coverage

Threads data-cy attributes through every component the new journey
specs target so selectors stay stable across refactors and aren't
coupled to translatable copy or react-select internals.

- Punishment forms (Ban / Mute / Warn / Note) and PlayerActions get
  per-input + submit hooks plus a canCreateNote permission check used
  by the moderation journey.
- Admin Server / Role / NotificationRule forms and list items get
  field, row, edit and delete hooks.
- Webhook (Custom + Discord) forms, list items, WebhookTestForm and
  WebhookDeliveryItem get hooks for the create / test / deliveries /
  edit / delete loop, including the status badge for delivery
  assertions.
- Admin DocumentsTable rows expose preview / delete hooks for the
  moderation journey's documents check.
- Report and appeal sidebars expose state and assignee controls for
  the lifecycle journeys.
- PunishmentPicker exposes per-row hooks (with type, id and server-id
  data attributes) plus filter and empty-state hooks for the appeal
  journey's picker filter coverage.
- NotificationContainer / NotificationList expose row and link hooks,
  the global PlayerSelector in DefaultLayout becomes
  data-cy=player-search, and dashboard widgets get container hooks.
- PlayerRegisterForm: the duplicate data-cy=password on the confirm
  field becomes confirm-password to match ResetPasswordForm.

No behavioural changes other than the canCreateNote gate and the
fixed register selector.

* test(e2e): add admin server / role / webhook lifecycle journeys

Three new journey specs covering deep admin flows that previously had
zero coverage. Each spec creates with a Date.now()-suffixed name and
deletes at the end so reruns work without a re-seed.

- admin-server-lifecycle: create, list, edit and delete a server,
  exercising the new ServerForm / ServerItem hooks.
- admin-role-lifecycle: create a custom role, assign it globally and
  per-server (using the second seeded server so AssignPlayersRoleForm's
  ServerSelector is meaningfully exercised), edit and delete.
- admin-webhook-lifecycle: parameterised across Custom and Discord
  variants. Walks add -> save -> Test webhook modal (with the example
  APPEAL_CREATED payload against httpbin.org/post) -> deliveries page
  -> edit URL -> delete, asserting the WebhookResponse status code and
  the WebhookDeliveryItem status badge.

All three specs use cy.loginAsAdmin() for setup and re-query elements
between steps to avoid Cypress unsafe chaining.

* test(e2e): add player moderation, appeal and report lifecycle journeys

- player-moderation: parameterised across ban / mute / warn / note. An
  admin opens the unbanned seeded player's profile, creates each
  punishment type, edits ban + mute via /player/{ban|mute}/[id], and
  asserts each entry appears in the relevant profile list. Also walks
  the admin documents page (preview modal, modal-confirm delete cancel)
  to cover the new admin-doc-* hooks.
- appeal-lifecycle: parameterised across ban / mute / warning. The
  banned user submits an appeal via /appeal -> punishment picker (uses
  the seeded picker filters and clear-filters control) -> the admin
  receives the pre-seeded APPEAL_CREATED notification, opens the
  appeal, comments, assigns, marches state to resolved, then asserts
  the notification flips to read. Notifications coverage is fully
  merged in here because APPEAL_CREATED is currently the only
  NotificationType. Includes a dedicated case that opens the
  AnimatedDisclosure on the picker row before clicking Appeal.
- report-lifecycle: an admin walks an open report through the full
  state machine (Open -> Assigned -> Resolved -> Closed), comments and
  assigns along the way, and asserts that comments cannot be added
  once closed. Also smoke-checks the /reports list page.

All three specs delete what they create and use re-queried selectors
to avoid Cypress unsafe chaining.

* test(e2e): add registration, PIN forgotten-password and error-pages journeys

- registration: covers PIN-as-login on /appeal for the seeded
  PIN-only player, account registration including the password
  mismatch branch, the forgotten-password page render, login via PIN
  for the existing seeded user through the appeal flow, and a smoke
  check of the global player search in the nav (data-cy=player-search
  in DefaultLayout) routing through to /player/{id}.
- error-pages: visits a deliberately bad route to assert the 404 page
  renders with the expected title and Homepage link, then visits /500
  directly with failOnStatusCode:false to assert the 500 page renders
  with its Homepage link too.

Specs are self-contained and use re-queried selectors throughout.

* test(e2e-setup): add isolated setup wizard run with installer + import specs

The normal Cypress run boots against an already-set-up DB and can never
exercise /setup. This adds a dedicated setup-mode E2E target driven by
its own Cypress config + supervisor process so the wizard can be
walked end-to-end without touching any developer or CI .env file.

Infrastructure:

- cypress.setup.config.js: separate config rooted at cypress/e2e-setup
  with custom tasks (prepareSetupDb, dropSetupDbs, writeBmConfigFixture,
  readEnvFile, clearEnvFile, getSetupConsoleUuid, sleep) and a CYPRESS_
  / SETUP_ env-var pipeline that flows DB credentials and the dotenv
  target path through to the installer specs.
- cypress/scripts/setup-server.js: supervisor that spawns server.js
  from a temporary sandbox cwd (so dotenv.config() can't pick up the
  developer's .env), strips DB / encryption env vars to force
  setup-mode boot, and respawns the child after a clean exit so the
  spec can verify the post-finalize transition into normal-mode and
  the resulting /setup -> 404. Sets DISABLE_UI=true on the second boot
  so it doesn't try to load Next.js.
- cypress/scripts/prepare-setup-db.js: drops + recreates throwaway
  WebUI and BM databases, runs BM-side migrations and seeds a Console
  player with a deterministic UUID using inetPton for the IP column.
- npm run e2e:setup:server / e2e:setup:run / e2e:setup:open scripts.

Specs:

- cypress/e2e-setup/installer.spec.js covers progress rendering, bad
  DB credentials, password mismatch, invalid UUID validation, the
  "Create database if missing" flow, and the full happy-path finalize
  including a polling check on /api/setup/state for the supervisor
  restart and a post-completion 404 assertion on /setup.
- cypress/e2e-setup/installer-config-import.spec.js covers paste-mode
  and path-mode ingestion of config.yml + console.yml (success +
  missing-section / missing-uuid / blank / non-existent-path errors).

Hooks + supporting tests:

- server/setup/static/installer.html / installer.js gain data-cy
  attributes on banners, the progress indicator, every step input,
  navigation buttons and the success / continue-to-login screen so the
  specs can target stable selectors.
- server/test/setup-mode-boot.test.js gets a SETUP_TOKEN describe
  block that exercises the API token gate (preflight no-leak, mutation
  rejection, wrong / right token flows). Token coverage stays at the
  jest layer to keep the E2E supervisor simple.

Helpers (setText, setTextArea, waitForState polling) are used in both
specs to avoid Cypress unsafe chaining and arbitrary cy.wait() values.

* ci: add setup-mode e2e job

New setup_e2e job that runs in parallel with the existing test matrix.
Boots MySQL on the runner, runs npm run e2e:setup:server (the
supervisor that handles setup-mode -> normal-mode restarts), waits on
http://127.0.0.1:3001/health and then runs the cypress/e2e-setup
specs.

CYPRESS_setup_* env vars are forwarded into the Cypress action so
prepareSetupDb / dropSetupDbs / readEnvFile / writeBmConfigFixture
tasks pick up the right host, port, user, password, dotenv path and
DB names.

* fix(e2e): stabilise lifecycle/journey specs after first CI run

- opengraph/player: resolve fonts dir absolutely so server boots in
  sandboxed CWDs (setup_e2e supervisor)
- cypress/setup: seed Console with deterministic UUID + expose via fixture
- pages/admin/roles: data-cy wrappers for assign-global/server-role panels
- admin-server spec: use fixture consoleUuid (was an invalid hex string)
- admin-role spec: target new data-cy wrappers and move portal selectors
  outside .within()
- admin-webhook spec: drop incorrect deliveries assertion, scope test
  modal to visible dialog only
- appeal-lifecycle spec: filter punishment picker by server-id, .first()
  on dual desktop/mobile sidebars
- report-lifecycle spec: .first() on dual sidebars, switch to existing
  /dashboard/reports listing page
- registration spec: combine register + mismatch tests so the PIN is only
  consumed once, simplify PIN appeal flow typing to use 6 inputmode=numeric
  inputs directly
- error-pages spec: failOnStatusCode:false on /500 visit

* fix(e2e): unblock setup wizard rate-limit + tighten lifecycle specs

- setup rate-limit: read from env so e2e can disable it. The default
  10 req/60s easily exhausts during retries (~6 step submits per spec)
  which previously presented as the wizard "hanging" between steps. The
  setup-server supervisor now runs with a 10000/60 budget.
- installer.spec: open the create-database <details> wrapper before
  checking the checkbox so the click isn't intercepted by the card body.
- admin-server-lifecycle: import the canonical tables/{key} mapping
  instead of generating bm_{key}, otherwise createServer rejects with
  "missing tables" and the redirect assertion times out.
- admin-webhook-lifecycle: drop :visible filtering on modal selectors -
  Headless UI's transition wraps these in animating containers that
  briefly fail Cypress's visibility heuristic, and the response payload
  can push action buttons out of the visible scroll viewport. Use
  click({ force: true }) for modal interactions and assert the form is
  removed from the DOM after closing.
- appeal-lifecycle: intercept POST /graphql for createAppeal so a
  server-side rejection produces an actionable assertion instead of a
  silent "URL didn't change". Also use .first().find() for both the
  assignee and state widgets so we always interact with the visible
  desktop sidebar (the mobile copy is still mounted but hidden).
- report-lifecycle: same .first().find() pattern for the report
  sidebar so .react_select__input.last() doesn't accidentally pick the
  hidden mobile input.

* fix(e2e): more lifecycle/installer fixes after CI run on 12ec80b

- installer.spec: use a valid v4 admin UUID. The committed value
  aaaa-bbbb-cccc-dddd-... was rejected by validator.isUUID() (v4 only
  by default), causing the admin step to fail and the wizard to never
  reach the Review step.
- admin-webhook-lifecycle: cy.reload() after navigating back to the
  list page so SWR's cached listWebhooks response (no
  revalidateOnMount, 2s dedupe) doesn't race the assertion that the
  edited URL is rendered.
- admin-server-lifecycle: intercept POST /graphql for createServer
  so a server-side rejection (missing tables, console UUID lookup,
  etc.) produces an actionable assertion instead of a silent
  "URL didn't change" timeout.
- report-lifecycle: drop the brittle initial 'Open' state assertion
  and just confirm the sidebar widgets mounted, mirroring the
  appeal-lifecycle pattern. The state transitions later in the test
  (Open -> Assigned -> Resolved -> Closed) implicitly prove the
  starting value was correct.

* fix(e2e): make sleep cypress task resolve to null

Cypress requires task handlers to return a value, null, or a Promise
that resolves to a value/null. The original `sleep` task returned
`new Promise((resolve) => setTimeout(resolve, ms))`, which resolves
to `undefined` and fails the installer happy-path waitForState polling
loop with `cy.task('sleep') failed: returned undefined`.

* fix(e2e): poll setup state via Node task to survive supervisor restart

The setup-mode child exits after finalize and is respawned by the
supervisor ~500ms later in normal mode. The waitForState polling loop
was using cy.request(), which surfaces ECONNREFUSED as a hard test
failure even though the gap is expected. Switched the poll to a new
fetchSetupState task that issues a plain Node http.request and
resolves to { ok: false } on connection errors so the loop can keep
retrying while the new process boots.

* fix(e2e): make lifecycle specs resilient to retries via per-attempt names

Cypress retries reuse the spec context, so describe-level random names get
reused across attempts. When the first attempt creates a row in the shared
test database and then fails on a later step, attempt 2 collides with that
row ("server with this name already exists" / stale webhook URL state).

- admin-server-lifecycle: move the random server name into the test body so
  every attempt mints a fresh Date.now()-based name.
- admin-webhook-lifecycle: derive the URLs from Date.now() per attempt and
  intercept create/update mutations so backend rejections produce actionable
  errors. Also assert the URL field hydrates the saved value before clearing
  so we never race react-hook-form's defaultValues sync into submitting the
  original URL.

* fix(test): pin MockDate to session.updated in setPassword test

The test's `MockDate.set(Date.now() - 5000)` was time-relative to
real wall-clock time, but the session cookie issued by the original
`getAuthPassword()` call carries `session.updated` derived from the
seeded user.updated timestamp. When total test runtime crossed the
5-second window the mocked time landed at >= the seeded value, so
the resolver's `session.updated = Math.floor(Date.now() / 1000)`
produced no change, koa-session detected nothing to commit, and the
response carried no `set-cookie` header. The follow-up
`header['set-cookie'].join(';')` then crashed with TypeError on
Node 22.x where the suite happens to run a few seconds slower.

Decode the session cookie, pluck its `updated` field, and pin the
mocked time precisely 5s before that so the new session always
strictly post-dates the existing one.

* fix: mount setup router before main router so /setup lockdown can fire

The main router's catch-all `(.*)` route was matching /setup and
/api/setup/* requests before the setup router had a chance to handle
them. With disableUI=true the catch-all would set ctx.respond=false and
never write a response (so the request hung); with disableUI=false it
would defer to Next.js, which has no /setup page.

Either way the post-setup lockdown middleware in server/routes/setup.js
never ran, meaning /setup stayed reachable after the WebUI database had
been provisioned. Re-ordering the middleware fixes both cases without
touching the catch-all itself.

Also covers the lockdown deterministically with a new jest test
(server/test/setup-lockdown-after-complete.test.js) and trims the e2e
installer happy-path to skip the post-finalize verification, which was
fragile against the supervisor's setup-mode -> normal-mode child
restart on Cypress retries.

* test(cypress): surface updateServer errors and fix webhook list races

Two related improvements after CI showed both lifecycle specs failing on
all 3 retry attempts:

- admin-server-lifecycle: alias the updateServer mutation the same way we
  already alias createServer, so a silent server-side rejection (e.g. the
  saved password not round-tripping through encrypt/decrypt) produces the
  actual GraphQL error instead of a "URL didn't change" timeout when the
  edit form fails to redirect.

- admin-webhook-lifecycle: capture the new webhook id directly from the
  createWebhook response and use it to scope all subsequent assertions,
  instead of reaching for `[data-cy-template=...].last()`. listWebhooks
  has no ORDER BY clause, so on Cypress retries (which reuse the shared
  test database) the .last() row could be an older webhook left over from
  a previous attempt with a stale URL. Also force a cy.reload() after the
  redirect back to /admin/webhooks so the SWR cache (no
  revalidate-on-mount, 2s default dedupe) picks up the brand-new row.

* test(cypress): trim admin-server-lifecycle names to fit the 20-char limit

The intercept added in the previous commit surfaced the underlying
failure: \`Cypress\${Date.now()}Renamed\` is 27 characters but
CreateServerInput.name / UpdateServerInput.name both have
\`@constraint(maxLength: 20)\`, so updateServer was being rejected with
a BAD_USER_INPUT error (and the silent rejection is exactly what kept
the form on /admin/servers/[id]/edit instead of redirecting back).

Switch to a \`Cy\${Date.now()}\` / \`\${name}R\` pattern (15 / 16 chars)
so both create and rename stay within the schema limit.

* test(cypress): reload admin servers list after edit to dodge SWR cache

After the rename redirects back to /admin/servers/[id], the test does a
\`cy.visit('/admin/servers')\` to assert the new name shows up in the
list. The list page reads servers via SWR with no
revalidate-on-mount and a default 2s dedupe window, so the cached
response from the very first \`cy.visit('/admin/servers')\` (with the
pre-rename label) was being served and the lookup by
\`data-cy-server="\${renamedServer}"\` timed out.

Mirror the webhook spec's pattern: \`cy.reload()\` immediately after
the visit to force SWR to refetch. Also bumps the lookup timeout to
10s for the same reason as the create case.

* fix(servers): refresh in-memory serversPool after updateServer

updateServer wrote to bm_web_servers but never touched
state.serversPool, so every resolver that reads from the pool (the
/admin/servers list, per-server scoped resolvers, etc.) kept serving
the pre-rename name and stale connection details until the 3-second
background sync in connections/servers-pool.js caught up.

createServer/deleteServer already keep the cache in sync inline; this
brings updateServer into the same shape:

- Refresh the cached config with the new name/host/port/user/db/tables.
- Rebuild the knex pool when any connection detail (or password) changed
  and destroy the previous pool, mirroring what the background sync does.

Also adds a jest test covering the refresh and drops the cy.reload()
workaround the cypress lifecycle test had been carrying to mask this
exact race.

* fix(admin): invalidate list SWR caches on add/edit + sort listWebhooks

The admin add/edit pages were calling router.push() back to the list page
without invalidating its SWR cache, so within SWR's 2s dedupe window the
list briefly showed pre-mutation data. The webhook lifecycle e2e papered
over this with cy.reload() — drop the workaround and fix it at the source
across webhooks, servers, notification-rules, and roles.

Also add an ORDER BY clause to listWebhooks: it was relying on MySQL's
implementation-defined row order, which makes LIMIT/OFFSET pagination
formally undefined and made the e2e test pick the wrong row with .last().

* chore: condense lingering verbose lifecycle/setup comments

Follow-up cleanup pass on multi-line comments that just narrate what the
code does. No behaviour change.

* fix(boot): boot in setup-mode when DB reachable but install incomplete

The Docker compose smoke job was failing with curl "Connection reset by
peer" because the webui container was crash-looping. Reproduced locally
with the production compose stack.

Root cause: when DB credentials + keys are set in the environment but no
admin user has been created yet (i.e. fresh `docker compose up` before
the operator visits /setup), server.js was running the strict non-setup
env validator before consulting getSetupState. The validator hard-fails
on missing CONTACT_EMAIL and called process.exit(1), so the container
restarted forever and /health was never reachable.

Reorder: consult getSetupState first, and when setup is incomplete
validate in setup mode (warnings, not errors) so the wizard can run.

Also extract the boot decision into a pure `decideBoot` function on
server.js so jest can cover the regression directly without spawning
the full process.

Workflow side-fix: the smoke job's "Wait for /health" step was missing
the MYSQL_PASSWORD env, so when the wait timed out and tried to dump
container logs via `docker compose logs`, compose re-parsed
docker-compose.prod.yml and bailed on the required-var check — hiding
the actual webui container logs. Switch to `docker logs <container>`
and add the env block so future failures are debuggable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant