- Introduced Playwright configuration for e2e testing. - Implemented authentication tests to validate login functionality. - Created tests for GRN (Goods Receipt Note) to ensure proper stock handling. - Developed production run tests to verify lifecycle and stock posting. - Added stock transfer tests to check movement between warehouses. - Implemented stock adjustment tests for positive and negative adjustments. - Established API seeder for test data setup and verification. - Enhanced utility functions for UI interactions and response handling.
8.9 KiB
ERP-Core E2E tests (Playwright)
End-to-end tests for the three phases requested first: GRN (receiving), Production runs, and Stock movement (transfers + adjustments), plus one chained scenario that walks all three in sequence. Sales and Accounts are intentionally out of scope for now.
Why Playwright, not Selenium
The frontend is Next.js 16 / React 19. Playwright auto-waits for React state updates, ships trace/video capture on failure, and can drive the backend API directly (used here to seed test data), which made it a better fit than Selenium for this stack.
Prerequisites
- Backend running locally:
cd Backend/ERPCore && dotnet ef database update && dotnet run(needsASPNETCORE_ENVIRONMENT=Developmentset — see the repo's local-env notes — and a reachable Postgres instance). Defaults tohttp://localhost:5224. - Frontend running locally:
cd Frontend/erp-system && npm install && npm run dev. Defaults tohttp://localhost:3000and proxies/api/v1/*to the backend same-origin. - A real login for the tests. Auth is fronted by an external AuthHex identity provider
(
Backend/ERPCore/Controllers/AuthController.cs) — there is no local seed for a user account, soE2E_ADMIN_EMAIL/E2E_ADMIN_PASSWORDmust be a real, already-provisioned account with access to Receiving, Production, and Stock. - A fresh-ish database is fine:
DataSeeder(Backend/ERPCore/Infra/Persistence/DataSeeder.cs) seeds theMAIN/SHOPwarehouses,PCS/BOXUOMs, and aGeneral Goodscategory that these tests rely on existing. Everything else (vendors, items, purchase orders, a production template) is created fresh per run bysupport/api.tswith unique timestamp-suffixed codes, so reruns never collide with previous data. E2E_BASE_URLmust usehttp://localhost, not127.0.0.1or a LAN IP. The session cookie is written withSecure = trueunconditionally (Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs); Chromium only treats plain-HTTPlocalhostas a secure-enough origin to accept and resend aSecurecookie, so anything else silently drops the session and every post-login request 401s.
Setup
cd Testing/e2e
npm install
npx playwright install --with-deps chromium
cp .env.e2e.example .env.e2e # then fill in E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD
Running
npm run test:e2e # headless, all specs
npm run test:e2e:ui # interactive UI mode — best for first-run locator debugging
npm run test:e2e:headed # headed browser
npm run report # open the last HTML report
The setup project (specs/global.setup.ts) logs in once through the real /login form
— the session is an httpOnly cookie, so there's no token to inject — and saves it to
.auth/admin.json. Every other spec's chromium project reuses that storage state, so
individual specs don't re-authenticate. auth.spec.ts is the exception: it explicitly runs
with no stored session so it can exercise the login form itself.
Layout
Testing/e2e/
├── playwright.config.ts
├── support/
│ ├── env.ts # reads .env.e2e, resolves the storageState path
│ └── api.ts # ApiSeeder — creates vendors/items/POs/templates, reads stock on-hand
├── pages/ # Page Object Models (one file per module)
└── specs/
├── global.setup.ts
├── auth.spec.ts
├── grn.spec.ts
├── production.spec.ts
├── stock-transfers.spec.ts
├── stock-adjustments.spec.ts
└── chained-flow.spec.ts # GRN -> Production -> Stock Transfer, one continuous scenario
Coverage vs. what's deferred
18 tests across auth, GRN, production runs, stock transfers/adjustments, and one chained flow. Deliberately deferred (all would need a second, multi-stage production template or custom-field scaffolding to exercise, which felt like scope creep for a first pass):
- Approve & transfer on a non-terminal stage, and transferring a held-back remainder
from an
Approvedstage — both only apply to a multi-stage graph; the seeded template is single-stage (entry == terminal) so every run here only ever exercises "Approve & receive". - Reject intake (pulling back delivered upstream WIP) — same reason, needs a parent→child edge.
- Client-side validation edges inside
StageDrawer: completing a stage with produced qty over the staged input, or a scrap qty with no scrap reason selected. - Adjustment reason-code → ledger-entry tagging spot-check (
GET /stock/ledger) — the positive/negative adjustment tests verify on-hand moves correctly but don't inspect the ledger rows themselves.
Long-run item-dropdown ceiling. The GRN/Transfer/Adjustment "new" pages load items via
itemsApi.list({ pageSize: 200 }) (a fixed page, not paginated further in the UI). Every
spec run mints 2-3 new permanent items through ApiSeeder.createItem, and nothing deletes
them. Once a dev database accumulates more than 200 active items, freshly-seeded items stop
appearing in the Item combobox (and if the list sorts ascending by id, it's exactly the
newest ones that fall off) — locators like getByRole("option", { name: item.name }) will
time out with no visible cause. If that starts happening, the fix is to seed one stable
per-module item once and reuse it across runs instead of minting a fresh one each time
(every assertion here is already delta-based, so that's a drop-in change).
Known limitation: a real, reproducible hydration bug
Every load of the GRN/Production/Stock pages throws a genuine React hydration error
("Minified React error #418" — text content mismatch between server and client render).
It is not intermittent — it fires on every navigation — but its effect is: hydration
recovery blanks the placeholder text of a random subset of that page's Select triggers for
the rest of that page's life, while leaving the sibling <Label>/<FieldLabel> and the
trigger's role="combobox" attribute intact. A getByRole("combobox", { name: ... })
lookup is therefore unreliable on these pages; support/ui.ts's comboboxByLabel() works
around it by finding the trigger via its stable sibling label + role alone, never its
(possibly-blanked) accessible name. The same file's retryClick/clickToReveal/
clickToRevealWithReload/submitAndWait cover two related, separately-confirmed issues:
short-lived disabled/not-yet-mounted trigger buttons (RunActions.tsx's "Cancel run"/
"Return leftover", gated on run.status), and stage/document actions whose UI only
reflects an async POST once the response lands — reading stock through the API immediately
after a click can otherwise race the backend commit. This is worth a look on the product
side (root-causing the actual SSR/CSR mismatch would remove the workaround entirely), but
was out of scope for a first E2E pass.
Backend also can't take concurrent Playwright workers yet. Reference-data GETs
(/warehouses, etc.) intermittently 500 when 2+ workers hit a plain dotnet run +
local Postgres backend at once — confirmed by re-running the exact same suite at
workers: 1 with zero failures. playwright.config.ts pins workers: 1 for that reason;
raise it only against a backend that can actually take concurrent load.
Known limitation: no data-testids yet
None of the GRN/Production/Stock Transfer/Stock Adjustment components in
Frontend/erp-system currently expose data-testid attributes, and several form controls
have no accessible name at all (the Qty/Unit cost/Disc%/VAT% <Input type="number"> cells
in the GRN and Transfer line tables aren't wrapped in a <label> or given aria-label).
Locators in pages/ work around this with role/placeholder matching where an accessible
name exists, and row + column-position locators (row.locator('input[type="number"]').nth(n))
where it doesn't — every such case is called out in a comment at the top of the relevant
pages/*.ts file, along with the couple of same-text button pairs (e.g. "Cancel run" is
both the trigger and the dialog's confirm label) that needed .first()/.last() to
disambiguate. If a component's copy or layout changes, run npm run test:e2e:ui to see
exactly which locator broke and fix it in pages/*.ts — the specs themselves shouldn't need
to change.
Recommended fast-follow (not done here, since it's a product-code change rather than a
test-authoring one): add data-testid to the Select triggers, the Qty/cost inputs, and the
line-table rows in the receiving/production/stock components. That would let every locator
above swap from role/position matching to exact data-testid matching in one pass.
CI
Not wired up yet — no GitHub Actions workflow exists in this repo. Once these specs are
green locally, add .github/workflows/e2e.yml (spin up Postgres + backend + frontend as
services, run npm run test:e2e, upload playwright-report/ as an artifact) as a
follow-up.