test(e2e): expand Cypress coverage with deep journeys, setup wizard run + CI#1764
Merged
confuser merged 22 commits intofeat/easier-setup-web-installerfrom Apr 19, 2026
Merged
Conversation
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.
BanManager-WebUI
|
||||||||||||||||||||||||||||
| Project |
BanManager-WebUI
|
| Branch Review |
test/expand-cypress-e2e-coverage
|
| Run status |
|
| Run duration | 02m 10s |
| Commit |
|
| Committer | James Mortemore |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
1
|
|
|
0
|
|
|
49
|
| View all changes introduced in this branch ↗︎ | |
- 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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on top of #feat/easier-setup-web-installer — please merge that PR first, then this one. GitHub will auto-retarget to
masteronce 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:
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)\"]What's covered
Application journeys (`cypress/e2e/journeys/`)
New page spec
Setup wizard (`cypress/e2e-setup/`, isolated run)
Jest
Infrastructure / scaffolding
Conventions
Test plan