Files
pi_mcps/zoo_backup/home/skills/playwright-e2e/SKILL.md
T
2026-06-24 19:27:14 +02:00

461 lines
14 KiB
Markdown

---
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 <FRONTEND_DIR>
pnpm add -D @playwright/test @axe-core/playwright
npx playwright install chromium
```
### Step 2: Create `playwright.config.ts`
Place in `<FRONTEND_DIR>/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 || '<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 || '<LOGIN_EMAIL>';
const password = process.env.TEST_PASSWORD || '<LOGIN_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 `<PROJECT_DIR>/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 (
'<LOGIN_EMAIL>',
'$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 `<FRONTEND_DIR>/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 `<FRONTEND_DIR>/.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 |