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:
| Service | Host port | Used for |
|---|---|---|
motorph_payroll_frontend (nginx) | 5173 | Everything. Both the UI and the /api proxy. |
motorph_payroll_backend | 8081 | Direct backend access; the suite does not use it. |
motorph_payroll_db (Postgres) | 5434 | Reseeding 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.
| Folder | Spec files | Tests | Covers |
|---|---|---|---|
e2e/CRM/ | 5 | 229 | Leads, Deals, Contacts, Organizations, Activities |
e2e/Payroll/ | 11 | 206 | Payroll runs, transactions, payslips, bonuses, employee deductions, EWT registry, TIN compliance, contribution rates, payroll settings, reimbursement requests and transactions |
e2e/HR_Management/ | 7 | 190 | Employees (grid + column set), departments, positions, timesheets, leave requests, overtime requests |
e2e/HR_Recruitment/ | 9 | 160 | Job requisitions, staffing plans, job openings, job applicants, interview schedules, job offers, recruitment analytics, government forms, BIR 1601-C |
e2e/ (root) | 4 | 11 | login.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 intofrontend/public/assets/. Guarded bytest.skip(!process.env.CAPTURE, ...), so a normal run skips it entirely; run it deliberately withCAPTURE=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. ATabbedServerGridwithshowCounttabs issues one grid fetch plus one count query per tab on load. The count queries usesize=1. A predicate that does not exclude them will resolve on the wrong response. - Pin the
*FilterTypecompanion param. A status tab fetch and the All-tab status column filter are nearly identical on the wire (status=Newversusstatus=New&statusFilterType=equals). TheFilterTypeparam 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
| Helper | Role |
|---|---|
serverGrid.ts | The shared library. New specs should import this directly. |
recruitmentGrid.ts | makeRecruitmentGrid per-endpoint waiters, the SEED baseline table, assertSeedBaseline drift guard, apiDeleteWhere. |
payrollGrid.ts | Payroll 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.ts | One per CRM grid. liveLeadTotal / liveLeads fetch the expected count immediately before the UI assertion. |
employeesGrid.ts | Thin 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
finallyblocks; - 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
- Everything failed in
beforeAllwith a 428. ALTCHA is on. SetALTCHA_ENABLED=falseand restart the backend. - Everything failed with
API login failed ... Is the docker stack running?The stack is down, orBASE_URLpoints somewhere else. - 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.
- A single mutation test failed and left a fixture behind. Look for the
finallyblock;apiDeleteWhereis idempotent, so re-running usually clears it. Recruitment specs will tell you exactly which baseline drifted. - Sporadic timeouts under high
--workers. Drop to--workers=4, or--workers=1for a full two-browser run. npx playwright show-reportfor the trace, screenshot and network log of any retried failure. Traces are capturedon-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.