diff --git a/compose.override.yaml b/compose.override.yaml index 620dedc..49ef1bd 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -50,8 +50,30 @@ services: # Reliable file-watching under the bind-mount. CHOKIDAR_USEPOLLING: "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 ### volumes: frontend_node_modules: + e2e_node_modules: diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..c129d9e --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules +/test-results +/playwright-report +/blob-report +/.cache diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..8dd2b4f --- /dev/null +++ b/e2e/README.md @@ -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. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..fcf5f75 --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..96b652d --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..54c3116 --- /dev/null +++ b/e2e/playwright.config.ts @@ -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'] }, + }, + ], +}) diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..c85e4c6 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -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) +})