diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 0000000..7a5e606
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,43 @@
+name: Playwright
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ e2e:
+ name: Frontend e2e
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps chromium
+ working-directory: packages/frontend
+
+ - name: Run Playwright tests
+ run: npm run test:e2e
+ working-directory: packages/frontend
+ env:
+ NEXT_PUBLIC_API_URL: http://localhost:4100
+
+ - name: Upload Playwright report on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: packages/frontend/playwright-report/
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index 7e347fd..3077ef4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,5 +40,11 @@ packages/backend/src/generated/
docker-compose.override.yml
Caddyfile
+# Playwright
+packages/frontend/test-results/
+packages/frontend/playwright-report/
+packages/frontend/blob-report/
+packages/frontend/playwright/.cache/
+
# Misc
*.tsbuildinfo
diff --git a/package-lock.json b/package-lock.json
index 605c469..50ad471 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3818,6 +3818,23 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@prisma/adapter-pg": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz",
@@ -16100,6 +16117,53 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/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/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -20161,6 +20225,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
+ "@playwright/test": "^1.59.1",
"@types/node": "^22.10.7",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
diff --git a/packages/frontend/next-env.d.ts b/packages/frontend/next-env.d.ts
index 9edff1c..c4b7818 100644
--- a/packages/frontend/next-env.d.ts
+++ b/packages/frontend/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 22e0e82..ad36aa7 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -8,7 +8,9 @@
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
- "lint": "eslint src/"
+ "lint": "eslint src/",
+ "test:e2e": "playwright test",
+ "test:e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
@@ -33,6 +35,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
+ "@playwright/test": "^1.59.1",
"@types/node": "^22.10.7",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts
new file mode 100644
index 0000000..bdfef20
--- /dev/null
+++ b/packages/frontend/playwright.config.ts
@@ -0,0 +1,41 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3100';
+const reuseServer = process.env.PLAYWRIGHT_USE_RUNNING_SERVER === '1';
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ timeout: 30_000,
+ expect: { timeout: 5_000 },
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ workers: 1,
+ reporter: [['list']],
+ use: {
+ baseURL,
+ trace: 'on-first-retry',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: reuseServer
+ ? undefined
+ : {
+ // The frontend uses `output: 'standalone'`, which makes `next start`
+ // refuse to run. The standalone build path embeds the absolute build
+ // dir, which differs between local + CI. `next dev` boots the same
+ // App Router and is good enough for these UI-only smoke checks.
+ command: 'npx next dev --port 3100',
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ env: {
+ NEXT_PUBLIC_API_URL:
+ process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4100',
+ },
+ },
+});
diff --git a/packages/frontend/tests/e2e/auth.spec.ts b/packages/frontend/tests/e2e/auth.spec.ts
new file mode 100644
index 0000000..276d69d
--- /dev/null
+++ b/packages/frontend/tests/e2e/auth.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/test';
+
+/**
+ * Minimal e2e for the auth flow + the new error/not-found pages.
+ *
+ * Aims to catch regressions on the things Sprint 2 actually changed:
+ * - login renders and the form has the new label/input pairing
+ * - 404 → branded not-found.tsx (not Next's default)
+ *
+ * Doesn't try to log in for real — that needs a backend. Anything backend-
+ * dependent is covered by scripts/smoke-test/run.sh.
+ */
+
+test.describe('auth UI', () => {
+ test('login page renders with accessible labels and label/input pairing', async ({
+ page,
+ }) => {
+ await page.goto('/login');
+ const email = page.locator('#auth-email');
+ const password = page.locator('#auth-password');
+ await expect(email).toBeVisible();
+ await expect(password).toBeVisible();
+ // Clicking the visible label focuses the matching input — proves htmlFor works.
+ await page.locator('label[for="auth-email"]').click();
+ await expect(email).toBeFocused();
+ });
+
+ test('forgot-password label/input pairing works', async ({ page }) => {
+ await page.goto('/forgot-password');
+ const email = page.locator('#forgot-email');
+ await expect(email).toBeVisible();
+ await page.locator('label[for="forgot-email"]').click();
+ await expect(email).toBeFocused();
+ await email.fill('user@example.com');
+ await expect(email).toHaveValue('user@example.com');
+ });
+
+ test('unknown route shows the branded 404', async ({ page }) => {
+ // /login/* is allowed by the auth proxy, so the request reaches Next
+ // (otherwise the proxy would redirect us back to /login with no 404).
+ const res = await page.goto('/login/this-route-does-not-exist');
+ expect(res?.status()).toBe(404);
+ await expect(
+ page.getByRole('heading', { name: /page not found/i }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole('link', { name: /go to dashboard/i }),
+ ).toBeVisible();
+ });
+});