52 - Add Playwright e2e auth smoke test (containerized)

This commit is contained in:
myrmidex 2026-06-28 18:04:02 +02:00
parent f761f4a791
commit a71276cb16
7 changed files with 219 additions and 0 deletions

View file

@ -50,8 +50,30 @@ services:
# Reliable file-watching under the bind-mount. # Reliable file-watching under the bind-mount.
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"
tty: true tty: true
# Playwright e2e runner (browsers + libs preinstalled). On-demand only — opt
# in with `--profile e2e`, so it never starts with a plain `dev-up`. Reaches
# the SPA by service name on the compose network (frontend:5173), so no host
# browsers are needed (NixOS host can't run Chromium natively). Image tag is
# pinned to the @playwright/test version in e2e/package.json — keep in sync.
playwright:
image: mcr.microsoft.com/playwright:v1.49.0-jammy
profiles: [e2e]
working_dir: /e2e
volumes:
- ./e2e:/e2e
- e2e_node_modules:/e2e/node_modules
environment:
# Target the SPA by compose service name, not localhost.
E2E_BASE_URL: "http://frontend:5173"
depends_on:
- frontend
# Install deps then run the suite; the image already has the browsers.
command: sh -c "npm install && npm test"
###> symfony/mercure-bundle ### ###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ### ###< symfony/mercure-bundle ###
volumes: volumes:
frontend_node_modules: frontend_node_modules:
e2e_node_modules:

5
e2e/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
/test-results
/playwright-report
/blob-report
/.cache

31
e2e/README.md Normal file
View file

@ -0,0 +1,31 @@
# E2E smoke tests (Playwright)
End-to-end smoke tests that drive a real browser against the **live dev stack**.
These verify the cross-stack behavior the component tests mock away: the real
session cookie, the Vite `/api``php:80` proxy, and the actual API contract.
## Runs in a container, not the host
Playwright needs real browser binaries. Rather than installing them on the host
(the NixOS host can't launch Chromium — missing shared libraries), the suite
runs in the **`playwright` compose service** (`mcr.microsoft.com/playwright`,
browsers + libs preinstalled). It's gated behind a compose profile so it never
starts with a plain `dev-up`.
## Running
1. Bring the stack up: `dev-up` (needs `frontend`, `php`, `database` services).
2. Run the suite via the `e2e` profile (installs deps + runs the tests in the
container; the image already has the browsers):
```
podman-compose -f compose.yaml -f compose.override.yaml --profile e2e run --rm playwright
```
The runner targets the SPA by compose service name (`http://frontend:5173`,
set via `E2E_BASE_URL`) — container-to-container, no host browsers involved.
## Version coupling
`@playwright/test` in `package.json` is pinned to an **exact** version that must
match the `mcr.microsoft.com/playwright:vX.Y.Z-jammy` image tag in
`compose.override.yaml`. A mismatch makes Playwright refuse to launch (the
runner and the image's bundled browsers must agree). Bump both together.

76
e2e/package-lock.json generated Normal file
View file

@ -0,0 +1,76 @@
{
"name": "buckets-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "buckets-e2e",
"devDependencies": {
"@playwright/test": "1.49.0"
}
},
"node_modules/@playwright/test": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0.tgz",
"integrity": "sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.49.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz",
"integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz",
"integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

13
e2e/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "buckets-e2e",
"private": true,
"description": "Playwright end-to-end smoke tests. Runs in the `playwright` compose service (browsers preinstalled) against the live stack, targeting frontend:5173. NOTE: @playwright/test version must match the image tag in compose.override.yaml.",
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "1.49.0"
}
}

32
e2e/playwright.config.ts Normal file
View file

@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright config for the host-run e2e smoke suite.
*
* These tests drive a real browser against the LIVE dev stack (the `frontend`,
* `php`, and `database` compose services must be up `dev-up`). The frontend
* proxies /api to the backend, so the e2e exercises the real cookie-session
* flow end to end.
*
* Runs in the `playwright` compose service (browsers preinstalled in the image)
* via `--profile e2e`, NOT on the host the NixOS host can't launch Chromium.
* baseURL defaults to the compose service name (http://frontend:5173) via
* E2E_BASE_URL; the localhost fallback is only for an ad-hoc host run.
*/
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'list',
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})

40
e2e/tests/auth.spec.ts Normal file
View file

@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test'
/**
* The auth happy path end to end against the live stack: register a brand-new
* account, log in with it, land on the authenticated app, then log out.
*
* Uses a unique email per run because this hits the real database (a duplicate
* email would 422 on register).
*/
test('register, log in, then log out', async ({ page }) => {
const email = `e2e-${Date.now()}@example.com`
const password = 'correct-horse-battery-staple'
// --- Register -----------------------------------------------------------
await page.goto('/register')
await page.getByLabel(/email/i).fill(email)
await page.getByLabel(/password/i).fill(password)
await page.getByRole('button', { name: /register/i }).click()
// Register does not auto-login — it redirects to the login screen.
await expect(page).toHaveURL(/\/login$/)
await expect(page.getByRole('button', { name: /log in/i })).toBeVisible()
// --- Log in -------------------------------------------------------------
await page.getByLabel(/email/i).fill(email)
await page.getByLabel(/password/i).fill(password)
await page.getByRole('button', { name: /log in/i }).click()
// Lands on the authenticated app (the scenarios placeholder + the logout
// control in the authed layout).
await expect(page.getByText(/emergency fund/i)).toBeVisible()
await expect(page.getByRole('button', { name: /log out/i })).toBeVisible()
// --- Log out ------------------------------------------------------------
await page.getByRole('button', { name: /log out/i }).click()
// Back to the login screen; the authed content is gone.
await expect(page).toHaveURL(/\/login$/)
await expect(page.getByText(/emergency fund/i)).toHaveCount(0)
})