README.md 10 KB

End-to-end tests

The vitest suite mocks Prisma, so it proves the actions think correctly and nothing else. These tests run the built app in a browser against a real Postgres, and cover what only breaks once the pieces are assembled: migrations, the session cookie, server actions wired to forms, and invoice numbering.

Layout

e2e/
  auth.setup.ts        signs in once; every spec starts with that session
  prepare-db.ts        reset + seed, run ahead of the server
  mail-sink.ts         a mail server that delivers nothing and keeps everything
  payment-sink.ts      Stripe and PayPal as far as the app can tell; no money moves
  google-standin.ts    Google's account chooser and token endpoint, for the cloud run
  support/             helpers specs share: reading mail, reading a PDF's text,
                       database peeks, TOTP, work order driving
  specs/
    auth/              sign-in, sign-up and invitations, account security
    invoices/          numbering, paying an invoice down, one document four ways,
                       what the job's own files do to it, the designer
                       and its drags
    work-orders/       pricing under each tax setting, quote to invoice,
                       the lifecycle of a job, what the editor refuses,
                       the shape of the page at both breakpoints
    quotes/            the quote a customer is handed
    calendar/          a booking keeps the time it was made at, in the
                       workshop's own timezone
    inventory/         a stocked part leaves the shelf exactly once
    reminders/         a due time survives being displayed and re-saved
    email/             the email template designer, and the mail it sends
    payments/          a customer pays online, and the payment is booked once
    security/          the doors the September 2026 audit found open: admin-only
                       actions, file paths, payment attribution, webhook signatures
    cloud/             run with E2E_MODE=cloud: plan limits, Google sign-in, the sign-up pitch
    tech/              the technician app's API contract
    smoke/             the build is alive

One folder per area of the app, one file per flow. A new area gets a new folder; a helper used by more than one spec goes under support/.

The app the suite starts runs with DISABLE_BACKGROUND_JOBS=1. Its schedulers would otherwise tick through the run: the due-reminder scan stamps notifiedAt on rows a spec is asserting on, the message and webhook processors send things, and all of them compete for the single CPU a serial suite is using. A spec that needs one of them should call the processor directly rather than wait for a timer.

One-time setup

npx playwright install --with-deps chromium
createdb torqvoice_e2e   # any empty database whose name contains "e2e" or "test"

Running

export E2E_DATABASE_URL="postgresql://torqvoice:torqvoice@localhost:5432/torqvoice_e2e"
npm run build          # NEXT_PUBLIC_APP_URL must match the base URL below
npm run test:e2e

The suite resets E2E_DATABASE_URL to a clean schema, runs the demo seed, starts the mail sink and next start on port 3100, signs in once, and reuses that session.

If something is already listening on port 3100, the suite uses it as it is and skips the reset, so a second run continues on the data the first one left. Stop that server when you want a clean slate.

npm run test:e2e:ui opens Playwright's watch mode, which is the sane way to write a new spec.

When Playwright has no browser for your machine

Playwright only ships Chromium for the operating systems it supports; on an older Debian, playwright install refuses. Run the browser from Playwright's own image instead, against a server started here:

export E2E_DATABASE_URL="postgresql://torqvoice:torqvoice@localhost:5432/torqvoice_e2e"
export BETTER_AUTH_SECRET=$(grep -oP '^BETTER_AUTH_SECRET="?\K[^"]+' .env)
npx tsx e2e/prepare-db.ts
DATABASE_URL="$E2E_DATABASE_URL" NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100 \
  DEMO_MODE=false AUTH_RATE_LIMIT=off TORQVOICE_MODE=self-hosted \
  SMTP_HOST=127.0.0.1 SMTP_PORT=1025 SMTP_FROM_EMAIL=workshop@e2e.test \
  npm run start -- --port 3100 &
docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
  -v "$PWD":/work -w /work \
  -e E2E_BASE_URL=http://127.0.0.1:3100 -e E2E_SKIP_SEED=1 -e E2E_DATABASE_URL \
  -e BETTER_AUTH_SECRET \
  mcr.microsoft.com/playwright:v1.63.0-noble npx playwright test

The image version must match @playwright/test in package.json. The secret goes in because the two-factor spec decrypts what the server stored, and a server started here takes its own from .env. The SMTP variables point the server at the mail sink, which Playwright starts inside the container; --network host is what puts them on the same localhost.

Pointing it at something already running

E2E_BASE_URL=https://staging.torqvoice.com E2E_SKIP_SEED=1 npm run test:e2e

With E2E_BASE_URL set, no app server is started. With E2E_SKIP_SEED=1, the database is left alone, which is what you want against a shared environment.

The mail sink still starts, but a server elsewhere sends its mail elsewhere, so the two specs that read mail (the invitation and the password reset) cannot pass against a shared environment unless that server is pointed here too.

The mail sink

Two things a workshop does can only be tested by reading the mail: an invitation is a link and nothing else, and the app deletes an invitation it could not send. So the harness runs its own mail server, e2e/mail-sink.ts. It speaks SMTP on port 1025, delivers nothing, keeps what it is given in memory, and hands it back over HTTP on port 8025. Playwright starts and stops it with everything else, so there is nothing to install or remember.

The app is pointed at it with SMTP_HOST and SMTP_PORT, which is all it takes: SMTP is the default provider, and the seeded workshop configures none of its own. A spec reads what was sent through support/mail.ts:

const mail = await waitForMail('someone@example.com')
await page.goto(linkIn(mail, /\/auth\/sign-up\?invite=/))

clearMailbox() empties it, which is worth doing before an action whose mail you are about to read twice in one file.

In CI

.github/workflows/e2e.yml runs the suite on every pull request to main, and on demand from the Actions tab. The specs are split into four shards (--shard=1/4 and so on) plus a job for the cloud specs, all at once. Each job has its own postgres:16-alpine service holding torqvoice_e2e, installs Chromium, builds with NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100, and seeds its own database, so the one-test-at-a-time rule still holds inside every job. A spec that only passes because another file ran before it will fail here.

To run one shard the way CI does:

npx playwright test --shard=2/4

On CI every job writes a blob report, and the last job, Playwright, merges them into one HTML report uploaded as the playwright-report artifact. It is green only when every shard and the cloud job are. Open a downloaded report with npx playwright show-report.

Reading a PDF

support/pdf.ts turns a PDF into its text (unpdf, which is pdf.js underneath), so a spec can assert what a customer actually reads rather than that a file arrived:

const pdf = await pdfContent(await response.body())
expect(pdf.flat).toContain('Total $4,312.50')
expect(pdf.text).toContain('Gates WP-4471\nwith gasket and coolant')

makePdf(['page one', 'page two']) builds a small PDF to attach to a job, and TINY_PNG / BROKEN_PNG are a valid photograph and a truncated one.

flat collapses all whitespace, for phrases that span a line break in the layout; text keeps the lines, which is how a multi-line description is checked. size is the file's own weight — a logo or QR code that goes missing changes nothing about the words, so parity checks compare both.

Variables

Variable Default Purpose
E2E_DATABASE_URL required The database the suite resets and seeds
E2E_BASE_URL starts its own server on 127.0.0.1:3100 Test an existing instance
E2E_SKIP_SEED unset Leave the database untouched
E2E_ALLOW_ANY_DB unset Override the guard on database names
E2E_USER_EMAIL / E2E_USER_PASSWORD demo@torqvoice.com / demo-e2e-pass The login the seed creates and the suite signs in with
E2E_TZ Europe/Oslo Browser and server timezone
E2E_SMTP_PORT 1025 Where the mail sink listens for the app
E2E_MAIL_API_PORT 8025 Where the mail sink answers the specs
E2E_MAIL_API http://127.0.0.1:8025 The sink a spec reads from, when it is not the local one

The suite's own server also runs with TORQVOICE_MODE=self-hosted, DEMO_MODE=false and AUTH_RATE_LIMIT=off. Pointed at another server, start it the same way or the plan limits, demo guards and sign-in limiter get in the way of the tests.

Rules that keep this suite worth having

The build must be made with the base URL the tests use. NEXT_PUBLIC_APP_URL is baked into the client bundle, and better-auth refuses a sign-in from an origin it was not built for.

Never point E2E_DATABASE_URL at a database you care about. The setup runs prisma migrate reset. There is a guard on the database name, and E2E_ALLOW_ANY_DB=1 removes it, so think before reaching for that.

Pin the language. Selectors read visible English. The config sets the locale, and the saved session carries a locale=en cookie.

The sign-in rate limit is off on the suite's own server (AUTH_RATE_LIMIT=off). Pointed at another server, keep sign-ins in a spec ten seconds apart or the third one is refused.

Demo mode stays off. It blocks invites, billing and outbound messages, which are behaviours a test should be able to exercise.

Read the link out of the mail, not out of the database. A token in a table proves nothing about what the person received; the sink is there so a spec can follow the address the app actually posted.

Prefer a role or a stable id over a class. Where an element has neither, add data-testid to the component rather than reaching through the DOM.