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

14 KiB

name, description
name description
playwright-e2e 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

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:

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

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:

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

-- 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

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

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

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

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:

{
  "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:

echo -n "test123" | htpasswd -bnBC 10 "" - | cut -d: -f2

2. waitForURL — use globs, not broad regex

// ❌ 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.

// ❌ 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:

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

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)

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