Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

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.ts opens 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.ts signs 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 to activity-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.ts changes 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.ts opens 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.ts sets 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.ts takes 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.ts takes 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 in Pacific/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 to activity-shots/, which CI uploads as the activity-shots artifact on every run (see Activity screenshots).
  • global.setup.ts runs first. It seeds data through the API (code in fixtures/) and signs in once through the login page. The other tests reuse that session.

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.

Terminal window
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 API
HARDCOUNT_BOOTSTRAP_EMAIL=e2e@example.com HARDCOUNT_BOOTSTRAP_PASSWORD=e2e-password \
go run ./cmd/hardcount-bootstrap
PORT=8190 go run ./cmd/hardcount
# 3. In web-v2/: build, serve, and run the tests
npm run build
HARDCOUNT_API_TARGET=http://127.0.0.1:8190 npx vite preview --host 127.0.0.1 --port 4190
npx playwright install chromium # once
E2E_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:e2e

Rebuild (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.

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:

Terminal window
gh run download <run-id> -n activity-shots -D before # main's latest run
gh run download <run-id> -n activity-shots -D after # the PR's run

Each 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.tsx gets a line in the routes list in smoke.spec.ts.