Skip to main content

End-to-end tests (Playwright)

The e2e suite drives a real browser against a real stack: the nginx-served React bundle, the Spring backend behind it, and a Postgres database with actual rows in it. Nothing is mocked. That is what makes it the most useful signal in the repo and also the most fragile — it fails when the app is broken, and it also fails when the database it shares with everything else has drifted.

The specs live in e2e/ at the repo root rather than inside frontend/, because they depend on the whole stack. Frontend unit tests are Vitest files inside frontend/src/ — see frontend-tests.md. Backend JUnit is backend-tests.md. The map of all three is README.md.

Config: playwright.config.ts. testDir is ./e2e, two projects (chromium, firefox), fullyParallel: true, HTML reporter. On CI (process.env.CI) it adds retries: 2, workers: 1, and forbidOnly.


Prerequisites

1. The Docker stack must already be running

Playwright does not start the app. There is no webServer block in the config — it is commented out. Bring the stack up yourself first:

docker compose up -d --build

The suite talks to the ports docker-compose.yml publishes:

ServiceHost portUsed for
motorph_payroll_frontend (nginx)5173Everything. Both the UI and the /api proxy.
motorph_payroll_backend8081Direct backend access; the suite does not use it.
motorph_payroll_db (Postgres)5434Reseeding and manual inspection.

BASE_URL in e2e/helpers/serverGrid.ts defaults to http://localhost:5173 and is overridable with the BASE_URL environment variable. Point it at nginx, not at 8081: the specs assert on requests the SPA makes to relative /api URLs, and going direct to the backend takes the proxy — the thing production actually uses — out of the picture.

2. ALTCHA_ENABLED must be false

This is the single most common reason a first run fails everywhere at once.

The helpers do not drive the login form. They POST /api/auth/login from Playwright's APIRequestContext and inject the resulting payload into localStorage under motorph.auth before any page script runs. That is much faster than typing into a form, and it persists exactly what the app persists itself.

It also completely bypasses the ALTCHA proof-of-work widget. When ALTCHA_ENABLED=true the backend rejects any login post that arrives without a solved challenge in the X-Altcha-Payload header, and it does so with HTTP 428 Precondition Required. fetchAuth treats any non-OK response as fatal, so every spec dies in its beforeAll with API login failed for hr_demo (428).

docker-compose.yml defaults the variable to false (ALTCHA_ENABLED: ${ALTCHA_ENABLED:-false}) and .env.example sets it to false explicitly, so a stack brought up without overriding it is already correct. If you turned ALTCHA on to test the login hardening, turn it back off before running the suite. Background: ../security/authentication.md.

3. The database must be seeded

See Seed data and reseeding below. A stack that booted against an empty database will fail the very first assertion — totalElements of 100 employees is the baseline several specs are built on.

A 20-second sanity check before blaming your code:

TOKEN=$(curl -s -X POST http://localhost:5173/api/auth/login \
-H 'Content-Type: application/json' -d '{"username":"hr_demo","password":"..."}' | jq -r .accessToken)
curl -s 'http://localhost:5173/api/employees?size=1' -H "Authorization: Bearer $TOKEN" | jq .totalElements

Expect 100. A 428 here means ALTCHA is on; a connection refused means the stack is down; a 0 means the seed never applied.


Running

Playwright is a devDependency of the root package.json, so run it from the repo root, not from frontend/.

npm ci # first time only

npx playwright test # everything, chromium + firefox
npx playwright test --project=chromium # one browser
npx playwright test e2e/Payroll # one folder
npx playwright test e2e/HR_Management/employees.spec.ts # one spec
npx playwright test e2e/CRM/leads-filters.spec.ts -g "date filter" # one test by title
npx playwright test --workers=1 # serialize everything
npx playwright show-report # open the last HTML report

Cap the workers. fullyParallel: true means Playwright will use roughly half your cores by default, and every worker mutates the same database. --workers=4 is comfortable on a developer machine; --workers=1 is the safest for a full run and matches how the suite was developed and verified. Both browser projects run the same mutation tests, which is another reason a full chromium+firefox run wants --workers=1.

Individual spec files declare test.describe.configure({ mode: 'serial' }) so mutation tests within one file cannot race each other over the same fixture row. That does nothing about two files racing, which is what --workers controls.


What is in the suite

36 *.spec.ts files under e2e/. Thirty-four of them are tests; two are rigs that happen to use the Playwright runner. Counts below are test(...) declarations per browser project — a default two-project run executes each of them twice.

FolderSpec filesTestsCovers
e2e/CRM/5229Leads, Deals, Contacts, Organizations, Activities
e2e/Payroll/11206Payroll runs, transactions, payslips, bonuses, employee deductions, EWT registry, TIN compliance, contribution rates, payroll settings, reimbursement requests and transactions
e2e/HR_Management/7190Employees (grid + column set), departments, positions, timesheets, leave requests, overtime requests
e2e/HR_Recruitment/9160Job requisitions, staffing plans, job openings, job applicants, interview schedules, job offers, recruitment analytics, government forms, BIR 1601-C
e2e/ (root)411login.spec.ts (1), smoke.spec.ts (2), plus two non-test rigs

That is 796 tests per project, of which 788 belong to the four module folders plus login and smoke.

The two root files that are not tests:

  • capture-screenshots.spec.ts — the marketing screenshot rig. It writes 1920×1080 product shots into frontend/public/assets/. Guarded by test.skip(!process.env.CAPTURE, ...), so a normal run skips it entirely; run it deliberately with CAPTURE=1 npx playwright test e2e/capture-screenshots.spec.ts --project=chromium.
  • _tmp_smoke_hero.spec.ts — an ad-hoc check on the marketing hero's WebGL scene and module legend. The _tmp_ prefix is accurate: it is scratch, not suite.

Nineteen test.skip(...) calls exist across the specs, and almost all of them are conditional rather than disabled tests — "needs more than one page of live data", "no populated estimated values in this status". They skip themselves when the live database happens not to contain enough rows to make the assertion meaningful. One is unconditional: bir-1601c-form.spec.ts carries test.skip(true, 'no payroll data for the default period on this stack').

Some folders carry their own README with the detail this page does not: e2e/README.md for the HR grids and the shared helper library, and e2e/HR_Recruitment/README.md for the recruitment seed-baseline table and the defects those specs pin down as current behaviour.


Helper conventions

Nearly all of the suite's mechanics live in e2e/helpers/. Read serverGrid.ts before writing a new spec; everything else is an adapter over it.

serverGrid.ts — the shared library

The page-agnostic core. The other eight helper modules all import from it.

Login, cached per worker. fetchAuth(request, creds) posts to /api/auth/login and memoises the result in a module-level Map keyed by username. Because Playwright gives each worker its own module registry, that is one login per persona per worker for the whole run rather than one per test. The response field is accessToken; the persisted localStorage shape uses token, matching the Redux auth slice — login() does that translation for you.

The same function retries on HTTP 429. Login is rate-limited to 10/min per IP by Bucket4j, and every worker logs in from the same IP within the first second of a run, so the burst can exceed the bucket. It waits out the refill with jitter, up to 12 attempts. A slow start on a wide --workers run is expected, not a fault.

Pin the active role. login() takes an activeRole argument, and for multi-role personas you must pass it. payroll_demo holds HR Administrator, Employee and Payroll Administrator; when activeRole is null the app auto-selects by ROLE_DISPLAY_ORDER, which lists HR Administrator first — so every payroll test would silently run against the HR view with the payroll nav and routes absent. Use loginAsHr(page, request) / loginAsPayroll(page, request), which pin it.

Wait on the API, not on the clock. waitForApi(page, pathnameSuffix, match) resolves on the response whose URL suffix and query params match a predicate. Every grid interaction in the suite is synchronised this way rather than with timeouts. The predicate has to be specific, because a tabbed page fires far more requests than it looks like it does:

  • Exclude size=1. A TabbedServerGrid with showCount tabs issues one grid fetch plus one count query per tab on load. The count queries use size=1. A predicate that does not exclude them will resolve on the wrong response.
  • Pin the *FilterType companion param. A status tab fetch and the All-tab status column filter are nearly identical on the wire (status=New versus status=New&statusFilterType=equals). The FilterType param is the only thing that tells them apart.

Scope your locators. gridRoot(page) for a single-grid page, activePanel(page) for a tabbed one. Tabbed pages mount every tab's grid at load, so an unscoped locator will match rows in a panel you are not looking at.

Filter and row drivers. openFilterPopup / setOperator / applyTextFilter / applyNumberFilter / applyDateFilter / clearFilter drive AG Grid v33's filter popups; clickSort, gotoGridPage, expectShowing, expectNoRows, cell, colCells and paginationText cover the rest of the grid surface.

Fixture disambiguation

Two problems, two helpers.

Finding the right row. On live-data grids a single column value is often not unique — several timesheets share one employeeId. openRowMenu(page, scope, colId, text) handles the unique case; findRowIndex and openRowMenuMulti take an array of { colId, text } matchers and locate the row that satisfies all of them. Reach for the multi-column form whenever the grid is not seed-frozen.

Making the row unique in the first place. Fixture rows are created with Date.now() baked into a name or a value so two concurrent runs, or two browser projects, cannot collide. Cleanup goes in a finally block via apiDeleteWhere (in recruitmentGrid.ts) so a failed assertion still removes the row it created.

Where the domain has no undo, rotate instead of deleting. Approved and rejected leave and overtime requests are terminal, exactly as in real usage, so those specs pick a fresh date per run (uniqueFutureDate, freeOvertimeDate) instead of cleaning up. Leave fixtures additionally rotate across the five demo personas (pickFixturePersonaAndType), because approving a leave request permanently burns that employee's balance and there is no top-up endpoint.

The per-module adapters

HelperRole
serverGrid.tsThe shared library. New specs should import this directly.
recruitmentGrid.tsmakeRecruitmentGrid per-endpoint waiters, the SEED baseline table, assertSeedBaseline drift guard, apiDeleteWhere.
payrollGrid.tsPayroll grids. No static baseline to guard — bonuses and payroll runs drift, and the transaction grids start empty, so specs derive expectations from the API (liveTotal) or build their own fixtures. Adds the emp_demo persona for self-service reimbursement fixtures.
crmLeadsGrid.ts, crmDealsGrid.ts, crmContactsGrid.ts, crmOrganizationsGrid.ts, crmActivitiesGrid.tsOne per CRM grid. liveLeadTotal / liveLeads fetch the expected count immediately before the UI assertion.
employeesGrid.tsThin Employees adapter kept for the call signatures employees.spec.ts already uses. Do not copy this pattern for new specs.

Seed data and reseeding

The suite splits its data into two categories and treats them very differently.

Static seed — assert absolute numbers. Employees (100), departments (12), positions (18), and the recruitment tables all have fixed baselines. Recruitment specs verify theirs in beforeAll through assertSeedBaseline, which checks both the total and the per-status split and hard-fails on drift; the baseline table is in e2e/HR_Recruitment/README.md. A drift failure means a previous run leaked a fixture or someone clicked through the app. employees.spec.ts self-heals employee status drift through the app's own API in its beforeAll; department and position drift needs a reseed.

Live data — never hardcode a total. Timesheets, leave requests, overtime requests, bonuses, payroll runs and every CRM table are organically generated and change between runs. Their specs fetch the expected count from the API immediately before each UI assertion. If you are adding a spec in one of those modules, follow that pattern; a hardcoded number there will pass today and fail next week.

Reseeding

Two ways, same endpoints.

From the UI, as a user who can reach /settings: the Demo Data section of frontend/src/pages/hr/Settings.tsx has Reset Demo Data and Generate Demo Data buttons, with live counts for timesheets, leave requests, overtime requests, bonuses, payroll runs and payslips.

From the shell, as sysadmin_demo:

curl -X POST http://localhost:5173/api/system-admin/demo-data/reset -H "Authorization: Bearer $SYSADMIN_TOKEN"
curl -X POST http://localhost:5173/api/system-admin/demo-data/generate -H "Authorization: Bearer $SYSADMIN_TOKEN"

This wipes and regenerates all timesheets, overtime, leave, bonuses and payroll runs, and shifts every generated date relative to today. Departments, positions and employees are untouched. Never run it from inside a test, and never while a suite is executing.

For a stack that has never been seeded — a fresh docker compose up on an empty volume — use scripts/ci-e2e-seed.sh instead. It is written for CI but runs against any fresh local stack. It applies demo/seed/V20__rich_seed_employees.sql, V22__rich_seed_crm.sql and V24__rich_seed_recruitment.sql with psql behind a temporary tenant_id DEFAULT 1 (the files predate multi-tenancy, and their version numbers now collide with the classpath's inventory migrations, so they cannot be mounted as Flyway migrations any more), then calls demo-data/reset + generate on top. It is idempotent: it skips the SQL step when the employee count is already at least 100.

Leave balances are deduct-only. Approving subtracts, nothing restores, and there is no admin top-up endpoint — so each full run burns roughly three day-credits from a pool of about 125 across the five demo personas. When pickFixturePersonaAndType reports every pool exhausted, a reset/generate cycle is the only refill.


The hazard: two runs, one database

This is the operational failure mode worth internalising, because it produces failures that look like bugs and are not.

The stack is a single shared, live database. Playwright workers within one run are coordinated — serial mode inside a file, unique fixture values across files. Two separate Playwright processes against the same stack are not coordinated at all. They will:

  • delete each other's fixture rows in their finally blocks;
  • move seed rows through status dialogs while the other run is asserting assertSeedBaseline, producing a drift failure in a spec that touched nothing;
  • race the login rate limiter from one IP, doubling the 429 pressure;
  • and, on the leave and overtime specs, consume the same finite balance pool.

The resulting report is a scatter of unrelated red tests with plausible-looking messages. Nothing in the tooling detects it.

Before starting a run, check that one is not already going:

ps aux | grep -c "[p]laywright"

The same applies to a colleague pointing BASE_URL at a stack you are using, and to leaving the app open in a browser tab where a stray click moves a seed row. If a failure looks impossible, confirm you had the database to yourself before spending an hour on it. On a shared worktree, rebuild the pre-change baseline on a spare port and reproduce there before concluding the failure is yours.


What CI runs

Only the smoke spec gates merges. .github/workflows/playwright.yml has two jobs: smoke (push and PR, e2e/smoke.spec.ts only, Chromium) and full (manual workflow_dispatch, the whole suite, Chromium, --workers=2). The full suite is deliberately not a gate — it has documented known-failing tests. Both jobs build the stack with docker compose up --build --wait, seed it with ci-e2e-seed.sh, and upload playwright-report/ as a 30-day artifact even on failure.

The full story, including how to read a failed run's report: ci-quality-gates.md.


When a run goes red

  1. Everything failed in beforeAll with a 428. ALTCHA is on. Set ALTCHA_ENABLED=false and restart the backend.
  2. Everything failed with API login failed ... Is the docker stack running? The stack is down, or BASE_URL points somewhere else.
  3. A baseline assertion failed on a spec you did not touch. Someone else's run, or a browser tab, moved seed data. Check for a concurrent run, then reseed.
  4. A single mutation test failed and left a fixture behind. Look for the finally block; apiDeleteWhere is idempotent, so re-running usually clears it. Recruitment specs will tell you exactly which baseline drifted.
  5. Sporadic timeouts under high --workers. Drop to --workers=4, or --workers=1 for a full two-browser run.
  6. npx playwright show-report for the trace, screenshot and network log of any retried failure. Traces are captured on-first-retry, which means locally (retries: 0) there is no trace unless you pass --retries=1.

Related: README.md for the three-suite map, backend-tests.md, frontend-tests.md, and ci-quality-gates.md for what actually blocks a merge.