Browser tests (Playwright)
These tests open the dashboard in Chromium against a real Hardcount API and real data. They check what only a browser can see: that pages load cleanly and fit the screen, and that the main things an operator does work end to end.
smoke.spec.tsopens every page at 1440px and 390px wide. A page fails if the browser logs a console error, an API call answers 400 or more, the page scrolls sideways, or its heading is missing. Each page is its own test, so a failure names the page. Public pages (sign-in, password reset, invite, update payment, the Stripe return page, the hosted invoice) are opened signed out, and the emailed-link pages with no code.journeys.spec.tssigns in, creates a customer, creates a subscription, advances a test clock to the renewal and opens the new invoice, opens the public invoice page, and grants credits. It picks a credit expiry day on a grant and on a commit line, and checks that the dashboard sends the next midnight in the account’s zone and shows “Valid through” the picked day. An API grant that stops mid-day shows “Expires” with its time. Those two screenshots go toactivity-shots/too. It also checks that six wrong passwords in a row make the sign-in page say how long to wait. Each journey checks that the screen shows what the API returned. The arithmetic behind the numbers is tested in Go, not here.subscriptions.spec.tschanges subscriptions on screen: a plan change now (with its proration invoice) and at renewal, a change now to a yearly plan (the dialog’s words and the invoice the toast names), a seat decrease whose credit goes partly to the balance and partly off an open invoice, a seat decrease over a draft renewal (its credit waits for the draft), cancel at period end, undo and cancel now, quantity and item changes against the upcoming invoice, and the trial and collection controls. Each journey checks the page, the toast and any invoice the change made against the API.dunning.spec.tsopens the Resolve dialog of a payment-retry run (nothing is chosen, and Resolve waits for a choice), and deletes a dunning policy that has run (the page shows why it is refused). Each run is made the real way: a customer with no card on file is billed.account-setup.spec.tssets up what an operator sells and how they collect. It creates a meter and a plan, then adds the plan’s price for the meter, which applies at once while no one has subscribed. It saves a graduated and a package price with labels. It previews and installs a recipe, which creates only a meter, a plan and its prices, then installs it again. It adds and ends a customer’s price override on a test clock and checks the upcoming invoice each time: each change applies from the next period. It creates and edits a dunning policy, assigns it to a customer, tries to delete it and makes it the default, then puts the old default back.invoices.spec.tstakes invoices through their life on screen: a one-off invoice from draft to finalized, through the Finalize confirmation (and Save & send), the Finalize confirmation’s email line with Stripe connected (the Stripe connection and the server’s email setting are set in the browser), a credit note before and after an offline payment, void and write-off, and the credit notes page. Each journey checks the dashboard and the customer’s payment page against the API.activity.spec.tstakes screenshots of the activity cards for people to compare: the invoice and subscription Activity cards, a Dunning run and the customer’s Sent emails, at 1440px and 390px wide. It runs each story for a customer on the real clock and for one on a test clock frozen at 2027-02-01 04:30 UTC. The steps that need days to pass, such as payment retries and a trial running out, happen only on the test clock. A person reads the shots. One test is a pass/fail check. The same operator steps on an invoice must give the same Activity rows on both clocks once times are taken out. Every test-clock row must have a Recorded part. No row may be wider than the card at either width. That test runs with the browser inPacific/Pago_Pago, not the account’s zone, and checks that the card’s zone header and day headers follow the account’s zone. The files go toactivity-shots/, which CI uploads as theactivity-shotsartifact on every run (see Activity screenshots).global.setup.tsruns first. It seeds data through the API (code infixtures/) and signs in once through the login page. The other tests reuse that session.
Run them locally
Section titled “Run them locally”You need a Hardcount API with a bootstrapped tenant, and the dashboard built and
served by vite preview, which forwards /v1 to the API. Use your own
database so the tests never touch your dev data.
export PGPASSWORD=hardcount# 1. A database of your own (the docker compose Postgres on :5432). The# hardcount_app role the API runs as is created by docker/init.sql.psql -h localhost -U hardcount -d postgres -c 'CREATE DATABASE hardcount_e2e OWNER hardcount'psql -h localhost -U hardcount -d hardcount_e2e -c 'GRANT ALL ON SCHEMA public TO hardcount_app'psql -h localhost -U hardcount -d hardcount_e2e -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO hardcount_app'psql -h localhost -U hardcount -d hardcount_e2e -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO hardcount_app'export DATABASE_URL='postgres://hardcount:hardcount@localhost:5432/hardcount_e2e?sslmode=disable'
# 2. Bootstrap a tenant (prints the hc_secret_test_ key) and start the APIHARDCOUNT_BOOTSTRAP_EMAIL=e2e@example.com HARDCOUNT_BOOTSTRAP_PASSWORD=e2e-password \ go run ./cmd/hardcount-bootstrapPORT=8190 go run ./cmd/hardcount
# 3. In web-v2/: build, serve, and run the testsnpm run buildHARDCOUNT_API_TARGET=http://127.0.0.1:8190 npx vite preview --host 127.0.0.1 --port 4190npx playwright install chromium # onceE2E_BASE_URL=http://127.0.0.1:4190 E2E_API_URL=http://127.0.0.1:8190 \E2E_API_KEY=hc_secret_test_... E2E_EMAIL=e2e@example.com E2E_PASSWORD=e2e-password \ npm run test:e2eRebuild (npm run build) after you change the dashboard: vite preview
serves the built files.
Useful flags: npx playwright test smoke -g invoices runs one page,
--ui opens the test runner, and npx playwright show-trace <trace.zip>
opens a trace from a CI failure (CI uploads them as the playwright-report
artifact).
Emails fail without a mail server. CI runs Mailpit, so its emails read as
sent. To do the same locally, run docker compose up -d mailpit and start
the API with SMTP_HOST=localhost SMTP_PORT=1025 SMTP_TLS=none.
Activity screenshots
Section titled “Activity screenshots”A change to an activity card is reviewed by comparing its screenshots with
main’s. Download both sets from the e2e job’s runs, each into its own
folder:
gh run download <run-id> -n activity-shots -D before # main's latest rungh run download <run-id> -n activity-shots -D after # the PR's runEach file is <surface>-<real|test>-<width>.png. Real-clock times and the
“Recorded” times on test-clock rows are the time of the run, so they change
every run. The test-clock dates stay the same.
- Find elements the way a person does:
getByRole,getByLabel. If a control has no accessible name, give it one in the app. - Never wait a fixed time. Wait for something on screen or for the API.
- Each test makes its own data with unique names, so tests can run in any order, in parallel, and many times against the same database.
- Before you commit a new or changed test, run it five times:
npx playwright test --repeat-each 5. Fix or delete a test that fails even once. CI retries a failed test once and reports a pass on retry as flaky. - A new page in
src/main.tsxgets a line in therouteslist insmoke.spec.ts.