--- name: playwright-e2e description: Set up standardized Playwright E2E test infrastructure for Next.js + Spring Boot projects. Creates 3-project config (setup/authenticated/unauthenticated), auth flow, Docker test environment, accessibility tests, and seed data. Use when asked to set up Playwright tests, create E2E test infrastructure, add Playwright to a project, or create an integration test harness. --- # Playwright E2E Test Infrastructure ## When to use - Setting up Playwright E2E tests for a Next.js + backend project - Creating test infrastructure from scratch - Adding authentication-aware E2E testing - Setting up accessibility testing with axe-core - Triggers: "set up Playwright tests", "create E2E test infrastructure", "add Playwright to this project", "E2E test suite", "create integration test harness" ## When NOT to use - Unit testing (use Jest/Vitest instead) - API-only testing without a frontend (use Postman/REST client) - Projects without a web frontend - Adding individual test files to an existing Playwright setup (just write the test directly) ## Required Inputs | Input | Source | Example | |-------|--------|---------| | `PROJECT_DIR` | Current workspace | `/Users/pplate/git/personal/inspectflow` | | `FRONTEND_DIR` | Relative path to frontend | `frontend/` | | `BASE_URL` | Dev server URL | `http://localhost:3000` | | `API_URL` | Backend URL | `http://localhost:8080` | | `LOGIN_EMAIL` | Test user email | `admin@test.de` | | `LOGIN_PASSWORD` | Test user password | `test123` | ## Expected Output - `playwright.config.ts` (3-project architecture) - `e2e/auth.setup.ts` - `e2e/auth-flow.unauth.spec.ts` - `e2e/crud-flow.spec.ts` (template) - `e2e/navigation.spec.ts` (template) - `e2e/accessibility.spec.ts` - `docker-compose.test.yml` - `scripts/seed.sql` - Updated `package.json` scripts - Updated `.gitignore` --- ## Workflow ### Step 1: Install dependencies ```bash cd pnpm add -D @playwright/test @axe-core/playwright npx playwright install chromium ``` ### Step 2: Create `playwright.config.ts` Place in `/playwright.config.ts`: ```typescript import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [['html'], ['list']], use: { baseURL: process.env.BASE_URL || '', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/, }, { name: 'authenticated', use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json', }, dependencies: ['setup'], testIgnore: /.*\.unauth\.spec\.ts/, }, { name: 'unauthenticated', use: { ...devices['Desktop Chrome'] }, testMatch: /.*\.unauth\.spec\.ts/, }, ], }); ``` **Key architecture:** - `setup` project runs `auth.setup.ts` first — logs in once, saves session - `authenticated` project reuses saved `storageState` — no repeated logins - `unauthenticated` project has no auth state — tests public pages and login flows ### Step 3: Create `e2e/auth.setup.ts` ```typescript import { test as setup, expect } from '@playwright/test'; const authFile = '.auth/admin.json'; setup('authenticate', async ({ page }) => { const email = process.env.TEST_EMAIL || ''; const password = process.env.TEST_PASSWORD || ''; await page.goto('/login'); await page.getByLabel('E-Mail').fill(email); await page.getByLabel('Passwort').fill(password); await page.getByRole('button', { name: /anmelden|login|sign in/i }).click(); // Wait for successful redirect to dashboard await page.waitForURL('**/dashboard'); await expect(page.locator('body')).not.toContainText('Login'); // Save authentication state await page.context().storageState({ path: authFile }); }); ``` ### Step 4: Create `docker-compose.test.yml` Place in `/docker-compose.test.yml`: ```yaml services: test-db: image: postgres:17-alpine environment: POSTGRES_DB: testdb POSTGRES_USER: test POSTGRES_PASSWORD: test ports: - "5433:5432" volumes: - ./scripts/seed.sql:/docker-entrypoint-initdb.d/01-seed.sql healthcheck: test: ["CMD-SHELL", "pg_isready -U test -d testdb"] interval: 5s timeout: 5s retries: 5 backend: build: context: ./backend dockerfile: Dockerfile environment: SPRING_PROFILES_ACTIVE: test SPRING_DATASOURCE_URL: jdbc:postgresql://test-db:5432/testdb SPRING_DATASOURCE_USERNAME: test SPRING_DATASOURCE_PASSWORD: test JWT_SECRET: test-secret-key-for-e2e-testing-only ports: - "8080:8080" depends_on: test-db: condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 10s timeout: 5s retries: 10 ``` ### Step 5: Create `scripts/seed.sql` ```sql -- E2E Test Seed Data -- Password "test123" as BCrypt hash -- Verify with: echo -n "test123" | htpasswd -bnBC 10 "" - | cut -d: -f2 INSERT INTO users (email, password_hash, name, role, active) VALUES ( '', '$2a$10$/FgU1KyveJ7MaQ7Xv4kxD.5EIQUHujJfZI4K2E1H7pS6parMHJpeG', 'Test Admin', 'ADMIN', true ) ON CONFLICT (email) DO NOTHING; -- Sample data (adapt to your schema) -- INSERT INTO companies (name, created_by) VALUES ('Test GmbH', 1); ``` ⚠️ **CRITICAL**: The BCrypt hash `$2a$10$/FgU1KyveJ7MaQ7Xv4kxD.5EIQUHujJfZI4K2E1H7pS6parMHJpeG` is for the password `test123`. If you change the password, regenerate the hash. This was the #1 source of broken tests in CannaManage. ### Step 6: Create test file templates #### `e2e/auth-flow.unauth.spec.ts` ```typescript import { test, expect } from '@playwright/test'; test.describe('Authentication Flow', () => { test('login page loads correctly', async ({ page }) => { await page.goto('/login'); await expect(page.getByRole('heading', { name: /anmelden|login/i })).toBeVisible(); await expect(page.getByLabel('E-Mail')).toBeVisible(); await expect(page.getByLabel('Passwort')).toBeVisible(); }); test('shows validation errors for empty form', async ({ page }) => { await page.goto('/login'); await page.getByRole('button', { name: /anmelden|login|sign in/i }).click(); // Expect validation messages await expect(page.locator('text=/required|pflichtfeld|eingeben/i')).toBeVisible(); }); test('redirects unauthenticated users to login', async ({ page }) => { await page.goto('/dashboard'); await page.waitForURL('**/login**'); }); test('registration page loads', async ({ page }) => { await page.goto('/register'); await expect(page.getByRole('heading', { name: /registr/i })).toBeVisible(); }); }); ``` #### `e2e/crud-flow.spec.ts` ```typescript import { test, expect } from '@playwright/test'; test.describe('CRUD Operations', () => { test('can navigate to list page', async ({ page }) => { await page.goto('/dashboard'); // Adapt: click sidebar link to your main entity // await page.getByRole('link', { name: 'Companies' }).click(); // await expect(page.getByRole('heading', { name: 'Companies' })).toBeVisible(); }); test('can create a new item', async ({ page }) => { // Adapt: navigate to creation form // await page.goto('/companies/new'); // await page.getByLabel('Name').fill('Test Company'); // await page.getByRole('button', { name: /erstellen|create|save/i }).click(); // await expect(page.locator('text=/erfolgreich|created|success/i')).toBeVisible(); }); test('can edit an existing item', async ({ page }) => { // Adapt: navigate to edit form // await page.goto('/companies'); // await page.getByRole('row').first().getByRole('link', { name: /edit|bearbeiten/i }).click(); // await page.getByLabel('Name').fill('Updated Company'); // await page.getByRole('button', { name: /speichern|save|update/i }).click(); // await expect(page.locator('text=/aktualisiert|updated|success/i')).toBeVisible(); }); test('can delete an item', async ({ page }) => { // Adapt: delete flow with confirmation dialog // await page.goto('/companies'); // await page.getByRole('row').first().getByRole('button', { name: /löschen|delete/i }).click(); // await page.getByRole('dialog').getByRole('button', { name: /bestätigen|confirm|delete/i }).click(); // await expect(page.locator('text=/gelöscht|deleted|success/i')).toBeVisible(); }); }); ``` #### `e2e/navigation.spec.ts` ```typescript import { test, expect } from '@playwright/test'; test.describe('Navigation', () => { test('sidebar navigation works', async ({ page }) => { await page.goto('/dashboard'); // Adapt: check your sidebar links exist const sidebar = page.locator('[data-sidebar]'); await expect(sidebar).toBeVisible(); }); test('breadcrumbs display correctly', async ({ page }) => { await page.goto('/dashboard'); // Adapt: verify breadcrumb trail // await expect(page.locator('nav[aria-label="breadcrumb"]')).toBeVisible(); }); test('mobile menu toggle works', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto('/dashboard'); // Adapt: check mobile menu behavior // await page.getByRole('button', { name: /menu/i }).click(); // await expect(page.locator('[data-sidebar]')).toBeVisible(); }); }); ``` #### `e2e/accessibility.spec.ts` ```typescript import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; const pages = [ { name: 'Dashboard', path: '/dashboard' }, // Add all pages to scan: // { name: 'Companies', path: '/companies' }, // { name: 'New Company', path: '/companies/new' }, ]; test.describe('Accessibility', () => { for (const { name, path } of pages) { test(`${name} has no accessibility violations`, async ({ page }) => { await page.goto(path); await page.waitForLoadState('networkidle'); const results = await new AxeBuilder({ page }).analyze(); // Log violations for debugging if (results.violations.length > 0) { console.log(`Accessibility violations on ${name}:`); results.violations.forEach((v) => { console.log(` [${v.impact}] ${v.id}: ${v.description}`); v.nodes.forEach((n) => console.log(` ${n.html}`)); }); } expect(results.violations).toEqual([]); }); } }); ``` ### Step 7: Add `package.json` scripts Add to `/package.json` scripts section: ```json { "scripts": { "e2e": "playwright test", "e2e:ui": "playwright test --ui", "e2e:headed": "playwright test --headed", "e2e:report": "playwright show-report" } } ``` ### Step 8: Update `.gitignore` Append to `/.gitignore`: ``` # Playwright .auth/ playwright-report/ test-results/ ``` --- ## Lessons Learned & Gotchas ### 1. BCrypt hash MUST match the password The #1 cause of broken E2E suites. If `seed.sql` has a hash that doesn't match the password in `auth.setup.ts`, all authenticated tests fail silently (login form rejects credentials). **Verify with:** ```bash echo -n "test123" | htpasswd -bnBC 10 "" - | cut -d: -f2 ``` ### 2. waitForURL — use globs, not broad regex ```typescript // ❌ BAD — matches too broadly, causes flaky tests await page.waitForURL(/dashboard|\//) // ✅ GOOD — specific glob pattern await page.waitForURL('**/dashboard') ``` ### 3. shadcn/ui selectors Don't use generic CSS class selectors for shadcn components. They use dynamic Tailwind classes. ```typescript // ❌ BAD page.locator('.sidebar') // ✅ GOOD page.locator('[data-sidebar]') // sidebar page.locator('[role="dialog"]') // modals/dialogs page.getByRole('button', { name: 'Save' }) // buttons page.getByLabel('Email') // form fields ``` ### 4. Console error filtering Tests fail on unexpected console errors. Filter known/expected ones: ```typescript const errors: string[] = []; page.on('console', msg => { if (msg.type() === 'error' && !msg.text().includes('expected-error-pattern')) { errors.push(msg.text()); } }); // ... do test actions ... expect(errors).toEqual([]); ``` ### 5. Auth state caching = 10x speed The `setup` project runs ONCE per test run. All `authenticated` tests reuse the saved `.auth/admin.json` session cookie. Never log in per-test unless testing auth specifically. ### 6. Accessibility scanning pattern ```typescript import AxeBuilder from '@axe-core/playwright'; const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); ``` Run on every page. Add new pages to the `pages` array in `accessibility.spec.ts`. ### 7. Visual regression (optional, add when ready) ```typescript await expect(page).toHaveScreenshot('page-name.png', { maxDiffPixels: 100 }); ``` First run creates baseline screenshots. Subsequent runs compare. Commit screenshots to git. ### 8. Docker test environment Backend should have a `test` Spring profile that: - Uses the test PostgreSQL (port 5433 to avoid conflicts) - Runs Flyway migrations + seed on startup - Has shorter JWT expiry for faster test cycles - Disables rate limiting and email sending --- ## Troubleshooting | Problem | Cause | Fix | |---------|-------|-----| | All auth tests fail | Wrong BCrypt hash in seed.sql | Regenerate hash for your password | | `waitForURL` times out | URL doesn't match pattern | Use `page.url()` to print actual URL, adjust pattern | | Tests pass locally, fail in CI | Missing `npx playwright install` | Add to CI setup step | | `storageState` file not found | `.auth/` directory doesn't exist | Create `.auth/` dir or let setup create it | | Flaky navigation tests | Page not fully loaded | Add `await page.waitForLoadState('networkidle')` | | axe violations on shadcn | Missing aria labels | Add `aria-label` to interactive elements |