Playwright Best Practices This skill provides comprehensive guidance for all aspects of Playwright test development, from writing new tests to debugging and maintaining existing test suites. Activity-Based Reference Guide Consult these references based on what you're doing: Writing New Tests When to use : Creating new test files, writing test cases, implementing test scenarios | Activity | Reference Files | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Wr…

, '')));\n expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));\n});\n```\n\n## API Routes\n\n### Direct API Testing\n\n```typescript\ntest('GET /api/products returns list', async ({ request }) => {\n const response = await request.get('/api/products');\n\n expect(response.ok()).toBeTruthy();\n const body = await response.json();\n expect(body.products).toBeInstanceOf(Array);\n expect(body.products[0]).toHaveProperty('id');\n expect(body.products[0]).toHaveProperty('name');\n});\n\ntest('POST /api/products creates item', async ({ request }) => {\n const response = await request.post('/api/products', {\n data: { name: 'Test Product', price: 29.99 },\n });\n\n expect(response.status()).toBe(201);\n const body = await response.json();\n expect(body.product.name).toBe('Test Product');\n});\n\ntest('POST /api/products validates fields', async ({ request }) => {\n const response = await request.post('/api/products', {\n data: { name: '' },\n });\n\n expect(response.status()).toBe(400);\n const body = await response.json();\n expect(body.error).toContainEqual(expect.objectContaining({ field: 'price' }));\n});\n```\n\n### API Through UI\n\n```typescript\ntest('form submission calls API', async ({ page }) => {\n await page.goto('/products/new');\n\n await page.getByLabel('Product name').fill('Widget');\n await page.getByLabel('Price').fill('19.99');\n await page.getByRole('button', { name: 'Create product' }).click();\n\n await expect(page.getByText('Product created successfully')).toBeVisible();\n await page.waitForURL('/products/**');\n});\n```\n\n## Middleware Testing\n\n### Auth Redirects\n\n```typescript\ntest('unauthenticated user redirected to login', async ({ page }) => {\n await page.goto('/dashboard');\n\n expect(page.url()).toContain('/login');\n await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();\n});\n\ntest('redirect preserves return URL', async ({ page }) => {\n await page.goto('/dashboard/settings');\n\n const url = new URL(page.url());\n expect(url.pathname).toBe('/login');\n expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))\n .toContain('/dashboard/settings');\n});\n```\n\n### Security Headers\n\n```typescript\ntest('middleware sets security headers', async ({ page }) => {\n const response = await page.goto('/');\n\n const headers = response!.headers();\n expect(headers['x-frame-options']).toBe('DENY');\n expect(headers['x-content-type-options']).toBe('nosniff');\n});\n```\n\n### Locale Rewrites\n\n```typescript\ntest('middleware rewrites based on locale', async ({ page, context }) => {\n await context.setExtraHTTPHeaders({\n 'Accept-Language': 'fr-FR,fr;q=0.9',\n });\n\n await page.goto('/');\n\n await expect(page.getByText('Bienvenue')).toBeVisible();\n});\n```\n\n## Hydration Testing\n\n### Console Error Detection\n\n```typescript\ntest('no hydration errors in console', async ({ page }) => {\n const consoleErrors: string[] = [];\n page.on('console', (msg) => {\n if (msg.type() === 'error') {\n consoleErrors.push(msg.text());\n }\n });\n\n await page.goto('/');\n await page.getByRole('button', { name: 'Get started' }).click();\n\n const hydrationErrors = consoleErrors.filter(\n (e) =>\n e.includes('Hydration') ||\n e.includes('hydration') ||\n e.includes('did not match')\n );\n expect(hydrationErrors).toEqual([]);\n});\n```\n\n### Interactive Elements After Hydration\n\n```typescript\ntest('interactive elements work after hydration', async ({ page }) => {\n await page.goto('/');\n\n const counter = page.getByTestId('counter-value');\n await expect(counter).toHaveText('0');\n\n await page.getByRole('button', { name: 'Increment' }).click();\n await expect(counter).toHaveText('1');\n});\n```\n\n## next/image Testing\n\n```typescript\ntest('hero image loads with srcset', async ({ page }) => {\n await page.goto('/');\n\n const heroImage = page.getByRole('img', { name: 'Hero banner' });\n await expect(heroImage).toBeVisible();\n\n const srcset = await heroImage.getAttribute('srcset');\n expect(srcset).toBeTruthy();\n expect(srcset).toContain('w=');\n\n const loading = await heroImage.getAttribute('loading');\n expect(loading).not.toBe('lazy');\n});\n\ntest('offscreen images lazy load', async ({ page }) => {\n await page.goto('/gallery');\n\n const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });\n\n await offscreenImage.scrollIntoViewIfNeeded();\n await expect(offscreenImage).toBeVisible();\n\n const naturalWidth = await offscreenImage.evaluate(\n (img: HTMLImageElement) => img.naturalWidth\n );\n expect(naturalWidth).toBeGreaterThan(0);\n});\n```\n\n## NextAuth.js Authentication\n\n### Setup Project\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n projects: [\n { name: 'setup', testMatch: /auth\\.setup\\.ts/ },\n {\n name: 'authenticated',\n use: { storageState: 'playwright/.auth/user.json' },\n dependencies: ['setup'],\n },\n { name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },\n ],\n});\n```\n\n### Auth Setup\n\n```typescript\n// tests/auth.setup.ts\nimport { test as setup, expect } from '@playwright/test';\n\nconst authFile = 'playwright/.auth/user.json';\n\nsetup('authenticate via credentials', async ({ page }) => {\n await page.goto('/login');\n await page.getByLabel('Email').fill('[email protected]');\n await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);\n await page.getByRole('button', { name: 'Sign in' }).click();\n\n await page.waitForURL('/dashboard');\n await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();\n\n await page.context().storageState({ path: authFile });\n});\n```\n\n### Authenticated Tests\n\n```typescript\ntest('authenticated user sees dashboard', async ({ page }) => {\n await page.goto('/dashboard');\n\n await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();\n await expect(page.getByText('[email protected]')).toBeVisible();\n});\n```\n\n## Tips\n\n### Dev Server vs Production Build\n\n| Scenario | Command | Trade-off |\n|---|---|---|\n| Local development | `npm run dev` | Fast iteration, no production behavior |\n| CI pipeline | `npm run build && npm run start` | Tests real production bundle |\n\n### Turbopack\n\n```typescript\nwebServer: {\n command: process.env.CI\n ? 'npm run build && npm run start'\n : 'npx next dev --turbopack',\n url: 'http://localhost:3000',\n reuseExistingServer: !process.env.CI,\n},\n```\n\n### Multiple webServer Entries\n\n```typescript\nwebServer: [\n {\n command: 'npm run dev:api',\n url: 'http://localhost:4000/health',\n reuseExistingServer: !process.env.CI,\n },\n {\n command: 'npm run dev',\n url: 'http://localhost:3000',\n reuseExistingServer: !process.env.CI,\n },\n],\n```\n\n## Anti-Patterns\n\n| Don't Do This | Problem | Do This Instead |\n|---|---|---|\n| `await page.waitForTimeout(3000)` | Arbitrary waits are fragile | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |\n| Test `getServerSideProps` directly | Depends on req/res context | Navigate to page and verify rendered output |\n| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |\n| `page.goto('http://localhost:3000/path')` | Breaks when port changes | Use `page.goto('/path')` with `baseURL` |\n| Run `npm run build` locally for every test | Extremely slow | Use `npm run dev` locally with `reuseExistingServer: true` |\n| Test `next/image` by checking exact URLs | Paths change between dev/prod | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset` |\n| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |\n\n## Related\n\n- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`\n- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`\n- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context\n- [react.md](react.md) -- React patterns for Next.js client components\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13273,"content_sha256":"1a6370ebb6f9b871e59027673e641fc968d24943d37ec73caad6e6b8463d7a4b"},{"filename":"frameworks/react.md","content":"# React Application Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Setup](#setup)\n3. [Framework Tips](#framework-tips)\n4. [Anti-Patterns](#anti-patterns)\n5. [Related](#related)\n\n> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.\n> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)\n\n## Patterns\n\n### Testing Context and Global State\n\n**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.\n**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('theme switching', () => {\n test('toggle applies dark mode across pages', async ({ page }) => {\n await page.goto('/preferences');\n\n const root = page.locator('html');\n await expect(root).not.toHaveClass(/dark-mode/);\n\n await page.getByRole('switch', { name: 'Enable dark theme' }).click();\n await expect(root).toHaveClass(/dark-mode/);\n\n await page.getByRole('link', { name: 'Dashboard' }).click();\n await expect(page.locator('html')).toHaveClass(/dark-mode/);\n });\n});\n\ntest.describe('cart state persistence', () => {\n test('item count updates globally', async ({ page }) => {\n await page.goto('/catalog');\n\n const badge = page.getByTestId('cart-badge');\n\n await page.getByRole('listitem')\n .filter({ hasText: 'Wireless Headphones' })\n .getByRole('button', { name: 'Add' })\n .click();\n await expect(badge).toHaveText('1');\n\n await page.getByRole('link', { name: 'Contact' }).click();\n await expect(badge).toHaveText('1');\n });\n});\n\ntest.describe('auth state', () => {\n test('login updates header across components', async ({ page }) => {\n await page.goto('/');\n\n await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();\n\n await page.getByRole('link', { name: 'Login' }).click();\n await page.getByLabel('Username').fill('testuser');\n await page.getByLabel('Password').fill('secret123');\n await page.getByRole('button', { name: 'Submit' }).click();\n\n await expect(page.getByRole('link', { name: 'Login' })).toBeHidden();\n await expect(page.getByText('testuser')).toBeVisible();\n });\n});\n```\n\n### React Router Navigation\n\n**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.\n**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('client routing', () => {\n test('navigation preserves SPA state', async ({ page }) => {\n await page.goto('/');\n\n await page.evaluate(() => {\n (window as any).__spaMarker = 'active';\n });\n\n await page.getByRole('link', { name: 'Inventory' }).click();\n await page.waitForURL('/inventory');\n\n const marker = await page.evaluate(() => (window as any).__spaMarker);\n expect(marker).toBe('active');\n });\n\n test('query params filter content', async ({ page }) => {\n await page.goto('/items?type=books');\n\n await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible();\n\n await page.getByRole('link', { name: 'Music' }).click();\n await page.waitForURL('/items?type=music');\n\n await expect(page.getByRole('heading', { name: 'Music' })).toBeVisible();\n });\n\n test('nested routes render layouts', async ({ page }) => {\n await page.goto('/account/security');\n\n await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();\n await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();\n\n await page.getByRole('link', { name: 'Privacy' }).click();\n await page.waitForURL('/account/privacy');\n\n await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();\n await expect(page.getByRole('heading', { name: 'Privacy', level: 2 })).toBeVisible();\n });\n\n test('history navigation works', async ({ page }) => {\n await page.goto('/');\n await page.getByRole('link', { name: 'Inventory' }).click();\n await page.waitForURL('/inventory');\n await page.getByRole('link', { name: 'Help' }).click();\n await page.waitForURL('/help');\n\n await page.goBack();\n await expect(page).toHaveURL(/\\/inventory/);\n\n await page.goBack();\n await expect(page).toHaveURL(/\\/$/);\n });\n\n test('protected route redirects', async ({ page }) => {\n await page.goto('/admin/users');\n\n await expect(page).toHaveURL(/\\/login/);\n });\n\n test('unknown route shows 404', async ({ page }) => {\n await page.goto('/nonexistent-path');\n\n await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();\n });\n});\n```\n\n### Testing Hooks Through UI\n\n**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.\n**Avoid when**: Hook logic is pure computation—use unit tests instead.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('useDebounce via SearchBox', () => {\n test('batches rapid input', async ({ page }) => {\n await page.goto('/search');\n\n const apiCalls: string[] = [];\n await page.route('**/api/query*', async (route) => {\n apiCalls.push(route.request().url());\n await route.continue();\n });\n\n await page.getByRole('textbox', { name: 'Search' }).pressSequentially('testing', {\n delay: 40,\n });\n\n await expect(page.getByRole('listitem')).toHaveCount(3);\n expect(apiCalls.length).toBeLessThanOrEqual(2);\n });\n});\n\ntest.describe('usePagination via DataGrid', () => {\n test('page controls work', async ({ page }) => {\n await page.goto('/records');\n\n await expect(page.getByText('Page 1 of 10')).toBeVisible();\n\n await page.getByRole('button', { name: 'Next' }).click();\n await expect(page.getByText('Page 2 of 10')).toBeVisible();\n\n await page.getByRole('button', { name: 'Previous' }).click();\n await expect(page.getByText('Page 1 of 10')).toBeVisible();\n await expect(page.getByRole('button', { name: 'Previous' })).toBeDisabled();\n });\n});\n```\n\n### Form Libraries (React Hook Form, Formik)\n\n**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('signup form', () => {\n test.beforeEach(async ({ page }) => {\n await page.goto('/signup');\n });\n\n test('validation on empty submit', async ({ page }) => {\n await page.getByRole('button', { name: 'Register' }).click();\n\n await expect(page.getByText('Email required')).toBeVisible();\n await expect(page.getByText('Password required')).toBeVisible();\n });\n\n test('inline validation on blur', async ({ page }) => {\n const email = page.getByLabel('Email');\n await email.fill('invalid');\n await email.blur();\n\n await expect(page.getByText('Invalid email format')).toBeVisible();\n });\n\n test('password strength indicator', async ({ page }) => {\n const pwd = page.getByLabel('Password', { exact: true });\n\n await pwd.fill('weak');\n await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/);\n\n await pwd.fill('StrongPass1!');\n await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/);\n });\n\n test('successful submission redirects', async ({ page }) => {\n await page.getByLabel('Name').fill('Alice');\n await page.getByLabel('Email').fill('[email protected]');\n await page.getByLabel('Password', { exact: true }).fill('Secure123!');\n await page.getByLabel('Confirm').fill('Secure123!');\n await page.getByLabel('Accept terms').check();\n\n await page.getByRole('button', { name: 'Register' }).click();\n\n await page.waitForURL('/welcome');\n await expect(page.getByText('Hello, Alice')).toBeVisible();\n });\n\n test('submit button disabled during request', async ({ page }) => {\n await page.route('**/api/signup', async (route) => {\n await new Promise((r) => setTimeout(r, 800));\n await route.fulfill({ status: 201, json: { id: 1 } });\n });\n\n await page.getByLabel('Name').fill('Bob');\n await page.getByLabel('Email').fill('[email protected]');\n await page.getByLabel('Password', { exact: true }).fill('Secure123!');\n await page.getByLabel('Confirm').fill('Secure123!');\n await page.getByLabel('Accept terms').check();\n\n await page.getByRole('button', { name: 'Register' }).click();\n\n await expect(page.getByRole('button', { name: /Registering|Loading/ })).toBeDisabled();\n });\n});\n```\n\n### Portals (Modals, Tooltips, Dropdowns)\n\n**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('portal components', () => {\n test('modal interaction', async ({ page }) => {\n await page.goto('/items');\n\n await page.getByRole('button', { name: 'Remove' }).first().click();\n\n const dialog = page.getByRole('dialog', { name: 'Confirm removal' });\n await expect(dialog).toBeVisible();\n await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();\n\n await dialog.getByRole('button', { name: 'Remove' }).click();\n await expect(dialog).toBeHidden();\n });\n\n test('escape closes modal', async ({ page }) => {\n await page.goto('/items');\n await page.getByRole('button', { name: 'Remove' }).first().click();\n\n const dialog = page.getByRole('dialog');\n await expect(dialog).toBeVisible();\n\n await page.keyboard.press('Escape');\n await expect(dialog).toBeHidden();\n });\n\n test('tooltip on hover', async ({ page }) => {\n await page.goto('/panel');\n\n await page.getByRole('button', { name: 'Help' }).hover();\n await expect(page.getByRole('tooltip')).toBeVisible();\n\n await page.mouse.move(0, 0);\n await expect(page.getByRole('tooltip')).toBeHidden();\n });\n\n test('dropdown menu', async ({ page }) => {\n await page.goto('/panel');\n\n await page.getByRole('button', { name: 'Actions' }).click();\n\n const menu = page.getByRole('menu');\n await expect(menu).toBeVisible();\n\n await menu.getByRole('menuitem', { name: 'Rename' }).click();\n await expect(menu).toBeHidden();\n });\n\n test('toast auto-dismisses', async ({ page }) => {\n await page.goto('/preferences');\n\n await page.getByRole('button', { name: 'Save' }).click();\n await expect(page.getByText('Preferences saved')).toBeVisible();\n\n await expect(page.getByText('Preferences saved')).toBeHidden({ timeout: 8000 });\n });\n});\n```\n\n### Error Boundaries\n\n**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.\n**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('error boundary', () => {\n test('shows fallback on crash', async ({ page }) => {\n await page.route('**/api/widgets', (route) => {\n route.fulfill({\n status: 200,\n json: { widgets: null },\n });\n });\n\n await page.goto('/panel');\n\n await expect(page.getByText('Something went wrong')).toBeVisible();\n await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();\n await expect(page.getByRole('navigation')).toBeVisible();\n });\n\n test('retry recovers component', async ({ page }) => {\n let calls = 0;\n await page.route('**/api/widgets', (route) => {\n calls++;\n if (calls === 1) {\n route.fulfill({ status: 200, json: { widgets: null } });\n } else {\n route.fulfill({ status: 200, json: { widgets: [{ id: 1, name: 'Chart' }] } });\n }\n });\n\n await page.goto('/panel');\n\n await expect(page.getByText('Something went wrong')).toBeVisible();\n\n await page.getByRole('button', { name: 'Retry' }).click();\n\n await expect(page.getByText('Something went wrong')).toBeHidden();\n await expect(page.getByText('Chart')).toBeVisible();\n });\n});\n```\n\n### Component Testing (Experimental)\n\n**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.\n**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.\n\n```typescript\n// playwright-ct.config.ts\nimport { defineConfig, devices } from '@playwright/experimental-ct-react';\n\nexport default defineConfig({\n testDir: './tests/components',\n testMatch: '**/*.ct.ts',\n use: {\n trace: 'on-first-retry',\n ctPort: 3100,\n },\n projects: [\n { name: 'chromium', use: { ...devices['Desktop Chrome'] } },\n ],\n});\n```\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport { test, expect } from '@playwright/experimental-ct-react';\nimport Stepper from '../../src/components/Stepper';\n\ntest('increments on click', async ({ mount }) => {\n const component = await mount(\u003cStepper initial={0} />);\n\n await expect(component.getByText('Value: 0')).toBeVisible();\n await component.getByRole('button', { name: '+' }).click();\n await expect(component.getByText('Value: 1')).toBeVisible();\n});\n\ntest('fires onChange callback', async ({ mount }) => {\n const values: number[] = [];\n const component = await mount(\n \u003cStepper initial={0} onChange={(v) => values.push(v)} />\n );\n\n await component.getByRole('button', { name: '+' }).click();\n await component.getByRole('button', { name: '+' }).click();\n\n expect(values).toEqual([1, 2]);\n});\n\ntest('respects min boundary', async ({ mount }) => {\n const component = await mount(\u003cStepper initial={0} min={0} />);\n\n await expect(component.getByRole('button', { name: '-' })).toBeDisabled();\n});\n```\n\n## Setup\n\n### E2E Config (Vite)\n\n```typescript\n// playwright.config.ts\nimport { defineConfig, devices } from '@playwright/test';\n\nexport default defineConfig({\n testDir: './tests',\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n workers: process.env.CI ? '50%' : undefined,\n\n use: {\n baseURL: 'http://localhost:5173',\n trace: 'on-first-retry',\n screenshot: 'only-on-failure',\n },\n\n projects: [\n { name: 'chromium', use: { ...devices['Desktop Chrome'] } },\n { name: 'firefox', use: { ...devices['Desktop Firefox'] } },\n { name: 'mobile', use: { ...devices['iPhone 14'] } },\n ],\n\n webServer: {\n command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',\n url: 'http://localhost:5173',\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n },\n});\n```\n\n### CRA vs Vite Differences\n\n| Aspect | Create React App | Vite |\n|---|---|---|\n| Default port | `3000` | `5173` |\n| Build output | `build/` | `dist/` |\n| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |\n| Env var prefix | `REACT_APP_*` | `VITE_*` |\n\n## Framework Tips\n\n### Strict Mode Double Effects\n\nReact Strict Mode runs effects twice in development. Tests should be resilient:\n\n- Don't assert exact API call counts in dev mode\n- Run against production build for call count assertions, or account for double invocations\n\n### Suspense and Lazy Components\n\n```typescript\ntest('lazy route loads content', async ({ page }) => {\n await page.goto('/');\n\n await page.getByRole('link', { name: 'Analytics' }).click();\n\n await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();\n});\n```\n\n### Detecting Memory Leaks\n\n```typescript\ntest('no unmounted state warnings', async ({ page }) => {\n const warnings: string[] = [];\n page.on('console', (msg) => {\n if (msg.type() === 'warning' && msg.text().includes('unmounted')) {\n warnings.push(msg.text());\n }\n });\n\n await page.goto('/panel');\n await page.getByRole('link', { name: 'Settings' }).click();\n await page.goBack();\n await page.getByRole('link', { name: 'Profile' }).click();\n\n expect(warnings).toEqual([]);\n});\n```\n\n## Anti-Patterns\n\n| Don't | Problem | Do Instead |\n|---|---|---|\n| `page.evaluate(() => store.getState())` | Couples tests to implementation | Assert on UI: `expect(badge).toHaveText('3')` |\n| Import components in E2E tests | E2E runs in Node, not browser | Use `@playwright/experimental-ct-react` for components |\n| `page.waitForTimeout(500)` after state changes | Timing varies across machines | `expect(locator).toHaveText('value')` auto-retries |\n| `page.locator('.MuiButton-root')` | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |\n| Test every component with CT | Overhead for simple components | CT for complex widgets, unit tests for logic, E2E for flows |\n| Skip keyboard navigation tests | Accessibility regressions common | Test Tab, Enter, Escape, Arrow interactions |\n| Assert on `__REACT_FIBER__` internals | Not stable across versions | Only interact with rendered DOM |\n\n## Related\n\n- [locators.md](../core/locators.md) — locator strategies for any React component library\n- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes\n- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns\n- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing\n- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions\n- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17650,"content_sha256":"67f248dfafd356c37354a9bc65c6306e9e48343f983c0352b78ffbc67a7d8391"},{"filename":"frameworks/vue.md","content":"# Vue and Nuxt Testing\n\n## Table of Contents\n\n1. [Commands](#commands)\n2. [Configuration](#configuration)\n3. [Patterns](#patterns)\n4. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)\n5. [Component Testing Dependencies](#component-testing-dependencies)\n6. [Testing v-model](#testing-v-model)\n7. [Capturing Vue Warnings](#capturing-vue-warnings)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.\n\n## Commands\n\n```bash\nnpm init playwright@latest\nnpm install -D @playwright/experimental-ct-vue\nnpx playwright test\nnpx playwright test -c playwright-ct.config.ts\n```\n\n## Configuration\n\n### Vue with Vite\n\n```typescript\n// playwright.config.ts\nimport { defineConfig, devices } from '@playwright/test';\n\nexport default defineConfig({\n testDir: './tests/e2e',\n testMatch: '**/*.spec.ts',\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n workers: process.env.CI ? '50%' : undefined,\n\n use: {\n baseURL: 'http://localhost:5173',\n trace: 'on-first-retry',\n screenshot: 'only-on-failure',\n },\n\n projects: [\n { name: 'chromium', use: { ...devices['Desktop Chrome'] } },\n { name: 'firefox', use: { ...devices['Desktop Firefox'] } },\n { name: 'mobile', use: { ...devices['iPhone 14'] } },\n ],\n\n webServer: {\n command: process.env.CI\n ? 'npm run build && npx vite preview --port 5173'\n : 'npm run dev',\n url: 'http://localhost:5173',\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n },\n});\n```\n\n### Nuxt 3\n\nNuxt uses port 3000 and requires a build step before testing.\n\n```typescript\n// playwright.config.ts\nimport { defineConfig, devices } from '@playwright/test';\n\nexport default defineConfig({\n testDir: './tests/e2e',\n testMatch: '**/*.spec.ts',\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n\n use: {\n baseURL: 'http://localhost:3000',\n trace: 'on-first-retry',\n screenshot: 'only-on-failure',\n },\n\n projects: [\n { name: 'chromium', use: { ...devices['Desktop Chrome'] } },\n ],\n\n webServer: {\n command: process.env.CI\n ? 'npx nuxi build && npx nuxi preview'\n : 'npx nuxi dev',\n url: 'http://localhost:3000',\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n env: {\n NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',\n },\n },\n});\n```\n\n### Component Testing\n\n```typescript\n// playwright-ct.config.ts\nimport { defineConfig, devices } from '@playwright/experimental-ct-vue';\n\nexport default defineConfig({\n testDir: './tests/components',\n testMatch: '**/*.ct.ts',\n\n use: {\n trace: 'on-first-retry',\n ctPort: 3100,\n },\n\n projects: [\n { name: 'chromium', use: { ...devices['Desktop Chrome'] } },\n ],\n});\n```\n\n## Patterns\n\n### Component Testing with Experimental CT\n\n**Use when**: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).\n\n**Avoid when**: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport { test, expect } from '@playwright/experimental-ct-vue';\nimport Stepper from '../../src/components/Stepper.vue';\n\ntest('increments value on button click', async ({ mount }) => {\n const component = await mount(Stepper, {\n props: { value: 0 },\n });\n\n await expect(component.getByText('Value: 0')).toBeVisible();\n await component.getByRole('button', { name: '+' }).click();\n await expect(component.getByText('Value: 1')).toBeVisible();\n});\n\ntest('emits change event', async ({ mount }) => {\n const changes: number[] = [];\n const component = await mount(Stepper, {\n props: { value: 10 },\n on: {\n change: (val: number) => changes.push(val),\n },\n });\n\n await component.getByRole('button', { name: '+' }).click();\n await component.getByRole('button', { name: '+' }).click();\n\n expect(changes).toEqual([11, 12]);\n});\n\ntest('renders slot content', async ({ mount }) => {\n const component = await mount(Stepper, {\n props: { value: 0 },\n slots: {\n default: '\u003cspan class=\"label\">Quantity\u003c/span>',\n },\n });\n\n await expect(component.getByText('Quantity')).toBeVisible();\n});\n```\n\n### Pinia Store Testing Through UI\n\n**Use when**: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.\n\n**Avoid when**: Testing pure store logic with no UI side effect—use unit tests with Vitest.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('shopping cart store', () => {\n test('adding products updates cart badge', async ({ page }) => {\n await page.goto('/shop');\n\n const badge = page.getByTestId('cart-badge');\n await expect(badge).toHaveText('0');\n\n await page.getByRole('listitem')\n .filter({ hasText: 'Hoodie' })\n .getByRole('button', { name: 'Add' })\n .click();\n await expect(badge).toHaveText('1');\n\n await page.getByRole('listitem')\n .filter({ hasText: 'Cap' })\n .getByRole('button', { name: 'Add' })\n .click();\n await expect(badge).toHaveText('2');\n\n await page.getByRole('link', { name: 'Cart' }).click();\n await page.waitForURL('/cart');\n\n await expect(page.getByText('Hoodie')).toBeVisible();\n await expect(page.getByText('Cap')).toBeVisible();\n });\n\n test('persisted state survives reload', async ({ page }) => {\n await page.goto('/shop');\n\n await page.getByRole('listitem')\n .filter({ hasText: 'Hoodie' })\n .getByRole('button', { name: 'Add' })\n .click();\n\n await page.reload();\n\n await expect(page.getByTestId('cart-badge')).toHaveText('1');\n });\n});\n```\n\n### Vue Router Navigation\n\n**Use when**: Testing client-side routing, navigation guards, URL parameters, browser history.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('router navigation', () => {\n test('client-side navigation preserves state', async ({ page }) => {\n await page.goto('/');\n\n await page.evaluate(() => {\n (window as any).__marker = 'spa';\n });\n\n await page.getByRole('link', { name: 'Shop' }).click();\n await page.waitForURL('/shop');\n\n const marker = await page.evaluate(() => (window as any).__marker);\n expect(marker).toBe('spa');\n });\n\n test('dynamic route params render content', async ({ page }) => {\n await page.goto('/items/99');\n\n await expect(page.getByRole('heading', { level: 1 })).toBeVisible();\n await expect(page.getByText('Item #99')).toBeVisible();\n });\n\n test('navigation guard redirects unauthorized users', async ({ page }) => {\n await page.goto('/admin/dashboard');\n\n await expect(page).toHaveURL(/\\/login/);\n await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();\n });\n\n test('browser history navigation works', async ({ page }) => {\n await page.goto('/');\n await page.getByRole('link', { name: 'Shop' }).click();\n await page.waitForURL('/shop');\n await page.getByRole('link', { name: 'Contact' }).click();\n await page.waitForURL('/contact');\n\n await page.goBack();\n await expect(page).toHaveURL(/\\/shop/);\n\n await page.goBack();\n await expect(page).toHaveURL(/\\/$/);\n\n await page.goForward();\n await expect(page).toHaveURL(/\\/shop/);\n });\n\n test('query params update reactive state', async ({ page }) => {\n await page.goto('/items?sort=price&type=clothing');\n\n await expect(page.getByRole('heading', { name: 'Clothing' })).toBeVisible();\n\n await page.getByRole('combobox', { name: 'Sort' }).selectOption('name');\n await expect(page).toHaveURL(/sort=name/);\n });\n\n test('catch-all route shows 404', async ({ page }) => {\n await page.goto('/nonexistent-page');\n\n await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();\n });\n});\n```\n\n### Teleport Components\n\n**Use when**: Testing components rendered via `\u003cTeleport>` (modals, notifications, overlay menus).\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('teleported elements', () => {\n test('modal is visible and interactive', async ({ page }) => {\n await page.goto('/items');\n\n await page.getByRole('button', { name: 'Remove' }).first().click();\n\n const dialog = page.getByRole('dialog', { name: 'Confirm' });\n await expect(dialog).toBeVisible();\n\n await dialog.getByRole('button', { name: 'Cancel' }).click();\n await expect(dialog).toBeHidden();\n });\n\n test('notification auto-dismisses', async ({ page }) => {\n await page.goto('/profile');\n\n await page.getByRole('button', { name: 'Update' }).click();\n\n const alert = page.getByRole('alert');\n await expect(alert).toBeVisible();\n await expect(alert).toContainText('Saved');\n\n await expect(alert).toBeHidden({ timeout: 10_000 });\n });\n\n test('dropdown closes on outside click', async ({ page }) => {\n await page.goto('/home');\n\n await page.getByRole('button', { name: 'Menu' }).click();\n\n const menu = page.getByRole('menu');\n await expect(menu).toBeVisible();\n\n await page.locator('body').click({ position: { x: 10, y: 10 } });\n await expect(menu).toBeHidden();\n });\n});\n```\n\n### Transitions and Animations\n\n**Use when**: Verifying `\u003cTransition>` and `\u003cTransitionGroup>` work correctly. Focus on end state, not animation details.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('transitions', () => {\n test('item appears after add', async ({ page }) => {\n await page.goto('/tasks');\n\n await page.getByRole('textbox', { name: 'Task' }).fill('Write tests');\n await page.getByRole('button', { name: 'Add' }).click();\n\n await expect(page.getByText('Write tests')).toBeVisible();\n });\n\n test('item disappears after delete', async ({ page }) => {\n await page.goto('/tasks');\n\n await page.getByRole('textbox', { name: 'Task' }).fill('Temp item');\n await page.getByRole('button', { name: 'Add' }).click();\n await expect(page.getByText('Temp item')).toBeVisible();\n\n await page.getByRole('listitem')\n .filter({ hasText: 'Temp item' })\n .getByRole('button', { name: 'Remove' })\n .click();\n\n await expect(page.getByText('Temp item')).toBeHidden();\n });\n\n test('disable animations for faster tests', async ({ page }) => {\n await page.addStyleTag({\n content: `\n *, *::before, *::after {\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n }\n `,\n });\n\n await page.goto('/tasks');\n\n await page.getByRole('textbox', { name: 'Task' }).fill('Quick task');\n await page.getByRole('button', { name: 'Add' }).click();\n\n await expect(page.getByText('Quick task')).toBeVisible();\n });\n});\n```\n\n### Composition API Components\n\n**Use when**: Testing components with `\u003cscript setup>` or `setup()`. From Playwright's perspective, Composition API and Options API are identical.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('composition API', () => {\n test('computed properties update reactively', async ({ page }) => {\n await page.goto('/pricing');\n\n await page.getByLabel('Amount').fill('50');\n await page.getByLabel('Qty').fill('4');\n\n await expect(page.getByTestId('sum')).toHaveText('$200.00');\n\n await page.getByLabel('Discount').fill('20');\n await expect(page.getByTestId('sum')).toHaveText('$160.00');\n });\n\n test('watcher triggers on change', async ({ page }) => {\n await page.goto('/preferences');\n\n await page.getByRole('combobox', { name: 'Locale' }).selectOption('de');\n\n await expect(page.getByRole('heading', { name: 'Einstellungen' })).toBeVisible();\n });\n\n test('composable provides debounced search', async ({ page }) => {\n await page.goto('/shop');\n\n const input = page.getByRole('textbox', { name: 'Search' });\n await input.pressSequentially('hoodie', { delay: 50 });\n\n await expect(page.getByRole('listitem')).toHaveCount(2);\n await expect(page.getByText('Black Hoodie')).toBeVisible();\n });\n\n test('provide/inject updates all consumers', async ({ page }) => {\n await page.goto('/home');\n\n await page.getByRole('switch', { name: 'Dark theme' }).click();\n\n await expect(page.locator('body')).toHaveClass(/dark/);\n });\n});\n```\n\n### Nuxt-Specific Patterns\n\n**Use when**: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('nuxt features', () => {\n test('SSR renders server-fetched data', async ({ page }) => {\n await page.goto('/posts');\n\n await expect(page.getByRole('article')).toHaveCount(10);\n await expect(page.getByRole('article').first()).toContainText(/\\w+/);\n });\n\n test('server route returns data', async ({ request }) => {\n const response = await request.get('/api/items');\n\n expect(response.ok()).toBeTruthy();\n const data = await response.json();\n expect(data).toBeInstanceOf(Array);\n expect(data[0]).toHaveProperty('id');\n });\n\n test('middleware redirects unauthorized', async ({ page }) => {\n await page.goto('/admin');\n\n await expect(page).toHaveURL(/\\/login/);\n });\n\n test('NuxtLink enables SPA navigation', async ({ page }) => {\n await page.goto('/');\n\n await page.evaluate(() => {\n (window as any).__marker = 'spa';\n });\n\n await page.getByRole('link', { name: 'Posts' }).click();\n await page.waitForURL('/posts');\n\n const marker = await page.evaluate(() => (window as any).__marker);\n expect(marker).toBe('spa');\n });\n\n test('useHead sets meta tags', async ({ page }) => {\n await page.goto('/posts/hello-world');\n\n const title = await page.title();\n expect(title).toContain('Hello World');\n\n const desc = await page.locator('meta[name=\"description\"]').getAttribute('content');\n expect(desc).toBeTruthy();\n expect(desc!.length).toBeGreaterThan(50);\n });\n});\n```\n\n## Vue vs Nuxt Differences\n\n| Aspect | Vue 3 (Vite) | Nuxt 3 |\n| --- | --- | --- |\n| Default port | `5173` | `3000` |\n| Dev command | `npm run dev` | `npx nuxi dev` |\n| Build + preview | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview` |\n| SSR | Optional | Built-in |\n| API routes | External backend | `/server/api/` built-in |\n| Env variables | `VITE_*` prefix | `NUXT_PUBLIC_*` (client), `NUXT_*` (server) |\n| File-based routing | No | Yes |\n\n## Component Testing Dependencies\n\nComponents depending on Pinia or Vue Router need these provided:\n\n```typescript\n// playwright/index.ts\nimport { beforeMount } from '@playwright/experimental-ct-vue/hooks';\nimport { createPinia } from 'pinia';\nimport { createMemoryHistory, createRouter } from 'vue-router';\n\nbeforeMount(async ({ app, hooksConfig }) => {\n const pinia = createPinia();\n app.use(pinia);\n\n if (hooksConfig?.routes) {\n const router = createRouter({\n history: createMemoryHistory(),\n routes: hooksConfig.routes,\n });\n app.use(router);\n }\n});\n```\n\n## Testing v-model\n\n`v-model` works through standard HTML events. Playwright methods trigger correct events automatically:\n\n```typescript\nawait page.getByLabel('Email').fill('[email protected]');\nawait page.getByRole('checkbox', { name: 'Subscribe' }).check();\nawait page.getByRole('combobox', { name: 'Country' }).selectOption('US');\n```\n\n## Capturing Vue Warnings\n\n```typescript\ntest('no Vue warnings during render', async ({ page }) => {\n const warnings: string[] = [];\n page.on('console', (msg) => {\n if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {\n warnings.push(msg.text());\n }\n });\n\n await page.goto('/home');\n await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();\n\n expect(warnings).toEqual([]);\n});\n```\n\n## Anti-Patterns\n\n| Avoid | Problem | Instead |\n| --- | --- | --- |\n| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades | Assert on UI that state produces |\n| `page.locator('[data-v-abc123]')` | Scoped style hashes change on every build | Use `getByRole`, `getByText`, `getByTestId` |\n| Import `.vue` files in E2E tests | E2E tests run in Node.js; `.vue` needs compilation | Use `@playwright/experimental-ct-vue` for component tests |\n| `page.waitForTimeout(300)` for transitions | Arbitrary waits are fragile | `await expect(locator).toBeVisible()` auto-waits |\n| Mock Pinia by patching `window.__pinia` | Fragile; may not trigger reactivity | Control state through UI or mock API responses |\n| Test composables via `page.evaluate` | Composables need Vue's setup context | Test through components or unit test with Vitest |\n| `page.locator('.v-btn')` for Vuetify | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |\n| Run Nuxt dev server in CI | Dev mode is slower with hot reload overhead | Use `npx nuxi build && npx nuxi preview` |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17005,"content_sha256":"b35f8d561dd9b5ebe8b86be7408ff4dbf03e189649714d790943ce763abb6718"},{"filename":"infrastructure-ci-cd/ci-cd.md","content":"# CI/CD Integration\n\n## Table of Contents\n\n1. [GitHub Actions](#github-actions)\n2. [Docker](#docker)\n3. [Reporting](#reporting)\n4. [Sharding](#sharding)\n5. [Environment Management](#environment-management)\n6. [Caching](#caching)\n\n## GitHub Actions\n\n### Basic Workflow\n\n```yaml\n# .github/workflows/playwright.yml\nname: Playwright Tests\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\njobs:\n test:\n timeout-minutes: 60\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: \"npm\"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Install Playwright browsers\n run: npx playwright install --with-deps\n\n - name: Run Playwright tests\n run: npx playwright test\n\n - uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: playwright-report\n path: playwright-report/\n retention-days: 30\n```\n\n### With Sharding\n\n```yaml\nname: Playwright Tests\n\non:\n push:\n branches: [main]\n\njobs:\n test:\n timeout-minutes: 60\n runs-on: ubuntu-latest\n strategy:\n fail-fast: false\n matrix:\n shardIndex: [1, 2, 3, 4]\n shardTotal: [4]\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: \"npm\"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Install Playwright browsers\n run: npx playwright install --with-deps\n\n - name: Run Playwright tests\n run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}\n\n - name: Upload blob report\n if: ${{ !cancelled() }}\n uses: actions/upload-artifact@v4\n with:\n name: blob-report-${{ matrix.shardIndex }}\n path: blob-report\n retention-days: 1\n\n merge-reports:\n if: ${{ !cancelled() }}\n needs: [test]\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: \"npm\"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Download blob reports\n uses: actions/download-artifact@v4\n with:\n path: all-blob-reports\n pattern: blob-report-*\n merge-multiple: true\n\n - name: Merge reports\n run: npx playwright merge-reports --reporter html ./all-blob-reports\n\n - name: Upload HTML report\n uses: actions/upload-artifact@v4\n with:\n name: html-report\n path: playwright-report\n retention-days: 14\n```\n\n### With Container\n\n```yaml\njobs:\n test:\n timeout-minutes: 60\n runs-on: ubuntu-latest\n container:\n # Use latest or more appropriate playwright version (match package.json)\n image: mcr.microsoft.com/playwright:v1.40.0-jammy\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: \"npm\"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Run tests\n run: npx playwright test\n env:\n HOME: /root\n```\n\n## Docker\n\n### Dockerfile\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.40.0-jammy\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\n### Docker Compose\n\n```yaml\n# docker-compose.yml\nversion: \"3.8\"\n\nservices:\n playwright:\n build: .\n volumes:\n - ./playwright-report:/app/playwright-report\n - ./test-results:/app/test-results\n environment:\n - CI=true\n - BASE_URL=http://app:3000\n depends_on:\n - app\n\n app:\n build: ./app\n ports:\n - \"3000:3000\"\n```\n\n### Run with Docker\n\n```bash\n# Build and run\ndocker build -t playwright-tests .\ndocker run --rm -v $(pwd)/playwright-report:/app/playwright-report playwright-tests\n\n# With docker-compose\ndocker-compose run --rm playwright\n```\n\n## Reporting\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n reporter: [\n // Always generate\n [\"html\", { outputFolder: \"playwright-report\" }],\n\n // Console output\n [\"list\"],\n\n // CI-friendly\n [\"github\"], // GitHub Actions annotations\n\n // JUnit for CI integration\n [\"junit\", { outputFile: \"results.xml\" }],\n\n // JSON for custom processing\n [\"json\", { outputFile: \"results.json\" }],\n\n // Blob for merging shards\n [\"blob\", { outputDir: \"blob-report\" }],\n ],\n});\n```\n\n### CI-Specific Reporter\n\n```typescript\nexport default defineConfig({\n reporter: process.env.CI\n ? [[\"github\"], [\"blob\"], [\"html\"]]\n : [[\"list\"], [\"html\"]],\n});\n```\n\n## Sharding\n\n### Command Line\n\n```bash\n# Split into 4 shards, run shard 1\nnpx playwright test --shard=1/4\n\n# Run shard 2\nnpx playwright test --shard=2/4\n```\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n // Evenly distribute tests across shards\n fullyParallel: true,\n\n // For blob reporter to merge later\n reporter: process.env.CI ? [[\"blob\"]] : [[\"html\"]],\n});\n```\n\n### Merge Sharded Reports\n\n```bash\n# After all shards complete, merge blob reports\nnpx playwright merge-reports --reporter html ./all-blob-reports\n```\n\n## Environment Management\n\n### Environment Variables\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\nimport dotenv from \"dotenv\";\n\n// Load env file based on environment\ndotenv.config({ path: `.env.${process.env.NODE_ENV || \"development\"}` });\n\nexport default defineConfig({\n use: {\n baseURL: process.env.BASE_URL || \"http://localhost:3000\",\n },\n});\n```\n\n### Multiple Environments\n\n```yaml\n# .github/workflows/playwright.yml\njobs:\n test:\n strategy:\n matrix:\n environment: [staging, production]\n steps:\n - name: Run tests\n run: npx playwright test\n env:\n BASE_URL: ${{ matrix.environment == 'staging' && 'https://staging.example.com' || 'https://example.com' }}\n TEST_USER: ${{ secrets[format('TEST_USER_{0}', matrix.environment)] }}\n```\n\n### Secrets Management\n\n```yaml\n# GitHub Actions secrets\n- name: Run tests\n run: npx playwright test\n env:\n TEST_EMAIL: ${{ secrets.TEST_EMAIL }}\n TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n```typescript\n// tests use environment variables\ntest(\"login\", async ({ page }) => {\n await page.getByLabel(\"Email\").fill(process.env.TEST_EMAIL!);\n await page.getByLabel(\"Password\").fill(process.env.TEST_PASSWORD!);\n});\n```\n\n## Caching\n\n### Cache Playwright Browsers\n\n```yaml\n- name: Cache Playwright browsers\n uses: actions/cache@v4\n id: playwright-cache\n with:\n path: ~/.cache/ms-playwright\n key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n- name: Install Playwright browsers\n if: steps.playwright-cache.outputs.cache-hit != 'true'\n run: npx playwright install --with-deps\n\n- name: Install system deps only\n if: steps.playwright-cache.outputs.cache-hit == 'true'\n run: npx playwright install-deps\n```\n\n### Cache Node Modules\n\n```yaml\n- uses: actions/setup-node@v4\n with:\n node-version: 22\n cache: \"npm\"\n\n- name: Install dependencies\n run: npm ci\n```\n\n## Tag-Based Test Filtering\n\n### Run Specific Tags in CI\n\n```yaml\n# Run smoke tests on PR\n- name: Run smoke tests\n run: npx playwright test --grep @smoke\n\n# Run full regression nightly\n- name: Run regression\n run: npx playwright test --grep @regression\n\n# Exclude flaky tests\n- name: Run stable tests\n run: npx playwright test --grep-invert @flaky\n```\n\n### PR vs Nightly Strategy\n\n```yaml\n# .github/workflows/pr.yml - Fast feedback\n- name: Run critical tests\n run: npx playwright test --grep \"@smoke|@critical\"\n\n# .github/workflows/nightly.yml - Full coverage\n- name: Run all tests\n run: npx playwright test --grep-invert @flaky\n```\n\n### Tag Filtering in Config\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n grep: process.env.CI ? /@smoke|@critical/ : undefined,\n grepInvert: process.env.CI ? /@flaky/ : undefined,\n});\n```\n\n### Project-Based Tag Filtering\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n projects: [\n {\n name: \"smoke\",\n grep: /@smoke/,\n },\n {\n name: \"regression\",\n grepInvert: /@smoke/,\n },\n ],\n});\n```\n\n## Best Practices\n\n| Practice | Benefit |\n| ----------------------------- | ------------------------- |\n| Use `npm ci` | Deterministic installs |\n| Run headless in CI | Faster, no display needed |\n| Set retries in CI only | Handle flakiness |\n| Upload artifacts on failure | Debug failures |\n| Use sharding for large suites | Faster execution |\n| Cache browsers | Faster setup |\n| Use blob reporter for shards | Merge reports correctly |\n| Use tags for PR vs nightly | Fast feedback + coverage |\n| Exclude @flaky in CI | Stable pipeline |\n\n## CI Configuration Reference\n\n```typescript\n// playwright.config.ts - CI optimized\nexport default defineConfig({\n testDir: \"./tests\",\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n workers: process.env.CI ? 1 : undefined,\n reporter: process.env.CI\n ? [[\"github\"], [\"blob\"], [\"html\"]]\n : [[\"list\"], [\"html\"]],\n use: {\n baseURL: process.env.BASE_URL || \"http://localhost:3000\",\n trace: \"on-first-retry\",\n screenshot: \"only-on-failure\",\n video: \"on-first-retry\",\n },\n});\n```\n\n## Related References\n\n- **Test tags**: See [test-tags.md](../core/test-tags.md) for tagging and filtering patterns\n- **Performance optimization**: See [performance.md](performance.md) for sharding and parallelization\n- **Debugging CI failures**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Test reporting**: See [debugging.md](../debugging/debugging.md) for trace viewer usage\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10006,"content_sha256":"a6951b34dbfc4eabaad53cc5ff63edea3ca7eb35c455a1b34a73992e9572757a"},{"filename":"infrastructure-ci-cd/docker.md","content":"# Container-Based Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Decision Guide](#decision-guide)\n3. [Anti-Patterns](#anti-patterns)\n4. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running tests in containers for reproducible environments, CI pipelines, or consistent browser versions across team machines.\n\n## Patterns\n\n### Official Image Usage\n\nRun tests without building a custom image:\n\n```bash\ndocker run --rm \\\n -v $(pwd):/app \\\n -w /app \\\n -e CI=true \\\n -e BASE_URL=http://host.docker.internal:3000 \\\n mcr.microsoft.com/playwright:v1.48.0-noble \\\n bash -c \"npm ci && npx playwright test\"\n```\n\nExtract reports with bind mounts:\n\n```bash\ndocker run --rm \\\n -v $(pwd):/app \\\n -v $(pwd)/playwright-report:/app/playwright-report \\\n -v $(pwd)/test-results:/app/test-results \\\n -w /app \\\n mcr.microsoft.com/playwright:v1.48.0-noble \\\n bash -c \"npm ci && npx playwright test\"\n```\n\n### Custom Dockerfile\n\nBuild a custom image when you need additional dependencies or pre-installed packages:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\n\nWORKDIR /app\n\nCOPY package.json package-lock.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nChromium-only slim image:\n\n```dockerfile\nFROM node:latest-slim\n\nRUN npx playwright install --with-deps chromium\n\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\", \"--project=chromium\"]\n```\n\n### Docker Compose Stack\n\nFull application stack with database, cache, and test runner:\n\n```yaml\nservices:\n app:\n build: .\n ports:\n - \"3000:3000\"\n environment:\n - NODE_ENV=test\n - DATABASE_URL=postgresql://postgres:postgres@db:5432/test\n depends_on:\n db:\n condition: service_healthy\n\n db:\n image: postgres:latest-alpine\n environment:\n POSTGRES_USER: postgres\n POSTGRES_PASSWORD: postgres\n POSTGRES_DB: test\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]\n interval: 5s\n timeout: 5s\n retries: 5\n tmpfs:\n - /var/lib/postgresql/data\n\n e2e:\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n working_dir: /app\n volumes:\n - .:/app\n - /app/node_modules\n environment:\n - CI=true\n - BASE_URL=http://app:3000\n depends_on:\n - app\n command: bash -c \"npm ci && npx playwright test\"\n profiles:\n - test\n```\n\nRun commands:\n\n```bash\ndocker compose --profile test up --abort-on-container-exit --exit-code-from e2e\n\ndocker compose --profile test down -v\n```\n\n### CI Container Jobs\n\n**GitHub Actions:**\n\n```yaml\njobs:\n test:\n runs-on: ubuntu-latest\n container:\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n steps:\n - uses: actions/checkout@v4\n - run: npm ci\n - run: npx playwright test\n env:\n HOME: /root\n```\n\n**GitLab CI:**\n\n```yaml\ntest:\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n script:\n - npm ci\n - npx playwright test\n```\n\n**Jenkins:**\n\n```groovy\npipeline {\n agent {\n docker {\n image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n args '-u root'\n }\n }\n stages {\n stage('Test') {\n steps {\n sh 'npm ci'\n sh 'npx playwright test'\n }\n }\n }\n}\n```\n\n### Dev Container Setup\n\nVS Code Dev Container or GitHub Codespaces configuration:\n\n```json\n{\n \"name\": \"Playwright Dev\",\n \"image\": \"mcr.microsoft.com/playwright:v1.48.0-noble\",\n \"features\": {\n \"ghcr.io/devcontainers/features/node:latest\": {\n \"version\": \"20\"\n }\n },\n \"postCreateCommand\": \"npm ci\",\n \"customizations\": {\n \"vscode\": {\n \"extensions\": [\"ms-playwright.playwright\"]\n }\n },\n \"forwardPorts\": [3000, 9323],\n \"remoteUser\": \"root\"\n}\n```\n\n## Decision Guide\n\n| Scenario | Approach |\n|---|---|\n| Simple CI pipeline | Official image as CI container |\n| Tests need database + cache | Docker Compose with app, db, e2e services |\n| Team needs identical environments | Dev Container or custom Dockerfile |\n| Only testing Chromium | Slim image with `install --with-deps chromium` |\n| Cross-browser testing | Official image (all browsers pre-installed) |\n| Local development | Run directly on host for faster iteration |\n\n## Anti-Patterns\n\n| Anti-Pattern | Problem | Solution |\n|---|---|---|\n| Installing browsers at runtime | Wastes 60-90 seconds per run | Use official image or bake browsers into custom image |\n| Running as non-root without sandbox config | Chromium sandbox permission errors | Run as root or disable sandbox |\n| Bind-mounting `node_modules` from host | Platform-specific binary crashes | Use anonymous volume: `-v /app/node_modules` |\n| No health checks on dependent services | Tests start before database ready | Add `healthcheck` with `depends_on: condition: service_healthy` |\n| Building application inside Playwright container | Large image, slow builds | Separate app and e2e containers |\n\n## Troubleshooting\n\n### \"browserType.launch: Executable doesn't exist\"\n\nPlaywright version mismatch with Docker image. Ensure `@playwright/test` version matches image tag:\n\n```bash\nnpm ls @playwright/test\ndocker pull mcr.microsoft.com/playwright:v\u003cmatching-version>-noble\n```\n\n### \"net::ERR_CONNECTION_REFUSED\" in docker-compose\n\nTests trying to reach `localhost` instead of service name. Configure `baseURL`:\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n use: {\n baseURL: process.env.BASE_URL || 'http://localhost:3000',\n },\n});\n```\n\n```yaml\ne2e:\n environment:\n - BASE_URL=http://app:3000\n```\n\n### Permission denied on mounted volumes\n\nMatch user IDs or run as root:\n\n```bash\ndocker run --rm -u $(id -u):$(id -g) \\\n -v $(pwd):/app -w /app \\\n mcr.microsoft.com/playwright:v1.48.0-noble \\\n npx playwright test\n```\n\n### Slow container tests on macOS/Windows\n\nDocker Desktop I/O overhead. Copy files instead of mounting:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\nWORKDIR /app\nCOPY . .\nRUN npm ci\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nOr use delegated mount:\n\n```bash\ndocker run --rm \\\n -v $(pwd):/app:delegated \\\n -w /app \\\n mcr.microsoft.com/playwright:v1.48.0-noble \\\n bash -c \"npm ci && npx playwright test\"\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6262,"content_sha256":"ba0318c1060fe7d2235899e03ab998e588581becc0da49c3562a78fdd71b6526"},{"filename":"infrastructure-ci-cd/github-actions.md","content":"# GitHub Actions for Playwright\n\n## Table of Contents\n\n1. [CLI Commands](#cli-commands)\n2. [Workflow Patterns](#workflow-patterns)\n3. [Scenario Guide](#scenario-guide)\n4. [Common Mistakes](#common-mistakes)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Automating Playwright tests on pull requests, main branch merges, or scheduled runs.\n\n## CLI Commands\n\n```bash\nnpx playwright install --with-deps # browsers + OS dependencies\nnpx playwright test --shard=1/4 # run shard 1 of 4\nnpx playwright test --reporter=github # PR annotations\nnpx playwright merge-reports ./blob-report # combine shard reports\n```\n\n## Workflow Patterns\n\n### Basic Workflow\n\n**Use when**: Starting a new project or running a small test suite.\n\n```yaml\n# .github/workflows/e2e.yml\nname: E2E Tests\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\nconcurrency:\n group: e2e-${{ github.ref }}\n cancel-in-progress: true\n\nenv:\n CI: true\n\njobs:\n test:\n timeout-minutes: 30\n runs-on: ubuntu-latest\n\n steps:\n - uses: actions/checkout@v4\n\n - run: npm ci\n\n - name: Cache browsers\n id: browser-cache\n uses: actions/cache@v4\n with:\n path: ~/.cache/ms-playwright\n key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n - name: Install browsers\n if: steps.browser-cache.outputs.cache-hit != 'true'\n run: npx playwright install --with-deps\n\n - name: Install OS dependencies\n if: steps.browser-cache.outputs.cache-hit == 'true'\n run: npx playwright install-deps\n\n - run: npx playwright test\n\n - name: Upload report\n uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: test-report\n path: playwright-report/\n retention-days: 14\n\n - name: Upload traces\n uses: actions/upload-artifact@v4\n if: failure()\n with:\n name: traces\n path: test-results/\n retention-days: 7\n```\n\n### Sharded Execution\n\n**Use when**: Test suite exceeds 10 minutes. Sharding cuts wall-clock time significantly.\n**Avoid when**: Suite runs under 5 minutes—sharding overhead negates benefits.\n\n```yaml\n# .github/workflows/e2e-sharded.yml\nname: E2E Tests (Sharded)\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\nconcurrency:\n group: e2e-${{ github.ref }}\n cancel-in-progress: true\n\nenv:\n CI: true\n\njobs:\n test:\n timeout-minutes: 20\n runs-on: ubuntu-latest\n strategy:\n fail-fast: false\n matrix:\n shard: [1/4, 2/4, 3/4, 4/4]\n\n steps:\n - uses: actions/checkout@v4\n\n - run: npm ci\n\n - name: Cache browsers\n id: browser-cache\n uses: actions/cache@v4\n with:\n path: ~/.cache/ms-playwright\n key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n - name: Install browsers\n if: steps.browser-cache.outputs.cache-hit != 'true'\n run: npx playwright install --with-deps\n\n - name: Install OS dependencies\n if: steps.browser-cache.outputs.cache-hit == 'true'\n run: npx playwright install-deps\n\n - name: Run tests (shard ${{ matrix.shard }})\n run: npx playwright test --shard=${{ matrix.shard }}\n\n - name: Upload blob report\n uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: blob-${{ strategy.job-index }}\n path: blob-report/\n retention-days: 1\n\n merge:\n if: ${{ !cancelled() }}\n needs: test\n runs-on: ubuntu-latest\n\n steps:\n - uses: actions/checkout@v4\n\n - run: npm ci\n\n - name: Download blob reports\n uses: actions/download-artifact@v4\n with:\n path: all-blobs\n pattern: blob-*\n merge-multiple: true\n\n - name: Merge reports\n run: npx playwright merge-reports --reporter=html ./all-blobs\n\n - name: Upload merged report\n uses: actions/upload-artifact@v4\n with:\n name: test-report\n path: playwright-report/\n retention-days: 14\n```\n\n**Config for sharding**—enable blob reporter:\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: process.env.CI\n ? [['blob'], ['github']]\n : [['html', { open: 'on-failure' }]],\n});\n```\n\n### Container-Based Execution\n\n**Use when**: Reproducible environment matching local Docker setup, or runner OS dependencies cause issues.\n**Avoid when**: Standard `ubuntu-latest` with `--with-deps` works fine.\n\n```yaml\n# .github/workflows/e2e-container.yml\nname: E2E Tests (Container)\n\non:\n pull_request:\n branches: [main]\n\njobs:\n test:\n timeout-minutes: 30\n runs-on: ubuntu-latest\n container:\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n\n steps:\n - uses: actions/checkout@v4\n\n - run: npm ci\n\n - name: Run tests\n run: npx playwright test\n env:\n HOME: /root\n\n - uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: test-report\n path: playwright-report/\n retention-days: 14\n```\n\n### Environment Secrets\n\n**Use when**: Tests target staging/production with credentials.\n**Avoid when**: Tests only run against local dev server.\n\n```yaml\n# .github/workflows/e2e-staging.yml\nname: Staging Tests\n\non:\n push:\n branches: [main]\n workflow_dispatch:\n\njobs:\n test:\n timeout-minutes: 30\n runs-on: ubuntu-latest\n environment: staging\n\n env:\n CI: true\n BASE_URL: ${{ vars.STAGING_URL }}\n TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}\n API_TOKEN: ${{ secrets.API_TOKEN }}\n\n steps:\n - uses: actions/checkout@v4\n\n - run: npm ci\n\n - name: Cache browsers\n id: browser-cache\n uses: actions/cache@v4\n with:\n path: ~/.cache/ms-playwright\n key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n - name: Install browsers\n if: steps.browser-cache.outputs.cache-hit != 'true'\n run: npx playwright install --with-deps\n\n - name: Install OS dependencies\n if: steps.browser-cache.outputs.cache-hit == 'true'\n run: npx playwright install-deps\n\n - name: Run smoke tests\n run: npx playwright test --grep @smoke\n\n - uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: staging-report\n path: playwright-report/\n retention-days: 14\n```\n\n### Scheduled Runs\n\n**Use when**: Full regression suite is too slow for every PR—run nightly instead.\n**Avoid when**: Suite runs under 15 minutes and can run on every PR.\n\n```yaml\n# .github/workflows/nightly.yml\nname: Nightly Regression\n\non:\n schedule:\n - cron: '0 3 * * 1-5'\n workflow_dispatch:\n\njobs:\n test:\n timeout-minutes: 60\n runs-on: ubuntu-latest\n\n env:\n CI: true\n BASE_URL: ${{ vars.STAGING_URL }}\n\n steps:\n - uses: actions/checkout@v4\n\n - run: npm ci\n\n - name: Install browsers\n run: npx playwright install --with-deps\n\n - name: Run full regression\n run: npx playwright test --grep @regression\n\n - uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: nightly-${{ github.run_number }}\n path: playwright-report/\n retention-days: 30\n\n - name: Notify on failure\n if: failure()\n uses: slackapi/slack-github-action@latest\n with:\n payload: |\n {\n \"text\": \"Nightly regression failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"\n }\n env:\n SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n```\n\n### Reusable Workflow\n\n**Use when**: Multiple repositories share the same Playwright setup.\n**Avoid when**: Single repo with one workflow.\n\n```yaml\n# .github/workflows/pw-reusable.yml\nname: Playwright Reusable\n\non:\n workflow_call:\n inputs:\n node-version:\n type: string\n default: 'lts/*'\n test-command:\n type: string\n default: 'npx playwright test'\n secrets:\n BASE_URL:\n required: false\n TEST_PASSWORD:\n required: false\n\njobs:\n test:\n timeout-minutes: 30\n runs-on: ubuntu-latest\n\n env:\n CI: true\n BASE_URL: ${{ secrets.BASE_URL }}\n TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: ${{ inputs.node-version }}\n cache: npm\n\n - run: npm ci\n\n - name: Cache browsers\n id: browser-cache\n uses: actions/cache@v4\n with:\n path: ~/.cache/ms-playwright\n key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n - name: Install browsers\n if: steps.browser-cache.outputs.cache-hit != 'true'\n run: npx playwright install --with-deps\n\n - name: Install OS dependencies\n if: steps.browser-cache.outputs.cache-hit == 'true'\n run: npx playwright install-deps\n\n - name: Run tests\n run: ${{ inputs.test-command }}\n\n - uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: test-report\n path: playwright-report/\n retention-days: 14\n```\n\n**Calling the reusable workflow:**\n\n```yaml\n# .github/workflows/ci.yml\nname: CI\non:\n pull_request:\n branches: [main]\n\njobs:\n e2e:\n uses: ./.github/workflows/pw-reusable.yml\n with:\n node-version: 'lts/*'\n secrets:\n BASE_URL: ${{ secrets.STAGING_URL }}\n TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n## Scenario Guide\n\n| Scenario | Approach |\n|---|---|\n| Small suite (\u003c 5 min) | Single job, no sharding |\n| Medium suite (5-20 min) | 2-4 shards with matrix |\n| Large suite (20+ min) | 4-8 shards + blob merge |\n| Cross-browser on PRs | Chromium only on PRs; all browsers on main |\n| Staging/prod smoke tests | Separate workflow with `environment:` |\n| Nightly full regression | `schedule` trigger + `workflow_dispatch` |\n| Multiple repos, same setup | Reusable workflow with `workflow_call` |\n| Reproducible env needed | Container job with Playwright image |\n\n## Common Mistakes\n\n| Mistake | Problem | Fix |\n|---|---|---|\n| No `concurrency` group | Duplicate runs waste minutes | Add `concurrency: { group: ..., cancel-in-progress: true }` |\n| `fail-fast: true` with sharding | One failure cancels others | Set `fail-fast: false` |\n| No browser caching | 60-90 seconds wasted per run | Cache `~/.cache/ms-playwright` |\n| No `timeout-minutes` | Stuck jobs run for 6 hours | Set explicit timeout: 20-30 minutes |\n| Artifacts only on failure | No report when tests pass | Use `if: ${{ !cancelled() }}` |\n| Hardcoded secrets | Security risk | Use GitHub Secrets and Environments |\n| All browsers on every PR | 3x CI cost | Chromium on PR; cross-browser on main |\n| No artifact retention | Default 90-day fills storage | Set `retention-days: 7-14` |\n| Missing `--with-deps` | Browser launch failures | Always use `npx playwright install --with-deps` |\n\n## Troubleshooting\n\n### Browser launch fails: \"Missing dependencies\"\n\n**Cause**: Browsers restored from cache but OS dependencies weren't cached.\n\n**Fix**: Run `npx playwright install-deps` on cache hit:\n\n```yaml\n- name: Install OS dependencies\n if: steps.browser-cache.outputs.cache-hit == 'true'\n run: npx playwright install-deps\n```\n\n### Tests pass locally but timeout in CI\n\n**Cause**: CI runners have fewer resources than dev machines.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n workers: process.env.CI ? '50%' : undefined,\n use: {\n actionTimeout: process.env.CI ? 15_000 : 10_000,\n navigationTimeout: process.env.CI ? 30_000 : 15_000,\n },\n});\n```\n\n### Sharded reports incomplete\n\n**Cause**: Artifact names collide or `merge-multiple` not set.\n\n**Fix**: Unique names per shard and enable merge:\n\n```yaml\n# Upload in each shard\n- uses: actions/upload-artifact@v4\n with:\n name: blob-${{ strategy.job-index }}\n path: blob-report/\n\n# Download in merge job\n- uses: actions/download-artifact@v4\n with:\n path: all-blobs\n pattern: blob-*\n merge-multiple: true\n```\n\n### `webServer` fails: \"port already in use\"\n\n**Cause**: Zombie process from previous run.\n\n**Fix**: Kill stale processes before starting:\n\n```yaml\n- name: Kill stale processes\n run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true\n```\n\n### No PR annotations\n\n**Cause**: `github` reporter not configured.\n\n**Fix**: Add `github` reporter for CI:\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: process.env.CI\n ? [['html', { open: 'never' }], ['github']]\n : [['html', { open: 'on-failure' }]],\n});\n```\n\n## Related\n\n- [test-tags.md](../core/test-tags.md) — tagging and filtering tests\n- [parallel-sharding.md](parallel-sharding.md) — sharding strategies\n- [reporting.md](reporting.md) — reporter configuration\n- [docker.md](docker.md) — container images\n- [gitlab.md](gitlab.md) — GitLab CI equivalent\n- [other-providers.md](other-providers.md) — CircleCI, Azure DevOps, Jenkins\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13383,"content_sha256":"1f243a1b43c3a25fd29484a3789f65e38a1725588149b88e9996b37e0e7c608f"},{"filename":"infrastructure-ci-cd/gitlab.md","content":"# GitLab CI/CD Configuration\n\n## Table of Contents\n\n1. [Key Commands](#key-commands)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running Playwright tests in GitLab pipelines on merge requests, merges to main, or scheduled pipelines.\n\n## Key Commands\n\n```bash\nnpx playwright install --with-deps # install browsers + OS deps\nnpx playwright test --shard=1/4 # run 1 of 4 parallel shards\nnpx playwright merge-reports ./blob-report # merge shard results\nnpx playwright test --reporter=dot # minimal output for CI logs\n```\n\n## Patterns\n\n### Basic Pipeline Configuration\n\n**Use when**: Any GitLab project with Playwright tests.\n\n```yaml\n# .gitlab-ci.yml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n - install\n - test\n - report\n\nvariables:\n CI: \"true\"\n npm_config_cache: \"$CI_PROJECT_DIR/.npm\"\n\ncache:\n key:\n files:\n - package-lock.json\n paths:\n - .npm/\n - node_modules/\n\nsetup:\n stage: install\n script:\n - npm ci\n artifacts:\n paths:\n - node_modules/\n expire_in: 1 hour\n\ne2e:\n stage: test\n needs: [setup]\n script:\n - npx playwright test\n artifacts:\n when: always\n paths:\n - playwright-report/\n - test-results/\n expire_in: 14 days\n rules:\n - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Sharded Parallel Execution\n\n**Use when**: Test suite exceeds 10 minutes. GitLab's `parallel` keyword splits across jobs automatically.\n**Avoid when**: Suite runs under 5 minutes.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n - install\n - test\n - report\n\nvariables:\n CI: \"true\"\n npm_config_cache: \"$CI_PROJECT_DIR/.npm\"\n\ncache:\n key:\n files:\n - package-lock.json\n paths:\n - .npm/\n - node_modules/\n\nsetup:\n stage: install\n script:\n - npm ci\n artifacts:\n paths:\n - node_modules/\n expire_in: 1 hour\n\ne2e:\n stage: test\n needs: [setup]\n parallel: 4\n script:\n - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL\n artifacts:\n when: always\n paths:\n - blob-report/\n expire_in: 1 hour\n rules:\n - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n\ncombine-reports:\n stage: report\n needs: [e2e]\n when: always\n script:\n - npx playwright merge-reports --reporter=html ./blob-report\n artifacts:\n when: always\n paths:\n - playwright-report/\n expire_in: 14 days\n```\n\n**Config for sharded pipelines:**\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n reporter: process.env.CI\n ? [[\"blob\"], [\"dot\"]]\n : [[\"html\", { open: \"on-failure\" }]],\n});\n```\n\n### Environment Variables and Secrets\n\n**Use when**: Tests need secrets (API keys, passwords) and should only run on merge requests or the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n - test\n\nvariables:\n CI: \"true\"\n\ne2e:staging:\n stage: test\n variables:\n BASE_URL: $STAGING_URL\n TEST_PASSWORD: $TEST_PASSWORD\n API_KEY: $API_KEY\n before_script:\n - npm ci\n script:\n - npx playwright test\n artifacts:\n when: always\n paths:\n - playwright-report/\n - test-results/\n expire_in: 14 days\n rules:\n - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n - when: manual\n allow_failure: true\n```\n\n**Setting variables in GitLab:**\nNavigate to **Settings > CI/CD > Variables** and add:\n\n- `STAGING_URL` -- not masked, not protected\n- `TEST_PASSWORD` -- masked, protected\n- `API_KEY` -- masked, protected\n\n### Multi-Browser Matrix\n\n**Use when**: Running Chromium on MRs and all browsers on the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n - install\n - test\n\nvariables:\n CI: \"true\"\n\nsetup:\n stage: install\n script:\n - npm ci\n artifacts:\n paths:\n - node_modules/\n expire_in: 1 hour\n\ne2e:chromium:\n stage: test\n needs: [setup]\n script:\n - npx playwright test --project=chromium\n artifacts:\n when: always\n paths:\n - playwright-report/\n - test-results/\n expire_in: 14 days\n rules:\n - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n\ne2e:all-browsers:\n stage: test\n needs: [setup]\n parallel:\n matrix:\n - PROJECT: [chromium, firefox, webkit]\n script:\n - npx playwright test --project=$PROJECT\n artifacts:\n when: always\n paths:\n - playwright-report/\n - test-results/\n expire_in: 14 days\n rules:\n - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services Integration (Database, Cache)\n\n**Use when**: Tests need the application running alongside Playwright, or you need external services.\n\n```yaml\nstages:\n - test\n\ne2e:integration:\n stage: test\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n services:\n - name: postgres:latest\n alias: db\n - name: redis:latest\n alias: cache\n variables:\n CI: \"true\"\n DATABASE_URL: \"postgresql://postgres:postgres@db:5432/testdb\"\n REDIS_URL: \"redis://cache:6379\"\n POSTGRES_PASSWORD: \"postgres\"\n POSTGRES_DB: \"testdb\"\n before_script:\n - npm ci\n - npx prisma db push\n - npx prisma db seed\n script:\n - npx playwright test\n artifacts:\n when: always\n paths:\n - playwright-report/\n - test-results/\n expire_in: 14 days\n rules:\n - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Scheduled Nightly Regression\n\n**Use when**: Full regression is too slow for every MR.\n\n```yaml\ne2e:nightly:\n stage: test\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n before_script:\n - npm ci\n script:\n - npx playwright test --grep @regression\n artifacts:\n when: always\n paths:\n - playwright-report/\n expire_in: 30 days\n rules:\n - if: $CI_PIPELINE_SOURCE == \"schedule\"\n```\n\nSet up the schedule in **CI/CD > Schedules**: `0 3 * * 1-5` (3 AM UTC, weekdays).\n\n## Decision Guide\n\n| Scenario | Approach | Why |\n| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |\n| Simple project, \u003c 5 min suite | Single `test` job using Playwright Docker image | No sharding overhead; artifacts capture report |\n| Suite > 10 min | `parallel: N` with `--shard` | GitLab auto-assigns `CI_NODE_INDEX`/`CI_NODE_TOTAL` |\n| Merge request fast feedback | Chromium only on MRs; all browsers on main | 3x fewer pipeline minutes on MRs |\n| External services needed (DB, Redis) | `services:` keyword with Postgres/Redis images | GitLab manages service lifecycle |\n| Secrets for staging environment | GitLab CI/CD Variables (masked + protected) | Never hardcode secrets in `.gitlab-ci.yml` |\n| Full nightly regression | Pipeline schedule (`CI_PIPELINE_SOURCE == \"schedule\"`) | Avoids blocking MR pipelines |\n| Report browsing | `artifacts:` with `paths: [playwright-report/]` | Browse directly in GitLab job artifacts UI |\n\n## Anti-Patterns\n\n| Anti-Pattern | Problem | Do This Instead |\n| ---------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- |\n| Not using the Playwright Docker image | Installing browsers every run adds 1-2 minutes | Use `mcr.microsoft.com/playwright:v1.48.0-noble` as base image |\n| `artifacts: when: on_failure` only | No report when tests pass; can't verify results | Use `when: always` to capture reports regardless |\n| No `expire_in` on artifacts | Artifacts accumulate and consume storage | Set `expire_in: 14 days` for reports, `1 hour` for intermediate artifacts |\n| Hardcoding `CI_NODE_TOTAL` in shard flag | Breaks when you change `parallel:` value | Use `--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL` |\n| Skipping `needs:` between stages | Jobs wait for all previous stage jobs, not just their dependencies | Use `needs:` for precise dependency graphs |\n| Large `cache:` including `node_modules/` without key | Stale cache causes version conflicts | Key cache on `package-lock.json` hash |\n\n## Troubleshooting\n\n### Browser launch fails: \"Failed to launch browser\"\n\n**Cause**: Not using the Playwright Docker image, or using a version that doesn't match your `@playwright/test` version.\n\n**Fix**: Match the Docker image tag to your Playwright version:\n\n```yaml\n# Check your version: npm ls @playwright/test\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n```\n\n### Tests hang in GitLab runner: \"Navigation timeout exceeded\"\n\n**Cause**: GitLab shared runners may have limited resources.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\nexport default defineConfig({\n workers: process.env.CI ? 2 : undefined,\n use: {\n navigationTimeout: process.env.CI ? 30_000 : 15_000,\n },\n});\n```\n\n### Pipeline runs on every push, not just merge requests\n\n**Cause**: Missing `rules:` configuration.\n\n**Fix**: Add explicit rules:\n\n```yaml\nrules:\n - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services (Postgres/Redis) not reachable from tests\n\n**Cause**: Using `localhost` instead of the service alias.\n\n**Fix**: Use the service alias as hostname:\n\n```yaml\nservices:\n - name: postgres:latest\n alias: db\n\nvariables:\n DATABASE_URL: \"postgresql://postgres:postgres@db:5432/testdb\"\n```\n\n### Merged report is empty after sharded run\n\n**Cause**: Each shard job needs the `blob` reporter, not `html`.\n\n**Fix**: Configure blob reporter for CI:\n\n```typescript\nexport default defineConfig({\n reporter: process.env.CI\n ? [[\"blob\"], [\"dot\"]]\n : [[\"html\", { open: \"on-failure\" }]],\n});\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10647,"content_sha256":"91d73a21bfa8f238a719a04700390f01866f1de9dc70a064e09705ebc60f8b72"},{"filename":"infrastructure-ci-cd/other-providers.md","content":"# CI: CircleCI, Azure DevOps, and Jenkins\n\n> **When to use**: Running Playwright tests in CI platforms other than GitHub Actions or GitLab.\n\n## Table of Contents\n\n1. [Common Commands](#common-commands)\n2. [Jenkins](#jenkins)\n3. [CircleCI](#circleci)\n4. [Azure DevOps](#azure-devops)\n5. [JUnit Reporter Config](#junit-reporter-config)\n6. [Platform Comparison](#platform-comparison)\n7. [Troubleshooting](#troubleshooting)\n8. [Anti-Patterns](#anti-patterns)\n\n---\n\n## Common Commands\n\n```bash\nnpx playwright install --with-deps # browsers + OS dependencies\nnpx playwright test --shard=1/4 # parallel sharding\nnpx playwright merge-reports ./blob-report # combine shard results\nnpx playwright test --reporter=dot,html # multiple reporters\n```\n\n## Jenkins\n\n### Declarative Pipeline\n\n```groovy\n// Jenkinsfile\npipeline {\n agent {\n docker {\n image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n args '-u root'\n }\n }\n\n environment {\n CI = 'true'\n HOME = '/root'\n npm_config_cache = \"${WORKSPACE}/.npm\"\n }\n\n options {\n timeout(time: 30, unit: 'MINUTES')\n disableConcurrentBuilds()\n }\n\n stages {\n stage('Install') {\n steps {\n sh 'npm ci'\n }\n }\n\n stage('Test') {\n steps {\n sh 'npx playwright test'\n }\n post {\n always {\n junit allowEmptyResults: true,\n testResults: 'results/junit.xml'\n archiveArtifacts artifacts: 'pw-report/**',\n allowEmptyArchive: true\n archiveArtifacts artifacts: 'results/**',\n allowEmptyArchive: true\n }\n }\n }\n }\n\n post {\n failure {\n echo 'Tests failed!'\n }\n cleanup {\n cleanWs()\n }\n }\n}\n```\n\n### Parallel Shards\n\n```groovy\n// Jenkinsfile (sharded)\npipeline {\n agent none\n\n environment {\n CI = 'true'\n HOME = '/root'\n }\n\n options {\n timeout(time: 30, unit: 'MINUTES')\n }\n\n stages {\n stage('Test') {\n parallel {\n stage('Shard 1') {\n agent {\n docker {\n image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n args '-u root'\n }\n }\n steps {\n sh 'npm ci'\n sh 'npx playwright test --shard=1/4'\n }\n post {\n always {\n archiveArtifacts artifacts: 'blob-report/**',\n allowEmptyArchive: true\n }\n }\n }\n stage('Shard 2') {\n agent {\n docker {\n image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n args '-u root'\n }\n }\n steps {\n sh 'npm ci'\n sh 'npx playwright test --shard=2/4'\n }\n post {\n always {\n archiveArtifacts artifacts: 'blob-report/**',\n allowEmptyArchive: true\n }\n }\n }\n stage('Shard 3') {\n agent {\n docker {\n image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n args '-u root'\n }\n }\n steps {\n sh 'npm ci'\n sh 'npx playwright test --shard=3/4'\n }\n post {\n always {\n archiveArtifacts artifacts: 'blob-report/**',\n allowEmptyArchive: true\n }\n }\n }\n stage('Shard 4') {\n agent {\n docker {\n image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n args '-u root'\n }\n }\n steps {\n sh 'npm ci'\n sh 'npx playwright test --shard=4/4'\n }\n post {\n always {\n archiveArtifacts artifacts: 'blob-report/**',\n allowEmptyArchive: true\n }\n }\n }\n }\n }\n }\n}\n```\n\n## CircleCI\n\n### Basic Pipeline\n\n```yaml\n# .circleci/config.yml\nversion: 2.1\n\nexecutors:\n pw:\n docker:\n - image: mcr.microsoft.com/playwright:v1.48.0-noble\n working_directory: ~/app\n\njobs:\n install:\n executor: pw\n steps:\n - checkout\n - restore_cache:\n keys:\n - deps-{{ checksum \"package-lock.json\" }}\n - run: npm ci\n - save_cache:\n key: deps-{{ checksum \"package-lock.json\" }}\n paths:\n - node_modules\n - persist_to_workspace:\n root: .\n paths:\n - node_modules\n\n test:\n executor: pw\n parallelism: 4\n steps:\n - checkout\n - attach_workspace:\n at: .\n - run:\n name: Run tests\n command: |\n npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL\n - store_artifacts:\n path: pw-report\n destination: pw-report\n - store_artifacts:\n path: results\n destination: results\n - store_test_results:\n path: results/junit.xml\n\nworkflows:\n test:\n jobs:\n - install\n - test:\n requires:\n - install\n```\n\n### Using Orbs\n\n```yaml\n# .circleci/config.yml\nversion: 2.1\n\norbs:\n node: circleci/node@latest\n\nexecutors:\n pw:\n docker:\n - image: mcr.microsoft.com/playwright:v1.48.0-noble\n\njobs:\n e2e:\n executor: pw\n parallelism: 4\n steps:\n - checkout\n - node/install-packages\n - run:\n name: Run tests\n command: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL\n - store_artifacts:\n path: pw-report\n - store_test_results:\n path: results/junit.xml\n\nworkflows:\n main:\n jobs:\n - e2e\n```\n\n## Azure DevOps\n\n### Basic Pipeline\n\n```yaml\n# azure-pipelines.yml\ntrigger:\n branches:\n include:\n - main\n\npr:\n branches:\n include:\n - main\n\npool:\n vmImage: \"ubuntu-latest\"\n\nvariables:\n CI: \"true\"\n npm_config_cache: $(Pipeline.Workspace)/.npm\n\nsteps:\n - task: NodeTool@0\n inputs:\n versionSpec: \"20.x\"\n displayName: \"Install Node.js\"\n\n - task: Cache@2\n inputs:\n key: 'npm | \"$(Agent.OS)\" | package-lock.json'\n restoreKeys: |\n npm | \"$(Agent.OS)\"\n path: $(npm_config_cache)\n displayName: \"Cache npm\"\n\n - script: npm ci\n displayName: \"Install dependencies\"\n\n - script: npx playwright install --with-deps\n displayName: \"Install browsers\"\n\n - script: npx playwright test\n displayName: \"Run tests\"\n\n - task: PublishTestResults@2\n condition: always()\n inputs:\n testResultsFormat: \"JUnit\"\n testResultsFiles: \"results/junit.xml\"\n mergeTestResults: true\n testRunTitle: \"E2E Tests\"\n displayName: \"Publish results\"\n\n - task: PublishPipelineArtifact@1\n condition: always()\n inputs:\n targetPath: pw-report\n artifact: pw-report\n publishLocation: \"pipeline\"\n displayName: \"Upload report\"\n```\n\n### With Sharding\n\n```yaml\n# azure-pipelines.yml\ntrigger:\n branches:\n include:\n - main\n\npr:\n branches:\n include:\n - main\n\nvariables:\n CI: \"true\"\n\nstages:\n - stage: Test\n jobs:\n - job: E2E\n pool:\n vmImage: \"ubuntu-latest\"\n strategy:\n matrix:\n shard1:\n SHARD: \"1/4\"\n shard2:\n SHARD: \"2/4\"\n shard3:\n SHARD: \"3/4\"\n shard4:\n SHARD: \"4/4\"\n steps:\n - task: NodeTool@0\n inputs:\n versionSpec: \"20.x\"\n\n - script: npm ci\n displayName: \"Install dependencies\"\n\n - script: npx playwright install --with-deps\n displayName: \"Install browsers\"\n\n - script: npx playwright test --shard=$(SHARD)\n displayName: \"Run tests (shard $(SHARD))\"\n\n - task: PublishPipelineArtifact@1\n condition: always()\n inputs:\n targetPath: blob-report\n artifact: blob-report-$(System.JobPositionInPhase)\n displayName: \"Upload blob report\"\n\n - stage: Report\n dependsOn: Test\n condition: always()\n jobs:\n - job: MergeReports\n pool:\n vmImage: \"ubuntu-latest\"\n steps:\n - task: NodeTool@0\n inputs:\n versionSpec: \"20.x\"\n\n - script: npm ci\n displayName: \"Install dependencies\"\n\n - task: DownloadPipelineArtifact@2\n inputs:\n patterns: \"blob-report-*/**\"\n path: all-blob-reports\n displayName: \"Download blob reports\"\n\n - script: npx playwright merge-reports --reporter=html ./all-blob-reports\n displayName: \"Merge reports\"\n\n - task: PublishPipelineArtifact@1\n inputs:\n targetPath: pw-report\n artifact: pw-report\n displayName: \"Upload merged report\"\n```\n\n## JUnit Reporter Config\n\nAll platforms benefit from JUnit output for native test result display:\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\n\nexport default defineConfig({\n reporter: process.env.CI\n ? [\n [\"dot\"],\n [\"html\", { open: \"never\" }],\n [\"junit\", { outputFile: \"results/junit.xml\" }],\n ]\n : [[\"html\", { open: \"on-failure\" }]],\n});\n```\n\n## Platform Comparison\n\n| Feature | CircleCI | Azure DevOps | Jenkins |\n| ----------------- | ----------------------------------------------- | -------------------------------- | ---------------------- |\n| Docker support | `docker:` executor | `vmImage` or container jobs | Docker Pipeline plugin |\n| Parallelism | `parallelism: N` + `CIRCLE_NODE_INDEX` | `strategy.matrix` | `parallel` stages |\n| Artifact upload | `store_artifacts` | `PublishPipelineArtifact@1` | `archiveArtifacts` |\n| JUnit integration | `store_test_results` | `PublishTestResults@2` | `junit` step |\n| Shard variable | `$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL` | Define in matrix: `SHARD: '1/4'` | Hardcode per stage |\n| Cache key | `checksum \"package-lock.json\"` | `Cache@2` with key template | `stash`/`unstash` |\n| Secrets | Context + env variables | Variable groups | Credentials plugin |\n\n## Troubleshooting\n\n### Jenkins: \"Browser closed unexpectedly\"\n\nRunning as non-root in container causes sandbox issues.\n\n```groovy\nagent {\n docker {\n image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n args '-u root'\n }\n}\nenvironment {\n HOME = '/root'\n}\n```\n\n### CircleCI: \"Executable doesn't exist\"\n\nImage version mismatch with `@playwright/test` version. Use `latest` tag or match versions:\n\n```yaml\ndocker:\n - image: mcr.microsoft.com/playwright:v1.48.0-noble\n```\n\n### Azure DevOps: Test results not showing\n\nMissing JUnit reporter or `PublishTestResults@2` task:\n\n```typescript\nreporter: [['junit', { outputFile: 'results/junit.xml' }]],\n```\n\n```yaml\n- task: PublishTestResults@2\n condition: always()\n inputs:\n testResultsFormat: \"JUnit\"\n testResultsFiles: \"results/junit.xml\"\n```\n\n### Shard index off by one\n\nCircleCI's `CIRCLE_NODE_INDEX` is 0-based, Playwright's `--shard` is 1-based:\n\n```yaml\n# CircleCI - add 1\ncommand: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL\n```\n\n## Anti-Patterns\n\n| Anti-Pattern | Problem | Solution |\n| ----------------------------------- | ----------------------------------------- | ---------------------------------------------------- |\n| Missing `--with-deps` on bare metal | OS libs missing, browser launch fails | Use Playwright Docker image or `--with-deps` |\n| No JUnit reporter | CI can't display test results | Add `['junit', { outputFile: 'results/junit.xml' }]` |\n| No job timeout | Hung tests consume resources indefinitely | Set explicit timeout (20-30 min) |\n| No artifact upload on success | Can't verify passing results | Always upload reports (`condition: always()`) |\n| Non-root in container without setup | Permission errors on browser binaries | Run as root or configure permissions |\n| Hardcoded shard count | Must update multiple places | Use CI-native variables |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13732,"content_sha256":"6aca1c5e00cb179853ee85b8f340481d59c2c938a49eea412f1d13a5e04a230a"},{"filename":"infrastructure-ci-cd/parallel-sharding.md","content":"# Sharding and Parallel Execution\n\n## Table of Contents\n\n1. [CLI Commands](#cli-commands)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Speeding up test suites by running tests concurrently on one machine (workers) or splitting across multiple CI jobs (sharding).\n\n## CLI Commands\n\n```bash\n# Parallelism within one machine\nnpx playwright test --workers=4\nnpx playwright test --workers=50%\n\n# Splitting across CI jobs\nnpx playwright test --shard=1/4\nnpx playwright test --shard=2/4\n\n# Merging shard outputs\nnpx playwright merge-reports ./blob-report\nnpx playwright merge-reports --reporter=html,json ./blob-report\n\n# Override config for single run\nnpx playwright test --fully-parallel\n```\n\n## Patterns\n\n### Worker Configuration\n\n**Use when**: Controlling concurrent test execution on a single machine.\n\n```ts\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\n\nexport default defineConfig({\n // Tests WITHIN a file also run in parallel\n fullyParallel: true,\n\n // Worker count options:\n // - undefined: auto-detect (half CPU cores)\n // - number: fixed count\n // - string: percentage of cores\n workers: process.env.CI ? \"50%\" : undefined,\n});\n```\n\n**`fullyParallel` behavior:**\n\n| Setting | Files parallel | Tests in file parallel |\n| -------------------------------- | -------------- | ---------------------- |\n| `fullyParallel: false` (default) | Yes | No (serial) |\n| `fullyParallel: true` | Yes | Yes |\n\n**Serial execution for specific files:**\n\n```ts\n// tests/checkout-flow.spec.ts\nimport { test, expect } from \"@playwright/test\";\n\ntest.describe.configure({ mode: \"serial\" });\n\ntest(\"add items to cart\", async ({ page }) => {\n // ...\n});\n\ntest(\"complete payment\", async ({ page }) => {\n // ...\n});\n```\n\n### Sharding Across CI Machines\n\n**Use when**: Suite exceeds 5 minutes even with maximum workers.\n\n```bash\n# Job 1 Job 2 Job 3 Job 4\n--shard=1/4 --shard=2/4 --shard=3/4 --shard=4/4\n```\n\n**Config for sharded runs:**\n\n```ts\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\n\nexport default defineConfig({\n fullyParallel: true,\n workers: process.env.CI ? \"50%\" : undefined,\n\n reporter: process.env.CI\n ? [[\"blob\"], [\"github\"]]\n : [[\"html\", { open: \"on-failure\" }]],\n});\n```\n\n### Merging Shard Reports\n\n**Use when**: Combining blob reports from multiple shards into a unified report.\n\n```bash\n# Merge all blobs into HTML\nnpx playwright merge-reports --reporter=html ./all-blob-reports\n\n# Multiple formats\nnpx playwright merge-reports --reporter=html,json,junit ./all-blob-reports\n\n# Custom output location\nPLAYWRIGHT_HTML_REPORT=merged-report npx playwright merge-reports --reporter=html ./all-blob-reports\n```\n\n**GitHub Actions merge job:**\n\n```yaml\nmerge-reports:\n if: ${{ !cancelled() }}\n needs: test\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: npm ci\n\n - uses: actions/download-artifact@v4\n with:\n path: all-blob-reports\n pattern: blob-report-*\n merge-multiple: true\n\n - run: npx playwright merge-reports --reporter=html ./all-blob-reports\n\n - uses: actions/upload-artifact@v4\n with:\n name: playwright-report\n path: playwright-report/\n retention-days: 14\n```\n\n### Worker-Scoped Fixtures\n\n**Use when**: Expensive resources (DB connections, auth tokens) should be created once per worker, not per test.\n\n```ts\n// fixtures.ts\nimport { test as base } from \"@playwright/test\";\n\ntype WorkerFixtures = {\n dbClient: DatabaseClient;\n apiToken: string;\n};\n\nexport const test = base.extend\u003c{}, WorkerFixtures>({\n dbClient: [\n async ({}, use) => {\n const client = await DatabaseClient.connect(process.env.DB_URL!);\n await use(client);\n await client.disconnect();\n },\n { scope: \"worker\" },\n ],\n\n apiToken: [\n async ({}, use, workerInfo) => {\n const res = await fetch(`${process.env.API_URL}/auth`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n user: `test-user-${workerInfo.workerIndex}`,\n password: process.env.TEST_PASSWORD,\n }),\n });\n const { token } = await res.json();\n await use(token);\n },\n { scope: \"worker\" },\n ],\n});\n\nexport { expect } from \"@playwright/test\";\n```\n\n### Test Isolation for Parallelism\n\n**Use when**: Preparing tests to run without interference.\n\nEach test must create its own state. No test should depend on or modify shared state.\n\n```ts\n// BAD: Shared user causes race conditions\ntest(\"edit settings\", async ({ page }) => {\n await page.goto(\"/users/test-user/settings\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByRole(\"button\", { name: \"Save\" }).click();\n});\n\n// GOOD: Unique user per test\ntest(\"edit settings\", async ({ page, request }) => {\n const res = await request.post(\"/api/users\", {\n data: { name: `user-${Date.now()}`, email: `${Date.now()}@test.com` },\n });\n const user = await res.json();\n\n await page.goto(`/users/${user.id}/settings`);\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByRole(\"button\", { name: \"Save\" }).click();\n await expect(page.getByLabel(\"Email\")).toHaveValue(\"[email protected]\");\n\n await request.delete(`/api/users/${user.id}`);\n});\n```\n\n**Using `testInfo` for unique identifiers:**\n\n```ts\nimport { test, expect } from \"@playwright/test\";\n\ntest(\"submit order\", async ({ page }, testInfo) => {\n const orderId = `order-${testInfo.workerIndex}-${Date.now()}`;\n await page.goto(`/orders/new?ref=${orderId}`);\n // ...\n});\n```\n\n### Dynamic Shard Count\n\n**Use when**: Automatically adjusting shards based on test count.\n\n```yaml\n# .github/workflows/playwright.yml\njobs:\n calculate-shards:\n runs-on: ubuntu-latest\n outputs:\n shard-count: ${{ steps.calc.outputs.count }}\n shard-matrix: ${{ steps.calc.outputs.matrix }}\n steps:\n - uses: actions/checkout@v4\n - run: npm ci\n - id: calc\n run: |\n TEST_COUNT=$(npx playwright test --list --reporter=json 2>/dev/null | node -e \"\n const data = require('fs').readFileSync('/dev/stdin', 'utf8');\n const parsed = JSON.parse(data);\n console.log(parsed.suites?.reduce((acc, s) => acc + (s.specs?.length || 0), 0) || 0);\n \")\n # 1 shard per 20 tests, min 1, max 8\n SHARDS=$(( (TEST_COUNT + 19) / 20 ))\n SHARDS=$(( SHARDS > 8 ? 8 : SHARDS ))\n SHARDS=$(( SHARDS \u003c 1 ? 1 : SHARDS ))\n MATRIX=\"[\"\n for i in $(seq 1 $SHARDS); do\n [ $i -gt 1 ] && MATRIX+=\",\"\n MATRIX+=\"\\\"$i/$SHARDS\\\"\"\n done\n MATRIX+=\"]\"\n echo \"count=$SHARDS\" >> $GITHUB_OUTPUT\n echo \"matrix=$MATRIX\" >> $GITHUB_OUTPUT\n\n test:\n needs: calculate-shards\n runs-on: ubuntu-latest\n strategy:\n fail-fast: false\n matrix:\n shard: ${{ fromJson(needs.calculate-shards.outputs.shard-matrix) }}\n steps:\n - uses: actions/checkout@v4\n - run: npm ci\n - run: npx playwright install --with-deps\n - run: npx playwright test --shard=${{ matrix.shard }}\n```\n\n## Decision Guide\n\n| Scenario | Workers | Shards | Reason |\n| -------------------------------- | -------------- | ------ | --------------------------------------- |\n| \u003c 50 tests, \u003c 5 min | Auto (default) | None | No optimization needed |\n| 50-200 tests, 5-15 min | `'50%'` in CI | 2-4 | Balance speed and cost |\n| 200+ tests, > 15 min | `'50%'` in CI | 4-8 | Keep feedback under 10 min |\n| Flaky due to resource contention | Reduce to 2 | Keep | Less CPU/memory pressure |\n| Tests modify shared database | 1 or isolate | Useful | Sharding splits files; workers run them |\n| CI has limited resources | 1 or `'25%'` | More | Compensate with more machines |\n\n| Aspect | Workers (in-process) | Shards (across machines) |\n| -------------- | ------------------------- | -------------------------- |\n| What it splits | Tests across CPU cores | Test files across CI jobs |\n| Controlled by | Config or `--workers` CLI | `--shard=X/Y` CLI flag |\n| Shares memory | Yes | No |\n| Report merging | Not needed | Required (`merge-reports`) |\n| Cost | Free (same machine) | More CI minutes |\n\n## Anti-Patterns\n\n| Anti-Pattern | Problem | Solution |\n| --------------------------------------- | ---------------------------------------- | ---------------------------------------------------- |\n| `fullyParallel: false` without reason | Tests in files run serially | Set `fullyParallel: true` unless tests need serial |\n| `workers: 1` in CI \"for safety\" | Negates parallelism | Fix isolation issues; use `workers: '50%'` |\n| Hardcoded shared user account | Race conditions in parallel runs | Each test creates unique data |\n| Sharding without blob reporter | Each shard produces separate HTML report | Configure `reporter: [['blob']]` for CI |\n| Sharding with 3 tests | Setup overhead exceeds time saved | Only shard when suite > 5 minutes |\n| `test.describe.serial()` everywhere | Kills parallelism, creates dependencies | Use only when tests genuinely need prior state |\n| Workers > CPU cores | Context switching overhead | Use `'50%'` or auto-detect |\n| Missing `fail-fast: false` in CI matrix | One shard failure cancels others | Always set `fail-fast: false` for sharded strategies |\n\n## Troubleshooting\n\n### Tests pass solo but fail together\n\n- **Shared state**. Make test data unique:\n ```ts\n test(\"create item\", async ({ request }, ti) => {\n await request.post(\"/api/items\", {\n data: { name: `Item-${ti.workerIndex}-${Date.now()}` },\n });\n });\n ```\n\n### \"No tests found\" in some shards\n\n- **Too many shards**. Never exceed file count:\n ```bash\n npx playwright test --shard=1/10 # ok if 10 files\n npx playwright test --shard=1/20 # too many, some shards empty\n ```\n\n### Merged report missing results\n\n- **Blob reports collide**. Use unique names:\n ```yaml\n # Each shard\n - uses: actions/upload-artifact@v4\n with:\n name: blob-report-${{ strategy.job-index }}\n path: blob-report/\n # Merge step\n - uses: actions/download-artifact@v4\n with:\n pattern: blob-report-*\n merge-multiple: true\n path: all-blob-reports\n ```\n\n### Worker-scoped fixture not working\n\n- **Missing `{ scope: 'worker' }`**. Fix:\n ```ts\n export const test = base.extend({\n resource: [\n async ({}, use) => {\n const r = await Resource.create();\n await use(r);\n await r.destroy();\n },\n { scope: \"worker\" },\n ],\n });\n ```\n\n### More workers = Slower\n\n- **Too many workers thrash**. Limit in CI:\n ```ts\n export default defineConfig({\n workers: process.env.CI ? 2 : undefined,\n });\n ```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11579,"content_sha256":"81837bf4cc6e034053a0cbc00d9e7347f5cd573ceef61db49b0587ddbeba7a23"},{"filename":"infrastructure-ci-cd/performance.md","content":"# Performance & Parallelization\n\n## Table of Contents\n\n1. [Parallel Execution](#parallel-execution)\n2. [Sharding](#sharding)\n3. [Test Optimization](#test-optimization)\n4. [Network Optimization](#network-optimization)\n5. [Isolation and Parallel Execution](#isolation-and-parallel-execution)\n6. [Resource Management](#resource-management)\n7. [Benchmarking](#benchmarking)\n\n## Parallel Execution\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n // Run test files in parallel\n fullyParallel: true,\n\n // Number of worker processes\n workers: process.env.CI ? 1 : undefined, // undefined = half CPU cores\n\n // Or explicit count\n // workers: 4,\n // workers: '50%', // Percentage of CPU cores\n});\n```\n\n### Serial Execution When Needed\n\n```typescript\n// Entire file serial\ntest.describe.configure({ mode: \"serial\" });\n\ntest.describe(\"Sequential Tests\", () => {\n test(\"first\", async ({ page }) => {\n // Runs first\n });\n\n test(\"second\", async ({ page }) => {\n // Runs after first\n });\n});\n```\n\n```typescript\n// Single describe block serial\ntest.describe(\"Parallel Tests\", () => {\n test(\"a\", async () => {}); // Parallel\n test(\"b\", async () => {}); // Parallel\n});\n\ntest.describe.serial(\"Serial Tests\", () => {\n test(\"c\", async () => {}); // Serial\n test(\"d\", async () => {}); // Serial\n});\n```\n\n### Parallel Projects\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n projects: [\n { name: \"chromium\", use: { ...devices[\"Desktop Chrome\"] } },\n { name: \"firefox\", use: { ...devices[\"Desktop Firefox\"] } },\n { name: \"webkit\", use: { ...devices[\"Desktop Safari\"] } },\n ],\n});\n```\n\n```bash\n# Run all projects in parallel\nnpx playwright test\n\n# Run specific project\nnpx playwright test --project=chromium\n```\n\n## Sharding\n\n### Basic Sharding\n\n```bash\n# Split tests across 4 machines\n# Machine 1:\nnpx playwright test --shard=1/4\n\n# Machine 2:\nnpx playwright test --shard=2/4\n\n# Machine 3:\nnpx playwright test --shard=3/4\n\n# Machine 4:\nnpx playwright test --shard=4/4\n```\n\n### Sharding Strategy\n\nTests are distributed evenly by file. For optimal sharding:\n\n- Keep test files similar in size\n- Use `fullyParallel: true` for even distribution\n- Balance slow tests across files\n\n### CI Sharding Pattern\n\n```yaml\n# GitHub Actions\njobs:\n test:\n strategy:\n matrix:\n shard: [1, 2, 3, 4]\n steps:\n - run: npx playwright test --shard=${{ matrix.shard }}/4\n```\n\n> **For comprehensive CI sharding** (blob reports, merging sharded results, full workflows), see [ci-cd.md](ci-cd.md#sharding).\n\n## Test Optimization\n\n### Reuse Authentication\n\nAvoid logging in for every test. Use setup projects with storage state to authenticate once and reuse the session.\n\n> **For authentication patterns** (storage state, multiple auth states, setup projects), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).\n\n### Reuse Page State (serial only — trade-off with isolation)\n\nSharing a single page/context across tests with `beforeAll`/`afterAll` is **not recommended** for most suites: it breaks test isolation, causes state leak between tests, and makes failures harder to debug. Prefer a fresh `page` per test (Playwright default). Use shared page only when you explicitly need serial execution and accept no isolation.\n\n```typescript\n// ⚠️ Serial only, no isolation: state from one test leaks into the next.\n// Prefer test.describe.configure({ mode: 'serial' }) + fresh page per test, or beforeEach + page.goto().\ntest.describe.configure({ mode: \"serial\" });\ntest.describe(\"Dashboard\", () => {\n let page: Page;\n\n test.beforeAll(async ({ browser }) => {\n const context = await browser.newContext({\n storageState: \".auth/user.json\",\n });\n page = await context.newPage();\n await page.goto(\"/dashboard\");\n });\n\n test.afterAll(async () => {\n await page?.close();\n });\n\n test(\"shows stats\", async () => {\n await expect(page.getByTestId(\"stats\")).toBeVisible();\n });\n\n test(\"shows chart\", async () => {\n await expect(page.getByTestId(\"chart\")).toBeVisible();\n });\n});\n```\n\n### Lazy Navigation\n\n```typescript\n// Bad: Navigate in every test\ntest(\"check header\", async ({ page }) => {\n await page.goto(\"/products\");\n await expect(page.getByRole(\"heading\")).toBeVisible();\n});\n\ntest(\"check footer\", async ({ page }) => {\n await page.goto(\"/products\");\n await expect(page.getByRole(\"contentinfo\")).toBeVisible();\n});\n\n// Good: Share navigation\ntest.describe(\"Products Page\", () => {\n test.beforeEach(async ({ page }) => {\n await page.goto(\"/products\");\n });\n\n test(\"check header\", async ({ page }) => {\n await expect(page.getByRole(\"heading\")).toBeVisible();\n });\n\n test(\"check footer\", async ({ page }) => {\n await expect(page.getByRole(\"contentinfo\")).toBeVisible();\n });\n});\n```\n\n### Skip Unnecessary Setup\n\n```typescript\n// Use test.skip for conditional execution\ntest(\"admin feature\", async ({ page }) => {\n test.skip(!process.env.ADMIN_ENABLED, \"Admin features disabled\");\n // ...\n});\n\n// Use test.fixme for known broken tests\ntest.fixme(\"broken feature\", async ({ page }) => {\n // Skipped but tracked\n});\n```\n\n## Network Optimization\n\n### Mock APIs\n\n```typescript\ntest.beforeEach(async ({ page }) => {\n // Mock slow/heavy endpoints\n await page.route(\"**/api/analytics\", (route) =>\n route.fulfill({ json: { views: 1000 } }),\n );\n\n await page.route(\"**/api/recommendations\", (route) =>\n route.fulfill({ json: [] }),\n );\n});\n```\n\n### Block Unnecessary Resources\n\n```typescript\ntest.beforeEach(async ({ page }) => {\n // Block analytics, ads, tracking\n await page.route(\"**/*\", (route) => {\n const url = route.request().url();\n if (\n url.includes(\"google-analytics\") ||\n url.includes(\"facebook\") ||\n url.includes(\"hotjar\")\n ) {\n return route.abort();\n }\n return route.continue();\n });\n});\n```\n\n### Block Resource Types\n\n```typescript\n// Block images and fonts for faster tests\nawait page.route(\"**/*\", (route) => {\n const resourceType = route.request().resourceType();\n if ([\"image\", \"font\", \"stylesheet\"].includes(resourceType)) {\n return route.abort();\n }\n return route.continue();\n});\n```\n\n### Cache API Responses\n\n```typescript\nconst apiCache = new Map\u003cstring, object>();\n\ntest.beforeEach(async ({ page }) => {\n await page.route(\"**/api/**\", async (route) => {\n const url = route.request().url();\n\n if (apiCache.has(url)) {\n return route.fulfill({ json: apiCache.get(url) });\n }\n\n const response = await route.fetch();\n const json = await response.json();\n apiCache.set(url, json);\n return route.fulfill({ json });\n });\n});\n```\n\n## Isolation and Parallel Execution\n\n### Default: one context per test\n\nPlaywright gives each test its own browser context (and page). That gives isolation: no shared cookies, storage, or DOM between tests, so failures don’t carry over and you can run tests in any order or in parallel. Keep this default unless you have a clear reason to share state.\n\n### Avoiding state leak in parallel runs\n\n- **Do not** rely on shared mutable state (e.g. a single `page` or `context` in `beforeAll`) when tests can run in parallel. State from one test can leak into another and cause flaky, order-dependent failures.\n- Use **fixtures** for setup/teardown and **`beforeEach`** for per-test navigation so each test gets a fresh page or a clean slate.\n- For **backend or DB state** shared across tests, isolate per worker so parallel workers don’t collide. Use a worker-scoped fixture and `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) to create unique data per worker (e.g. unique user or DB prefix). See [fixtures-hooks.md](../core/fixtures-hooks.md) for worker-scoped fixtures and [debugging.md](../debugging/debugging.md) for debugging flaky parallel runs.\n\n### Debugging flaky parallel runs\n\nIf a test is flaky only with multiple workers:\n\n1. **Reproduce**: Run with default workers and `--repeat-each=10` (or `--repeat-each=100 --max-failures=1`).\n2. **Confirm parallel-specific**: Run with `--workers=1`. If the failure disappears, the cause is likely shared state or non-isolated backend/DB data.\n3. **Fix**: Remove shared page/context; use per-test fixtures and `beforeEach`; isolate test data per worker with `workerIndex` in a worker-scoped fixture.\n\nWorkers are restarted after a test failure so subsequent tests in that worker get a clean environment; fixing isolation still prevents the initial flakiness.\n\n## Resource Management\n\n### Browser Contexts\n\n```typescript\n// Recommended: One context per test (default) — full isolation\ntest(\"isolated test\", async ({ page }) => {\n // Fresh context automatically\n});\n\n// Manual context for specific needs\ntest(\"multiple tabs\", async ({ browser }) => {\n const context = await browser.newContext();\n const page1 = await context.newPage();\n const page2 = await context.newPage();\n\n // Clean up\n await context.close();\n});\n```\n\n### Memory Management\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n // Limit concurrent workers\n workers: 2,\n\n // Limit parallel tests per worker\n use: {\n // Lower memory usage\n launchOptions: {\n args: [\"--disable-dev-shm-usage\"],\n },\n },\n});\n```\n\n### Timeouts\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n // Global test timeout\n timeout: 30000,\n\n // Assertion timeout\n expect: {\n timeout: 5000,\n },\n\n // Navigation timeout\n use: {\n navigationTimeout: 15000,\n actionTimeout: 10000,\n },\n});\n```\n\n## Benchmarking\n\n### Measure Test Duration\n\n```typescript\ntest(\"performance test\", async ({ page }, testInfo) => {\n const startTime = Date.now();\n\n await page.goto(\"/\");\n\n const loadTime = Date.now() - startTime;\n console.log(`Page load: ${loadTime}ms`);\n\n // Add to test report\n testInfo.annotations.push({\n type: \"performance\",\n description: `Load time: ${loadTime}ms`,\n });\n});\n```\n\n### Performance Metrics\n\n```typescript\ntest(\"collect metrics\", async ({ page }) => {\n await page.goto(\"/\");\n\n const metrics = await page.evaluate(() => ({\n // Navigation timing\n loadTime:\n performance.timing.loadEventEnd - performance.timing.navigationStart,\n domContentLoaded:\n performance.timing.domContentLoadedEventEnd -\n performance.timing.navigationStart,\n\n // Performance entries\n resources: performance.getEntriesByType(\"resource\").length,\n\n // Memory (Chrome only)\n // @ts-ignore\n memory: performance.memory?.usedJSHeapSize,\n }));\n\n console.log(\"Metrics:\", metrics);\n expect(metrics.loadTime).toBeLessThan(3000);\n});\n```\n\n### Lighthouse Integration\n\n```typescript\nimport { playAudit } from \"playwright-lighthouse\";\n\ntest(\"lighthouse audit\", async ({ page }) => {\n await page.goto(\"/\");\n\n const audit = await playAudit({\n page,\n thresholds: {\n performance: 80,\n accessibility: 90,\n \"best-practices\": 80,\n seo: 80,\n },\n port: 9222,\n });\n\n expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(\n 80,\n );\n});\n```\n\n## Performance Checklist\n\n| Optimization | Impact |\n| ------------------------------ | ---------- |\n| Enable `fullyParallel` | High |\n| Reuse authentication | High |\n| Mock heavy APIs | High |\n| Block tracking scripts | Medium |\n| Use sharding in CI | High |\n| Reduce workers if memory-bound | Medium |\n| Cache API responses | Medium |\n| Skip unnecessary tests | Low-Medium |\n\n## Related References\n\n- **CI/CD sharding**: See [ci-cd.md](ci-cd.md) for CI configuration\n- **Test organization**: See [test-suite-structure.md](../core/test-suite-structure.md) for structuring tests\n- **Fixtures for reuse**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for authentication patterns\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11867,"content_sha256":"01073c6bc622a7e2d347dbbea066dd8b05cb394c7717e2b2a8b26ea6f1e6e838"},{"filename":"infrastructure-ci-cd/reporting.md","content":"# Test Reports & Artifacts\n\n## Table of Contents\n\n1. [CLI Commands](#cli-commands)\n2. [Reporter Configuration](#reporter-configuration)\n3. [Custom Reporter](#custom-reporter)\n4. [Trace Configuration](#trace-configuration)\n5. [Screenshot & Video Settings](#screenshot--video-settings)\n6. [Artifact Directory Structure](#artifact-directory-structure)\n7. [CI Artifact Upload](#ci-artifact-upload)\n8. [Decision Guide](#decision-guide)\n9. [Anti-Patterns](#anti-patterns)\n10. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Configuring test output for debugging, CI dashboards, and team visibility.\n\n## CLI Commands\n\n```bash\n# Display last HTML report\nnpx playwright show-report\n\n# Specify reporter\nnpx playwright test --reporter=html\nnpx playwright test --reporter=dot # minimal CI output\nnpx playwright test --reporter=line # one line per test\nnpx playwright test --reporter=json # machine-readable\nnpx playwright test --reporter=junit # CI integration\n\n# Combine reporters\nnpx playwright test --reporter=dot,html\n\n# Merge sharded reports\nnpx playwright merge-reports --reporter=html ./blob-report\n```\n\n## Reporter Configuration\n\n### Environment-Based Setup\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: process.env.CI\n ? [\n ['dot'],\n ['html', { open: 'never' }],\n ['junit', { outputFile: 'results/junit.xml' }],\n ['github'],\n ]\n : [\n ['list'],\n ['html', { open: 'on-failure' }],\n ],\n});\n```\n\n### Reporter Types\n\n| Reporter | Output | Use Case |\n|---|---|---|\n| `list` | One line per test | Local development |\n| `line` | Single updating line | Local, less verbose |\n| `dot` | `.` pass, `F` fail | CI logs |\n| `html` | Interactive HTML page | Post-run analysis |\n| `json` | Machine-readable JSON | Custom tooling |\n| `junit` | JUnit XML | CI platforms |\n| `github` | PR annotations | GitHub Actions |\n| `blob` | Binary archive | Shard merging |\n\n### JSON Output to File\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: [\n ['json', { outputFile: 'results/output.json' }],\n ],\n});\n```\n\n### JUnit Customization\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: [\n ['junit', {\n outputFile: 'results/junit.xml',\n stripANSIControlSequences: true,\n includeProjectInTestName: true,\n }],\n ],\n});\n```\n\n## Custom Reporter\n\nBuild custom reporters for Slack notifications, database logging, or dashboards.\n\n```typescript\n// reporters/notification-reporter.ts\nimport type {\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nclass NotificationReporter implements Reporter {\n private passed = 0;\n private failed = 0;\n private skipped = 0;\n private failures: string[] = [];\n\n onTestEnd(test: TestCase, result: TestResult) {\n switch (result.status) {\n case 'passed':\n this.passed++;\n break;\n case 'failed':\n case 'timedOut':\n this.failed++;\n this.failures.push(`${test.title}: ${result.error?.message?.split('\\n')[0]}`);\n break;\n case 'skipped':\n this.skipped++;\n break;\n }\n }\n\n async onEnd(result: FullResult) {\n const total = this.passed + this.failed + this.skipped;\n const status = this.failed > 0 ? 'FAILED' : 'PASSED';\n const message = [\n `Tests ${status}`,\n `Passed: ${this.passed} | Failed: ${this.failed} | Skipped: ${this.skipped}`,\n `Duration: ${(result.duration / 1000).toFixed(1)}s`,\n ];\n\n if (this.failures.length > 0) {\n message.push('', 'Failures:');\n this.failures.slice(0, 5).forEach((f) => message.push(` - ${f}`));\n if (this.failures.length > 5) {\n message.push(` ...and ${this.failures.length - 5} more`);\n }\n }\n\n const webhookUrl = process.env.NOTIFICATION_WEBHOOK;\n if (webhookUrl) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 5000);\n try {\n await fetch(webhookUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ text: message.join('\\n') }),\n signal: controller.signal,\n });\n } catch (error) {\n // Intentionally swallow notifier failures to avoid blocking test completion\n console.warn('Webhook notification failed:', error.message);\n } finally {\n clearTimeout(timeout);\n }\n }\n }\n}\n\nexport default NotificationReporter;\n```\n\n**Register custom reporter:**\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: [\n ['dot'],\n ['html', { open: 'never' }],\n ['./reporters/notification-reporter.ts'],\n ],\n});\n```\n\n## Trace Configuration\n\nTraces capture actions, network requests, DOM snapshots, and console logs.\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n retries: process.env.CI ? 2 : 0,\n use: {\n trace: 'on-first-retry',\n },\n});\n```\n\n### Trace Options\n\n| Value | Behavior | Overhead |\n|---|---|---|\n| `'off'` | Never records | None |\n| `'on'` | Every test | High |\n| `'on-first-retry'` | On first retry after failure | Minimal |\n| `'retain-on-failure'` | Records all, keeps failures | Medium |\n| `'retain-on-first-failure'` | Records all, keeps first failure | Medium |\n\n### Viewing Traces\n\n```bash\n# Local trace viewer\nnpx playwright show-trace results/my-test/trace.zip\n\n# From HTML report (click Traces tab)\nnpx playwright show-report\n\n# Online viewer: https://trace.playwright.dev\n```\n\n## Screenshot & Video Settings\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n use: {\n screenshot: 'only-on-failure',\n video: 'retain-on-failure',\n },\n});\n```\n\n### Video with Custom Size\n\n```typescript\nuse: {\n video: {\n mode: 'retain-on-failure',\n size: { width: 1280, height: 720 },\n },\n},\n```\n\n### Screenshot Options\n\n| Value | Captures | Disk Cost |\n|---|---|---|\n| `'off'` | Never | None |\n| `'on'` | Every test | High |\n| `'only-on-failure'` | Failed tests | Low |\n\n### Video Options\n\n| Value | Records | Keeps | Disk Cost |\n|---|---|---|---|\n| `'off'` | Never | — | None |\n| `'on'` | Every test | All | Very high |\n| `'on-first-retry'` | On retry | Retried | Low |\n| `'retain-on-failure'` | Every test | Failed | Medium |\n\n## Artifact Directory Structure\n\n```text\ntest-results/\n├── checkout-test-chromium/\n│ ├── trace.zip\n│ ├── test-failed-1.png\n│ └── video.webm\n├── login-test-firefox/\n│ ├── trace.zip\n│ └── test-failed-1.png\n└── junit.xml\n\nplaywright-report/\n├── index.html\n└── data/\n\nblob-report/\n└── report-1.zip\n```\n\n## CI Artifact Upload\n\n### GitHub Actions\n\n```yaml\n- uses: actions/upload-artifact@v4\n if: ${{ !cancelled() }}\n with:\n name: playwright-report\n path: playwright-report/\n retention-days: 14\n\n- uses: actions/upload-artifact@v4\n if: failure()\n with:\n name: test-traces\n path: |\n test-results/**/trace.zip\n test-results/**/*.png\n test-results/**/*.webm\n retention-days: 7\n```\n\n## Decision Guide\n\n| Scenario | Reporter Configuration |\n|---|---|\n| Local development | `[['list'], ['html', { open: 'on-failure' }]]` |\n| GitHub Actions | `[['dot'], ['html'], ['github']]` |\n| GitLab CI | `[['dot'], ['html'], ['junit']]` |\n| Azure DevOps / Jenkins | `[['dot'], ['html'], ['junit']]` |\n| Sharded CI | `[['blob'], ['github']]` |\n| Custom dashboard | `[['json', { outputFile: '...' }]]` + custom reporter |\n\n| Artifact | When to Collect | Retention | Upload Condition |\n|---|---|---|---|\n| HTML report | Always | 14 days | `if: ${{ !cancelled() }}` |\n| Traces | On failure | 7 days | `if: failure()` |\n| Screenshots | On failure | 7 days | `if: failure()` |\n| Videos | On failure | 7 days | `if: failure()` |\n| JUnit XML | Always | 14 days | `if: ${{ !cancelled() }}` |\n| Blob report | Always (sharded) | 1 day | `if: ${{ !cancelled() }}` |\n\n## Anti-Patterns\n\n| Anti-Pattern | Problem | Solution |\n|---|---|---|\n| No reporter configured | Default `list` only; no persistent report | Configure `html` + CI reporter |\n| `trace: 'on'` in CI | Massive artifacts, slow uploads | Use `trace: 'on-first-retry'` |\n| `video: 'on'` in CI | Enormous storage, slower tests | Use `video: 'retain-on-failure'` |\n| Upload artifacts only on failure | No report when tests pass | Upload with `if: ${{ !cancelled() }}` |\n| No retention limits | CI storage fills quickly | Set `retention-days: 7-14` |\n| Only `dot` reporter | Cannot drill into failures | Pair `dot` with `html` |\n| JUnit to stdout | Interferes with console output | Write to file |\n| Blocking `onEnd` in custom reporter | Slow HTTP calls delay pipeline | Use `Promise.race` with timeout |\n\n## Troubleshooting\n\n### Empty HTML Report\n\nCheck reporter config. HTML report defaults to `playwright-report/`:\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: [['html', { outputFolder: 'playwright-report', open: 'never' }]],\n});\n```\n\n### Traces Too Large\n\nSwitch from `trace: 'on'` to `'on-first-retry'` with retries enabled:\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n retries: process.env.CI ? 2 : 0,\n use: {\n trace: 'on-first-retry',\n },\n});\n```\n\n### JUnit XML Not Recognized\n\nEnsure path matches CI configuration:\n\n```typescript\nreporter: [['junit', { outputFile: 'results/junit.xml' }]],\n```\n\n```yaml\n# GitHub Actions\n- uses: dorny/test-reporter@latest\n with:\n path: results/junit.xml\n reporter: java-junit\n\n# Azure DevOps\n- task: PublishTestResults@latest\n inputs:\n testResultsFiles: 'results/junit.xml'\n\n# Jenkins\njunit 'results/junit.xml'\n```\n\n### Empty Merged Report\n\nUse `blob` reporter for sharded runs (not `html`):\n\n```typescript\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n reporter: process.env.CI\n ? [['blob'], ['dot']]\n : [['html', { open: 'on-failure' }]],\n});\n```\n\n### Missing Screenshots in Report\n\nEnable screenshots and keep both directories:\n\n```typescript\nuse: {\n screenshot: 'only-on-failure',\n},\n```\n\nThe HTML report embeds screenshots from `test-results/`. Deleting that directory removes screenshots from the report.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10492,"content_sha256":"e7bd41103689230d3fe3d5f7019f95048548e5a1b8d70da7617cc3897236efd7"},{"filename":"infrastructure-ci-cd/test-coverage.md","content":"# Test Coverage\n\n## Table of Contents\n\n1. [Coverage Setup](#coverage-setup)\n2. [Collecting Coverage](#collecting-coverage)\n3. [Coverage Reports](#coverage-reports)\n4. [Coverage Thresholds](#coverage-thresholds)\n5. [Advanced Patterns](#advanced-patterns)\n6. [CI Integration](#ci-integration)\n\n## Coverage Setup\n\n### Install Dependencies\n\n```bash\n# For V8 coverage (built into Playwright)\n# No additional dependencies needed\n\n# For Istanbul-based coverage (more features)\nnpm install -D nyc @istanbuljs/nyc-config-typescript\n```\n\n### Basic Configuration\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\n\nexport default defineConfig({\n use: {\n // Enable coverage collection\n contextOptions: {\n // V8 coverage is automatic with the API below\n },\n },\n});\n```\n\n### V8 Coverage Fixture\n\n```typescript\n// fixtures/coverage.ts\nimport { test as base, expect } from \"@playwright/test\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { randomUUID } from \"crypto\";\n\nexport const test = base.extend\u003c{}, { collectCoverage: void }>({\n collectCoverage: [\n async ({ browser }, use) => {\n // Start coverage for all pages\n const context = await browser.newContext();\n const page = await context.newPage();\n\n await page.coverage.startJSCoverage();\n await page.coverage.startCSSCoverage();\n\n await use();\n\n // Collect coverage\n const [jsCoverage, cssCoverage] = await Promise.all([\n page.coverage.stopJSCoverage(),\n page.coverage.stopCSSCoverage(),\n ]);\n\n // Save coverage data\n const coverageDir = \"./coverage\";\n if (!fs.existsSync(coverageDir)) {\n fs.mkdirSync(coverageDir, { recursive: true });\n }\n\n fs.writeFileSync(\n path.join(coverageDir, `coverage-${randomUUID()}.json`),\n JSON.stringify([...jsCoverage, ...cssCoverage])\n );\n\n await context.close();\n },\n { scope: \"worker\", auto: true },\n ],\n});\n```\n\n## Collecting Coverage\n\n### Per-Test Coverage\n\n```typescript\ntest(\"collect coverage for single test\", async ({ page }) => {\n // Start coverage collection\n await page.coverage.startJSCoverage({\n resetOnNavigation: false,\n });\n\n // Run test\n await page.goto(\"/app\");\n await page.getByRole(\"button\", { name: \"Submit\" }).click();\n await expect(page.getByText(\"Success\")).toBeVisible();\n\n // Stop and get coverage\n const coverage = await page.coverage.stopJSCoverage();\n\n // Filter to only your source files\n const appCoverage = coverage.filter((entry) => entry.url.includes(\"/src/\"));\n\n console.log(`Covered ${appCoverage.length} source files`);\n});\n```\n\n### Coverage for Specific Files\n\n```typescript\ntest(\"track specific module coverage\", async ({ page }) => {\n await page.coverage.startJSCoverage();\n\n await page.goto(\"/checkout\");\n await page.getByRole(\"button\", { name: \"Pay\" }).click();\n\n const coverage = await page.coverage.stopJSCoverage();\n\n // Find coverage for checkout module\n const checkoutCoverage = coverage.find((c) => c.url.includes(\"checkout.js\"));\n\n if (checkoutCoverage) {\n const totalBytes = checkoutCoverage.text?.length || 0;\n const coveredBytes = checkoutCoverage.ranges.reduce(\n (sum, range) => sum + (range.end - range.start),\n 0\n );\n const percentage = (coveredBytes / totalBytes) * 100;\n\n console.log(`Checkout module: ${percentage.toFixed(1)}% covered`);\n expect(percentage).toBeGreaterThan(80);\n }\n});\n```\n\n### CSS Coverage\n\n```typescript\ntest(\"collect CSS coverage\", async ({ page }) => {\n await page.coverage.startCSSCoverage();\n\n await page.goto(\"/app\");\n\n // Interact to trigger different CSS states\n await page.getByRole(\"button\").hover();\n await page.getByRole(\"dialog\").waitFor();\n\n const cssCoverage = await page.coverage.stopCSSCoverage();\n\n // Find unused CSS\n for (const entry of cssCoverage) {\n const totalBytes = entry.text?.length || 0;\n const usedBytes = entry.ranges.reduce(\n (sum, range) => sum + (range.end - range.start),\n 0\n );\n const unusedPercentage = ((totalBytes - usedBytes) / totalBytes) * 100;\n\n if (unusedPercentage > 50) {\n console.warn(`${entry.url}: ${unusedPercentage.toFixed(1)}% unused CSS`);\n }\n }\n});\n```\n\n## Coverage Reports\n\n### Converting to Istanbul Format\n\n```typescript\n// scripts/convert-coverage.ts\nimport { execSync } from \"child_process\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport v8ToIstanbul from \"v8-to-istanbul\";\n\nasync function convertCoverage() {\n const coverageDir = \"./coverage\";\n const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(\".json\"));\n\n const istanbulCoverage: any = {};\n\n for (const file of files) {\n const coverageData = JSON.parse(\n fs.readFileSync(path.join(coverageDir, file), \"utf-8\")\n );\n\n for (const entry of coverageData) {\n if (!entry.url.startsWith(\"file://\")) continue;\n\n const filePath = entry.url.replace(\"file://\", \"\");\n const converter = v8ToIstanbul(filePath);\n\n await converter.load();\n converter.applyCoverage(entry.functions || []);\n\n const istanbul = converter.toIstanbul();\n Object.assign(istanbulCoverage, istanbul);\n }\n }\n\n fs.writeFileSync(\n path.join(coverageDir, \"coverage-final.json\"),\n JSON.stringify(istanbulCoverage)\n );\n}\n\nconvertCoverage();\n```\n\n### Generating HTML Report\n\n```bash\n# Using nyc to generate report\nnpx nyc report --reporter=html --reporter=text --temp-dir=./coverage\n```\n\n```typescript\n// package.json scripts\n{\n \"scripts\": {\n \"test\": \"playwright test\",\n \"test:coverage\": \"playwright test && npm run coverage:report\",\n \"coverage:report\": \"npx nyc report --reporter=html --reporter=lcov --temp-dir=./coverage\"\n }\n}\n```\n\n### Custom Coverage Reporter\n\n```typescript\n// reporters/coverage-reporter.ts\nimport type { Reporter, FullResult } from \"@playwright/test/reporter\";\nimport fs from \"fs\";\nimport path from \"path\";\n\nclass CoverageReporter implements Reporter {\n private coverageData: any[] = [];\n\n onEnd(result: FullResult) {\n // Aggregate all coverage files\n const coverageDir = \"./coverage\";\n const files = fs\n .readdirSync(coverageDir)\n .filter((f) => f.endsWith(\".json\"));\n\n for (const file of files) {\n const data = JSON.parse(\n fs.readFileSync(path.join(coverageDir, file), \"utf-8\")\n );\n this.coverageData.push(...data);\n }\n\n // Generate summary\n const summary = this.generateSummary();\n console.log(\"\\n📊 Coverage Summary:\");\n console.log(` Files: ${summary.totalFiles}`);\n console.log(` Lines: ${summary.lineCoverage.toFixed(1)}%`);\n console.log(` Bytes: ${summary.byteCoverage.toFixed(1)}%`);\n\n if (summary.lineCoverage \u003c 80) {\n console.warn(\"⚠️ Coverage below 80% threshold!\");\n }\n }\n\n private generateSummary() {\n let totalBytes = 0;\n let coveredBytes = 0;\n const files = new Set\u003cstring>();\n\n for (const entry of this.coverageData) {\n if (entry.url.includes(\"/src/\")) {\n files.add(entry.url);\n totalBytes += entry.text?.length || 0;\n coveredBytes += entry.ranges.reduce(\n (sum: number, r: any) => sum + (r.end - r.start),\n 0\n );\n }\n }\n\n return {\n totalFiles: files.size,\n byteCoverage: (coveredBytes / totalBytes) * 100,\n lineCoverage: (coveredBytes / totalBytes) * 100, // Simplified\n };\n }\n}\n\nexport default CoverageReporter;\n```\n\n## Coverage Thresholds\n\n### Enforcing Minimum Coverage\n\n```typescript\n// tests/coverage.spec.ts\nimport { test, expect } from \"@playwright/test\";\nimport fs from \"fs\";\nimport path from \"path\";\n\ntest.afterAll(async () => {\n const coverageDir = \"./coverage\";\n const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(\".json\"));\n\n let totalBytes = 0;\n let coveredBytes = 0;\n\n for (const file of files) {\n const coverage = JSON.parse(\n fs.readFileSync(path.join(coverageDir, file), \"utf-8\")\n );\n\n for (const entry of coverage) {\n if (!entry.url.includes(\"/src/\")) continue;\n totalBytes += entry.text?.length || 0;\n coveredBytes += entry.ranges.reduce(\n (sum: number, r: any) => sum + (r.end - r.start),\n 0\n );\n }\n }\n\n const coveragePercent = (coveredBytes / totalBytes) * 100;\n\n // Enforce threshold\n expect(coveragePercent).toBeGreaterThan(80);\n});\n```\n\n### Per-Directory Thresholds\n\n```typescript\n// coverage-check.ts\ninterface CoverageThreshold {\n pattern: RegExp;\n minCoverage: number;\n}\n\nconst thresholds: CoverageThreshold[] = [\n { pattern: /\\/src\\/core\\//, minCoverage: 90 },\n { pattern: /\\/src\\/utils\\//, minCoverage: 85 },\n { pattern: /\\/src\\/components\\//, minCoverage: 70 },\n { pattern: /\\/src\\/pages\\//, minCoverage: 60 },\n];\n\nfunction checkThresholds(coverage: any[]): string[] {\n const violations: string[] = [];\n\n for (const threshold of thresholds) {\n const matchingFiles = coverage.filter((c) => threshold.pattern.test(c.url));\n\n let total = 0;\n let covered = 0;\n\n for (const file of matchingFiles) {\n total += file.text?.length || 0;\n covered += file.ranges.reduce(\n (sum: number, r: any) => sum + (r.end - r.start),\n 0\n );\n }\n\n const percent = total > 0 ? (covered / total) * 100 : 0;\n\n if (percent \u003c threshold.minCoverage) {\n violations.push(\n `${threshold.pattern}: ${percent.toFixed(1)}% \u003c ${\n threshold.minCoverage\n }%`\n );\n }\n }\n\n return violations;\n}\n```\n\n## Advanced Patterns\n\n### Merging Coverage Across Shards\n\n```typescript\n// scripts/merge-coverage.ts\nimport fs from \"fs\";\nimport { glob } from \"glob\";\n\nasync function mergeCoverage() {\n const files = await glob(\"shard-*/coverage/*.json\");\n const merged = new Map\u003cstring, any>();\n\n for (const file of files) {\n const data = JSON.parse(fs.readFileSync(file, \"utf-8\"));\n for (const entry of data) {\n if (merged.has(entry.url)) {\n const existing = merged.get(entry.url);\n existing.ranges.push(...entry.ranges);\n } else {\n merged.set(entry.url, { ...entry });\n }\n }\n }\n\n fs.writeFileSync(\n \"./coverage/merged.json\",\n JSON.stringify([...merged.values()])\n );\n}\n\nmergeCoverage();\n```\n\n### Incremental Coverage\n\n```typescript\n// Check coverage only for changed files in CI\nimport { execSync } from \"child_process\";\nimport fs from \"fs\";\n\nconst changedFiles = execSync(\"git diff --name-only HEAD~1\")\n .toString()\n .split(\"\\n\")\n .filter((f) => f.endsWith(\".ts\"));\n\nconst coverage = JSON.parse(fs.readFileSync(\"./coverage/merged.json\", \"utf-8\"));\n\nfor (const file of changedFiles) {\n const entry = coverage.find((c: any) => c.url.includes(file));\n if (entry) {\n const percent =\n (entry.ranges.reduce((s: number, r: any) => s + r.end - r.start, 0) /\n (entry.text?.length || 1)) *\n 100;\n console.log(`${file}: ${percent.toFixed(1)}%`);\n }\n}\n```\n\n## CI Integration\n\n### GitHub Actions\n\n```yaml\n# .github/workflows/test.yml\nname: Tests with Coverage\n\non: [push, pull_request]\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n\n - run: npm ci\n - run: npx playwright install --with-deps\n\n - name: Run tests with coverage\n run: npm run test:coverage\n\n - name: Upload coverage to Codecov\n uses: codecov/codecov-action@v3\n with:\n files: ./coverage/lcov.info\n fail_ci_if_error: true\n\n - name: Check coverage threshold\n run: |\n COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')\n if (( $(echo \"$COVERAGE \u003c 80\" | bc -l) )); then\n echo \"Coverage $COVERAGE% is below 80% threshold\"\n exit 1\n fi\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ---------------------------- | -------------------------------------- | --------------------------- |\n| Coverage for coverage's sake | Gaming metrics | Focus on critical paths |\n| 100% coverage target | Diminishing returns, tests for getters | Set realistic thresholds |\n| Ignoring coverage drops | Technical debt | Enforce thresholds in CI |\n| No source map support | Wrong line numbers | Enable source maps in build |\n| Coverage only in CI | Late feedback | Run locally too |\n\n## Related References\n\n- **CI/CD**: See [ci-cd.md](ci-cd.md) for pipeline configuration\n- **Performance**: See [performance.md](performance.md) for optimizing coverage collection\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12725,"content_sha256":"b70eb2fa03e5cf3d4d7b98d4b342083fee1ea9092023d2c0c0cb685bfd8aa303"},{"filename":"README.md","content":"```\n\n░█▀█░█░░░█▀█░█░█░█░█░█▀▄░▀█▀░█▀▀░█░█░▀█▀░░░█▀▄░█▀▀░█▀▀░▀█▀░░░█▀█░█▀▄░█▀█░█▀▀░▀█▀░▀█▀░█▀▀░█▀▀░█▀▀░\n░█▀▀░█░░░█▀█░░█░░█▄█░█▀▄░░█░░█░█░█▀█░░█░░░░█▀▄░█▀▀░▀▀█░░█░░░░█▀▀░█▀▄░█▀█░█░░░░█░░░█░░█░░░█▀▀░▀▀█░\n░▀░░░▀▀▀░▀░▀░░▀░░▀░▀░▀░▀░▀▀▀░▀▀▀░▀░▀░░▀░░░░▀▀░░▀▀▀░▀▀▀░░▀░░░░▀░░░▀░▀░▀░▀░▀▀▀░░▀░░▀▀▀░▀▀▀░▀▀▀░▀▀▀░\n```\n\u003cimg src=\"https://currents.dev/favicon-96x96.png\" width=\"24\" height=\"24\" align=\"left\" />by [currents.dev](https://currents.dev?utm_source=ai-skill) - The all-in-one Dashboard for Playwright Testing.\n\n# Playwright Best Practices Skill\n\nA skill that gives the AI specialized guidance for writing, debugging, and maintaining **Playwright** tests in **TypeScript**. Use it in any repo where you work with Playwright so the assistant follows best practices for E2E, component, API, visual regression, accessibility, security, i18n, Electron, and browser extension testing.\n\n## Installation\n\n```bash\nnpx skills add https://github.com/currents-dev/playwright-best-practices-skill\n```\n\nThe skill is activity-based: the AI is directed to the right reference depending on what you're doing, so you get focused advice without loading everything at once.\n\n## When the Skill Is Used\n\nThe skill triggers when the AI infers you need help with things like:\n\n- Writing new E2E, component, API, visual regression, or accessibility tests\n- Testing mobile/responsive layouts, touch gestures, or device emulation\n- Implementing file uploads/downloads, date/time mocking, or WebSocket testing\n- Handling OAuth popups, geolocation, permissions, or multi-tab flows\n- Testing iframes, canvas/WebGL, service workers, or PWA features\n- Testing Electron desktop apps or browser extensions\n- Internationalization (i18n), locales, RTL layouts, or date/number formats\n- Testing error states, offline mode, or network failure scenarios\n- Security testing (XSS, CSRF, authentication, authorization)\n- Performance testing with Web Vitals or Lighthouse\n- Reviewing or refactoring Playwright test code\n- Fixing flaky tests or debugging failures\n- Setting up CI/CD, test coverage, or global setup/teardown\n- Configuring projects, dependencies, parallel runs, or sharding\n\nYou don't have to mention \"skill\" or \"Playwright best practices\"; describe your task (e.g. \"fix this flaky login test\" or \"add accessibility tests\") and the AI will use the skill when it's relevant.\n\n## What's Inside\n\n**57 reference documents** organized into 8 categories:\n\n### Core (`core/`)\n\n| Topic | Reference | Use for |\n| -------------------- | --------------------------- | ------------------------------------------------ |\n| Test structure | `test-suite-structure.md` | Structure, config, E2E/component/API/visual tests |\n| Locators | `locators.md` | Selectors, robustness, avoiding brittle locators |\n| Assertions & waiting | `assertions-waiting.md` | Expect APIs, auto-waiting, polling |\n| Page Object Model | `page-object-model.md` | POM structure and patterns |\n| Fixtures & hooks | `fixtures-hooks.md` | Setup, teardown, auth, custom fixtures |\n| Test data | `test-data.md` | Factories, Faker, data-driven testing |\n| Annotations | `annotations.md` | skip, fixme, slow, test steps |\n| Configuration | `configuration.md` | playwright.config.ts options |\n| Global setup | `global-setup.md` | globalSetup/Teardown, DB migrations |\n| Projects | `projects-dependencies.md` | Project config, dependencies, filtering |\n\n### Debugging (`debugging/`)\n\n| Topic | Reference | Use for |\n| -------------- | -------------------- | ------------------------------------------ |\n| Debugging | `debugging.md` | Trace viewer, inspector, common issues |\n| Flaky tests | `flaky-tests.md` | Detection, diagnosis, fixing, quarantine |\n| Error testing | `error-testing.md` | Error boundaries, offline, network failures |\n| Console errors | `console-errors.md` | Capturing and failing on JS errors |\n\n### Testing Patterns (`testing-patterns/`)\n\n| Topic | Reference | Use for |\n| ------------------- | ------------------------- | ---------------------------------------------- |\n| Accessibility | `accessibility.md` | Axe-core, keyboard nav, ARIA, focus management |\n| API testing | `api-testing.md` | REST API testing, request context |\n| Component testing | `component-testing.md` | CT setup, mounting, props, mocking |\n| Visual regression | `visual-regression.md` | Screenshot comparison, thresholds |\n| File operations | `file-operations.md` | Upload, download basics |\n| File upload/download| `file-upload-download.md` | Progress, cancellation, retry patterns |\n| Forms validation | `forms-validation.md` | Form testing, validation states |\n| Drag and drop | `drag-drop.md` | Drag-and-drop interactions |\n| GraphQL testing | `graphql-testing.md` | GraphQL queries, mutations, mocking |\n| Canvas/WebGL | `canvas-webgl.md` | Canvas testing, charts, WebGL, games |\n| i18n | `i18n.md` | Locales, RTL, date/number formats |\n| Electron | `electron.md` | Desktop apps, IPC, main/renderer process |\n| Browser extensions | `browser-extensions.md` | Popup, background, content scripts, APIs |\n| Security testing | `security-testing.md` | XSS, CSRF, auth security, authorization |\n| Performance testing | `performance-testing.md` | Web Vitals, budgets, Lighthouse |\n\n### Advanced (`advanced/`)\n\n| Topic | Reference | Use for |\n| ---------------- | ----------------------- | ------------------------------------------- |\n| Authentication | `authentication.md` | Login flows, session storage, cookies |\n| Auth flows | `authentication-flows.md` | MFA, password reset, complex auth |\n| Mobile testing | `mobile-testing.md` | Device emulation, touch gestures, viewports |\n| Clock mocking | `clock-mocking.md` | Date/time mocking, timezones, timers |\n| Multi-context | `multi-context.md` | Popups, new tabs, OAuth flows |\n| Multi-user | `multi-user.md` | Collaboration, RBAC, concurrent actions |\n| Network advanced | `network-advanced.md` | GraphQL, HAR, request modification |\n| Third-party | `third-party.md` | OAuth, payments, email/SMS mocking |\n\n### Browser APIs (`browser-apis/`)\n\n| Topic | Reference | Use for |\n| --------------- | -------------------- | ------------------------------------------- |\n| Browser APIs | `browser-apis.md` | Geolocation, permissions, clipboard, camera |\n| WebSockets | `websockets.md` | Real-time testing, SSE, reconnection |\n| iFrames | `iframes.md` | Cross-origin, nested, dynamic iframes |\n| Service workers | `service-workers.md` | PWA, caching, offline, push notifications |\n\n### Architecture (`architecture/`)\n\n| Topic | Reference | Use for |\n| ----------------- | ---------------------- | ------------------------------------ |\n| POM vs fixtures | `pom-vs-fixtures.md` | Choosing between patterns |\n| Test architecture | `test-architecture.md` | Test type selection, structure |\n| When to mock | `when-to-mock.md` | Mock vs real services decisions |\n\n### Frameworks (`frameworks/`)\n\n| Topic | Reference | Use for |\n| ------- | ------------- | ------------------------------ |\n| React | `react.md` | React-specific testing patterns |\n| Angular | `angular.md` | Angular-specific testing |\n| Vue | `vue.md` | Vue/Nuxt testing patterns |\n| Next.js | `nextjs.md` | Next.js SSR/SSG testing |\n\n### Infrastructure & CI/CD (`infrastructure-ci-cd/`)\n\n| Topic | Reference | Use for |\n| ---------------- | ---------------------- | ------------------------------------ |\n| CI/CD | `ci-cd.md` | Pipelines, general CI setup |\n| GitHub Actions | `github-actions.md` | GitHub-specific workflows |\n| GitLab CI | `gitlab.md` | GitLab-specific pipelines |\n| Other providers | `other-providers.md` | CircleCI, Azure DevOps, Jenkins |\n| Docker | `docker.md` | Container setup, Playwright images |\n| Parallel/sharding| `parallel-sharding.md` | Sharding, parallel execution |\n| Performance | `performance.md` | Parallel runs, optimization |\n| Reporting | `reporting.md` | Test reporters, artifacts |\n| Test coverage | `test-coverage.md` | V8 coverage, reports, thresholds, CI |\n\nThe skill's `SKILL.md` maps your current activity to these references so the right content is used in context.\n\n## License\n\nMIT\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10123,"content_sha256":"ab96dc47d58823c6878d96d2e8887a77d0fc3666b323e225066a8b159c7b7f28"},{"filename":"testing-patterns/accessibility.md","content":"# Accessibility Testing\n\n## Table of Contents\n\n1. [Axe-Core Integration](#axe-core-integration)\n2. [Keyboard Navigation](#keyboard-navigation)\n3. [ARIA Validation](#aria-validation)\n4. [Focus Management](#focus-management)\n5. [Color & Contrast](#color--contrast)\n\n## Axe-Core Integration\n\n### Setup\n\n```bash\nnpm install -D @axe-core/playwright\n```\n\n### Basic A11y Test\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\nimport AxeBuilder from \"@axe-core/playwright\";\n\ntest(\"homepage should have no a11y violations\", async ({ page }) => {\n await page.goto(\"/\");\n\n const results = await new AxeBuilder({ page }).analyze();\n\n expect(results.violations).toEqual([]);\n});\n```\n\n### Scoped Analysis\n\n```typescript\ntest(\"form accessibility\", async ({ page }) => {\n await page.goto(\"/contact\");\n\n // Analyze only the form\n const results = await new AxeBuilder({ page })\n .include(\"#contact-form\")\n .analyze();\n\n expect(results.violations).toEqual([]);\n});\n\ntest(\"ignore known issues\", async ({ page }) => {\n await page.goto(\"/legacy-page\");\n\n const results = await new AxeBuilder({ page })\n .exclude(\".legacy-widget\") // Skip legacy component\n .disableRules([\"color-contrast\"]) // Disable specific rule\n .analyze();\n\n expect(results.violations).toEqual([]);\n});\n```\n\n### A11y Fixture\n\n```typescript\n// fixtures/a11y.fixture.ts\nimport { test as base } from \"@playwright/test\";\nimport AxeBuilder from \"@axe-core/playwright\";\n\ntype A11yFixtures = {\n makeAxeBuilder: () => AxeBuilder;\n};\n\nexport const test = base.extend\u003cA11yFixtures>({\n makeAxeBuilder: async ({ page }, use) => {\n await use(() =>\n new AxeBuilder({ page }).withTags([\n \"wcag2a\",\n \"wcag2aa\",\n \"wcag21a\",\n \"wcag21aa\",\n ]),\n );\n },\n});\n\n// Usage\ntest(\"dashboard a11y\", async ({ page, makeAxeBuilder }) => {\n await page.goto(\"/dashboard\");\n const results = await makeAxeBuilder().analyze();\n expect(results.violations).toEqual([]);\n});\n```\n\n### Detailed Violation Reporting\n\n```typescript\ntest(\"report a11y issues\", async ({ page }) => {\n await page.goto(\"/\");\n\n const results = await new AxeBuilder({ page }).analyze();\n\n // Custom failure message with details\n const violations = results.violations.map((v) => ({\n id: v.id,\n impact: v.impact,\n description: v.description,\n nodes: v.nodes.map((n) => n.html),\n }));\n\n expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);\n});\n```\n\n## Keyboard Navigation\n\n### Tab Order Testing\n\n```typescript\ntest(\"correct tab order in form\", async ({ page }) => {\n await page.goto(\"/signup\");\n\n // Start from the beginning\n await page.keyboard.press(\"Tab\");\n await expect(page.getByLabel(\"Email\")).toBeFocused();\n\n await page.keyboard.press(\"Tab\");\n await expect(page.getByLabel(\"Password\")).toBeFocused();\n\n await page.keyboard.press(\"Tab\");\n await expect(page.getByRole(\"button\", { name: \"Sign up\" })).toBeFocused();\n});\n```\n\n### Keyboard-Only Interaction\n\n```typescript\ntest(\"complete flow with keyboard only\", async ({ page }) => {\n await page.goto(\"/products\");\n\n // Navigate to product with keyboard\n await page.keyboard.press(\"Tab\"); // Skip to main content\n await page.keyboard.press(\"Tab\"); // First product\n await page.keyboard.press(\"Enter\"); // Open product\n\n await expect(page).toHaveURL(/\\/products\\/\\d+/);\n\n // Add to cart with keyboard\n await page.keyboard.press(\"Tab\");\n await page.keyboard.press(\"Tab\"); // Navigate to \"Add to Cart\"\n await page.keyboard.press(\"Enter\");\n\n await expect(page.getByRole(\"alert\")).toContainText(\"Added to cart\");\n});\n```\n\n### Skip Links\n\n```typescript\ntest(\"skip link works\", async ({ page }) => {\n await page.goto(\"/\");\n\n await page.keyboard.press(\"Tab\");\n const skipLink = page.getByRole(\"link\", { name: /skip to main/i });\n await expect(skipLink).toBeFocused();\n\n await page.keyboard.press(\"Enter\");\n\n // Focus should move to main content\n await expect(page.getByRole(\"main\")).toBeFocused();\n});\n```\n\n### Escape Key Handling\n\n```typescript\ntest(\"escape closes modal\", async ({ page }) => {\n await page.goto(\"/dashboard\");\n await page.getByRole(\"button\", { name: \"Settings\" }).click();\n\n const modal = page.getByRole(\"dialog\");\n await expect(modal).toBeVisible();\n\n await page.keyboard.press(\"Escape\");\n\n await expect(modal).toBeHidden();\n // Focus should return to trigger\n await expect(page.getByRole(\"button\", { name: \"Settings\" })).toBeFocused();\n});\n```\n\n## ARIA Validation\n\n### Role Verification\n\n```typescript\ntest(\"correct ARIA roles\", async ({ page }) => {\n await page.goto(\"/dashboard\");\n\n // Verify landmark roles\n await expect(page.getByRole(\"navigation\")).toBeVisible();\n await expect(page.getByRole(\"main\")).toBeVisible();\n await expect(page.getByRole(\"contentinfo\")).toBeVisible(); // footer\n\n // Verify interactive roles\n await expect(page.getByRole(\"button\", { name: \"Menu\" })).toBeVisible();\n await expect(page.getByRole(\"search\")).toBeVisible();\n});\n```\n\n### ARIA States\n\n```typescript\ntest(\"aria-expanded updates correctly\", async ({ page }) => {\n await page.goto(\"/faq\");\n\n const accordion = page.getByRole(\"button\", { name: \"Shipping\" });\n\n // Initially collapsed\n await expect(accordion).toHaveAttribute(\"aria-expanded\", \"false\");\n\n await accordion.click();\n\n // Now expanded\n await expect(accordion).toHaveAttribute(\"aria-expanded\", \"true\");\n\n // Content is visible\n const panel = page.getByRole(\"region\", { name: \"Shipping\" });\n await expect(panel).toBeVisible();\n});\n```\n\n### Live Regions\n\n```typescript\ntest(\"live region announces updates\", async ({ page }) => {\n await page.goto(\"/checkout\");\n\n // Find live region\n const liveRegion = page.locator('[aria-live=\"polite\"]');\n\n await page.getByLabel(\"Quantity\").fill(\"3\");\n\n // Live region should update with new total\n await expect(liveRegion).toContainText(\"Total: $29.97\");\n});\n```\n\n## Focus Management\n\n### Focus Trap in Modal\n\n```typescript\ntest(\"focus trapped in modal\", async ({ page }) => {\n await page.goto(\"/\");\n await page.getByRole(\"button\", { name: \"Open Modal\" }).click();\n\n const modal = page.getByRole(\"dialog\");\n await expect(modal).toBeVisible();\n\n // Get all focusable elements in modal\n const focusableElements = modal.locator(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])',\n );\n const count = await focusableElements.count();\n\n // Tab through all elements, should stay in modal\n for (let i = 0; i \u003c count + 1; i++) {\n await page.keyboard.press(\"Tab\");\n const focused = page.locator(\":focus\");\n await expect(modal).toContainText((await focused.textContent()) || \"\");\n }\n});\n```\n\n### Focus Restoration\n\n```typescript\ntest(\"focus returns after modal close\", async ({ page }) => {\n await page.goto(\"/\");\n\n const trigger = page.getByRole(\"button\", { name: \"Delete Item\" });\n await trigger.click();\n\n await page.getByRole(\"button\", { name: \"Cancel\" }).click();\n\n // Focus should return to the trigger\n await expect(trigger).toBeFocused();\n});\n```\n\n## Color & Contrast\n\n### High Contrast Mode\n\n```typescript\ntest(\"works in high contrast mode\", async ({ page }) => {\n await page.emulateMedia({ forcedColors: \"active\" });\n await page.goto(\"/\");\n\n // Verify key elements are visible\n await expect(page.getByRole(\"navigation\")).toBeVisible();\n await expect(page.getByRole(\"button\", { name: \"Sign In\" })).toBeVisible();\n\n // Take screenshot for visual verification\n await expect(page).toHaveScreenshot(\"high-contrast.png\");\n});\n```\n\n### Reduced Motion\n\n```typescript\ntest(\"respects reduced motion preference\", async ({ page }) => {\n await page.emulateMedia({ reducedMotion: \"reduce\" });\n await page.goto(\"/\");\n\n // Animations should be disabled\n const hero = page.getByTestId(\"hero-animation\");\n const animation = await hero.evaluate(\n (el) => getComputedStyle(el).animationDuration,\n );\n\n expect(animation).toBe(\"0s\");\n});\n```\n\n## CI Integration\n\n### A11y as CI Gate\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n projects: [\n {\n name: \"a11y\",\n testMatch: /.*\\.a11y\\.spec\\.ts/,\n use: { ...devices[\"Desktop Chrome\"] },\n },\n ],\n});\n```\n\n```yaml\n# .github/workflows/a11y.yml\n- name: Run accessibility tests\n run: npx playwright test --project=a11y\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ----------------------------- | ---------------------------- | ------------------------------------------ |\n| Testing a11y only on homepage | Misses issues on other pages | Test all critical user flows |\n| Ignoring all violations | No value from tests | Address or explicitly exclude known issues |\n| Only automated testing | Misses many a11y issues | Combine with manual testing |\n| Testing without screen reader | Misses interaction issues | Test with VoiceOver/NVDA periodically |\n\n## Related References\n\n- **Locators**: See [locators.md](../core/locators.md) for role-based selectors\n- **Visual testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot comparison\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9176,"content_sha256":"a52a8d1590c841f8470ee6bea8ea73c052010a36ae9a6afed7939d12bdbf800a"},{"filename":"testing-patterns/api-testing.md","content":"# API Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Decision Guide](#decision-guide)\n3. [Anti-Patterns](#anti-patterns)\n4. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Testing REST APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead.\n> **See also**: [graphql-testing.md](graphql-testing.md) for GraphQL-specific patterns.\n\n## Patterns\n\n### Request Fixtures for Authenticated Clients\n\n**Use when**: Multiple tests need an authenticated API client with shared configuration.\n**Avoid when**: A single test makes one-off API calls — use the built-in `request` fixture directly.\n\n```typescript\n// fixtures/api-fixtures.ts\nimport { test as base, expect, APIRequestContext } from \"@playwright/test\";\n\ntype ApiFixtures = {\n authApi: APIRequestContext;\n adminApi: APIRequestContext;\n};\n\nexport const test = base.extend\u003cApiFixtures>({\n authApi: async ({ playwright }, use) => {\n const ctx = await playwright.request.newContext({\n baseURL: \"https://api.myapp.io\",\n extraHTTPHeaders: {\n Authorization: `Bearer ${process.env.API_TOKEN}`,\n Accept: \"application/json\",\n },\n });\n await use(ctx);\n await ctx.dispose();\n },\n\n adminApi: async ({ playwright }, use) => {\n const loginCtx = await playwright.request.newContext({\n baseURL: \"https://api.myapp.io\",\n });\n const loginResp = await loginCtx.post(\"/auth/login\", {\n data: {\n email: process.env.ADMIN_EMAIL,\n password: process.env.ADMIN_PASSWORD,\n },\n });\n expect(loginResp.ok()).toBeTruthy();\n const { token } = await loginResp.json();\n await loginCtx.dispose();\n\n const ctx = await playwright.request.newContext({\n baseURL: \"https://api.myapp.io\",\n extraHTTPHeaders: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n },\n });\n await use(ctx);\n await ctx.dispose();\n },\n});\n\nexport { expect };\n```\n\n```typescript\n// tests/api/admin.spec.ts\nimport { test, expect } from \"../../fixtures/api-fixtures\";\n\ntest(\"admin retrieves all accounts\", async ({ adminApi }) => {\n const resp = await adminApi.get(\"/admin/accounts\");\n expect(resp.status()).toBe(200);\n const body = await resp.json();\n expect(body.accounts.length).toBeGreaterThan(0);\n});\n```\n\n### CRUD Operations\n\n**Use when**: Making HTTP requests — GET, POST, PUT, PATCH, DELETE with headers, query params, and bodies.\n**Avoid when**: You need to test browser-rendered responses (redirects, cookies with `HttpOnly`).\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\ntest(\"full CRUD cycle\", async ({ request }) => {\n // GET with query params\n const listResp = await request.get(\"/api/items\", {\n params: { page: 1, limit: 10, category: \"tools\" },\n });\n expect(listResp.ok()).toBeTruthy();\n\n // POST with JSON body\n const createResp = await request.post(\"/api/items\", {\n data: {\n title: \"Hammer\",\n price: 19.99,\n category: \"tools\",\n },\n });\n expect(createResp.status()).toBe(201);\n const created = await createResp.json();\n\n // PUT — full replacement\n const putResp = await request.put(`/api/items/${created.id}`, {\n data: {\n title: \"Claw Hammer\",\n price: 24.99,\n category: \"tools\",\n },\n });\n expect(putResp.ok()).toBeTruthy();\n\n // PATCH — partial update\n const patchResp = await request.patch(`/api/items/${created.id}`, {\n data: { price: 22.5 },\n });\n expect(patchResp.ok()).toBeTruthy();\n const patched = await patchResp.json();\n expect(patched.price).toBe(22.5);\n\n // DELETE\n const delResp = await request.delete(`/api/items/${created.id}`);\n expect(delResp.status()).toBe(204);\n\n // Verify deletion\n const getDeleted = await request.get(`/api/items/${created.id}`);\n expect(getDeleted.status()).toBe(404);\n});\n\ntest(\"form-urlencoded body\", async ({ request }) => {\n const resp = await request.post(\"/oauth/token\", {\n form: {\n grant_type: \"client_credentials\",\n client_id: \"my-client\",\n client_secret: \"secret-value\",\n },\n });\n expect(resp.ok()).toBeTruthy();\n const token = await resp.json();\n expect(token).toHaveProperty(\"access_token\");\n});\n```\n\n### Dedicated API Project Configuration\n\n**Use when**: Writing dedicated API test suites that do not need a browser.\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\n\nexport default defineConfig({\n projects: [\n {\n name: \"api\",\n testDir: \"./tests/api\",\n use: {\n baseURL: \"https://api.myapp.io\",\n extraHTTPHeaders: { Accept: \"application/json\" },\n },\n },\n {\n name: \"e2e\",\n testDir: \"./tests/e2e\",\n use: {\n baseURL: \"https://myapp.io\",\n browserName: \"chromium\",\n },\n },\n ],\n});\n```\n\n### Response Assertions\n\n**Use when**: Validating response status, headers, and body structure.\n**Avoid when**: Never skip these — every API test should assert on status and body.\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\ntest(\"comprehensive response validation\", async ({ request }) => {\n const resp = await request.get(\"/api/items/101\");\n\n // Status code — always check first\n expect(resp.status()).toBe(200);\n expect(resp.ok()).toBeTruthy();\n\n // Headers\n expect(resp.headers()[\"content-type\"]).toContain(\"application/json\");\n expect(resp.headers()[\"cache-control\"]).toMatch(/max-age=\\d+/);\n\n const item = await resp.json();\n\n // Exact match on known fields\n expect(item.id).toBe(101);\n expect(item.title).toBe(\"Widget\");\n\n // Partial match — ignore fields you don't care about\n expect(item).toMatchObject({\n id: 101,\n title: \"Widget\",\n status: expect.stringMatching(/^(active|inactive|archived)$/),\n });\n\n // Type checks\n expect(item).toMatchObject({\n id: expect.any(Number),\n title: expect.any(String),\n createdAt: expect.any(String),\n tags: expect.any(Array),\n });\n\n // Array content\n expect(item.tags).toEqual(expect.arrayContaining([\"featured\"]));\n expect(item.tags).not.toContain(\"deprecated\");\n\n // Nested object\n expect(item.metadata).toMatchObject({\n views: expect.any(Number),\n rating: expect.any(Number),\n });\n\n // Date format\n expect(new Date(item.createdAt).toISOString()).toBe(item.createdAt);\n});\n\ntest(\"list response structure\", async ({ request }) => {\n const resp = await request.get(\"/api/items\");\n const body = await resp.json();\n\n expect(body.items).toHaveLength(10);\n\n for (const item of body.items) {\n expect(item).toMatchObject({\n id: expect.any(Number),\n title: expect.any(String),\n price: expect.any(Number),\n });\n }\n\n expect(body.pagination).toEqual({\n page: 1,\n limit: 10,\n total: expect.any(Number),\n totalPages: expect.any(Number),\n });\n});\n```\n\n### API Data Seeding\n\n**Use when**: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup.\n**Avoid when**: The test specifically validates the creation flow through the UI.\n\n```typescript\nimport { test as base, expect } from \"@playwright/test\";\n\ntype SeedFixtures = {\n seedAccount: { id: number; email: string; password: string };\n seedWorkspace: { id: number; name: string };\n};\n\nexport const test = base.extend\u003cSeedFixtures>({\n seedAccount: async ({ request }, use) => {\n const email = `account-${Date.now()}@test.io`;\n const password = \"SecurePass123!\";\n\n const resp = await request.post(\"/api/accounts\", {\n data: { name: \"Test Account\", email, password },\n });\n expect(resp.ok()).toBeTruthy();\n const account = await resp.json();\n\n await use({ id: account.id, email, password });\n\n // Cleanup\n await request.delete(`/api/accounts/${account.id}`);\n },\n\n seedWorkspace: async ({ request, seedAccount }, use) => {\n const resp = await request.post(\"/api/workspaces\", {\n data: { name: `Workspace ${Date.now()}`, ownerId: seedAccount.id },\n });\n expect(resp.ok()).toBeTruthy();\n const workspace = await resp.json();\n\n await use({ id: workspace.id, name: workspace.name });\n\n await request.delete(`/api/workspaces/${workspace.id}`);\n },\n});\n\nexport { expect };\n```\n\n```typescript\n// tests/e2e/workspace-dashboard.spec.ts\nimport { test, expect } from \"../../fixtures/seed-fixtures\";\n\ntest(\"user sees workspace on dashboard\", async ({\n page,\n seedAccount,\n seedWorkspace,\n}) => {\n await page.goto(\"/login\");\n await page.getByLabel(\"Email\").fill(seedAccount.email);\n await page.getByLabel(\"Password\").fill(seedAccount.password);\n await page.getByRole(\"button\", { name: \"Sign in\" }).click();\n\n await page.waitForURL(\"/dashboard\");\n await expect(\n page.getByRole(\"heading\", { name: seedWorkspace.name })\n ).toBeVisible();\n});\n```\n\n### Error Response Testing\n\n**Use when**: Every API has error paths — test them. A missing 401 test today is a security hole tomorrow.\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\ntest.describe(\"Error responses\", () => {\n test(\"400 — validation error with details\", async ({ request }) => {\n const resp = await request.post(\"/api/items\", {\n data: { title: \"\", price: -5 },\n });\n expect(resp.status()).toBe(400);\n\n const body = await resp.json();\n expect(body).toMatchObject({\n error: \"Validation Error\",\n details: expect.any(Array),\n });\n expect(body.details).toEqual(\n expect.arrayContaining([\n expect.objectContaining({\n field: \"title\",\n message: expect.any(String),\n }),\n expect.objectContaining({\n field: \"price\",\n message: expect.any(String),\n }),\n ])\n );\n });\n\n test(\"401 — missing authentication\", async ({ request }) => {\n const resp = await request.get(\"/api/protected/resource\", {\n headers: { Authorization: \"\" },\n });\n expect(resp.status()).toBe(401);\n const body = await resp.json();\n expect(body.error).toMatch(/unauthorized|unauthenticated/i);\n });\n\n test(\"403 — insufficient permissions\", async ({ request }) => {\n const resp = await request.delete(\"/api/admin/items/1\");\n expect(resp.status()).toBe(403);\n const body = await resp.json();\n expect(body.error).toMatch(/forbidden|insufficient permissions/i);\n });\n\n test(\"404 — resource not found\", async ({ request }) => {\n const resp = await request.get(\"/api/items/999999\");\n expect(resp.status()).toBe(404);\n const body = await resp.json();\n expect(body).toMatchObject({ error: expect.stringMatching(/not found/i) });\n });\n\n test(\"409 — conflict on duplicate\", async ({ request }) => {\n const sku = `SKU-${Date.now()}`;\n await request.post(\"/api/items\", { data: { title: \"First\", sku } });\n\n const resp = await request.post(\"/api/items\", {\n data: { title: \"Duplicate\", sku },\n });\n expect(resp.status()).toBe(409);\n });\n\n test(\"422 — unprocessable entity\", async ({ request }) => {\n const resp = await request.post(\"/api/orders\", {\n data: { items: [] },\n });\n expect(resp.status()).toBe(422);\n const body = await resp.json();\n expect(body.error).toContain(\"at least one item\");\n });\n\n test(\"429 — rate limiting\", async ({ request }) => {\n const responses = await Promise.all(\n Array.from({ length: 50 }, () =>\n request.get(\"/api/search\", { params: { q: \"test\" } })\n )\n );\n const rateLimited = responses.filter((r) => r.status() === 429);\n expect(rateLimited.length).toBeGreaterThan(0);\n expect(rateLimited[0].headers()[\"retry-after\"]).toBeDefined();\n });\n});\n```\n\n### File Upload via API\n\n**Use when**: Testing file upload endpoints with multipart form data.\n**Avoid when**: You need to test the browser file picker dialog — use `page.setInputFiles()` instead.\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\nimport path from \"path\";\nimport fs from \"fs\";\n\ntest(\"upload file via multipart\", async ({ request }) => {\n const filePath = path.resolve(\"tests/fixtures/report.pdf\");\n\n const resp = await request.post(\"/api/documents/upload\", {\n multipart: {\n file: {\n name: \"report.pdf\",\n mimeType: \"application/pdf\",\n buffer: fs.readFileSync(filePath),\n },\n description: \"Monthly report\",\n category: \"reports\",\n },\n });\n\n expect(resp.status()).toBe(201);\n const body = await resp.json();\n expect(body).toMatchObject({\n id: expect.any(String),\n filename: \"report.pdf\",\n mimeType: \"application/pdf\",\n size: expect.any(Number),\n url: expect.stringMatching(/^https:\\/\\//),\n });\n});\n\ntest(\"rejects oversized files\", async ({ request }) => {\n const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB\n\n const resp = await request.post(\"/api/documents/upload\", {\n multipart: {\n file: {\n name: \"large-file.bin\",\n mimeType: \"application/octet-stream\",\n buffer: largeBuffer,\n },\n },\n });\n\n expect(resp.status()).toBe(413);\n});\n```\n\n### Chained API Calls\n\n**Use when**: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions.\n**Avoid when**: You can test each endpoint in isolation and the interactions are trivial.\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\ntest(\"complete order workflow\", async ({ request }) => {\n // Step 1: Create a product\n const productResp = await request.post(\"/api/products\", {\n data: { name: \"Gadget\", price: 49.99, stock: 50 },\n });\n expect(productResp.status()).toBe(201);\n const product = await productResp.json();\n\n // Step 2: Create a cart\n const cartResp = await request.post(\"/api/carts\", {\n data: { items: [{ productId: product.id, quantity: 3 }] },\n });\n expect(cartResp.status()).toBe(201);\n const cart = await cartResp.json();\n expect(cart.total).toBe(149.97);\n\n // Step 3: Checkout\n const orderResp = await request.post(\"/api/orders\", {\n data: {\n cartId: cart.id,\n shippingAddress: {\n street: \"456 Main Ave\",\n city: \"Metropolis\",\n zip: \"54321\",\n },\n },\n });\n expect(orderResp.status()).toBe(201);\n const order = await orderResp.json();\n expect(order.status).toBe(\"pending\");\n expect(order.items).toHaveLength(1);\n\n // Step 4: Verify order in list\n const ordersResp = await request.get(\"/api/orders\");\n const orders = await ordersResp.json();\n expect(orders.items.map((o: any) => o.id)).toContain(order.id);\n\n // Step 5: Verify stock decreased\n const updatedProduct = await (\n await request.get(`/api/products/${product.id}`)\n ).json();\n expect(updatedProduct.stock).toBe(47);\n\n // Cleanup\n await request.delete(`/api/orders/${order.id}`);\n await request.delete(`/api/products/${product.id}`);\n});\n\ntest(\"state machine transitions — publish workflow\", async ({ request }) => {\n const createResp = await request.post(\"/api/articles\", {\n data: { title: \"Draft Article\", body: \"Content here.\" },\n });\n const article = await createResp.json();\n expect(article.status).toBe(\"draft\");\n\n // Submit for review\n const reviewResp = await request.patch(`/api/articles/${article.id}/status`, {\n data: { status: \"in_review\" },\n });\n expect(reviewResp.ok()).toBeTruthy();\n expect((await reviewResp.json()).status).toBe(\"in_review\");\n\n // Approve\n const approveResp = await request.patch(\n `/api/articles/${article.id}/status`,\n {\n data: { status: \"published\" },\n }\n );\n expect(approveResp.ok()).toBeTruthy();\n expect((await approveResp.json()).status).toBe(\"published\");\n\n // Cannot revert to draft from published\n const revertResp = await request.patch(`/api/articles/${article.id}/status`, {\n data: { status: \"draft\" },\n });\n expect(revertResp.status()).toBe(422);\n\n await request.delete(`/api/articles/${article.id}`);\n});\n\ntest(\"API + E2E hybrid — seed via API, verify in browser\", async ({\n request,\n page,\n}) => {\n const resp = await request.post(\"/api/products\", {\n data: {\n name: `Hybrid Product ${Date.now()}`,\n price: 35.0,\n published: true,\n },\n });\n const product = await resp.json();\n\n await page.goto(\"/products\");\n await expect(page.getByRole(\"heading\", { name: product.name })).toBeVisible();\n await expect(page.getByText(\"$35.00\")).toBeVisible();\n\n await request.delete(`/api/products/${product.id}`);\n});\n```\n\n### Schema Validation with Zod\n\n**Use when**: Verifying API responses match a contract — field types, required fields, value constraints.\n**Avoid when**: You only need to check one or two specific fields — use `toMatchObject` instead.\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\nimport { z } from \"zod\";\n\nconst ItemSchema = z.object({\n id: z.number().positive(),\n title: z.string().min(1),\n price: z.number().nonnegative(),\n status: z.enum([\"active\", \"inactive\", \"archived\"]),\n createdAt: z.string().datetime(),\n metadata: z.object({\n views: z.number().int().nonnegative(),\n rating: z.number().min(0).max(5).nullable(),\n }),\n});\n\nconst PaginatedItemsSchema = z.object({\n items: z.array(ItemSchema),\n pagination: z.object({\n page: z.number().int().positive(),\n limit: z.number().int().positive(),\n total: z.number().int().nonnegative(),\n }),\n});\n\ntest(\"GET /api/items matches schema\", async ({ request }) => {\n const resp = await request.get(\"/api/items\");\n expect(resp.ok()).toBeTruthy();\n\n const body = await resp.json();\n const result = PaginatedItemsSchema.safeParse(body);\n\n if (!result.success) {\n throw new Error(\n `Schema validation failed:\\n${result.error.issues\n .map((i) => ` ${i.path.join(\".\")}: ${i.message}`)\n .join(\"\\n\")}`\n );\n }\n});\n```\n\n## Decision Guide\n\n| Scenario | Use API Tests | Use E2E Tests | Why |\n| ------------------------------------------------ | --------------------------- | ------------------------------ | ------------------------------------------------------------------ |\n| Validate response status/body/headers | Yes | No | No browser needed; 10-100x faster |\n| Test business logic (calculations, rules) | Yes | No | API tests isolate backend logic from UI |\n| Verify form submission creates correct data | Seed via API, submit via UI | Yes | UI test validates the form; API check confirms persistence |\n| Test error messages shown to user | No | Yes | Error rendering is a UI concern |\n| Validate pagination, filtering, sorting | Yes | Maybe both | API test for correctness; E2E test only if the UI logic is complex |\n| Seed test data for E2E tests | Yes (fixture) | No | API seeding is fast and reliable |\n| Test auth flows (login/logout/RBAC) | Yes for token/session logic | Yes for UI flow | Both matter: API protects resources, UI guides users |\n| Verify file upload processing | Yes | Only if testing file picker UI | API test validates backend processing |\n| Contract/schema regression testing | Yes | No | Schema tests run in milliseconds |\n| Test third-party webhook handling | Yes | No | Webhooks are API-to-API; no UI involved |\n| Verify redirect behavior after action | No | Yes | Redirects are browser/navigation concerns |\n| Test real-time updates (WebSocket + API trigger) | API triggers | E2E verifies | Seed via API, observe in browser |\n\n## Anti-Patterns\n\n| Don't Do This | Problem | Do This Instead |\n| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |\n| Use E2E tests to validate pure API responses | Slow, flaky, launches a browser for no reason | Use `request` fixture — no browser, direct HTTP |\n| Ignore `response.status()` | A 500 with a fallback body can pass all body assertions | Always assert status first: `expect(response.status()).toBe(200)` |\n| Skip response header checks | Missing `Content-Type`, `Cache-Control`, CORS headers cause production bugs | Assert critical headers |\n| Only test the happy path | Real users trigger 400, 401, 403, 404, 409, 422 — every one needs a test | Dedicate a `describe` block to error responses |\n| Hardcode IDs in API tests | Tests break when database is reset or IDs are reassigned | Create resources in the test, use returned IDs |\n| Share mutable state between tests | Tests that depend on execution order are flaky and cannot run in parallel | Each test creates and cleans up its own data |\n| Parse `response.text()` then `JSON.parse()` manually | Playwright's `response.json()` handles this and throws clear errors on non-JSON | Use `await response.json()` |\n| Forget cleanup after creating resources | Test pollution: subsequent tests may see stale data or hit unique constraints | Use fixtures with teardown or explicit `delete` calls |\n| Use `page.request` when you don't need a page | `page.request` shares cookies with the browser context, which may cause auth confusion | Use the standalone `request` fixture for pure API tests |\n\n## Troubleshooting\n\n### \"Request failed: connect ECONNREFUSED 127.0.0.1:3000\"\n\n**Cause**: The API server is not running, or `baseURL` points to the wrong host/port.\n\n**Fix**: Verify the server is running before tests. Use `webServer` in config to start it automatically.\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n webServer: {\n command: \"npm run start:api\",\n url: \"http://localhost:3000/api/health\",\n reuseExistingServer: !process.env.CI,\n },\n use: { baseURL: \"http://localhost:3000\" },\n});\n```\n\n### \"response.json() failed — body is not valid JSON\"\n\n**Cause**: The endpoint returned HTML (error page), plain text, or an empty body instead of JSON.\n\n**Fix**: Check `response.status()` first — a 500 or 302 often returns HTML. Log `await response.text()` to see the actual body. Verify the `Accept: application/json` header is set.\n\n```typescript\nconst resp = await request.get(\"/api/endpoint\");\nif (!resp.ok()) {\n console.error(`Status: ${resp.status()}, Body: ${await resp.text()}`);\n}\nconst body = await resp.json();\n```\n\n### \"401 Unauthorized\" when using `request` fixture\n\n**Cause**: The built-in `request` fixture does not carry browser cookies or auth tokens automatically.\n\n**Fix**: Set `extraHTTPHeaders` in config or create a custom authenticated fixture. If you need cookies from a browser login, use `page.request` instead.\n\n```typescript\n// Option A: config-level headers\nexport default defineConfig({\n use: {\n extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },\n },\n});\n\n// Option B: per-request headers\nconst resp = await request.get(\"/api/resource\", {\n headers: { Authorization: `Bearer ${token}` },\n});\n\n// Option C: use page.request to inherit browser cookies\ntest(\"API call with browser auth\", async ({ page }) => {\n await page.goto(\"/login\");\n // ... login via UI ...\n const resp = await page.request.get(\"/api/profile\");\n expect(resp.ok()).toBeTruthy();\n});\n```\n\n### Tests pass locally but fail in CI\n\n**Cause**: Different environments, database state, or missing environment variables.\n\n**Fix**: Use `process.env` for secrets and base URLs. Run database seeds or migrations in `globalSetup`. Use unique identifiers (timestamps, UUIDs) for test data. Check that the CI `baseURL` matches the deployed service.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":24927,"content_sha256":"b7b94a9015655e4d8f1826b440d5270fc8a7d969466d80aad4e7196faf60079f"},{"filename":"testing-patterns/browser-extensions.md","content":"# Browser Extension Testing\n\n## Table of Contents\n\n1. [Setup & Configuration](#setup--configuration)\n2. [Loading Extensions](#loading-extensions)\n3. [Popup Testing](#popup-testing)\n4. [Background Script Testing](#background-script-testing)\n5. [Content Script Testing](#content-script-testing)\n6. [Extension APIs](#extension-apis)\n7. [Cross-Browser Testing](#cross-browser-testing)\n\n## Setup & Configuration\n\n### Prerequisites\n\n```bash\nnpm install -D @playwright/test\nnpx playwright install chromium # Extensions only work in Chromium\n```\n\n### Basic Configuration\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\nimport path from \"path\";\n\nexport default defineConfig({\n testDir: \"./tests\",\n use: {\n // Extensions require non-headless Chromium\n headless: false,\n },\n projects: [\n {\n name: \"chromium-extension\",\n use: {\n browserName: \"chromium\",\n },\n },\n ],\n});\n```\n\n### Extension Fixture\n\n```typescript\n// fixtures/extension.ts\nimport { test as base, chromium, BrowserContext, Page } from \"@playwright/test\";\nimport path from \"path\";\n\ntype ExtensionFixtures = {\n context: BrowserContext;\n extensionId: string;\n backgroundPage: Page;\n};\n\nexport const test = base.extend\u003cExtensionFixtures>({\n context: async ({}, use) => {\n const pathToExtension = path.join(__dirname, \"../extension\");\n\n const context = await chromium.launchPersistentContext(\"\", {\n headless: false,\n args: [\n `--disable-extensions-except=${pathToExtension}`,\n `--load-extension=${pathToExtension}`,\n ],\n });\n\n await use(context);\n await context.close();\n },\n\n extensionId: async ({ context }, use) => {\n // Get extension ID from service worker URL\n let extensionId = \"\";\n\n // Wait for service worker to be registered\n const serviceWorker =\n context.serviceWorkers()[0] ||\n (await context.waitForEvent(\"serviceworker\"));\n\n extensionId = serviceWorker.url().split(\"/\")[2];\n\n await use(extensionId);\n },\n\n backgroundPage: async ({ context }, use) => {\n // For Manifest V2 extensions\n const backgroundPage =\n context.backgroundPages()[0] ||\n (await context.waitForEvent(\"backgroundpage\"));\n\n await use(backgroundPage);\n },\n});\n\nexport { expect } from \"@playwright/test\";\n```\n\n## Loading Extensions\n\n### Manifest V3 (Service Worker)\n\n```typescript\ntest(\"load MV3 extension\", async () => {\n const pathToExtension = path.join(__dirname, \"../my-extension\");\n\n const context = await chromium.launchPersistentContext(\"\", {\n headless: false,\n args: [\n `--disable-extensions-except=${pathToExtension}`,\n `--load-extension=${pathToExtension}`,\n ],\n });\n\n // Wait for service worker\n const serviceWorker = await context.waitForEvent(\"serviceworker\");\n expect(serviceWorker.url()).toContain(\"chrome-extension://\");\n\n await context.close();\n});\n```\n\n### Manifest V2 (Background Page)\n\n```typescript\ntest(\"load MV2 extension\", async () => {\n const pathToExtension = path.join(__dirname, \"../my-extension-v2\");\n\n const context = await chromium.launchPersistentContext(\"\", {\n headless: false,\n args: [\n `--disable-extensions-except=${pathToExtension}`,\n `--load-extension=${pathToExtension}`,\n ],\n });\n\n // Wait for background page\n const backgroundPage = await context.waitForEvent(\"backgroundpage\");\n expect(backgroundPage.url()).toContain(\"chrome-extension://\");\n\n await context.close();\n});\n```\n\n### Multiple Extensions\n\n```typescript\ntest(\"load multiple extensions\", async () => {\n const extension1 = path.join(__dirname, \"../extension1\");\n const extension2 = path.join(__dirname, \"../extension2\");\n\n const context = await chromium.launchPersistentContext(\"\", {\n headless: false,\n args: [\n `--disable-extensions-except=${extension1},${extension2}`,\n `--load-extension=${extension1},${extension2}`,\n ],\n });\n\n // Both service workers should be available\n await context.waitForEvent(\"serviceworker\");\n await context.waitForEvent(\"serviceworker\");\n\n expect(context.serviceWorkers().length).toBe(2);\n\n await context.close();\n});\n```\n\n## Popup Testing\n\n### Opening Extension Popup\n\n```typescript\ntest(\"test popup UI\", async ({ context, extensionId }) => {\n // Open popup directly by URL\n const popupPage = await context.newPage();\n await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);\n\n // Test popup interactions\n await expect(popupPage.getByRole(\"heading\")).toHaveText(\"My Extension\");\n await popupPage.getByRole(\"button\", { name: \"Enable\" }).click();\n await expect(popupPage.getByText(\"Enabled\")).toBeVisible();\n});\n```\n\n### Popup State Persistence\n\n```typescript\ntest(\"popup remembers state\", async ({ context, extensionId }) => {\n // First interaction\n const popup1 = await context.newPage();\n await popup1.goto(`chrome-extension://${extensionId}/popup.html`);\n await popup1.getByRole(\"checkbox\", { name: \"Dark Mode\" }).check();\n await popup1.close();\n\n // Reopen popup\n const popup2 = await context.newPage();\n await popup2.goto(`chrome-extension://${extensionId}/popup.html`);\n\n // State should persist\n await expect(\n popup2.getByRole(\"checkbox\", { name: \"Dark Mode\" }),\n ).toBeChecked();\n});\n```\n\n### Popup Communication with Background\n\n```typescript\ntest(\"popup sends message to background\", async ({ context, extensionId }) => {\n const popup = await context.newPage();\n await popup.goto(`chrome-extension://${extensionId}/popup.html`);\n\n // Set up listener for response\n const responsePromise = popup.evaluate(() => {\n return new Promise((resolve) => {\n chrome.runtime.onMessage.addListener((message) => {\n if (message.type === \"RESPONSE\") resolve(message.data);\n });\n });\n });\n\n // Click button that sends message\n await popup.getByRole(\"button\", { name: \"Fetch Data\" }).click();\n\n // Verify response\n const response = await responsePromise;\n expect(response).toBeDefined();\n});\n```\n\n## Background Script Testing\n\n### Manifest V3 Service Worker\n\n```typescript\ntest(\"service worker handles messages\", async ({ context, extensionId }) => {\n const page = await context.newPage();\n await page.goto(\"https://example.com\");\n\n // Send message to service worker from page\n const response = await page.evaluate(async (extId) => {\n return new Promise((resolve) => {\n chrome.runtime.sendMessage(extId, { type: \"GET_STATUS\" }, resolve);\n });\n }, extensionId);\n\n expect(response).toEqual({ status: \"active\" });\n});\n```\n\n### Testing Background Logic\n\n```typescript\ntest(\"background script logic\", async ({ context }) => {\n const serviceWorker =\n context.serviceWorkers()[0] ||\n (await context.waitForEvent(\"serviceworker\"));\n\n // Evaluate in service worker context\n const result = await serviceWorker.evaluate(async () => {\n // Access extension APIs\n const storage = await chrome.storage.local.get(\"settings\");\n return storage;\n });\n\n expect(result.settings).toBeDefined();\n});\n```\n\n### Alarms and Timers\n\n```typescript\ntest(\"alarm triggers correctly\", async ({ context }) => {\n const serviceWorker = await context.waitForEvent(\"serviceworker\");\n\n // Create alarm\n await serviceWorker.evaluate(async () => {\n await chrome.alarms.create(\"test-alarm\", { delayInMinutes: 0.01 });\n });\n\n // Wait for alarm handler\n await serviceWorker.evaluate(() => {\n return new Promise\u003cvoid>((resolve) => {\n chrome.alarms.onAlarm.addListener((alarm) => {\n if (alarm.name === \"test-alarm\") resolve();\n });\n });\n });\n\n // Verify alarm was handled (check side effects)\n const wasHandled = await serviceWorker.evaluate(async () => {\n const { alarmTriggered } = await chrome.storage.local.get(\"alarmTriggered\");\n return alarmTriggered;\n });\n\n expect(wasHandled).toBe(true);\n});\n```\n\n## Content Script Testing\n\n### Injected Content Script\n\n```typescript\ntest(\"content script injects UI\", async ({ context }) => {\n const page = await context.newPage();\n await page.goto(\"https://example.com\");\n\n // Wait for content script to inject elements\n await expect(page.locator(\"#my-extension-widget\")).toBeVisible();\n\n // Interact with injected UI\n await page.locator(\"#my-extension-widget button\").click();\n await expect(page.locator(\"#my-extension-widget .result\")).toHaveText(\n \"Success\",\n );\n});\n```\n\n### Content Script Communication\n\n```typescript\ntest(\"content script communicates with background\", async ({\n context,\n extensionId,\n}) => {\n const page = await context.newPage();\n await page.goto(\"https://example.com\");\n\n // Trigger content script action\n await page.locator(\"#my-extension-button\").click();\n\n // Wait for background response reflected in UI\n await expect(page.locator(\"#my-extension-status\")).toHaveText(\"Connected\");\n});\n```\n\n### Page Modification Testing\n\n```typescript\ntest(\"content script modifies page\", async ({ context }) => {\n const page = await context.newPage();\n await page.goto(\"https://example.com\");\n\n // Verify content script modifications\n const hasModification = await page.evaluate(() => {\n // Check for injected styles\n const styles = document.querySelectorAll('style[data-extension=\"my-ext\"]');\n return styles.length > 0;\n });\n\n expect(hasModification).toBe(true);\n\n // Check DOM modifications\n const modifiedElements = await page\n .locator(\"[data-modified-by-extension]\")\n .count();\n expect(modifiedElements).toBeGreaterThan(0);\n});\n```\n\n## Extension APIs\n\n### Storage API\n\n```typescript\ntest(\"chrome.storage operations\", async ({ context }) => {\n const serviceWorker = await context.waitForEvent(\"serviceworker\");\n\n // Set storage\n await serviceWorker.evaluate(async () => {\n await chrome.storage.local.set({ key: \"value\", count: 42 });\n });\n\n // Get storage\n const data = await serviceWorker.evaluate(async () => {\n return await chrome.storage.local.get([\"key\", \"count\"]);\n });\n\n expect(data).toEqual({ key: \"value\", count: 42 });\n\n // Test storage.sync\n await serviceWorker.evaluate(async () => {\n await chrome.storage.sync.set({ synced: true });\n });\n\n const syncData = await serviceWorker.evaluate(async () => {\n return await chrome.storage.sync.get(\"synced\");\n });\n\n expect(syncData.synced).toBe(true);\n});\n```\n\n### Tabs API\n\n```typescript\ntest(\"chrome.tabs operations\", async ({ context }) => {\n const serviceWorker = await context.waitForEvent(\"serviceworker\");\n\n // Create a tab\n const page = await context.newPage();\n await page.goto(\"https://example.com\");\n\n // Query tabs from service worker\n const tabs = await serviceWorker.evaluate(async () => {\n return await chrome.tabs.query({ url: \"*://example.com/*\" });\n });\n\n expect(tabs.length).toBeGreaterThan(0);\n expect(tabs[0].url).toContain(\"example.com\");\n\n // Send message to tab\n await serviceWorker.evaluate(async (tabId) => {\n await chrome.tabs.sendMessage(tabId, { type: \"PING\" });\n }, tabs[0].id);\n});\n```\n\n### Context Menus\n\n```typescript\ntest(\"context menu actions\", async ({ context, extensionId }) => {\n const serviceWorker = await context.waitForEvent(\"serviceworker\");\n\n // Create context menu\n await serviceWorker.evaluate(async () => {\n await chrome.contextMenus.create({\n id: \"test-menu\",\n title: \"Test Action\",\n contexts: [\"selection\"],\n });\n });\n\n // Simulate context menu click\n const page = await context.newPage();\n await page.goto(\"https://example.com\");\n\n // Select text\n await page.evaluate(() => {\n const range = document.createRange();\n range.selectNodeContents(document.body.firstChild!);\n window.getSelection()?.addRange(range);\n });\n\n // Trigger context menu action programmatically\n await serviceWorker.evaluate(async () => {\n // Simulate the click handler\n chrome.contextMenus.onClicked.dispatch(\n { menuItemId: \"test-menu\", selectionText: \"selected text\" },\n { id: 1, url: \"https://example.com\" },\n );\n });\n});\n```\n\n### Permissions API\n\n```typescript\ntest(\"request permissions\", async ({ context, extensionId }) => {\n const popup = await context.newPage();\n await popup.goto(`chrome-extension://${extensionId}/popup.html`);\n\n // Check current permissions\n const hasPermission = await popup.evaluate(async () => {\n return await chrome.permissions.contains({\n origins: [\"https://*.github.com/*\"],\n });\n });\n\n // Request new permission (will show prompt in real scenario)\n // For testing, we check the request is made correctly\n const permissionRequest = popup.evaluate(async () => {\n try {\n return await chrome.permissions.request({\n origins: [\"https://*.github.com/*\"],\n });\n } catch (e) {\n return false;\n }\n });\n\n // In automated tests, permission prompts are typically auto-granted or mocked\n});\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ------------------------------ | --------------------- | ---------------------------------------- |\n| Testing in headless mode | Extensions don't load | Use `headless: false` |\n| Not waiting for service worker | Race conditions | Wait for `serviceworker` event |\n| Hardcoding extension ID | ID changes on reload | Extract ID from service worker URL |\n| Testing packed extensions only | Slow iteration | Test unpacked during development |\n| Ignoring MV3 differences | Breaking changes | Test both MV2 and MV3 if supporting both |\n\n## Related References\n\n- **Service Workers**: See [service-workers.md](../browser-apis/service-workers.md) for SW testing patterns\n- **Multi-Context**: See [multi-context.md](../advanced/multi-context.md) for popup handling\n- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions testing\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13826,"content_sha256":"2f05f8f62f02ced311802c44e96198db3ceade61d2224501939ded091e952ee2"},{"filename":"testing-patterns/canvas-webgl.md","content":"# Canvas & WebGL Testing\n\n## Table of Contents\n\n1. [Canvas Basics](#canvas-basics)\n2. [Visual Comparison](#visual-comparison)\n3. [Interaction Testing](#interaction-testing)\n4. [WebGL Testing](#webgl-testing)\n5. [Chart Libraries](#chart-libraries)\n6. [Game & Animation Testing](#game--animation-testing)\n\n## Canvas Basics\n\n### Locating Canvas Elements\n\n```typescript\ntest(\"find canvas\", async ({ page }) => {\n await page.goto(\"/canvas-app\");\n\n // By tag\n const canvas = page.locator(\"canvas\");\n\n // By ID or class\n const gameCanvas = page.locator(\"canvas#game\");\n const chartCanvas = page.locator(\"canvas.chart-canvas\");\n\n // Verify canvas is present and visible\n await expect(canvas).toBeVisible();\n\n // Get canvas dimensions\n const box = await canvas.boundingBox();\n console.log(`Canvas size: ${box?.width}x${box?.height}`);\n});\n```\n\n### Canvas Screenshot Testing\n\n```typescript\ntest(\"canvas renders correctly\", async ({ page }) => {\n await page.goto(\"/chart\");\n\n // Wait for canvas to be ready (check for specific content)\n await page.waitForFunction(() => {\n const canvas = document.querySelector(\"canvas\");\n const ctx = canvas?.getContext(\"2d\");\n // Check if canvas has been drawn to\n return ctx && !isCanvasBlank(canvas);\n\n function isCanvasBlank(canvas) {\n const ctx = canvas.getContext(\"2d\");\n const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;\n return !data.some((channel) => channel !== 0);\n }\n });\n\n // Screenshot just the canvas\n const canvas = page.locator(\"canvas\");\n await expect(canvas).toHaveScreenshot(\"chart.png\");\n});\n```\n\n### Extracting Canvas Data\n\n```typescript\ntest(\"verify canvas content\", async ({ page }) => {\n await page.goto(\"/drawing-app\");\n\n // Get canvas image data\n const imageData = await page.evaluate(() => {\n const canvas = document.querySelector(\"canvas\") as HTMLCanvasElement;\n return canvas.toDataURL(\"image/png\");\n });\n\n // Verify it's not empty\n expect(imageData).toMatch(/^data:image\\/png;base64,.+/);\n\n // Get pixel data at specific location\n const pixelColor = await page.evaluate(() => {\n const canvas = document.querySelector(\"canvas\") as HTMLCanvasElement;\n const ctx = canvas.getContext(\"2d\")!;\n const pixel = ctx.getImageData(100, 100, 1, 1).data;\n return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] };\n });\n\n // Verify specific pixel color\n expect(pixelColor.r).toBeGreaterThan(200); // Expecting red-ish\n});\n```\n\n## Visual Comparison\n\n### Screenshot Assertions\n\n```typescript\ntest(\"chart matches baseline\", async ({ page }) => {\n await page.goto(\"/dashboard\");\n\n // Wait for chart animation to complete\n await page.waitForTimeout(1000); // Or better: wait for specific state\n\n // Full page screenshot\n await expect(page).toHaveScreenshot(\"dashboard.png\", {\n maxDiffPixels: 100, // Allow small differences\n });\n\n // Just the canvas\n const chart = page.locator(\"canvas#sales-chart\");\n await expect(chart).toHaveScreenshot(\"sales-chart.png\", {\n maxDiffPixelRatio: 0.01, // 1% difference allowed\n });\n});\n```\n\n### Handling Animation\n\n```typescript\ntest(\"animated canvas\", async ({ page }) => {\n await page.goto(\"/animated-chart\");\n\n // Pause animation before screenshot\n await page.evaluate(() => {\n // Common pattern: chart libraries expose pause method\n window.chartInstance?.stop?.();\n\n // Or override requestAnimationFrame\n window.requestAnimationFrame = () => 0;\n });\n\n await expect(page.locator(\"canvas\")).toHaveScreenshot();\n});\n\ntest(\"wait for animation complete\", async ({ page }) => {\n await page.goto(\"/chart-with-animation\");\n\n // Wait for animation complete event\n await page.evaluate(() => {\n return new Promise\u003cvoid>((resolve) => {\n if (window.chart?.isAnimating === false) {\n resolve();\n } else {\n window.chart?.on(\"animationComplete\", resolve);\n }\n });\n });\n\n await expect(page.locator(\"canvas\")).toHaveScreenshot();\n});\n```\n\n### Threshold Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n expect: {\n toHaveScreenshot: {\n // Increased threshold for canvas (anti-aliasing differences)\n maxDiffPixelRatio: 0.02,\n threshold: 0.3, // Per-pixel color threshold\n animations: \"disabled\",\n },\n },\n});\n```\n\n## Interaction Testing\n\n### Click on Canvas\n\n```typescript\ntest(\"click on canvas element\", async ({ page }) => {\n await page.goto(\"/interactive-map\");\n\n const canvas = page.locator(\"canvas\");\n\n // Click at specific coordinates\n await canvas.click({ position: { x: 150, y: 200 } });\n\n // Verify click was registered\n await expect(page.locator(\"#info-panel\")).toContainText(\"Location: Paris\");\n});\n```\n\n### Drawing on Canvas\n\n```typescript\ntest(\"draw on canvas\", async ({ page }) => {\n await page.goto(\"/whiteboard\");\n\n const canvas = page.locator(\"canvas\");\n const box = await canvas.boundingBox();\n\n // Draw a line using mouse\n await page.mouse.move(box!.x + 50, box!.y + 50);\n await page.mouse.down();\n await page.mouse.move(box!.x + 200, box!.y + 200, { steps: 10 });\n await page.mouse.up();\n\n // Verify something was drawn\n const hasDrawing = await page.evaluate(() => {\n const canvas = document.querySelector(\"canvas\") as HTMLCanvasElement;\n const ctx = canvas.getContext(\"2d\")!;\n const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;\n return data.some((v, i) => i % 4 !== 3 && v !== 255); // Non-white pixels\n });\n\n expect(hasDrawing).toBe(true);\n});\n```\n\n### Drag and Drop\n\n```typescript\ntest(\"drag canvas element\", async ({ page }) => {\n await page.goto(\"/diagram-editor\");\n\n const canvas = page.locator(\"canvas\");\n const box = await canvas.boundingBox();\n\n // Drag shape from position A to B\n await page.mouse.move(box!.x + 100, box!.y + 100);\n await page.mouse.down();\n await page.mouse.move(box!.x + 300, box!.y + 200, { steps: 20 });\n await page.mouse.up();\n\n // Verify via screenshot or state check\n await expect(canvas).toHaveScreenshot(\"shape-moved.png\");\n});\n```\n\n### Touch Gestures on Canvas\n\n```typescript\ntest(\"pinch zoom on canvas\", async ({ page }) => {\n await page.goto(\"/map\");\n\n const canvas = page.locator(\"canvas\");\n const box = await canvas.boundingBox();\n const centerX = box!.x + box!.width / 2;\n const centerY = box!.y + box!.height / 2;\n\n // Simulate pinch zoom using two touch points\n await page.touchscreen.tap(centerX, centerY);\n\n // Use evaluate for complex gestures\n await page.evaluate(\n async ({ x, y }) => {\n const target = document.querySelector(\"canvas\")!;\n\n // Simulate pinch start\n const touch1 = new Touch({\n identifier: 1,\n target,\n clientX: x - 50,\n clientY: y,\n });\n const touch2 = new Touch({\n identifier: 2,\n target,\n clientX: x + 50,\n clientY: y,\n });\n\n target.dispatchEvent(\n new TouchEvent(\"touchstart\", {\n touches: [touch1, touch2],\n targetTouches: [touch1, touch2],\n bubbles: true,\n }),\n );\n\n // Simulate pinch out\n const touch1End = new Touch({\n identifier: 1,\n target,\n clientX: x - 100,\n clientY: y,\n });\n const touch2End = new Touch({\n identifier: 2,\n target,\n clientX: x + 100,\n clientY: y,\n });\n\n target.dispatchEvent(\n new TouchEvent(\"touchmove\", {\n touches: [touch1End, touch2End],\n targetTouches: [touch1End, touch2End],\n bubbles: true,\n }),\n );\n\n target.dispatchEvent(new TouchEvent(\"touchend\", { bubbles: true }));\n },\n { x: centerX, y: centerY },\n );\n\n // Verify zoom level changed\n const zoomLevel = await page.locator(\"#zoom-indicator\").textContent();\n expect(parseFloat(zoomLevel!)).toBeGreaterThan(1);\n});\n```\n\n## WebGL Testing\n\n### Checking WebGL Support\n\n```typescript\ntest(\"WebGL is supported\", async ({ page }) => {\n await page.goto(\"/3d-viewer\");\n\n const hasWebGL = await page.evaluate(() => {\n const canvas = document.createElement(\"canvas\");\n const gl =\n canvas.getContext(\"webgl\") || canvas.getContext(\"experimental-webgl\");\n return !!gl;\n });\n\n expect(hasWebGL).toBe(true);\n});\n```\n\n### WebGL Screenshot Testing\n\n```typescript\ntest(\"3D scene renders\", async ({ page }) => {\n await page.goto(\"/3d-model-viewer\");\n\n // Wait for WebGL scene to render\n await page.waitForFunction(() => {\n const canvas = document.querySelector(\"canvas\");\n if (!canvas) return false;\n\n const gl = canvas.getContext(\"webgl\") || canvas.getContext(\"webgl2\");\n if (!gl) return false;\n\n // Check if something has been drawn\n const pixels = new Uint8Array(4);\n gl.readPixels(\n canvas.width / 2,\n canvas.height / 2,\n 1,\n 1,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n pixels,\n );\n return pixels.some((p) => p > 0);\n });\n\n // Screenshot comparison (higher threshold for WebGL)\n await expect(page.locator(\"canvas\")).toHaveScreenshot(\"3d-scene.png\", {\n maxDiffPixelRatio: 0.05, // WebGL can have more variation\n });\n});\n```\n\n### Testing Three.js Applications\n\n```typescript\ntest(\"Three.js scene interaction\", async ({ page }) => {\n await page.goto(\"/three-demo\");\n\n // Wait for scene to be ready\n await page.waitForFunction(() => window.scene?.children?.length > 0);\n\n // Interact with scene (orbit controls)\n const canvas = page.locator(\"canvas\");\n const box = await canvas.boundingBox();\n\n // Rotate camera by dragging\n await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);\n await page.mouse.down();\n await page.mouse.move(\n box!.x + box!.width / 2 + 100,\n box!.y + box!.height / 2,\n {\n steps: 10,\n },\n );\n await page.mouse.up();\n\n // Verify camera position changed\n const cameraRotation = await page.evaluate(() => {\n return window.camera?.rotation?.y;\n });\n\n expect(cameraRotation).not.toBe(0);\n});\n```\n\n## Chart Libraries\n\n### Chart.js Testing\n\n```typescript\ntest(\"Chart.js renders data\", async ({ page }) => {\n await page.goto(\"/chartjs-demo\");\n\n // Wait for Chart.js to initialize\n await page.waitForFunction(() => {\n return window.Chart && document.querySelector(\"canvas\")?.__chart__;\n });\n\n // Get chart data via Chart.js API\n const chartData = await page.evaluate(() => {\n const canvas = document.querySelector(\"canvas\") as any;\n const chart = canvas.__chart__;\n return chart.data.datasets[0].data;\n });\n\n expect(chartData).toEqual([12, 19, 3, 5, 2, 3]);\n\n // Screenshot test\n await expect(page.locator(\"canvas\")).toHaveScreenshot();\n});\n```\n\n### D3.js / ECharts Testing\n\n```typescript\ntest(\"chart library interaction\", async ({ page }) => {\n await page.goto(\"/chart-demo\");\n\n // Wait for chart to render\n await page.waitForFunction(() => document.querySelector(\"canvas, svg.chart\"));\n\n // For SVG charts (D3)\n const bars = page.locator(\"svg.chart rect.bar\");\n if ((await bars.count()) > 0) {\n await bars.first().hover();\n await expect(page.locator(\".tooltip\")).toBeVisible();\n }\n\n // For canvas charts (ECharts, Chart.js)\n const canvas = page.locator(\"canvas\");\n await canvas.click({ position: { x: 200, y: 150 } });\n});\n```\n\n## Game & Animation Testing\n\n### Frame-by-Frame Testing\n\n```typescript\ntest(\"game frame control\", async ({ page }) => {\n await page.goto(\"/game\");\n\n // Pause and step through frames\n await page.evaluate(() => window.gameLoop?.pause());\n await page.evaluate(() => window.gameLoop?.tick());\n await expect(page.locator(\"canvas\")).toHaveScreenshot(\"frame-1.png\");\n\n for (let i = 0; i \u003c 10; i++) {\n await page.evaluate(() => window.gameLoop?.tick());\n }\n await expect(page.locator(\"canvas\")).toHaveScreenshot(\"frame-11.png\");\n});\n```\n\n### Testing Game State\n\n```typescript\ntest(\"game state changes\", async ({ page }) => {\n await page.goto(\"/game\");\n\n const initialScore = await page.evaluate(() => window.game?.score);\n expect(initialScore).toBe(0);\n\n await page.keyboard.press(\"Space\"); // Action\n await page.waitForTimeout(500);\n\n const newScore = await page.evaluate(() => window.game?.score);\n expect(newScore).toBeGreaterThan(0);\n});\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ------------------------ | ------------------------ | ----------------------------------- |\n| Pixel-perfect assertions | Fails across browsers/OS | Use maxDiffPixelRatio threshold |\n| Not waiting for render | Blank canvas screenshots | Wait for draw completion |\n| Testing raw pixel data | Brittle and slow | Use visual comparison |\n| Ignoring animation | Flaky screenshots | Pause/disable animations |\n| Hardcoded coordinates | Breaks on resize | Calculate relative to canvas bounds |\n\n## Related References\n\n- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for visual regression setup\n- **Mobile Gestures**: See [mobile-testing.md](../advanced/mobile-testing.md) for touch interactions\n- **Performance**: See [performance-testing.md](performance-testing.md) for FPS monitoring\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13183,"content_sha256":"39d62688c1e74a2e5af44efa03204be268fb2f4f62661d58aeffedfd3385998f"},{"filename":"testing-patterns/component-testing.md","content":"# Component Testing\n\n## Table of Contents\n\n1. [Setup & Configuration](#setup--configuration)\n2. [Mounting Components](#mounting-components)\n3. [Props & State Testing](#props--state-testing)\n4. [Events & Interactions](#events--interactions)\n5. [Slots & Children](#slots--children)\n6. [Mocking Dependencies](#mocking-dependencies)\n7. [Framework-Specific Patterns](#framework-specific-patterns)\n\n## Setup & Configuration\n\n### Installation\n\n```bash\n# React\nnpm init playwright@latest -- --ct\n\n# Vue\nnpm init playwright@latest -- --ct\n\n# Svelte\nnpm init playwright@latest -- --ct\n\n# Solid\nnpm init playwright@latest -- --ct\n```\n\n### Configuration\n\n```typescript\n// playwright-ct.config.ts\nimport { defineConfig, devices } from \"@playwright/experimental-ct-react\";\n\nexport default defineConfig({\n testDir: \"./tests/components\",\n snapshotDir: \"./tests/components/__snapshots__\",\n\n use: {\n ctPort: 3100,\n ctViteConfig: {\n resolve: {\n alias: {\n \"@\": \"/src\",\n },\n },\n },\n },\n\n projects: [\n { name: \"chromium\", use: { ...devices[\"Desktop Chrome\"] } },\n { name: \"firefox\", use: { ...devices[\"Desktop Firefox\"] } },\n { name: \"webkit\", use: { ...devices[\"Desktop Safari\"] } },\n ],\n});\n```\n\n### Project Structure\n\n```\nsrc/\n components/\n Button.tsx\n Modal.tsx\ntests/\n components/\n Button.spec.tsx\n Modal.spec.tsx\nplaywright/\n index.html # CT entry point\n index.tsx # CT setup (providers, styles)\n```\n\n## Mounting Components\n\n### Basic Mount\n\n```tsx\n// Button.spec.tsx\nimport { test, expect } from \"@playwright/experimental-ct-react\";\nimport { Button } from \"@/components/Button\";\n\ntest(\"renders button with text\", async ({ mount }) => {\n const component = await mount(\u003cButton>Click me\u003c/Button>);\n\n await expect(component).toContainText(\"Click me\");\n await expect(component).toBeVisible();\n});\n```\n\n### Mount with Props\n\n```tsx\ntest(\"renders with all props\", async ({ mount }) => {\n const component = await mount(\n \u003cButton variant=\"primary\" size=\"large\" disabled={false} icon=\"check\">\n Submit\n \u003c/Button>,\n );\n\n await expect(component).toHaveClass(/primary/);\n await expect(component).toHaveClass(/large/);\n await expect(component.locator(\"svg\")).toBeVisible(); // icon\n});\n```\n\n### Mount with Wrapper/Provider\n\n```tsx\n// playwright/index.tsx - Global providers\nimport { ThemeProvider } from \"@/providers/theme\";\nimport { QueryClientProvider } from \"@tanstack/react-query\";\nimport \"@/styles/globals.css\";\n\nexport default function PlaywrightWrapper({ children }) {\n return (\n \u003cQueryClientProvider client={queryClient}>\n \u003cThemeProvider>{children}\u003c/ThemeProvider>\n \u003c/QueryClientProvider>\n );\n}\n```\n\n```tsx\n// Or per-test wrapper\ntest(\"with custom provider\", async ({ mount }) => {\n const component = await mount(\n \u003cAuthProvider initialUser={{ name: \"Test\" }}>\n \u003cUserProfile />\n \u003c/AuthProvider>,\n );\n\n await expect(component.getByText(\"Test\")).toBeVisible();\n});\n```\n\n## Props & State Testing\n\n### Testing Prop Variations\n\n```tsx\ntest.describe(\"Button variants\", () => {\n const variants = [\"primary\", \"secondary\", \"danger\", \"ghost\"] as const;\n\n for (const variant of variants) {\n test(`renders ${variant} variant`, async ({ mount }) => {\n const component = await mount(\u003cButton variant={variant}>Button\u003c/Button>);\n await expect(component).toHaveClass(new RegExp(variant));\n });\n }\n});\n```\n\n### Updating Props\n\n```tsx\ntest(\"responds to prop changes\", async ({ mount }) => {\n const component = await mount(\u003cCounter initialCount={0} />);\n\n await expect(component.getByTestId(\"count\")).toHaveText(\"0\");\n\n // Update props\n await component.update(\u003cCounter initialCount={10} />);\n await expect(component.getByTestId(\"count\")).toHaveText(\"10\");\n});\n```\n\n### Testing Controlled Components\n\n```tsx\ntest(\"controlled input\", async ({ mount }) => {\n let externalValue = \"\";\n\n const component = await mount(\n \u003cInput\n value={externalValue}\n onChange={(e) => {\n externalValue = e.target.value;\n }}\n />,\n );\n\n await component.locator(\"input\").fill(\"hello\");\n\n // For controlled components, update with new value\n await component.update(\n \u003cInput value=\"hello\" onChange={(e) => (externalValue = e.target.value)} />,\n );\n\n await expect(component.locator(\"input\")).toHaveValue(\"hello\");\n});\n```\n\n### Testing Internal State\n\n```tsx\ntest(\"internal state updates\", async ({ mount }) => {\n const component = await mount(\u003cToggle defaultChecked={false} />);\n\n // Initial state\n await expect(component.locator('[role=\"switch\"]')).toHaveAttribute(\n \"aria-checked\",\n \"false\",\n );\n\n // Trigger state change\n await component.click();\n\n // Verify state updated\n await expect(component.locator('[role=\"switch\"]')).toHaveAttribute(\n \"aria-checked\",\n \"true\",\n );\n});\n```\n\n## Events & Interactions\n\n### Testing Click Events\n\n```tsx\ntest(\"click event fires\", async ({ mount }) => {\n let clicked = false;\n\n const component = await mount(\n \u003cButton onClick={() => (clicked = true)}>Click\u003c/Button>,\n );\n\n await component.click();\n\n expect(clicked).toBe(true);\n});\n```\n\n### Testing Event Payloads\n\n```tsx\ntest(\"onChange provides correct value\", async ({ mount }) => {\n const values: string[] = [];\n\n const component = await mount(\n \u003cSelect\n options={[\"a\", \"b\", \"c\"]}\n onChange={(value) => values.push(value)}\n />,\n );\n\n await component.getByRole(\"combobox\").click();\n await component.getByRole(\"option\", { name: \"b\" }).click();\n\n expect(values).toEqual([\"b\"]);\n});\n```\n\n### Testing Form Submission\n\n```tsx\ntest(\"form submission\", async ({ mount }) => {\n let submittedData: FormData | null = null;\n\n const component = await mount(\n \u003cLoginForm\n onSubmit={(data) => {\n submittedData = data;\n }}\n />,\n );\n\n await component.getByLabel(\"Email\").fill(\"[email protected]\");\n await component.getByLabel(\"Password\").fill(\"secret123\");\n await component.getByRole(\"button\", { name: \"Sign in\" }).click();\n\n expect(submittedData).toEqual({\n email: \"[email protected]\",\n password: \"secret123\",\n });\n});\n```\n\n### Testing Keyboard Interactions\n\n```tsx\ntest(\"keyboard navigation\", async ({ mount }) => {\n const component = await mount(\n \u003cDropdown options={[\"Apple\", \"Banana\", \"Cherry\"]} />,\n );\n\n // Open dropdown\n await component.getByRole(\"button\").click();\n\n // Navigate with keyboard\n await component.press(\"ArrowDown\");\n await component.press(\"ArrowDown\");\n await component.press(\"Enter\");\n\n await expect(component.getByRole(\"button\")).toHaveText(\"Banana\");\n});\n```\n\n## Slots & Children\n\n### Testing Children Content\n\n```tsx\ntest(\"renders children\", async ({ mount }) => {\n const component = await mount(\n \u003cCard>\n \u003ch2>Title\u003c/h2>\n \u003cp>Description\u003c/p>\n \u003c/Card>,\n );\n\n await expect(component.getByRole(\"heading\")).toHaveText(\"Title\");\n await expect(component.getByText(\"Description\")).toBeVisible();\n});\n```\n\n### Testing Named Slots (Vue)\n\n```tsx\n// Vue component with slots\ntest(\"renders named slots\", async ({ mount }) => {\n const component = await mount(Modal, {\n slots: {\n header: \"\u003ch2>Modal Title\u003c/h2>\",\n default: \"\u003cp>Modal content\u003c/p>\",\n footer: \"\u003cbutton>Close\u003c/button>\",\n },\n });\n\n await expect(component.getByRole(\"heading\")).toHaveText(\"Modal Title\");\n await expect(component.getByRole(\"button\")).toHaveText(\"Close\");\n});\n```\n\n### Testing Render Props\n\n```tsx\ntest(\"render prop pattern\", async ({ mount }) => {\n const component = await mount(\n \u003cDataFetcher url=\"/api/users\">\n {({ data, loading }) =>\n loading ? \u003cspan>Loading...\u003c/span> : \u003cspan>{data.name}\u003c/span>\n }\n \u003c/DataFetcher>,\n );\n\n // Initially loading\n await expect(component.getByText(\"Loading...\")).toBeVisible();\n\n // After data loads\n await expect(component.getByText(/User/)).toBeVisible();\n});\n```\n\n## Mocking Dependencies\n\n### Mocking Imports\n\n```tsx\n// playwright/index.tsx - Mock at setup level\nimport { beforeMount } from \"@playwright/experimental-ct-react/hooks\";\n\nbeforeMount(async ({ hooksConfig }) => {\n // Mock analytics\n window.analytics = {\n track: () => {},\n identify: () => {},\n };\n\n // Mock feature flags\n if (hooksConfig?.featureFlags) {\n window.__FEATURE_FLAGS__ = hooksConfig.featureFlags;\n }\n});\n```\n\n```tsx\n// Test with mocked config\ntest(\"with feature flag\", async ({ mount }) => {\n const component = await mount(\u003cFeatureComponent />, {\n hooksConfig: {\n featureFlags: { newFeature: true },\n },\n });\n\n await expect(component.getByText(\"New Feature\")).toBeVisible();\n});\n```\n\n### Mocking API Calls\n\n```tsx\ntest(\"component with API\", async ({ mount, page }) => {\n // Mock API before mounting\n await page.route(\"**/api/user\", (route) => {\n route.fulfill({\n json: { id: 1, name: \"Test User\" },\n });\n });\n\n const component = await mount(\u003cUserProfile userId={1} />);\n\n await expect(component.getByText(\"Test User\")).toBeVisible();\n});\n```\n\n### Mocking Hooks\n\n```tsx\n// Mock custom hook via module mock\ntest(\"with mocked hook\", async ({ mount }) => {\n const component = await mount(\u003cDashboard />, {\n hooksConfig: {\n mockAuth: { user: { name: \"Admin\" }, isAdmin: true },\n },\n });\n\n await expect(component.getByText(\"Admin Panel\")).toBeVisible();\n});\n```\n\n## Framework-Specific Patterns\n\n### React Testing\n\n```tsx\n// React with refs\ntest(\"exposes ref methods\", async ({ mount }) => {\n let inputRef: HTMLInputElement | null = null;\n\n const component = await mount(\u003cInput ref={(el) => (inputRef = el)} />);\n\n await component.locator(\"input\").fill(\"test\");\n expect(inputRef?.value).toBe(\"test\");\n});\n\n// React with context\ntest(\"uses context\", async ({ mount }) => {\n const component = await mount(\n \u003cUserContext.Provider value={{ name: \"Test\" }}>\n \u003cUserGreeting />\n \u003c/UserContext.Provider>,\n );\n\n await expect(component).toContainText(\"Hello, Test\");\n});\n```\n\n### Vue Testing\n\n```tsx\nimport { test, expect } from \"@playwright/experimental-ct-vue\";\nimport MyInput from \"@/components/MyInput.vue\";\n\n// With v-model\ntest(\"v-model binding\", async ({ mount }) => {\n let modelValue = \"\";\n const component = await mount(MyInput, {\n props: {\n modelValue,\n \"onUpdate:modelValue\": (v: string) => (modelValue = v),\n },\n });\n\n await component.locator(\"input\").fill(\"test\");\n expect(modelValue).toBe(\"test\");\n});\n```\n\n### Svelte Testing\n\n```tsx\nimport { test, expect } from \"@playwright/experimental-ct-svelte\";\nimport Counter from \"./Counter.svelte\";\n\ntest(\"Svelte component\", async ({ mount }) => {\n const component = await mount(Counter, { props: { initialCount: 5 } });\n await expect(component.getByTestId(\"count\")).toHaveText(\"5\");\n await component.getByRole(\"button\", { name: \"+\" }).click();\n await expect(component.getByTestId(\"count\")).toHaveText(\"6\");\n});\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ------------------------------ | ------------------- | --------------------------------- |\n| Testing implementation details | Brittle tests | Test behavior, not internal state |\n| Snapshot testing everything | Maintenance burden | Use for visual regression only |\n| Not isolating components | Hidden dependencies | Mock all external dependencies |\n| Testing framework behavior | Redundant | Focus on your component logic |\n| Skipping accessibility | Misses real issues | Include a11y checks in CT |\n\n## Related References\n\n- **Accessibility**: See [accessibility.md](accessibility.md) for a11y testing in components\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for shared test setup\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11701,"content_sha256":"2fb0d0d5053ccc0353022337beba2fa76b68389c6461b363cafacc32e8fa0c68"},{"filename":"testing-patterns/drag-drop.md","content":"# Drag and Drop Testing\n\n## Table of Contents\n\n1. [Kanban Board (Cross-Column Movement)](#kanban-board-cross-column-movement)\n2. [Sortable Lists (Reordering)](#sortable-lists-reordering)\n3. [Native HTML5 Drag and Drop](#native-html5-drag-and-drop)\n4. [File Drop Zone](#file-drop-zone)\n5. [Canvas Coordinate-Based Dragging](#canvas-coordinate-based-dragging)\n6. [Custom Drag Preview](#custom-drag-preview)\n7. [Variations](#variations)\n8. [Tips](#tips)\n\n> **When to use**: Testing drag-and-drop interactions — sortable lists, kanban boards, file drop zones, or repositionable elements.\n\n---\n\n## Kanban Board (Cross-Column Movement)\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest('moves card between columns', async ({ page }) => {\n await page.goto('/board');\n\n const backlog = page.locator('[data-column=\"backlog\"]');\n const active = page.locator('[data-column=\"active\"]');\n\n const ticket = backlog.getByText('Update API docs');\n await expect(ticket).toBeVisible();\n\n const backlogCountBefore = await backlog.getByRole('article').count();\n const activeCountBefore = await active.getByRole('article').count();\n\n await ticket.dragTo(active);\n\n await expect(active.getByText('Update API docs')).toBeVisible();\n await expect(backlog.getByText('Update API docs')).not.toBeVisible();\n\n await expect(backlog.getByRole('article')).toHaveCount(backlogCountBefore - 1);\n await expect(active.getByRole('article')).toHaveCount(activeCountBefore + 1);\n});\n\ntest('progresses card through workflow stages', async ({ page }) => {\n await page.goto('/board');\n\n const cols = {\n backlog: page.locator('[data-column=\"backlog\"]'),\n active: page.locator('[data-column=\"active\"]'),\n review: page.locator('[data-column=\"review\"]'),\n complete: page.locator('[data-column=\"complete\"]'),\n };\n\n await cols.backlog.getByText('Update API docs').dragTo(cols.active);\n await expect(cols.active.getByText('Update API docs')).toBeVisible();\n\n await cols.active.getByText('Update API docs').dragTo(cols.review);\n await expect(cols.review.getByText('Update API docs')).toBeVisible();\n\n await cols.review.getByText('Update API docs').dragTo(cols.complete);\n await expect(cols.complete.getByText('Update API docs')).toBeVisible();\n\n await expect(cols.backlog.getByText('Update API docs')).not.toBeVisible();\n await expect(cols.active.getByText('Update API docs')).not.toBeVisible();\n await expect(cols.review.getByText('Update API docs')).not.toBeVisible();\n});\n\ntest('reorders cards within same column', async ({ page }) => {\n await page.goto('/board');\n\n const backlog = page.locator('[data-column=\"backlog\"]');\n\n const itemX = backlog.getByRole('article').filter({ hasText: 'Item X' });\n const itemZ = backlog.getByRole('article').filter({ hasText: 'Item Z' });\n\n await itemZ.dragTo(itemX);\n\n const cards = await backlog.getByRole('article').allTextContents();\n expect(cards.indexOf('Item Z')).toBeLessThan(cards.indexOf('Item X'));\n});\n\ntest('verifies drag persists via API', async ({ page }) => {\n await page.goto('/board');\n\n const backlog = page.locator('[data-column=\"backlog\"]');\n const active = page.locator('[data-column=\"active\"]');\n\n const responsePromise = page.waitForResponse(\n (r) => r.url().includes('/api/tickets') && r.request().method() === 'PATCH'\n );\n\n await backlog.getByText('Update API docs').dragTo(active);\n\n const response = await responsePromise;\n expect(response.status()).toBe(200);\n\n const body = await response.json();\n expect(body.column).toBe('active');\n\n await page.reload();\n await expect(active.getByText('Update API docs')).toBeVisible();\n});\n```\n\n---\n\n## Sortable Lists (Reordering)\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest('reorders list items', async ({ page }) => {\n await page.goto('/priorities');\n\n const list = page.getByRole('list', { name: 'Priority list' });\n\n const initial = await list.getByRole('listitem').allTextContents();\n expect(initial[0]).toContain('Priority A');\n expect(initial[1]).toContain('Priority B');\n expect(initial[2]).toContain('Priority C');\n\n const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });\n const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });\n\n await priorityC.dragTo(priorityA);\n\n const reordered = await list.getByRole('listitem').allTextContents();\n expect(reordered[0]).toContain('Priority C');\n expect(reordered[1]).toContain('Priority A');\n expect(reordered[2]).toContain('Priority B');\n});\n\ntest('reorders via drag handle', async ({ page }) => {\n await page.goto('/priorities');\n\n const list = page.getByRole('list', { name: 'Priority list' });\n\n const handle = list\n .getByRole('listitem')\n .filter({ hasText: 'Priority C' })\n .getByRole('button', { name: /drag|reorder|grip/i });\n\n const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });\n\n await handle.dragTo(target);\n\n const items = await list.getByRole('listitem').allTextContents();\n expect(items[0]).toContain('Priority C');\n});\n\ntest('reorder persists after reload', async ({ page }) => {\n await page.goto('/priorities');\n\n const list = page.getByRole('list', { name: 'Priority list' });\n\n const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });\n const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });\n\n await priorityC.dragTo(priorityA);\n\n await page.waitForResponse((response) =>\n response.url().includes('/api/priorities/reorder') && response.status() === 200\n );\n\n await page.reload();\n\n const items = await list.getByRole('listitem').allTextContents();\n expect(items[0]).toContain('Priority C');\n expect(items[1]).toContain('Priority A');\n expect(items[2]).toContain('Priority B');\n});\n```\n\n### Incremental Mouse Movement for Custom Libraries\n\nSome drag libraries (react-beautiful-dnd, dnd-kit) require incremental mouse movements:\n\n```typescript\ntest('reorders with incremental mouse movements', async ({ page }) => {\n await page.goto('/priorities');\n\n const list = page.getByRole('list', { name: 'Priority list' });\n const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });\n const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });\n\n const sourceBox = await source.boundingBox();\n const targetBox = await target.boundingBox();\n\n await source.hover();\n await page.mouse.down();\n\n const steps = 10;\n for (let i = 1; i \u003c= steps; i++) {\n await page.mouse.move(\n sourceBox!.x + sourceBox!.width / 2,\n sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),\n { steps: 1 }\n );\n }\n\n await page.mouse.up();\n\n const items = await list.getByRole('listitem').allTextContents();\n expect(items[0]).toContain('Priority C');\n});\n```\n\n---\n\n## Native HTML5 Drag and Drop\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest('drags item to drop zone', async ({ page }) => {\n await page.goto('/drag-example');\n\n const source = page.getByText('Movable Element');\n const dropArea = page.locator('#target-zone');\n\n await expect(source).toBeVisible();\n await expect(dropArea).not.toContainText('Movable Element');\n\n await source.dragTo(dropArea);\n\n await expect(dropArea).toContainText('Movable Element');\n});\n\ntest('drags between zones', async ({ page }) => {\n await page.goto('/drag-example');\n\n const item = page.locator('[data-testid=\"element-1\"]');\n const areaA = page.locator('[data-testid=\"area-a\"]');\n const areaB = page.locator('[data-testid=\"area-b\"]');\n\n await expect(areaA).toContainText('Element 1');\n\n await item.dragTo(areaB);\n\n await expect(areaB).toContainText('Element 1');\n await expect(areaA).not.toContainText('Element 1');\n\n await areaB.getByText('Element 1').dragTo(areaA);\n\n await expect(areaA).toContainText('Element 1');\n await expect(areaB).not.toContainText('Element 1');\n});\n\ntest('verifies drag visual feedback', async ({ page }) => {\n await page.goto('/drag-example');\n\n const source = page.getByText('Movable Element');\n const dropArea = page.locator('#target-zone');\n\n await source.hover();\n await page.mouse.down();\n\n const dropBox = await dropArea.boundingBox();\n await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);\n\n await expect(dropArea).toHaveClass(/drag-over|highlight/);\n\n await page.mouse.up();\n\n await expect(dropArea).not.toHaveClass(/drag-over|highlight/);\n await expect(dropArea).toContainText('Movable Element');\n});\n```\n\n---\n\n## File Drop Zone\n\n```typescript\nimport { test, expect } from '@playwright/test';\nimport path from 'path';\n\ntest('uploads file via drop zone', async ({ page }) => {\n await page.goto('/upload');\n\n const dropZone = page.locator('[data-testid=\"file-drop-zone\"]');\n\n await expect(dropZone).toContainText('Drag files here');\n\n const fileInput = page.locator('input[type=\"file\"]');\n\n await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));\n\n await expect(page.getByText('report.pdf')).toBeVisible();\n await expect(page.getByText(/\\d+ KB/)).toBeVisible();\n});\n\ntest('simulates drag-over visual feedback', async ({ page }) => {\n await page.goto('/upload');\n\n const dropZone = page.locator('[data-testid=\"file-drop-zone\"]');\n\n await dropZone.dispatchEvent('dragenter', {\n dataTransfer: { types: ['Files'] },\n });\n\n await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);\n await expect(dropZone).toContainText(/drop.*here|release.*upload/i);\n\n await dropZone.dispatchEvent('dragleave');\n\n await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);\n});\n\ntest('rejects invalid file types', async ({ page }) => {\n await page.goto('/upload');\n\n const fileInput = page.locator('input[type=\"file\"]');\n\n await fileInput.setInputFiles({\n name: 'script.exe',\n mimeType: 'application/x-msdownload',\n buffer: Buffer.from('fake-content'),\n });\n\n await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);\n await expect(page.getByText('script.exe')).not.toBeVisible();\n});\n```\n\n---\n\n## Canvas Coordinate-Based Dragging\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest('drags element to specific coordinates', async ({ page }) => {\n await page.goto('/design-tool');\n\n const canvas = page.locator('#editor-canvas');\n const shape = page.locator('[data-testid=\"shape-1\"]');\n\n const canvasBox = await canvas.boundingBox();\n const targetX = canvasBox!.x + 300;\n const targetY = canvasBox!.y + 200;\n\n await shape.hover();\n await page.mouse.down();\n await page.mouse.move(targetX, targetY, { steps: 10 });\n await page.mouse.up();\n\n const newBox = await shape.boundingBox();\n expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);\n expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);\n});\n\ntest('snaps element to grid', async ({ page }) => {\n await page.goto('/design-tool');\n\n const shape = page.locator('[data-testid=\"shape-1\"]');\n const canvas = page.locator('#editor-canvas');\n\n const canvasBox = await canvas.boundingBox();\n\n await shape.hover();\n await page.mouse.down();\n await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });\n await page.mouse.up();\n\n const snappedBox = await shape.boundingBox();\n expect(snappedBox!.x % 20).toBeCloseTo(0, 0);\n expect(snappedBox!.y % 20).toBeCloseTo(0, 0);\n});\n\ntest('constrains drag within boundaries', async ({ page }) => {\n await page.goto('/design-tool');\n\n const shape = page.locator('[data-testid=\"bounded-shape\"]');\n const container = page.locator('#bounds-container');\n\n const containerBox = await container.boundingBox();\n\n await shape.hover();\n await page.mouse.down();\n await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {\n steps: 10,\n });\n await page.mouse.up();\n\n const shapeBox = await shape.boundingBox();\n\n expect(shapeBox!.x).toBeGreaterThanOrEqual(containerBox!.x);\n expect(shapeBox!.y).toBeGreaterThanOrEqual(containerBox!.y);\n expect(shapeBox!.x + shapeBox!.width).toBeLessThanOrEqual(\n containerBox!.x + containerBox!.width\n );\n expect(shapeBox!.y + shapeBox!.height).toBeLessThanOrEqual(\n containerBox!.y + containerBox!.height\n );\n});\n\ntest('resizes element via handle', async ({ page }) => {\n await page.goto('/design-tool');\n\n const shape = page.locator('[data-testid=\"shape-1\"]');\n await shape.click();\n\n const resizeHandle = shape.locator('.resize-handle-se');\n const handleBox = await resizeHandle.boundingBox();\n\n const initialBox = await shape.boundingBox();\n\n await resizeHandle.hover();\n await page.mouse.down();\n await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });\n await page.mouse.up();\n\n const newBox = await shape.boundingBox();\n expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);\n expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);\n});\n```\n\n---\n\n## Custom Drag Preview\n\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest('shows custom drag preview', async ({ page }) => {\n await page.goto('/board');\n\n const card = page.locator('[data-testid=\"ticket-1\"]');\n const targetCol = page.locator('[data-column=\"active\"]');\n\n const cardBox = await card.boundingBox();\n const targetBox = await targetCol.boundingBox();\n\n await card.hover();\n await page.mouse.down();\n\n const midX = (cardBox!.x + targetBox!.x) / 2;\n const midY = (cardBox!.y + targetBox!.y) / 2;\n await page.mouse.move(midX, midY, { steps: 5 });\n\n await expect(page.locator('.drag-preview')).toBeVisible();\n await expect(card).toHaveClass(/dragging|placeholder/);\n\n await page.mouse.move(\n targetBox!.x + targetBox!.width / 2,\n targetBox!.y + targetBox!.height / 2,\n { steps: 5 }\n );\n await page.mouse.up();\n\n await expect(page.locator('.drag-preview')).not.toBeVisible();\n});\n\ntest('multi-select drag shows item count', async ({ page }) => {\n await page.goto('/board');\n\n await page.locator('[data-testid=\"ticket-1\"]').click();\n await page.locator('[data-testid=\"ticket-2\"]').click({ modifiers: ['Shift'] });\n await page.locator('[data-testid=\"ticket-3\"]').click({ modifiers: ['Shift'] });\n\n const card = page.locator('[data-testid=\"ticket-1\"]');\n const targetCol = page.locator('[data-column=\"complete\"]');\n\n await card.hover();\n await page.mouse.down();\n\n const targetBox = await targetCol.boundingBox();\n await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });\n\n await expect(page.locator('.drag-preview')).toContainText('3 items');\n\n await page.mouse.up();\n\n await expect(targetCol.locator('[data-testid=\"ticket-1\"]')).toBeVisible();\n await expect(targetCol.locator('[data-testid=\"ticket-2\"]')).toBeVisible();\n await expect(targetCol.locator('[data-testid=\"ticket-3\"]')).toBeVisible();\n});\n```\n\n---\n\n## Variations\n\n### Keyboard-Based Reordering\n\n```typescript\ntest('reorders using keyboard', async ({ page }) => {\n await page.goto('/priorities');\n\n const list = page.getByRole('list', { name: 'Priority list' });\n const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });\n\n await priorityC.focus();\n await page.keyboard.press('Space');\n\n await page.keyboard.press('ArrowUp');\n await page.keyboard.press('ArrowUp');\n\n await page.keyboard.press('Space');\n\n const items = await list.getByRole('listitem').allTextContents();\n expect(items[0]).toContain('Priority C');\n});\n```\n\n### Cross-Frame Dragging\n\n```typescript\ntest('drags between main page and iframe', async ({ page }) => {\n await page.goto('/composer');\n\n const sourceWidget = page.getByText('Component A');\n const iframe = page.frameLocator('#preview-frame');\n const iframeElement = page.locator('#preview-frame');\n\n const sourceBox = await sourceWidget.boundingBox();\n const iframeBox = await iframeElement.boundingBox();\n\n const targetX = iframeBox!.x + 100;\n const targetY = iframeBox!.y + 100;\n\n await sourceWidget.hover();\n await page.mouse.down();\n await page.mouse.move(targetX, targetY, { steps: 20 });\n await page.mouse.up();\n\n await expect(iframe.getByText('Component A')).toBeVisible();\n});\n```\n\n### Touch-Based Drag on Mobile\n\n```typescript\ntest('drags via touch events', async ({ page }) => {\n await page.goto('/priorities');\n\n const list = page.getByRole('list', { name: 'Priority list' });\n const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });\n const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });\n\n const sourceBox = await source.boundingBox();\n const targetBox = await target.boundingBox();\n\n await source.dispatchEvent('touchstart', {\n touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],\n });\n\n for (let i = 1; i \u003c= 5; i++) {\n const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);\n await source.dispatchEvent('touchmove', {\n touches: [{ clientX: sourceBox!.x + 10, clientY: y }],\n });\n }\n\n await source.dispatchEvent('touchend');\n\n const items = await list.getByRole('listitem').allTextContents();\n expect(items[0]).toContain('Priority C');\n});\n```\n\n---\n\n## Tips\n\n1. **Start with `dragTo()`, fall back to manual mouse events**. Playwright's `dragTo()` handles most HTML5 drag-and-drop. Use `page.mouse.down()` / `move()` / `up()` only for custom libraries (react-beautiful-dnd, dnd-kit, SortableJS) that need specific event sequences.\n\n2. **Add intermediate mouse steps for drag libraries**. Libraries like `react-beautiful-dnd` require multiple `mousemove` events. Use `{ steps: 10 }` or a manual loop — a single jump often fails silently.\n\n3. **Assert final state, not just the drop event**. Verify DOM reflects the change — item order, column contents, position coordinates. Visual feedback during drag is secondary to the persisted state.\n\n4. **Use `boundingBox()` for coordinate assertions**. For canvas editors or position-sensitive drops, capture bounding box after the operation and compare with `toBeCloseTo()` for tolerance.\n\n5. **Test undo after drag operations**. If your app supports Ctrl+Z, verify the drag is reversible — this catches state management bugs.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":18124,"content_sha256":"14c470974e736566e1c7a1c7f10c9d5d8b66085ec5c8179a6b3c33002252541d"},{"filename":"testing-patterns/electron.md","content":"# Electron Testing\n\n## Table of Contents\n\n1. [Setup & Configuration](#setup--configuration)\n2. [Launching Electron Apps](#launching-electron-apps)\n3. [Main Process Testing](#main-process-testing)\n4. [Renderer Process Testing](#renderer-process-testing)\n5. [IPC Communication](#ipc-communication)\n6. [Native Features](#native-features)\n7. [Packaging & Distribution](#packaging--distribution)\n\n## Setup & Configuration\n\n### Installation\n\n```bash\nnpm install -D @playwright/test electron\n```\n\n### Basic Configuration\n\n```typescript\n// playwright.config.ts\nimport { defineConfig } from \"@playwright/test\";\n\nexport default defineConfig({\n testDir: \"./tests\",\n timeout: 30000,\n use: {\n trace: \"on-first-retry\",\n },\n});\n```\n\n### Electron Test Fixture\n\n```typescript\n// fixtures/electron.ts\nimport {\n test as base,\n _electron as electron,\n ElectronApplication,\n Page,\n} from \"@playwright/test\";\n\ntype ElectronFixtures = {\n electronApp: ElectronApplication;\n window: Page;\n};\n\nexport const test = base.extend\u003cElectronFixtures>({\n electronApp: async ({}, use) => {\n // Launch Electron app\n const electronApp = await electron.launch({\n args: [\".\", \"--no-sandbox\"],\n env: {\n ...process.env,\n NODE_ENV: \"test\",\n },\n });\n\n await use(electronApp);\n\n // Cleanup\n await electronApp.close();\n },\n\n window: async ({ electronApp }, use) => {\n // Wait for first window\n const window = await electronApp.firstWindow();\n\n // Wait for app to be ready\n await window.waitForLoadState(\"domcontentloaded\");\n\n await use(window);\n },\n});\n\nexport { expect } from \"@playwright/test\";\n```\n\n### Launch Options\n\n```typescript\n// Advanced launch configuration\nconst electronApp = await electron.launch({\n args: [\"main.js\", \"--custom-flag\"],\n cwd: \"/path/to/app\",\n env: {\n ...process.env,\n ELECTRON_ENABLE_LOGGING: \"1\",\n NODE_ENV: \"test\",\n },\n timeout: 30000,\n // For packaged apps\n executablePath: \"/path/to/MyApp.app/Contents/MacOS/MyApp\",\n});\n```\n\n## Launching Electron Apps\n\n### Development Mode\n\n```typescript\ntest(\"launch in dev mode\", async () => {\n const electronApp = await electron.launch({\n args: [\".\"], // Points to package.json main\n });\n\n const window = await electronApp.firstWindow();\n await expect(window.locator(\"h1\")).toContainText(\"My App\");\n\n await electronApp.close();\n});\n```\n\n### Packaged Application\n\n```typescript\ntest(\"launch packaged app\", async () => {\n const appPath =\n process.platform === \"darwin\"\n ? \"/Applications/MyApp.app/Contents/MacOS/MyApp\"\n : process.platform === \"win32\"\n ? \"C:\\\\Program Files\\\\MyApp\\\\MyApp.exe\"\n : \"/usr/bin/myapp\";\n\n const electronApp = await electron.launch({\n executablePath: appPath,\n });\n\n const window = await electronApp.firstWindow();\n await expect(window).toHaveTitle(/MyApp/);\n\n await electronApp.close();\n});\n```\n\n### Multiple Windows\n\n```typescript\ntest(\"handle multiple windows\", async ({ electronApp }) => {\n const mainWindow = await electronApp.firstWindow();\n\n // Trigger new window\n await mainWindow.getByRole(\"button\", { name: \"Open Settings\" }).click();\n\n // Wait for new window\n const settingsWindow = await electronApp.waitForEvent(\"window\");\n\n // Both windows are now accessible\n await expect(settingsWindow.locator(\"h1\")).toHaveText(\"Settings\");\n await expect(mainWindow.locator(\"h1\")).toHaveText(\"Main\");\n\n // Get all windows\n const windows = electronApp.windows();\n expect(windows.length).toBe(2);\n});\n```\n\n## Main Process Testing\n\n### Evaluate in Main Process\n\n```typescript\ntest(\"access main process\", async ({ electronApp }) => {\n // Evaluate in main process context\n const appPath = await electronApp.evaluate(async ({ app }) => {\n return app.getAppPath();\n });\n\n expect(appPath).toContain(\"my-electron-app\");\n});\n```\n\n### Access Electron APIs\n\n```typescript\ntest(\"electron API access\", async ({ electronApp }) => {\n // Get app version\n const version = await electronApp.evaluate(async ({ app }) => {\n return app.getVersion();\n });\n expect(version).toMatch(/^\\d+\\.\\d+\\.\\d+$/);\n\n // Get platform info\n const platform = await electronApp.evaluate(async ({ app }) => {\n return process.platform;\n });\n expect([\"darwin\", \"win32\", \"linux\"]).toContain(platform);\n\n // Check if app is ready\n const isReady = await electronApp.evaluate(async ({ app }) => {\n return app.isReady();\n });\n expect(isReady).toBe(true);\n});\n```\n\n### BrowserWindow Properties\n\n```typescript\ntest(\"check window properties\", async ({ electronApp, window }) => {\n // Get BrowserWindow from main process\n const windowBounds = await electronApp.evaluate(async ({ BrowserWindow }) => {\n const win = BrowserWindow.getAllWindows()[0];\n return win.getBounds();\n });\n\n expect(windowBounds.width).toBeGreaterThan(0);\n expect(windowBounds.height).toBeGreaterThan(0);\n\n // Check window state\n const isMaximized = await electronApp.evaluate(async ({ BrowserWindow }) => {\n const win = BrowserWindow.getAllWindows()[0];\n return win.isMaximized();\n });\n\n // Check window title\n const title = await electronApp.evaluate(async ({ BrowserWindow }) => {\n const win = BrowserWindow.getAllWindows()[0];\n return win.getTitle();\n });\n expect(title).toBeTruthy();\n});\n```\n\n## Renderer Process Testing\n\n### Standard Page Testing\n\n```typescript\ntest(\"renderer interactions\", async ({ window }) => {\n // Standard Playwright page interactions\n await window.getByRole(\"button\", { name: \"Click Me\" }).click();\n await expect(window.getByText(\"Clicked!\")).toBeVisible();\n\n // Fill forms\n await window.getByLabel(\"Username\").fill(\"testuser\");\n await window.getByLabel(\"Password\").fill(\"password123\");\n await window.getByRole(\"button\", { name: \"Login\" }).click();\n\n // Verify navigation\n await expect(window).toHaveURL(/dashboard/);\n});\n```\n\n### Access Node.js in Renderer\n\n```typescript\ntest(\"node integration\", async ({ window }) => {\n // If nodeIntegration is enabled\n const nodeVersion = await window.evaluate(() => {\n return (window as any).process?.version;\n });\n\n // Check if Node APIs are available\n const hasFs = await window.evaluate(() => {\n return typeof (window as any).require === \"function\";\n });\n});\n```\n\n### Context Isolation Testing\n\n```typescript\ntest(\"context isolation\", async ({ window }) => {\n // Test preload script exposed APIs\n const apiAvailable = await window.evaluate(() => {\n return typeof (window as any).electronAPI !== \"undefined\";\n });\n expect(apiAvailable).toBe(true);\n\n // Call exposed API\n const result = await window.evaluate(async () => {\n return await (window as any).electronAPI.getAppVersion();\n });\n expect(result).toMatch(/^\\d+\\.\\d+\\.\\d+$/);\n});\n```\n\n## IPC Communication\n\n### Testing IPC from Renderer\n\n```typescript\ntest(\"IPC invoke\", async ({ window }) => {\n // Test preload-exposed IPC call\n const result = await window.evaluate(async () => {\n return await (window as any).electronAPI.getData(\"user-settings\");\n });\n\n expect(result).toHaveProperty(\"theme\");\n});\n```\n\n### Testing IPC from Main Process\n\n```typescript\ntest(\"main to renderer IPC\", async ({ electronApp, window }) => {\n // Set up listener in renderer\n await window.evaluate(() => {\n (window as any).receivedMessage = null;\n (window as any).electronAPI.onMessage((msg: string) => {\n (window as any).receivedMessage = msg;\n });\n });\n\n // Send from main process\n await electronApp.evaluate(async ({ BrowserWindow }) => {\n const win = BrowserWindow.getAllWindows()[0];\n win.webContents.send(\"message\", \"Hello from main!\");\n });\n\n // Verify receipt\n await window.waitForFunction(() => (window as any).receivedMessage !== null);\n const message = await window.evaluate(() => (window as any).receivedMessage);\n expect(message).toBe(\"Hello from main!\");\n});\n```\n\n### Mock IPC Handlers\n\n```typescript\n// In test setup or fixture\ntest(\"mock IPC handler\", async ({ electronApp, window }) => {\n // Override IPC handler in main process\n await electronApp.evaluate(async ({ ipcMain }) => {\n // Remove existing handler\n ipcMain.removeHandler(\"fetch-data\");\n\n // Add mock handler\n ipcMain.handle(\"fetch-data\", async () => {\n return { mocked: true, data: \"test-data\" };\n });\n });\n\n // Test with mocked handler\n const result = await window.evaluate(async () => {\n return await (window as any).electronAPI.fetchData();\n });\n\n expect(result.mocked).toBe(true);\n});\n```\n\n## Native Features\n\n### File System Dialogs\n\n```typescript\ntest(\"file dialog\", async ({ electronApp, window }) => {\n // Mock dialog response\n await electronApp.evaluate(async ({ dialog }) => {\n dialog.showOpenDialog = async () => ({\n canceled: false,\n filePaths: [\"/mock/path/file.txt\"],\n });\n });\n\n // Trigger file open\n await window.getByRole(\"button\", { name: \"Open File\" }).click();\n\n // Verify file was \"opened\"\n await expect(window.getByText(\"file.txt\")).toBeVisible();\n});\n\ntest(\"save dialog\", async ({ electronApp, window }) => {\n await electronApp.evaluate(async ({ dialog }) => {\n dialog.showSaveDialog = async () => ({\n canceled: false,\n filePath: \"/mock/path/saved-file.txt\",\n });\n });\n\n await window.getByRole(\"button\", { name: \"Save\" }).click();\n await expect(window.getByText(\"Saved successfully\")).toBeVisible();\n});\n```\n\n### Menu Testing\n\n```typescript\ntest(\"application menu\", async ({ electronApp }) => {\n // Get menu structure\n const menuLabels = await electronApp.evaluate(async ({ Menu }) => {\n const menu = Menu.getApplicationMenu();\n return menu?.items.map((item) => item.label) || [];\n });\n\n expect(menuLabels).toContain(\"File\");\n expect(menuLabels).toContain(\"Edit\");\n\n // Trigger menu action\n await electronApp.evaluate(async ({ Menu }) => {\n const menu = Menu.getApplicationMenu();\n const fileMenu = menu?.items.find((item) => item.label === \"File\");\n const newItem = fileMenu?.submenu?.items.find(\n (item) => item.label === \"New\",\n );\n newItem?.click();\n });\n});\n```\n\n### Native Notifications\n\n```typescript\ntest(\"notifications\", async ({ electronApp, window }) => {\n // Mock Notification\n let notificationShown = false;\n await electronApp.evaluate(async ({ Notification }) => {\n const OriginalNotification = Notification;\n (global as any).Notification = class extends OriginalNotification {\n constructor(options: any) {\n super(options);\n (global as any).lastNotification = options;\n }\n };\n });\n\n // Trigger notification\n await window.getByRole(\"button\", { name: \"Notify\" }).click();\n\n // Verify notification was created\n const notification = await electronApp.evaluate(async () => {\n return (global as any).lastNotification;\n });\n\n expect(notification.title).toBe(\"New Message\");\n});\n```\n\n### Clipboard\n\n```typescript\ntest(\"clipboard operations\", async ({ electronApp, window }) => {\n // Write to clipboard\n await electronApp.evaluate(async ({ clipboard }) => {\n clipboard.writeText(\"Test clipboard content\");\n });\n\n // Paste in app\n await window.getByRole(\"textbox\").focus();\n await window.keyboard.press(\"ControlOrMeta+v\");\n\n // Read clipboard\n const clipboardContent = await electronApp.evaluate(async ({ clipboard }) => {\n return clipboard.readText();\n });\n\n expect(clipboardContent).toBe(\"Test clipboard content\");\n});\n```\n\n## Packaging & Distribution\n\n### Testing Packaged Apps\n\n```typescript\n// fixtures/packaged-electron.ts\nimport { test as base, _electron as electron } from \"@playwright/test\";\nimport path from \"path\";\nimport { execSync } from \"child_process\";\n\nexport const test = base.extend({\n electronApp: async ({}, use) => {\n // Build the app first (or use pre-built)\n const distPath = path.join(__dirname, \"../dist\");\n\n let executablePath: string;\n if (process.platform === \"darwin\") {\n executablePath = path.join(\n distPath,\n \"mac\",\n \"MyApp.app\",\n \"Contents\",\n \"MacOS\",\n \"MyApp\",\n );\n } else if (process.platform === \"win32\") {\n executablePath = path.join(distPath, \"win-unpacked\", \"MyApp.exe\");\n } else {\n executablePath = path.join(distPath, \"linux-unpacked\", \"myapp\");\n }\n\n const electronApp = await electron.launch({ executablePath });\n await use(electronApp);\n await electronApp.close();\n },\n});\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ------------------------------------- | ---------------------------- | -------------------------------------------- |\n| Not closing ElectronApplication | Resource leaks | Always call `electronApp.close()` in cleanup |\n| Hardcoded executable paths | Breaks cross-platform | Use platform detection |\n| Testing packaged app without building | Outdated code | Build before testing or test dev mode |\n| Ignoring IPC in tests | Missing coverage | Test IPC communication explicitly |\n| Not mocking native dialogs | Tests hang waiting for input | Mock dialog responses |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for custom fixture patterns\n- **Component Testing**: See [component-testing.md](component-testing.md) for renderer testing patterns\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13512,"content_sha256":"093d00d665b96befa45d63385f32de34065785ef87f992526a333be91f83478c"},{"filename":"testing-patterns/file-operations.md","content":"# File Upload & Download Testing\n\n> For advanced patterns (progress tracking, cancellation, retry logic), see [file-upload-download.md](./file-upload-download.md)\n\n## Table of Contents\n\n1. [File Downloads](#file-downloads)\n2. [File Uploads](#file-uploads)\n3. [Drag and Drop](#drag-and-drop)\n4. [File Content Verification](#file-content-verification)\n\n## File Downloads\n\n### Basic Download\n\n```typescript\ntest(\"download PDF report\", async ({ page }) => {\n await page.goto(\"/reports\");\n\n // Start waiting for download before clicking\n const downloadPromise = page.waitForEvent(\"download\");\n await page.getByRole(\"button\", { name: \"Download PDF\" }).click();\n const download = await downloadPromise;\n\n // Verify filename\n expect(download.suggestedFilename()).toBe(\"report.pdf\");\n\n // Save to specific path\n await download.saveAs(\"./downloads/report.pdf\");\n});\n```\n\n### Download with Custom Path\n\n```typescript\ntest(\"download to temp directory\", async ({ page }, testInfo) => {\n await page.goto(\"/exports\");\n\n const downloadPromise = page.waitForEvent(\"download\");\n await page.getByRole(\"link\", { name: \"Export CSV\" }).click();\n const download = await downloadPromise;\n\n // Save to test output directory\n const path = testInfo.outputPath(download.suggestedFilename());\n await download.saveAs(path);\n\n // Attach to test report\n await testInfo.attach(\"downloaded-file\", { path });\n});\n```\n\n### Verify Download Content\n\n```typescript\nimport fs from \"fs\";\nimport path from \"path\";\n\ntest(\"verify CSV content\", async ({ page }, testInfo) => {\n await page.goto(\"/data\");\n\n const downloadPromise = page.waitForEvent(\"download\");\n await page.getByRole(\"button\", { name: \"Export\" }).click();\n const download = await downloadPromise;\n\n const filePath = testInfo.outputPath(\"export.csv\");\n await download.saveAs(filePath);\n\n // Read and verify content\n const content = fs.readFileSync(filePath, \"utf-8\");\n expect(content).toContain(\"Name,Email,Status\");\n expect(content).toContain(\"John Doe\");\n\n // Verify row count\n const rows = content.trim().split(\"\\n\");\n expect(rows.length).toBeGreaterThan(1);\n});\n```\n\n### Multiple Downloads\n\n```typescript\ntest(\"download multiple files\", async ({ page }) => {\n await page.goto(\"/batch-export\");\n\n await page.getByRole(\"checkbox\", { name: \"Select All\" }).check();\n\n // Collect all downloads\n const downloads: Download[] = [];\n page.on(\"download\", (download) => downloads.push(download));\n\n await page.getByRole(\"button\", { name: \"Download Selected\" }).click();\n\n // Wait for all downloads\n await expect.poll(() => downloads.length, { timeout: 30000 }).toBe(5);\n\n // Verify each download\n for (const download of downloads) {\n expect(download.suggestedFilename()).toMatch(/\\.pdf$/);\n }\n});\n```\n\n### Download Fixture\n\n```typescript\n// fixtures/download.fixture.ts\nimport { test as base, Download } from \"@playwright/test\";\nimport fs from \"fs\";\nimport path from \"path\";\n\ntype DownloadFixtures = {\n downloadDir: string;\n downloadAndVerify: (\n trigger: () => Promise\u003cvoid>,\n expectedFilename: string,\n ) => Promise\u003cstring>;\n};\n\nexport const test = base.extend\u003cDownloadFixtures>({\n downloadDir: async ({}, use, testInfo) => {\n const dir = testInfo.outputPath(\"downloads\");\n fs.mkdirSync(dir, { recursive: true });\n await use(dir);\n },\n\n downloadAndVerify: async ({ page, downloadDir }, use) => {\n await use(async (trigger, expectedFilename) => {\n const downloadPromise = page.waitForEvent(\"download\");\n await trigger();\n const download = await downloadPromise;\n\n expect(download.suggestedFilename()).toBe(expectedFilename);\n\n const filePath = path.join(downloadDir, expectedFilename);\n await download.saveAs(filePath);\n return filePath;\n });\n },\n});\n```\n\n## File Uploads\n\n### Basic Upload\n\n```typescript\ntest(\"upload profile picture\", async ({ page }) => {\n await page.goto(\"/settings/profile\");\n\n // Upload file\n await page\n .getByLabel(\"Profile Picture\")\n .setInputFiles(\"./fixtures/avatar.png\");\n\n // Verify preview\n await expect(page.getByAltText(\"Profile preview\")).toBeVisible();\n\n await page.getByRole(\"button\", { name: \"Save\" }).click();\n await expect(page.getByText(\"Profile updated\")).toBeVisible();\n});\n```\n\n### Multiple File Upload\n\n```typescript\ntest(\"upload multiple documents\", async ({ page }) => {\n await page.goto(\"/documents/upload\");\n\n await page\n .getByLabel(\"Documents\")\n .setInputFiles([\n \"./fixtures/doc1.pdf\",\n \"./fixtures/doc2.pdf\",\n \"./fixtures/doc3.pdf\",\n ]);\n\n // Verify all files listed\n await expect(page.getByText(\"doc1.pdf\")).toBeVisible();\n await expect(page.getByText(\"doc2.pdf\")).toBeVisible();\n await expect(page.getByText(\"doc3.pdf\")).toBeVisible();\n\n await page.getByRole(\"button\", { name: \"Upload All\" }).click();\n await expect(page.getByText(\"3 files uploaded\")).toBeVisible();\n});\n```\n\n### Upload with File Chooser\n\n```typescript\ntest(\"upload via file chooser dialog\", async ({ page }) => {\n await page.goto(\"/upload\");\n\n // Handle file chooser\n const fileChooserPromise = page.waitForEvent(\"filechooser\");\n await page.getByRole(\"button\", { name: \"Choose File\" }).click();\n const fileChooser = await fileChooserPromise;\n\n await fileChooser.setFiles(\"./fixtures/document.pdf\");\n\n await expect(page.getByText(\"document.pdf\")).toBeVisible();\n});\n```\n\n### Clear and Re-upload\n\n```typescript\ntest(\"replace uploaded file\", async ({ page }) => {\n await page.goto(\"/upload\");\n\n const input = page.getByLabel(\"Document\");\n\n // Upload first file\n await input.setInputFiles(\"./fixtures/old.pdf\");\n await expect(page.getByText(\"old.pdf\")).toBeVisible();\n\n // Clear selection\n await input.setInputFiles([]);\n\n // Upload new file\n await input.setInputFiles(\"./fixtures/new.pdf\");\n await expect(page.getByText(\"new.pdf\")).toBeVisible();\n await expect(page.getByText(\"old.pdf\")).toBeHidden();\n});\n```\n\n### Upload from Buffer\n\n```typescript\ntest(\"upload generated file\", async ({ page }) => {\n await page.goto(\"/upload\");\n\n // Create file content dynamically\n const content = \"Name,Email\\nJohn,[email protected]\";\n\n await page.getByLabel(\"CSV File\").setInputFiles({\n name: \"users.csv\",\n mimeType: \"text/csv\",\n buffer: Buffer.from(content),\n });\n\n await expect(page.getByText(\"users.csv\")).toBeVisible();\n});\n```\n\n## Drag and Drop\n\n### Drag and Drop Upload\n\n```typescript\ntest(\"drag and drop file upload\", async ({ page }) => {\n await page.goto(\"/upload\");\n\n const dropzone = page.getByTestId(\"dropzone\");\n\n // Create a DataTransfer with the file\n const dataTransfer = await page.evaluateHandle(() => new DataTransfer());\n\n // Read file and add to DataTransfer\n const buffer = fs.readFileSync(\"./fixtures/image.png\");\n await page.evaluate(\n async ([dataTransfer, data]) => {\n const file = new File([new Uint8Array(data)], \"image.png\", {\n type: \"image/png\",\n });\n dataTransfer.items.add(file);\n },\n [dataTransfer, [...buffer]] as const,\n );\n\n // Dispatch drop event\n await dropzone.dispatchEvent(\"drop\", { dataTransfer });\n\n await expect(page.getByText(\"image.png uploaded\")).toBeVisible();\n});\n```\n\n### Simpler Drag and Drop\n\n```typescript\ntest(\"drag and drop with setInputFiles\", async ({ page }) => {\n await page.goto(\"/upload\");\n\n // Most dropzones have a hidden file input\n const input = page.locator('input[type=\"file\"]');\n\n // This works even if the input is hidden\n await input.setInputFiles(\"./fixtures/document.pdf\");\n\n await expect(page.getByText(\"document.pdf\")).toBeVisible();\n});\n```\n\n## File Content Verification\n\n### Verify PDF Content\n\n```typescript\nimport pdf from \"pdf-parse\";\n\ntest(\"verify PDF content\", async ({ page }, testInfo) => {\n await page.goto(\"/invoice/123\");\n\n const downloadPromise = page.waitForEvent(\"download\");\n await page.getByRole(\"button\", { name: \"Download Invoice\" }).click();\n const download = await downloadPromise;\n\n const path = testInfo.outputPath(\"invoice.pdf\");\n await download.saveAs(path);\n\n // Parse PDF\n const dataBuffer = fs.readFileSync(path);\n const data = await pdf(dataBuffer);\n\n expect(data.text).toContain(\"Invoice #123\");\n expect(data.text).toContain(\"Total: $99.99\");\n});\n```\n\n### Verify Excel Content\n\n```typescript\nimport XLSX from \"xlsx\";\n\ntest(\"verify Excel export\", async ({ page }, testInfo) => {\n await page.goto(\"/reports\");\n\n const downloadPromise = page.waitForEvent(\"download\");\n await page.getByRole(\"button\", { name: \"Export Excel\" }).click();\n const download = await downloadPromise;\n\n const path = testInfo.outputPath(\"report.xlsx\");\n await download.saveAs(path);\n\n // Parse Excel\n const workbook = XLSX.readFile(path);\n const sheet = workbook.Sheets[workbook.SheetNames[0]];\n const data = XLSX.utils.sheet_to_json(sheet);\n\n expect(data).toHaveLength(10);\n expect(data[0]).toHaveProperty(\"Name\");\n expect(data[0]).toHaveProperty(\"Email\");\n});\n```\n\n### Verify JSON Download\n\n```typescript\ntest(\"verify JSON export\", async ({ page }, testInfo) => {\n await page.goto(\"/api-data\");\n\n const downloadPromise = page.waitForEvent(\"download\");\n await page.getByRole(\"button\", { name: \"Export JSON\" }).click();\n const download = await downloadPromise;\n\n const path = testInfo.outputPath(\"data.json\");\n await download.saveAs(path);\n\n const content = JSON.parse(fs.readFileSync(path, \"utf-8\"));\n\n expect(content.users).toHaveLength(5);\n expect(content.exportDate).toBeDefined();\n});\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ------------------------------------- | ------------------------------- | --------------------------------------------- |\n| Not waiting for download | Race condition, test fails | Always use `waitForEvent(\"download\")` |\n| Hardcoded download paths | Conflicts in parallel runs | Use `testInfo.outputPath()` |\n| Skipping content verification | Download might be empty/corrupt | Verify file content when possible |\n| Using `force: true` for hidden inputs | May not trigger proper events | Use `setInputFiles` on hidden inputs directly |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for download fixture patterns\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting download issues\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10447,"content_sha256":"50f3818af9a2c5a9d1e83c832c7324c7bc94c0c93899c9cedb40d534e4335f60"},{"filename":"testing-patterns/file-upload-download.md","content":"# File Upload and Download Testing\n\n> **When to use**: Testing file uploads (single, multiple, drag-and-drop), downloads (content verification, filename, type), upload progress indicators, or file type/size restrictions.\n\n## Table of Contents\n\n1. [Downloading Files](#downloading-files)\n2. [Single File Upload](#single-file-upload)\n3. [Multiple File Upload](#multiple-file-upload)\n4. [Drag-and-Drop Zones](#drag-and-drop-zones)\n5. [File Chooser Dialog](#file-chooser-dialog)\n6. [Upload Progress and Cancellation](#upload-progress-and-cancellation)\n7. [Retry After Failure](#retry-after-failure)\n8. [File Type and Size Restrictions](#file-type-and-size-restrictions)\n9. [Image Preview](#image-preview)\n10. [Authenticated Downloads](#authenticated-downloads)\n11. [Tips](#tips)\n\n---\n\n## Downloading Files\n\n### Capturing Downloads and Verifying Content\n\n```typescript\nimport { test, expect } from '@playwright/test';\nimport fs from 'fs';\nimport path from 'path';\n\ntest('verifies downloaded CSV content', async ({ page }) => {\n await page.goto('/exports');\n\n // Set up download listener BEFORE triggering the download\n const downloadPromise = page.waitForEvent('download');\n await page.getByRole('link', { name: 'transactions.csv' }).click();\n\n const download = await downloadPromise;\n const savePath = path.join(__dirname, '../tmp', download.suggestedFilename());\n await download.saveAs(savePath);\n\n const content = fs.readFileSync(savePath, 'utf-8');\n expect(content).toContain('id,amount,date');\n expect(content).toContain('1001,250.00,2025-01-15');\n\n const rows = content.trim().split('\\n');\n expect(rows.length).toBeGreaterThan(1);\n\n fs.unlinkSync(savePath);\n});\n\ntest('reads download via stream without disk I/O', async ({ page }) => {\n await page.goto('/exports');\n\n const downloadPromise = page.waitForEvent('download');\n await page.getByRole('link', { name: 'transactions.csv' }).click();\n\n const download = await downloadPromise;\n const readable = await download.createReadStream();\n const chunks: Buffer[] = [];\n\n for await (const chunk of readable!) {\n chunks.push(Buffer.from(chunk));\n }\n\n const content = Buffer.concat(chunks).toString('utf-8');\n expect(content).toContain('id,amount,date');\n});\n```\n\n### Verifying Filename and Format\n\n```typescript\ntest('export filename matches selected format', async ({ page }) => {\n await page.goto('/analytics');\n\n const downloadPromise = page.waitForEvent('download');\n await page.getByRole('button', { name: 'Export PDF' }).click();\n\n const download = await downloadPromise;\n expect(download.suggestedFilename()).toMatch(/^analytics-\\d{4}-\\d{2}-\\d{2}\\.pdf$/);\n});\n\ntest('format selector changes output extension', async ({ page }) => {\n await page.goto('/analytics');\n\n await page.getByLabel('Format').selectOption('csv');\n const csvDownload = page.waitForEvent('download');\n await page.getByRole('button', { name: 'Download' }).click();\n expect((await csvDownload).suggestedFilename()).toMatch(/\\.csv$/);\n\n await page.getByLabel('Format').selectOption('xlsx');\n const xlsxDownload = page.waitForEvent('download');\n await page.getByRole('button', { name: 'Download' }).click();\n expect((await xlsxDownload).suggestedFilename()).toMatch(/\\.xlsx$/);\n});\n```\n\n### Checking Response Headers\n\n```typescript\ntest('download response has correct MIME type', async ({ page }) => {\n await page.goto('/analytics');\n\n const responsePromise = page.waitForResponse('**/api/analytics/export**');\n const downloadPromise = page.waitForEvent('download');\n\n await page.getByRole('button', { name: 'Export PDF' }).click();\n\n const response = await responsePromise;\n expect(response.headers()['content-type']).toContain('application/pdf');\n expect(response.headers()['content-disposition']).toContain('attachment');\n\n await downloadPromise;\n});\n```\n\n### Handling Download Failures\n\n```typescript\ntest('shows error when download fails', async ({ page }) => {\n await page.route('**/api/analytics/export**', async (route) => {\n await route.fulfill({\n status: 500,\n contentType: 'application/json',\n body: JSON.stringify({ error: 'Generation failed' }),\n });\n });\n\n await page.goto('/analytics');\n await page.getByRole('button', { name: 'Export PDF' }).click();\n\n await expect(page.getByRole('alert')).toContainText(/failed|error/i);\n});\n```\n\n---\n\n## Single File Upload\n\n### From Fixture File\n\n```typescript\nimport path from 'path';\n\ntest('uploads document from fixture', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/invoice.pdf'));\n\n await expect(page.getByText('invoice.pdf')).toBeVisible();\n\n await page.getByRole('button', { name: 'Upload' }).click();\n await expect(page.getByRole('alert')).toContainText('uploaded successfully');\n await expect(page.getByRole('link', { name: 'invoice.pdf' })).toBeVisible();\n});\n```\n\n### From In-Memory Buffer\n\n```typescript\ntest('uploads in-memory CSV', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles({\n name: 'contacts.csv',\n mimeType: 'text/csv',\n buffer: Buffer.from('name,email\\nAlice,[email protected]\\nBob,[email protected]'),\n });\n\n await expect(page.getByText('contacts.csv')).toBeVisible();\n await page.getByRole('button', { name: 'Upload' }).click();\n await expect(page.getByRole('alert')).toContainText('uploaded successfully');\n});\n```\n\n### Clearing Selection\n\n```typescript\ntest('clears selected file', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles({\n name: 'draft.txt',\n mimeType: 'text/plain',\n buffer: Buffer.from('draft content'),\n });\n\n await expect(page.getByText('draft.txt')).toBeVisible();\n\n // Clear via API\n await fileInput.setInputFiles([]);\n await expect(page.getByText('draft.txt')).not.toBeVisible();\n});\n```\n\n---\n\n## Multiple File Upload\n\n```typescript\ntest('uploads multiple files at once', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles([\n { name: 'doc1.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf1') },\n { name: 'doc2.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf2') },\n { name: 'doc3.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf3') },\n ]);\n\n await expect(page.getByText('doc1.pdf')).toBeVisible();\n await expect(page.getByText('doc2.pdf')).toBeVisible();\n await expect(page.getByText('doc3.pdf')).toBeVisible();\n await expect(page.getByText('3 files selected')).toBeVisible();\n\n await page.getByRole('button', { name: 'Upload all' }).click();\n await expect(page.getByRole('alert')).toContainText('3 files uploaded');\n});\n\ntest('removes one file from selection', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles([\n { name: 'keep.txt', mimeType: 'text/plain', buffer: Buffer.from('keep') },\n { name: 'discard.txt', mimeType: 'text/plain', buffer: Buffer.from('discard') },\n ]);\n\n const discardRow = page.getByText('discard.txt').locator('..');\n await discardRow.getByRole('button', { name: /remove|delete|×/i }).click();\n\n await expect(page.getByText('discard.txt')).not.toBeVisible();\n await expect(page.getByText('keep.txt')).toBeVisible();\n});\n```\n\n---\n\n## Drag-and-Drop Zones\n\nDrop zones always have an underlying `input[type=\"file\"]`—target it directly instead of simulating OS-level drag events.\n\n```typescript\ntest('uploads via drop zone', async ({ page }) => {\n await page.goto('/attachments');\n\n const dropZone = page.locator('[data-testid=\"drop-zone\"]');\n await expect(dropZone).toContainText(/drag.*here|drop.*files/i);\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles({\n name: 'dropped.pdf',\n mimeType: 'application/pdf',\n buffer: Buffer.from('pdf-content'),\n });\n\n await expect(dropZone.getByText('dropped.pdf')).toBeVisible();\n await page.getByRole('button', { name: 'Upload' }).click();\n await expect(page.getByRole('alert')).toContainText('uploaded successfully');\n});\n\ntest('shows visual feedback on drag-over', async ({ page }) => {\n await page.goto('/attachments');\n\n const dropZone = page.locator('[data-testid=\"drop-zone\"]');\n\n await dropZone.dispatchEvent('dragenter', {\n dataTransfer: { types: ['Files'], files: [] },\n });\n\n await expect(dropZone).toHaveClass(/active|highlight|drag-over/);\n await expect(dropZone).toContainText(/release|drop now/i);\n\n await dropZone.dispatchEvent('dragleave');\n await expect(dropZone).not.toHaveClass(/active|highlight|drag-over/);\n});\n```\n\n---\n\n## File Chooser Dialog\n\n```typescript\ntest('uploads via native file chooser', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileChooserPromise = page.waitForEvent('filechooser');\n await page.getByRole('button', { name: 'Choose file' }).click();\n\n const fileChooser = await fileChooserPromise;\n expect(fileChooser.isMultiple()).toBe(false);\n\n await fileChooser.setFiles({\n name: 'selected.pdf',\n mimeType: 'application/pdf',\n buffer: Buffer.from('pdf-content'),\n });\n\n await expect(page.getByText('selected.pdf')).toBeVisible();\n});\n```\n\n---\n\n## Upload Progress and Cancellation\n\n```typescript\ntest('displays upload progress for large file', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');\n\n await fileInput.setInputFiles({\n name: 'dataset.bin',\n mimeType: 'application/octet-stream',\n buffer: largeBuffer,\n });\n\n await page.getByRole('button', { name: 'Upload' }).click();\n\n const progressBar = page.getByRole('progressbar');\n await expect(progressBar).toBeVisible();\n\n await expect(async () => {\n const value = await progressBar.getAttribute('aria-valuenow');\n expect(Number(value)).toBeGreaterThan(0);\n }).toPass({ timeout: 10000 });\n\n await expect(progressBar).not.toBeVisible({ timeout: 60000 });\n await expect(page.getByRole('alert')).toContainText('uploaded successfully');\n});\n\ntest('cancels in-progress upload', async ({ page }) => {\n await page.route('**/api/attachments/upload', async (route) => {\n await new Promise((resolve) => setTimeout(resolve, 10000));\n await route.continue();\n });\n\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles({\n name: 'large.bin',\n mimeType: 'application/octet-stream',\n buffer: Buffer.alloc(5 * 1024 * 1024, 'x'),\n });\n\n await page.getByRole('button', { name: 'Upload' }).click();\n await expect(page.getByRole('progressbar')).toBeVisible();\n\n await page.getByRole('button', { name: 'Cancel upload' }).click();\n\n await expect(page.getByRole('progressbar')).not.toBeVisible();\n await expect(page.getByText(/cancelled|aborted/i)).toBeVisible();\n await expect(page.getByRole('link', { name: 'large.bin' })).not.toBeVisible();\n});\n```\n\n---\n\n## Retry After Failure\n\n```typescript\ntest('retries failed upload', async ({ page }) => {\n let attempt = 0;\n\n await page.route('**/api/attachments/upload', async (route) => {\n attempt++;\n if (attempt === 1) {\n await route.fulfill({\n status: 500,\n contentType: 'application/json',\n body: JSON.stringify({ error: 'Server error' }),\n });\n } else {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ id: 'abc', name: 'data.csv' }),\n });\n }\n });\n\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles({\n name: 'data.csv',\n mimeType: 'text/csv',\n buffer: Buffer.from('col1,col2\\nval1,val2'),\n });\n\n await page.getByRole('button', { name: 'Upload' }).click();\n await expect(page.getByText(/upload failed|error/i)).toBeVisible();\n\n await page.getByRole('button', { name: /retry/i }).click();\n await expect(page.getByRole('alert')).toContainText('uploaded successfully');\n expect(attempt).toBe(2);\n});\n```\n\n---\n\n## File Type and Size Restrictions\n\n### Validating Allowed Types\n\n```typescript\ntest('accepts allowed file types', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await expect(fileInput).toHaveAttribute('accept', /\\.pdf|\\.doc|\\.docx|\\.txt/);\n\n await fileInput.setInputFiles({\n name: 'report.pdf',\n mimeType: 'application/pdf',\n buffer: Buffer.from('pdf-content'),\n });\n\n await expect(page.getByText('report.pdf')).toBeVisible();\n await expect(page.getByText(/not allowed|invalid/i)).not.toBeVisible();\n});\n\ntest('rejects disallowed file types', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n // setInputFiles bypasses the accept attribute—tests JavaScript validation\n await fileInput.setInputFiles({\n name: 'malware.exe',\n mimeType: 'application/x-msdownload',\n buffer: Buffer.from('exe-content'),\n });\n\n await expect(page.getByRole('alert')).toContainText(\n /not allowed|unsupported file type|only .pdf, .doc/i\n );\n await expect(page.getByText('malware.exe')).not.toBeVisible();\n});\n```\n\n### Enforcing Size Limits\n\n```typescript\ntest('rejects oversized file', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n const oversizedBuffer = Buffer.alloc(11 * 1024 * 1024, 'x');\n\n await fileInput.setInputFiles({\n name: 'huge.pdf',\n mimeType: 'application/pdf',\n buffer: oversizedBuffer,\n });\n\n await expect(page.getByRole('alert')).toContainText(/file.*too large|exceeds.*10 ?MB/i);\n await expect(page.getByText('huge.pdf')).not.toBeVisible();\n});\n```\n\n### Enforcing File Count Limits\n\n```typescript\ntest('rejects too many files', async ({ page }) => {\n await page.goto('/attachments');\n\n const fileInput = page.locator('input[type=\"file\"]');\n const files = Array.from({ length: 6 }, (_, i) => ({\n name: `file-${i + 1}.txt`,\n mimeType: 'text/plain' as const,\n buffer: Buffer.from(`content ${i + 1}`),\n }));\n\n await fileInput.setInputFiles(files);\n\n await expect(page.getByRole('alert')).toContainText(/maximum.*5 files|too many files/i);\n});\n```\n\n### Validating Image Dimensions\n\n```typescript\ntest('rejects image below minimum dimensions', async ({ page }) => {\n await page.goto('/profile/avatar');\n\n const fileInput = page.locator('input[type=\"file\"]');\n // Minimal 1x1 PNG\n const tinyPng = Buffer.from(\n 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',\n 'base64'\n );\n\n await fileInput.setInputFiles({\n name: 'tiny.png',\n mimeType: 'image/png',\n buffer: tinyPng,\n });\n\n await expect(page.getByRole('alert')).toContainText(/minimum.*dimensions|too small/i);\n});\n```\n\n---\n\n## Image Preview\n\n```typescript\ntest('shows image preview after selection', async ({ page }) => {\n await page.goto('/profile/avatar');\n\n const fileInput = page.locator('input[type=\"file\"]');\n await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/photo.jpg'));\n\n const preview = page.getByRole('img', { name: /preview|avatar/i });\n await expect(preview).toBeVisible();\n\n const src = await preview.getAttribute('src');\n expect(src).toMatch(/^(blob:|data:image)/);\n});\n```\n\n---\n\n## Authenticated Downloads\n\n```typescript\ntest('downloads file requiring authentication', async ({ page, request }) => {\n await page.goto('/attachments');\n\n // Browser download works because cookies are sent\n const downloadPromise = page.waitForEvent('download');\n await page.getByRole('link', { name: 'confidential.pdf' }).click();\n\n const download = await downloadPromise;\n expect(download.suggestedFilename()).toBe('confidential.pdf');\n\n // Verify via API request (carries auth context)\n const response = await request.get('/api/attachments/456/download');\n expect(response.ok()).toBeTruthy();\n expect(response.headers()['content-type']).toContain('application/pdf');\n});\n```\n\n---\n\n## Tips\n\n1. **Use `setInputFiles` for uploads**. Even drag-and-drop zones have an underlying `input[type=\"file\"]`. Target it directly instead of simulating OS-level drag events.\n\n2. **Prefer in-memory buffers**. Creating files with `Buffer.from()` keeps tests self-contained. Use fixture files only when you need real content (e.g., a valid PDF your app parses).\n\n3. **Set up download listener before clicking**. Call `page.waitForEvent('download')` before the click that triggers the download—otherwise you may miss the event.\n\n4. **Use `createReadStream()` for content verification**. Reading directly from the stream avoids disk I/O and cleanup of temporary files.\n\n5. **Test both `accept` attribute and JavaScript validation**. The HTML `accept` attribute only filters the OS file dialog. `setInputFiles()` bypasses it, which is exactly what you need to test your app's JavaScript validation.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17233,"content_sha256":"2aa6a50f0f3e8703f08f4365065d6e79350f1ef7ae4646662ab67d66e8e1d3c7"},{"filename":"testing-patterns/forms-validation.md","content":"# Form Testing Patterns\n\n## Table of Contents\n\n1. [Quick Reference](#quick-reference)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.\n\n## Quick Reference\n\n```typescript\n// Text input\nawait page.getByLabel(\"Username\").fill(\"john_doe\");\n\n// Select dropdown\nawait page.getByLabel(\"Region\").selectOption(\"EU\");\nawait page.getByLabel(\"Region\").selectOption({ label: \"Europe\" });\n\n// Checkbox and radio\nawait page.getByLabel(\"Subscribe\").check();\nawait page.getByLabel(\"Priority shipping\").click();\n\n// Date input\nawait page.getByLabel(\"Departure\").fill(\"2025-08-20\");\n\n// Clear a field\nawait page.getByLabel(\"Username\").clear();\n\n// Submit\nawait page.getByRole(\"button\", { name: \"Register\" }).click();\n\n// Verify validation error\nawait expect(page.getByText(\"Username is required\")).toBeVisible();\n```\n\n## Patterns\n\n### Auto-Complete and Typeahead Fields\n\n**Use when**: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.\n\n```typescript\ntest(\"select from typeahead suggestions\", async ({ page }) => {\n await page.goto(\"/products\");\n\n const searchBox = page.getByRole(\"combobox\", { name: \"Find product\" });\n await searchBox.pressSequentially(\"lapt\", { delay: 100 });\n\n const suggestionList = page.getByRole(\"listbox\");\n await expect(suggestionList).toBeVisible();\n\n await suggestionList.getByRole(\"option\", { name: \"Laptop Pro\" }).click();\n await expect(searchBox).toHaveValue(\"Laptop Pro\");\n});\n\ntest(\"typeahead with API-driven suggestions\", async ({ page }) => {\n await page.goto(\"/shipping\");\n\n const streetField = page.getByLabel(\"Street\");\n const responsePromise = page.waitForResponse(\"**/api/address-lookup*\");\n await streetField.pressSequentially(\"456 Elm\", { delay: 50 });\n\n await responsePromise;\n\n await page.getByRole(\"option\", { name: /456 Elm St/ }).click();\n\n await expect(page.getByLabel(\"Town\")).toHaveValue(\"Austin\");\n await expect(page.getByLabel(\"State\")).toHaveValue(\"TX\");\n await expect(page.getByLabel(\"Postal code\")).toHaveValue(\"78701\");\n});\n\ntest(\"dismiss suggestions and enter custom value\", async ({ page }) => {\n await page.goto(\"/labels\");\n\n const labelInput = page.getByLabel(\"New label\");\n await labelInput.pressSequentially(\"my-label\");\n\n await labelInput.press(\"Escape\");\n await expect(page.getByRole(\"listbox\")).not.toBeVisible();\n\n await labelInput.press(\"Enter\");\n await expect(page.getByText(\"my-label\")).toBeVisible();\n});\n```\n\n### Dynamic Forms — Conditional Fields\n\n**Use when**: Form fields appear, disappear, or change based on the value of other fields.\n\n```typescript\ntest(\"conditional fields appear based on selection\", async ({ page }) => {\n await page.goto(\"/loan/apply\");\n\n await page.getByLabel(\"Applicant type\").selectOption(\"corporate\");\n\n await expect(page.getByLabel(\"Business name\")).toBeVisible();\n await expect(page.getByLabel(\"EIN\")).toBeVisible();\n\n await page.getByLabel(\"Business name\").fill(\"TechCorp Inc\");\n await page.getByLabel(\"EIN\").fill(\"98-7654321\");\n\n await page.getByLabel(\"Applicant type\").selectOption(\"individual\");\n await expect(page.getByLabel(\"Business name\")).not.toBeVisible();\n await expect(page.getByLabel(\"EIN\")).not.toBeVisible();\n});\n\ntest(\"checkbox toggles additional section\", async ({ page }) => {\n await page.goto(\"/delivery\");\n\n await page.getByLabel(\"Separate invoice address\").check();\n\n const invoiceSection = page.getByRole(\"group\", { name: \"Invoice address\" });\n await expect(invoiceSection).toBeVisible();\n\n await invoiceSection.getByLabel(\"Address\").fill(\"789 Pine Rd\");\n await invoiceSection.getByLabel(\"City\").fill(\"Denver\");\n\n await page.getByLabel(\"Separate invoice address\").uncheck();\n await expect(invoiceSection).not.toBeVisible();\n});\n\ntest(\"dependent dropdown chains\", async ({ page }) => {\n await page.goto(\"/region-selector\");\n\n await page.getByLabel(\"Country\").selectOption(\"CA\");\n\n const provinceDropdown = page.getByLabel(\"Province\");\n await expect(provinceDropdown.getByRole(\"option\")).not.toHaveCount(0);\n\n await provinceDropdown.selectOption(\"ON\");\n\n const cityDropdown = page.getByLabel(\"City\");\n await expect(cityDropdown.getByRole(\"option\")).not.toHaveCount(0);\n\n await cityDropdown.selectOption({ label: \"Toronto\" });\n});\n```\n\n### Multi-Step Forms and Wizards\n\n**Use when**: The form spans multiple pages or steps, with next/previous navigation and per-step validation.\n\n```typescript\ntest(\"complete a multi-step booking wizard\", async ({ page }) => {\n await page.goto(\"/booking\");\n\n await test.step(\"enter guest information\", async () => {\n await expect(\n page.getByRole(\"heading\", { name: \"Guest Info\" }),\n ).toBeVisible();\n\n await page.getByLabel(\"Full name\").fill(\"Alice Smith\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Phone\").fill(\"555-1234\");\n\n await page.getByRole(\"button\", { name: \"Next\" }).click();\n });\n\n await test.step(\"select room options\", async () => {\n await expect(\n page.getByRole(\"heading\", { name: \"Room Selection\" }),\n ).toBeVisible();\n\n await page.getByLabel(\"Room type\").selectOption(\"suite\");\n await page.getByLabel(\"Check-in\").fill(\"2025-09-01\");\n await page.getByLabel(\"Check-out\").fill(\"2025-09-05\");\n\n await page.getByRole(\"button\", { name: \"Next\" }).click();\n });\n\n await test.step(\"confirm booking\", async () => {\n await expect(\n page.getByRole(\"heading\", { name: \"Confirmation\" }),\n ).toBeVisible();\n\n await expect(page.getByText(\"Alice Smith\")).toBeVisible();\n await expect(page.getByText(\"suite\")).toBeVisible();\n\n await page.getByRole(\"button\", { name: \"Confirm booking\" }).click();\n });\n\n await expect(\n page.getByRole(\"heading\", { name: \"Booking complete\" }),\n ).toBeVisible();\n});\n\ntest(\"wizard validates each step before proceeding\", async ({ page }) => {\n await page.goto(\"/booking\");\n\n await page.getByRole(\"button\", { name: \"Next\" }).click();\n\n await expect(page.getByRole(\"heading\", { name: \"Guest Info\" })).toBeVisible();\n await expect(page.getByText(\"Full name is required\")).toBeVisible();\n});\n\ntest(\"wizard supports going back without losing data\", async ({ page }) => {\n await page.goto(\"/booking\");\n\n await page.getByLabel(\"Full name\").fill(\"Alice Smith\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Phone\").fill(\"555-1234\");\n await page.getByRole(\"button\", { name: \"Next\" }).click();\n\n await page.getByRole(\"button\", { name: \"Previous\" }).click();\n\n await expect(page.getByLabel(\"Full name\")).toHaveValue(\"Alice Smith\");\n await expect(page.getByLabel(\"Email\")).toHaveValue(\"[email protected]\");\n});\n```\n\n### Form Submission and Response Handling\n\n**Use when**: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission.\n\n```typescript\ntest(\"successful form submission shows confirmation\", async ({ page }) => {\n await page.goto(\"/feedback\");\n\n await page.getByLabel(\"Subject\").fill(\"Feature request\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Details\").fill(\"Please add dark mode\");\n\n const responsePromise = page.waitForResponse(\"**/api/feedback\");\n await page.getByRole(\"button\", { name: \"Submit feedback\" }).click();\n const response = await responsePromise;\n\n expect(response.status()).toBe(200);\n await expect(page.getByText(\"Feedback received\")).toBeVisible();\n});\n\ntest(\"form submission shows server-side validation errors\", async ({\n page,\n}) => {\n await page.goto(\"/signup\");\n\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Password\", { exact: true }).fill(\"Secure1@pass\");\n await page.getByRole(\"button\", { name: \"Sign up\" }).click();\n\n await expect(\n page.getByText(\"Email address already registered\"),\n ).toBeVisible();\n});\n\ntest(\"form shows loading state during submission\", async ({ page }) => {\n await page.goto(\"/feedback\");\n\n await page.getByLabel(\"Subject\").fill(\"Bug report\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Details\").fill(\"Found an issue\");\n\n const submit = page.getByRole(\"button\", {\n name: /Submit feedback|Submitting/,\n });\n await submit.click();\n\n await expect(submit).toHaveText(/Submitting/);\n await expect(submit).toBeDisabled();\n\n await expect(submit).toHaveText(\"Submit feedback\");\n await expect(submit).toBeEnabled();\n});\n\ntest(\"form redirects after successful submission\", async ({ page }) => {\n await page.goto(\"/auth/login\");\n\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Password\").fill(\"admin123\");\n await page.getByRole(\"button\", { name: \"Log in\" }).click();\n\n await page.waitForURL(\"/home\");\n await expect(page.getByRole(\"heading\", { name: \"Welcome\" })).toBeVisible();\n});\n```\n\n### Filling Basic Form Fields\n\n**Use when**: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.\n\n```typescript\ntest(\"fill and submit a signup form\", async ({ page }) => {\n await page.goto(\"/signup\");\n\n await page.getByLabel(\"First name\").fill(\"Bob\");\n await page.getByLabel(\"Last name\").fill(\"Wilson\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Password\", { exact: true }).fill(\"P@ssw0rd!\");\n await page.getByLabel(\"Confirm password\").fill(\"P@ssw0rd!\");\n\n await page.getByLabel(\"About you\").fill(\"Developer with 5 years experience.\");\n await page.getByLabel(\"Years of experience\").fill(\"5\");\n\n await page.getByLabel(\"Country\").selectOption(\"UK\");\n await page.getByLabel(\"City\").selectOption({ label: \"London\" });\n await page\n .getByLabel(\"Skills\")\n .selectOption([\"typescript\", \"playwright\", \"nodejs\"]);\n\n await page.getByLabel(\"Accept terms\").check();\n await expect(page.getByLabel(\"Accept terms\")).toBeChecked();\n\n await page.getByLabel(\"Annual billing\").check();\n await expect(page.getByLabel(\"Annual billing\")).toBeChecked();\n\n await page.getByRole(\"button\", { name: \"Create account\" }).click();\n await expect(page.getByRole(\"heading\", { name: \"Welcome\" })).toBeVisible();\n});\n```\n\n### Date and Time Inputs\n\n**Use when**: Testing native `\u003cinput type=\"date\">`, `\u003cinput type=\"time\">`, `\u003cinput type=\"datetime-local\">`, or third-party date pickers.\n\n```typescript\ntest(\"fill native date and time inputs\", async ({ page }) => {\n await page.goto(\"/reservation\");\n\n await page.getByLabel(\"Reservation date\").fill(\"2025-07-10\");\n await expect(page.getByLabel(\"Reservation date\")).toHaveValue(\"2025-07-10\");\n\n await page.getByLabel(\"Time slot\").fill(\"18:00\");\n await page.getByLabel(\"Reminder\").fill(\"2025-07-10T17:30\");\n});\n\ntest(\"interact with a third-party date picker\", async ({ page }) => {\n await page.goto(\"/reservation\");\n\n await page.getByLabel(\"Event date\").click();\n await page.getByRole(\"button\", { name: \"Next month\" }).click();\n await page.getByRole(\"gridcell\", { name: \"25\" }).click();\n\n await expect(page.getByLabel(\"Event date\")).toHaveValue(/2025/);\n});\n```\n\n### Required Field Validation\n\n**Use when**: Testing that the form shows appropriate error messages when required fields are empty.\n\n```typescript\ntest(\"shows validation errors for empty required fields\", async ({ page }) => {\n await page.goto(\"/inquiry\");\n\n await page.getByRole(\"button\", { name: \"Send inquiry\" }).click();\n\n await expect(page.getByText(\"Name is required\")).toBeVisible();\n await expect(page.getByText(\"Email is required\")).toBeVisible();\n await expect(page.getByText(\"Question is required\")).toBeVisible();\n\n await expect(page).toHaveURL(/\\/inquiry/);\n});\n\ntest(\"clears validation errors when fields are filled\", async ({ page }) => {\n await page.goto(\"/inquiry\");\n\n await page.getByRole(\"button\", { name: \"Send inquiry\" }).click();\n await expect(page.getByText(\"Name is required\")).toBeVisible();\n\n await page.getByLabel(\"Name\").fill(\"Carol Brown\");\n await page.getByLabel(\"Email\").focus();\n\n await expect(page.getByText(\"Name is required\")).not.toBeVisible();\n});\n\ntest(\"native HTML5 validation with required attribute\", async ({ page }) => {\n await page.goto(\"/basic-form\");\n\n await page.getByRole(\"button\", { name: \"Submit\" }).click();\n\n const emailInput = page.getByLabel(\"Email\");\n const validationMessage = await emailInput.evaluate(\n (el: HTMLInputElement) => el.validationMessage,\n );\n expect(validationMessage).toBeTruthy();\n});\n```\n\n### Format Validation and Custom Rules\n\n**Use when**: Testing email format, phone number format, password strength, and business-specific validation rules.\n\n```typescript\ntest(\"validates email format\", async ({ page }) => {\n await page.goto(\"/signup\");\n\n const emailField = page.getByLabel(\"Email\");\n\n const invalidEmails = [\n \"invalid\",\n \"missing@\",\n \"@nodomain.com\",\n \"has [email protected]\",\n ];\n\n for (const email of invalidEmails) {\n await emailField.fill(email);\n await emailField.blur();\n await expect(page.getByText(\"Enter a valid email address\")).toBeVisible();\n }\n\n await emailField.fill(\"[email protected]\");\n await emailField.blur();\n await expect(page.getByText(\"Enter a valid email address\")).not.toBeVisible();\n});\n\ntest(\"validates password strength rules\", async ({ page }) => {\n await page.goto(\"/signup\");\n\n const passwordField = page.getByLabel(\"Password\", { exact: true });\n\n await passwordField.fill(\"Xy1!\");\n await passwordField.blur();\n await expect(page.getByText(\"Minimum 8 characters\")).toBeVisible();\n\n await passwordField.fill(\"lowercase1!\");\n await passwordField.blur();\n await expect(page.getByText(\"Include an uppercase letter\")).toBeVisible();\n\n await passwordField.fill(\"SecureP@ss1\");\n await passwordField.blur();\n await expect(page.getByText(/Minimum|Include/)).not.toBeVisible();\n});\n\ntest(\"validates custom business rule — minimum amount\", async ({ page }) => {\n await page.goto(\"/transfer\");\n\n await page.getByLabel(\"Amount\").fill(\"5\");\n await page.getByLabel(\"Amount\").blur();\n await expect(page.getByText(\"Minimum transfer is $10\")).toBeVisible();\n\n await page.getByLabel(\"Amount\").fill(\"1000000\");\n await page.getByLabel(\"Amount\").blur();\n await expect(page.getByText(\"Maximum transfer is $100,000\")).toBeVisible();\n\n await page.getByLabel(\"Amount\").fill(\"500\");\n await page.getByLabel(\"Amount\").blur();\n await expect(page.getByText(/Minimum|Maximum/)).not.toBeVisible();\n});\n```\n\n### Form Reset Testing\n\n**Use when**: Testing \"clear form\" or \"reset\" functionality, verifying that fields return to their default values.\n\n```typescript\ntest(\"reset button clears all fields to defaults\", async ({ page }) => {\n await page.goto(\"/preferences\");\n\n await page.getByLabel(\"Nickname\").fill(\"CustomNick\");\n await page.getByLabel(\"Language\").selectOption(\"es\");\n await page.getByLabel(\"Email alerts\").uncheck();\n\n await page.getByRole(\"button\", { name: \"Reset\" }).click();\n\n await expect(page.getByLabel(\"Nickname\")).toHaveValue(\"\");\n await expect(page.getByLabel(\"Language\")).toHaveValue(\"en\");\n await expect(page.getByLabel(\"Email alerts\")).toBeChecked();\n});\n\ntest(\"confirmation dialog before resetting a dirty form\", async ({ page }) => {\n await page.goto(\"/document\");\n\n await page.getByLabel(\"Document title\").fill(\"Draft document\");\n\n page.on(\"dialog\", (dialog) => dialog.accept());\n await page.getByRole(\"button\", { name: \"Clear changes\" }).click();\n\n await expect(page.getByLabel(\"Document title\")).toHaveValue(\"\");\n});\n```\n\n## Decision Guide\n\n| Scenario | Approach | Key API |\n| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |\n| Standard text input | `fill()` (clears, then types) | `page.getByLabel('Field').fill('value')` |\n| Need keystroke events (autocomplete) | `pressSequentially()` with delay | `locator.pressSequentially('text', { delay: 100 })` |\n| Native `\u003cselect>` dropdown | `selectOption()` by value or label | `locator.selectOption('US')` or `{ label: 'United States' }` |\n| Custom dropdown (ARIA listbox) | Click trigger, then select option role | `getByRole('option', { name: '...' }).click()` |\n| Checkbox | `check()` / `uncheck()` (idempotent) | `locator.check()` — safe to call even if already checked |\n| Radio button | `check()` on the target radio | `page.getByLabel('Option').check()` |\n| Date input (native) | `fill()` with ISO format | `locator.fill('2025-03-15')` |\n| Date picker (third-party) | Click to open, navigate, select day | `getByRole('gridcell', { name: '15' }).click()` |\n| Validation errors | Submit, then assert error text | `expect(page.getByText('Required')).toBeVisible()` |\n| Multi-step wizard | `test.step()` per step, assert heading | `await test.step('Step 1', async () => { ... })` |\n| Conditional/dynamic fields | Change trigger field, assert new field visibility | `expect(locator).toBeVisible()` / `.not.toBeVisible()` |\n| Form submission | `waitForResponse` + click submit | Register response listener before click |\n| Auto-complete | `pressSequentially()`, wait for listbox, select option | `getByRole('option', { name }).click()` |\n| Form reset | Click reset, assert default values | `expect(locator).toHaveValue('')` |\n\n## Anti-Patterns\n\n| Don't Do This | Problem | Do This Instead |\n| ------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |\n| `await page.getByLabel('Field').type('value')` | `type()` appends to existing content; does not clear first | `await page.getByLabel('Field').fill('value')` |\n| `await page.getByLabel('Option').click()` | `click()` toggles — if already checked, it unchecks | `await page.getByLabel('Option').check()` |\n| `await page.fill('#email', '[email protected]')` | CSS selector is fragile | `await page.getByLabel('Email').fill('[email protected]')` |\n| `await page.selectOption('select', 'US')` without label | Targets first `\u003cselect>` on page; ambiguous | `await page.getByLabel('Country').selectOption('US')` |\n| Testing every invalid input in one test | Test becomes huge, slow, and hard to debug | One test per validation rule or group related rules |\n| `expect(await input.inputValue()).toBe('value')` | Resolves once — no retry. Race condition. | `await expect(input).toHaveValue('value')` |\n| Filling fields with `page.evaluate()` | Bypasses event handlers (no `input`, `change` events fire) | Use `fill()` or `pressSequentially()` |\n| Not waiting for conditional fields before filling | `fill()` fails on hidden/detached elements | `await expect(field).toBeVisible()` first |\n| Hardcoding wait after selecting a dropdown | `waitForTimeout(500)` is flaky and slow | Wait for the dependent element to appear |\n| Skipping server-side validation tests | Client-side validation can be bypassed | Test both client-side UX and server response |\n\n## Troubleshooting\n\n### `fill()` does nothing or clears but doesn't type\n\n**Cause**: The input field uses a contenteditable div (rich text editors), not a real `\u003cinput>` or `\u003ctextarea>`.\n\n```typescript\nconst isContentEditable = await page\n .getByTestId(\"editor\")\n .evaluate((el) => el.getAttribute(\"contenteditable\"));\n\nif (isContentEditable) {\n await page.getByTestId(\"editor\").click();\n await page.getByTestId(\"editor\").pressSequentially(\"Hello world\");\n}\n```\n\n### Date picker does not accept `fill()` value\n\n**Cause**: Third-party date pickers often render custom UI over a hidden input. `fill()` sets the hidden input but the UI does not update.\n\n```typescript\nawait page.getByLabel(\"Date\").click();\nawait page.getByRole(\"button\", { name: \"Next month\" }).click();\nawait page.getByRole(\"gridcell\", { name: \"15\" }).click();\n\n// Alternatively, if the library reads from the input on change:\nawait page.getByLabel(\"Date\").fill(\"2025-06-15\");\nawait page.getByLabel(\"Date\").dispatchEvent(\"change\");\n```\n\n### `selectOption()` throws \"not a select element\"\n\n**Cause**: The dropdown is a custom component (ARIA listbox), not a native `\u003cselect>`.\n\n```typescript\nawait page.getByRole(\"combobox\", { name: \"Country\" }).click();\nawait page.getByRole(\"option\", { name: \"United States\" }).click();\n```\n\n### Validation errors do not appear after `fill()` and submit\n\n**Cause**: The validation triggers on `blur` (focus leaving the field), but `fill()` does not trigger blur automatically.\n\n```typescript\nawait page.getByLabel(\"Email\").fill(\"invalid\");\nawait page.getByLabel(\"Email\").blur();\nawait expect(page.getByText(\"Enter a valid email\")).toBeVisible();\n\n// Or move focus to the next field\nawait page.getByLabel(\"Password\").focus();\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22177,"content_sha256":"124cc991bd22106d202f6c0d7e15c79edb3dc4ca683b2fedfa700b6512df647a"},{"filename":"testing-patterns/graphql-testing.md","content":"# GraphQL Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Anti-Patterns](#anti-patterns)\n3. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Testing GraphQL APIs — queries, mutations, variables, and error handling.\n\n## Patterns\n\n### Basic Query with Variables\n\nAll GraphQL requests go through `POST` to a single endpoint. Send `query`, `variables`, and optionally `operationName` in the JSON body.\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\nconst GQL_ENDPOINT = \"/graphql\";\n\ntest(\"query with variables\", async ({ request }) => {\n const resp = await request.post(GQL_ENDPOINT, {\n data: {\n query: `\n query FetchItem($id: ID!) {\n item(id: $id) {\n id\n title\n price\n reviews { id rating }\n }\n }\n `,\n variables: { id: \"101\" },\n },\n });\n\n expect(resp.ok()).toBeTruthy();\n const { data, errors } = await resp.json();\n\n // GraphQL returns 200 even on errors — always check both\n expect(errors).toBeUndefined();\n expect(data.item).toMatchObject({\n id: \"101\",\n title: expect.any(String),\n price: expect.any(Number),\n });\n expect(data.item.reviews).toEqual(\n expect.arrayContaining([\n expect.objectContaining({\n id: expect.any(String),\n rating: expect.any(Number),\n }),\n ])\n );\n});\n```\n\n### Mutations\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\nconst GQL_ENDPOINT = \"/graphql\";\n\ntest(\"mutation creates resource\", async ({ request }) => {\n const resp = await request.post(GQL_ENDPOINT, {\n data: {\n query: `\n mutation AddItem($input: ItemInput!) {\n addItem(input: $input) {\n id\n title\n status\n }\n }\n `,\n variables: {\n input: {\n title: \"New Widget\",\n price: 15.0,\n status: \"DRAFT\",\n },\n },\n },\n });\n\n const { data, errors } = await resp.json();\n expect(errors).toBeUndefined();\n expect(data.addItem).toMatchObject({\n id: expect.any(String),\n title: \"New Widget\",\n status: \"DRAFT\",\n });\n});\n```\n\n### Validation Errors\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\nconst GQL_ENDPOINT = \"/graphql\";\n\ntest(\"handles validation errors\", async ({ request }) => {\n const resp = await request.post(GQL_ENDPOINT, {\n data: {\n query: `\n mutation AddItem($input: ItemInput!) {\n addItem(input: $input) { id }\n }\n `,\n variables: { input: { title: \"\" } },\n },\n });\n\n const { data, errors } = await resp.json();\n expect(errors).toBeDefined();\n expect(errors.length).toBeGreaterThan(0);\n expect(errors[0].message).toContain(\"title\");\n expect(errors[0].extensions?.code).toBe(\"BAD_USER_INPUT\");\n});\n```\n\n### Authorization Errors\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\nconst GQL_ENDPOINT = \"/graphql\";\n\ntest(\"handles authorization errors\", async ({ request }) => {\n const resp = await request.post(GQL_ENDPOINT, {\n data: {\n query: `\n query AdminDashboard {\n adminMetrics { revenue activeUsers }\n }\n `,\n },\n });\n\n const { data, errors } = await resp.json();\n expect(errors).toBeDefined();\n expect(errors[0].extensions?.code).toBe(\"UNAUTHORIZED\");\n expect(data?.adminMetrics).toBeNull();\n});\n```\n\n### Authenticated GraphQL Fixture\n\n```typescript\n// fixtures/graphql-fixtures.ts\nimport { test as base, expect, APIRequestContext } from \"@playwright/test\";\n\ntype GraphQLFixtures = {\n gqlClient: APIRequestContext;\n adminGqlClient: APIRequestContext;\n};\n\nexport const test = base.extend\u003cGraphQLFixtures>({\n gqlClient: async ({ playwright }, use) => {\n const ctx = await playwright.request.newContext({\n baseURL: \"https://api.myapp.io\",\n extraHTTPHeaders: {\n Authorization: `Bearer ${process.env.API_TOKEN}`,\n \"Content-Type\": \"application/json\",\n },\n });\n await use(ctx);\n await ctx.dispose();\n },\n\n adminGqlClient: async ({ playwright }, use) => {\n const loginCtx = await playwright.request.newContext({\n baseURL: \"https://api.myapp.io\",\n });\n const loginResp = await loginCtx.post(\"/graphql\", {\n data: {\n query: `\n mutation Login($email: String!, $password: String!) {\n login(email: $email, password: $password) { token }\n }\n `,\n variables: {\n email: process.env.ADMIN_EMAIL,\n password: process.env.ADMIN_PASSWORD,\n },\n },\n });\n const { data } = await loginResp.json();\n \n if (!data?.login?.token) {\n throw new Error(`Admin login failed: status ${loginResp.status()}, response: ${JSON.stringify(data)}`);\n }\n \n await loginCtx.dispose();\n\n const ctx = await playwright.request.newContext({\n baseURL: \"https://api.myapp.io\",\n extraHTTPHeaders: {\n Authorization: `Bearer ${data.login.token}`,\n \"Content-Type\": \"application/json\",\n },\n });\n await use(ctx);\n await ctx.dispose();\n },\n});\n\nexport { expect };\n```\n\n### GraphQL Helper Function\n\n```typescript\n// utils/graphql.ts\nimport { APIRequestContext, expect } from \"@playwright/test\";\n\nexport async function gqlQuery\u003cT = any>(\n request: APIRequestContext,\n query: string,\n variables?: Record\u003cstring, any>\n): Promise\u003c{ data: T; errors?: any[] }> {\n const resp = await request.post(\"/graphql\", {\n data: { query, variables },\n });\n expect(resp.ok()).toBeTruthy();\n return resp.json();\n}\n\nexport async function gqlMutation\u003cT = any>(\n request: APIRequestContext,\n mutation: string,\n variables?: Record\u003cstring, any>\n): Promise\u003c{ data: T; errors?: any[] }> {\n return gqlQuery\u003cT>(request, mutation, variables);\n}\n```\n\n```typescript\n// tests/api/items.spec.ts\nimport { test, expect } from \"@playwright/test\";\nimport { gqlQuery, gqlMutation } from \"../../utils/graphql\";\n\ntest(\"fetch and update item\", async ({ request }) => {\n const { data: fetchData } = await gqlQuery(\n request,\n `query GetItem($id: ID!) { item(id: $id) { id title } }`,\n { id: \"101\" }\n );\n expect(fetchData.item.title).toBeDefined();\n\n const { data: updateData, errors } = await gqlMutation(\n request,\n `mutation UpdateItem($id: ID!, $title: String!) {\n updateItem(id: $id, title: $title) { id title }\n }`,\n { id: \"101\", title: \"Updated Title\" }\n );\n expect(errors).toBeUndefined();\n expect(updateData.updateItem.title).toBe(\"Updated Title\");\n});\n```\n\n## Anti-Patterns\n\n| Don't Do This | Problem | Do This Instead |\n| --- | --- | --- |\n| Check only `response.ok()` | GraphQL returns 200 even on errors — `errors` array is the real signal | Always check both `data` and `errors` in the response body |\n| Ignore `errors` array | Validation and auth errors appear in `errors`, not HTTP status | Destructure and assert: `expect(errors).toBeUndefined()` |\n| Hardcode query strings inline everywhere | Duplicated queries are hard to maintain | Extract queries to constants or use a helper function |\n| Skip variable validation | Invalid variables cause cryptic server errors | Validate input shape before sending |\n\n## Troubleshooting\n\n### GraphQL returns 200 but data is null\n\n**Cause**: GraphQL servers return HTTP 200 even when the query has errors. The actual error is in the `errors` array.\n\n**Fix**: Always destructure and check both `data` and `errors`.\n\n```typescript\nconst { data, errors } = await resp.json();\nif (errors) {\n console.error(\"GraphQL errors:\", JSON.stringify(errors, null, 2));\n}\nexpect(errors).toBeUndefined();\nexpect(data.item).toBeDefined();\n```\n\n### \"Cannot query field X on type Y\"\n\n**Cause**: The field doesn't exist in the schema, or you're querying the wrong type.\n\n**Fix**: Verify the schema. Use introspection or check your GraphQL IDE for available fields.\n\n```typescript\n// Introspection query to debug schema\nconst { data } = await request.post(\"/graphql\", {\n data: {\n query: `{ __type(name: \"Item\") { fields { name type { name } } } }`,\n },\n});\nconsole.log(data.__type.fields);\n```\n\n### Variables not being applied\n\n**Cause**: Variable names in the query don't match the `variables` object keys, or types don't match.\n\n**Fix**: Ensure variable names match exactly (case-sensitive) and types align with the schema.\n\n```typescript\n// Wrong: variable name mismatch\nconst resp = await request.post(\"/graphql\", {\n data: {\n query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,\n variables: { id: \"101\" }, // Should be { itemId: \"101\" }\n },\n});\n\n// Correct\nconst resp = await request.post(\"/graphql\", {\n data: {\n query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,\n variables: { itemId: \"101\" },\n },\n});\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8714,"content_sha256":"25706e8c8133a86ae01a7579fab4d17000d01d4e5d2c67c174da6bcd9dbf1a36"},{"filename":"testing-patterns/i18n.md","content":"# Internationalization (i18n) Testing\n\n## Table of Contents\n\n1. [Locale Configuration](#locale-configuration)\n2. [Testing Multiple Locales](#testing-multiple-locales)\n3. [RTL Layout Testing](#rtl-layout-testing)\n4. [Date, Time & Number Formats](#date-time--number-formats)\n5. [Translation Verification](#translation-verification)\n6. [Visual Regression for i18n](#visual-regression-for-i18n)\n\n## Locale Configuration\n\n### Setting Browser Locale\n\n```typescript\n// playwright.config.ts\nimport { defineConfig, devices } from \"@playwright/test\";\n\nexport default defineConfig({\n projects: [\n {\n name: \"english\",\n use: {\n ...devices[\"Desktop Chrome\"],\n locale: \"en-US\",\n timezoneId: \"America/New_York\",\n },\n },\n {\n name: \"german\",\n use: {\n ...devices[\"Desktop Chrome\"],\n locale: \"de-DE\",\n timezoneId: \"Europe/Berlin\",\n },\n },\n {\n name: \"japanese\",\n use: {\n ...devices[\"Desktop Chrome\"],\n locale: \"ja-JP\",\n timezoneId: \"Asia/Tokyo\",\n },\n },\n {\n name: \"arabic\",\n use: {\n ...devices[\"Desktop Chrome\"],\n locale: \"ar-SA\",\n timezoneId: \"Asia/Riyadh\",\n },\n },\n ],\n});\n```\n\n### Per-Test Locale Override\n\n```typescript\ntest(\"test in French locale\", async ({ browser }) => {\n const context = await browser.newContext({\n locale: \"fr-FR\",\n timezoneId: \"Europe/Paris\",\n });\n\n const page = await context.newPage();\n await page.goto(\"/\");\n\n // Verify French content\n await expect(page.getByRole(\"button\", { name: \"Connexion\" })).toBeVisible();\n\n await context.close();\n});\n```\n\n### Accept-Language Header\n\n```typescript\ntest(\"server-side locale detection\", async ({ browser }) => {\n const context = await browser.newContext({\n locale: \"es-ES\",\n extraHTTPHeaders: {\n \"Accept-Language\": \"es-ES,es;q=0.9,en;q=0.8\",\n },\n });\n\n const page = await context.newPage();\n await page.goto(\"/\");\n\n // Server should respond with Spanish content\n await expect(page.locator(\"html\")).toHaveAttribute(\"lang\", \"es\");\n});\n```\n\n## Testing Multiple Locales\n\n### Parameterized Locale Tests\n\n```typescript\nconst locales = [\n { locale: \"en-US\", greeting: \"Hello\", button: \"Sign In\" },\n { locale: \"de-DE\", greeting: \"Hallo\", button: \"Anmelden\" },\n { locale: \"fr-FR\", greeting: \"Bonjour\", button: \"Se connecter\" },\n { locale: \"ja-JP\", greeting: \"こんにちは\", button: \"ログイン\" },\n];\n\nfor (const { locale, greeting, button } of locales) {\n test(`login page in ${locale}`, async ({ browser }) => {\n const context = await browser.newContext({ locale });\n const page = await context.newPage();\n\n await page.goto(\"/login\");\n\n await expect(page.getByText(greeting)).toBeVisible();\n await expect(page.getByRole(\"button\", { name: button })).toBeVisible();\n\n await context.close();\n });\n}\n```\n\n### Locale Fixture\n\n```typescript\n// fixtures/i18n.ts\nimport { test as base } from \"@playwright/test\";\n\ntype LocaleFixtures = {\n localePage: (locale: string) => Promise\u003cPage>;\n};\n\nexport const test = base.extend\u003cLocaleFixtures>({\n localePage: async ({ browser }, use) => {\n const pages: Page[] = [];\n\n const createLocalePage = async (locale: string) => {\n const context = await browser.newContext({ locale });\n const page = await context.newPage();\n pages.push(page);\n return page;\n };\n\n await use(createLocalePage);\n\n // Cleanup\n for (const page of pages) {\n await page.context().close();\n }\n },\n});\n\n// Usage\ntest(\"compare locales\", async ({ localePage }) => {\n const enPage = await localePage(\"en-US\");\n const dePage = await localePage(\"de-DE\");\n\n await enPage.goto(\"/pricing\");\n await dePage.goto(\"/pricing\");\n\n const enPrice = await enPage.getByTestId(\"price\").textContent();\n const dePrice = await dePage.getByTestId(\"price\").textContent();\n\n expect(enPrice).toContain(\"$\");\n expect(dePrice).toContain(\"€\");\n});\n```\n\n### Testing Locale Switching\n\n```typescript\ntest(\"user can switch locale\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Initial locale (from browser)\n await expect(page.locator(\"html\")).toHaveAttribute(\"lang\", \"en\");\n\n // Switch to German\n await page.getByRole(\"button\", { name: \"Language\" }).click();\n await page.getByRole(\"menuitem\", { name: \"Deutsch\" }).click();\n\n // Verify switch\n await expect(page.locator(\"html\")).toHaveAttribute(\"lang\", \"de\");\n await expect(page.getByRole(\"heading\", { level: 1 })).toContainText(\n /Willkommen/,\n );\n\n // Verify persistence (reload)\n await page.reload();\n await expect(page.locator(\"html\")).toHaveAttribute(\"lang\", \"de\");\n});\n```\n\n## RTL Layout Testing\n\n### Setting Up RTL Tests\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n projects: [\n {\n name: \"rtl-arabic\",\n use: {\n locale: \"ar-SA\",\n // RTL is usually set by the app based on locale\n },\n },\n {\n name: \"rtl-hebrew\",\n use: {\n locale: \"he-IL\",\n },\n },\n ],\n});\n```\n\n### Verifying RTL Direction\n\n```typescript\ntest(\"RTL layout is applied\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Check document direction\n await expect(page.locator(\"html\")).toHaveAttribute(\"dir\", \"rtl\");\n\n // Or check computed style\n const direction = await page.evaluate(() => {\n return window.getComputedStyle(document.body).direction;\n });\n expect(direction).toBe(\"rtl\");\n});\n```\n\n### RTL-Specific Element Positioning\n\n```typescript\ntest(\"sidebar is on the right in RTL\", async ({ page }) => {\n await page.goto(\"/dashboard\");\n\n const sidebar = page.getByTestId(\"sidebar\");\n const main = page.getByTestId(\"main-content\");\n\n const sidebarBox = await sidebar.boundingBox();\n const mainBox = await main.boundingBox();\n\n // In RTL, sidebar should be to the right of main content\n expect(sidebarBox!.x).toBeGreaterThan(mainBox!.x);\n});\n```\n\n### RTL Visual Regression\n\n```typescript\ntest(\"RTL layout matches snapshot\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Screenshot for RTL comparison\n await expect(page).toHaveScreenshot(\"homepage-rtl.png\", {\n // Separate snapshots per locale/direction\n fullPage: true,\n });\n});\n\n// LTR comparison\ntest(\"LTR layout matches snapshot\", async ({ browser }) => {\n const context = await browser.newContext({ locale: \"en-US\" });\n const page = await context.newPage();\n\n await page.goto(\"/\");\n await expect(page).toHaveScreenshot(\"homepage-ltr.png\", { fullPage: true });\n});\n```\n\n### Testing Bidirectional Text\n\n```typescript\ntest(\"bidirectional text renders correctly\", async ({ page }) => {\n await page.goto(\"/profile\");\n\n // Mixed LTR/RTL content\n const nameField = page.getByTestId(\"full-name\");\n\n // Arabic name with English email\n await expect(nameField).toContainText(\"محمد ([email protected])\");\n\n // Verify text doesn't overlap or break\n const box = await nameField.boundingBox();\n expect(box!.width).toBeGreaterThan(100); // Content not collapsed\n});\n```\n\n## Date, Time & Number Formats\n\n### Testing Date Formats\n\n```typescript\ntest(\"dates are formatted per locale\", async ({ browser }) => {\n const testDate = new Date(\"2024-03-15\");\n\n const formats = [\n { locale: \"en-US\", expected: \"March 15, 2024\" },\n { locale: \"en-GB\", expected: \"15 March 2024\" },\n { locale: \"de-DE\", expected: \"15. März 2024\" },\n { locale: \"ja-JP\", expected: \"2024年3月15日\" },\n ];\n\n for (const { locale, expected } of formats) {\n const context = await browser.newContext({ locale });\n const page = await context.newPage();\n\n await page.goto(`/event?date=${testDate.toISOString()}`);\n\n const dateDisplay = page.getByTestId(\"event-date\");\n await expect(dateDisplay).toContainText(expected);\n\n await context.close();\n }\n});\n```\n\n### Testing Number Formats\n\n```typescript\ntest(\"numbers are formatted per locale\", async ({ browser }) => {\n const testNumber = 1234567.89;\n\n const formats = [\n { locale: \"en-US\", expected: \"1,234,567.89\" },\n { locale: \"de-DE\", expected: \"1.234.567,89\" },\n { locale: \"fr-FR\", expected: \"1 234 567,89\" },\n ];\n\n for (const { locale, expected } of formats) {\n const context = await browser.newContext({ locale });\n const page = await context.newPage();\n\n await page.goto(`/stats?value=${testNumber}`);\n\n await expect(page.getByTestId(\"formatted-number\")).toHaveText(expected);\n\n await context.close();\n }\n});\n```\n\n### Testing Currency Formats\n\n```typescript\ntest(\"currency displays correctly\", async ({ browser }) => {\n const price = 99.99;\n\n const currencies = [\n { locale: \"en-US\", currency: \"USD\", expected: \"$99.99\" },\n { locale: \"de-DE\", currency: \"EUR\", expected: \"99,99 €\" },\n { locale: \"ja-JP\", currency: \"JPY\", expected: \"¥100\" }, // JPY has no decimals\n { locale: \"en-GB\", currency: \"GBP\", expected: \"£99.99\" },\n ];\n\n for (const { locale, currency, expected } of currencies) {\n const context = await browser.newContext({ locale });\n const page = await context.newPage();\n\n await page.goto(`/product?price=${price}¤cy=${currency}`);\n\n await expect(page.getByTestId(\"price\")).toContainText(expected);\n\n await context.close();\n }\n});\n```\n\n## Translation Verification\n\n### Checking for Missing Translations\n\n```typescript\ntest(\"no missing translations\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Common patterns for missing translations\n const missingPatterns = [\n /\\{\\{.*\\}\\}/, // Handlebars-style\n /\\$\\{.*\\}/, // Template literal style\n /t\\([\"'][\\w.]+[\"']\\)/, // i18n key exposed\n /MISSING_TRANSLATION/, // Common placeholder\n /\\[UNTRANSLATED\\]/, // Another placeholder\n ];\n\n const bodyText = await page.locator(\"body\").textContent();\n\n for (const pattern of missingPatterns) {\n expect(bodyText).not.toMatch(pattern);\n }\n});\n```\n\n### Detecting Text Overflow\n\n```typescript\ntest(\"translations fit UI containers\", async ({ browser }) => {\n const locales = [\"en-US\", \"de-DE\", \"fr-FR\", \"es-ES\"];\n const issues: string[] = [];\n\n for (const locale of locales) {\n const context = await browser.newContext({ locale });\n const page = await context.newPage();\n await page.goto(\"/\");\n\n const overflowing = await page.evaluate(() => {\n const elements = document.querySelectorAll(\"button, .label, h1, h2, h3\");\n return Array.from(elements)\n .filter(\n (el) =>\n (el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth,\n )\n .map((el) => `${el.tagName}: \"${el.textContent?.substring(0, 20)}...\"`);\n });\n\n if (overflowing.length > 0)\n issues.push(`${locale}: ${overflowing.join(\", \")}`);\n await context.close();\n }\n\n expect(issues).toEqual([]);\n});\n```\n\n## Visual Regression for i18n\n\n### Locale-Specific Snapshots\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n snapshotPathTemplate:\n \"{testDir}/__snapshots__/{projectName}/{testFilePath}/{arg}{ext}\",\n\n projects: [\n { name: \"en-US\", use: { locale: \"en-US\" } },\n { name: \"de-DE\", use: { locale: \"de-DE\" } },\n { name: \"ja-JP\", use: { locale: \"ja-JP\" } },\n { name: \"ar-SA\", use: { locale: \"ar-SA\" } },\n ],\n});\n```\n\n```typescript\n// test file\ntest(\"homepage visual\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Snapshot auto-saved to {projectName}/homepage.png\n await expect(page).toHaveScreenshot(\"homepage.png\");\n});\n```\n\n### Critical Element Screenshots\n\n```typescript\ntest(\"navigation in all locales\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Just the nav - catches overflow, truncation\n const nav = page.getByRole(\"navigation\");\n await expect(nav).toHaveScreenshot(\"navigation.png\");\n});\n\ntest(\"buttons dont truncate\", async ({ page }) => {\n await page.goto(\"/checkout\");\n\n const ctaButton = page.getByRole(\"button\", {\n name: /checkout|kaufen|acheter/i,\n });\n await expect(ctaButton).toHaveScreenshot(\"checkout-button.png\");\n});\n```\n\n### Font Loading for i18n\n\n```typescript\ntest(\"wait for fonts before screenshot\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Wait for fonts (important for CJK, Arabic)\n await page.evaluate(() => document.fonts.ready);\n await page.waitForFunction(() =>\n document.fonts.check(\"16px 'Noto Sans Arabic'\"),\n );\n\n await expect(page).toHaveScreenshot(\"with-fonts.png\");\n});\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| ------------------------- | ------------------------------- | ------------------------------- |\n| Hardcoded text assertions | Breaks in other locales | Use test IDs or parameterize |\n| Single locale testing | Misses i18n bugs | Test multiple locales |\n| Ignoring RTL | Layout broken for RTL users | Dedicated RTL project |\n| No font wait | Screenshots with fallback fonts | Wait for `document.fonts.ready` |\n\n## Related References\n\n- **Clock Mocking**: See [clock-mocking.md](../advanced/clock-mocking.md) for timezone testing\n- **Mobile Testing**: See [mobile-testing.md](../advanced/mobile-testing.md) for device-specific locales\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13134,"content_sha256":"348fd0817e83ce9ac1318ad2ff87f9ac5b52de6c21aecdbd99690538214c87f1"},{"filename":"testing-patterns/performance-testing.md","content":"# Performance Testing & Web Vitals\n\n## Table of Contents\n\n1. [Core Web Vitals](#core-web-vitals)\n2. [Performance Metrics](#performance-metrics)\n3. [Performance Budgets](#performance-budgets)\n4. [Lighthouse Integration](#lighthouse-integration)\n5. [Performance Fixtures](#performance-fixtures)\n6. [CI Performance Monitoring](#ci-performance-monitoring)\n\n## Core Web Vitals\n\n### Measure LCP, FID, CLS\n\n```typescript\ntest(\"core web vitals within thresholds\", async ({ page }) => {\n // Inject web-vitals library\n await page.addInitScript(() => {\n (window as any).__webVitals = {};\n\n // Simplified web vitals collection\n new PerformanceObserver((list) => {\n for (const entry of list.getEntries()) {\n if (entry.entryType === \"largest-contentful-paint\") {\n (window as any).__webVitals.lcp = entry.startTime;\n }\n }\n }).observe({ type: \"largest-contentful-paint\", buffered: true });\n\n new PerformanceObserver((list) => {\n let cls = 0;\n for (const entry of list.getEntries() as any[]) {\n if (!entry.hadRecentInput) {\n cls += entry.value;\n }\n }\n (window as any).__webVitals.cls = cls;\n }).observe({ type: \"layout-shift\", buffered: true });\n });\n\n await page.goto(\"/\");\n\n // Wait for page to stabilize\n await page.waitForLoadState(\"networkidle\");\n\n // Get metrics\n const vitals = await page.evaluate(() => (window as any).__webVitals);\n\n // Assert thresholds (Google's \"good\" thresholds)\n expect(vitals.lcp).toBeLessThan(2500); // LCP \u003c 2.5s\n expect(vitals.cls).toBeLessThan(0.1); // CLS \u003c 0.1\n});\n```\n\n### Using web-vitals Library\n\n```typescript\ntest(\"web vitals with library\", async ({ page }) => {\n await page.addInitScript(() => {\n (window as any).__vitals = {};\n });\n\n // Inject web-vitals after navigation\n await page.goto(\"/\");\n\n await page.addScriptTag({\n url: \"https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js\",\n });\n\n await page.evaluate(() => {\n const { onLCP, onFID, onCLS, onFCP, onTTFB } = (window as any).webVitals;\n\n onLCP((metric: any) => ((window as any).__vitals.lcp = metric.value));\n onFID((metric: any) => ((window as any).__vitals.fid = metric.value));\n onCLS((metric: any) => ((window as any).__vitals.cls = metric.value));\n onFCP((metric: any) => ((window as any).__vitals.fcp = metric.value));\n onTTFB((metric: any) => ((window as any).__vitals.ttfb = metric.value));\n });\n\n // Trigger FID by clicking\n await page.getByRole(\"button\").first().click();\n\n // Wait and collect\n await page.waitForTimeout(1000);\n\n const vitals = await page.evaluate(() => (window as any).__vitals);\n\n console.log(\"Web Vitals:\", vitals);\n\n // Assertions\n if (vitals.lcp) expect(vitals.lcp).toBeLessThan(2500);\n if (vitals.fid) expect(vitals.fid).toBeLessThan(100);\n if (vitals.cls) expect(vitals.cls).toBeLessThan(0.1);\n});\n```\n\n## Performance Metrics\n\n### Navigation Timing\n\n```typescript\ntest(\"page load performance\", async ({ page }) => {\n await page.goto(\"/\");\n\n const timing = await page.evaluate(() => {\n const nav = performance.getEntriesByType(\n \"navigation\",\n )[0] as PerformanceNavigationTiming;\n\n return {\n // Time to First Byte\n ttfb: nav.responseStart - nav.requestStart,\n // DOM Content Loaded\n domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,\n // Full page load\n loadComplete: nav.loadEventEnd - nav.startTime,\n // DNS lookup\n dns: nav.domainLookupEnd - nav.domainLookupStart,\n // Connection time\n connection: nav.connectEnd - nav.connectStart,\n // Download time\n download: nav.responseEnd - nav.responseStart,\n // DOM processing\n domProcessing: nav.domComplete - nav.domInteractive,\n };\n });\n\n console.log(\"Performance timing:\", timing);\n\n // Assertions\n expect(timing.ttfb).toBeLessThan(600); // TTFB \u003c 600ms\n expect(timing.domContentLoaded).toBeLessThan(2000); // DCL \u003c 2s\n expect(timing.loadComplete).toBeLessThan(4000); // Load \u003c 4s\n});\n```\n\n### Resource Timing\n\n```typescript\ntest(\"resource loading performance\", async ({ page }) => {\n await page.goto(\"/\");\n\n const resources = await page.evaluate(() => {\n return performance.getEntriesByType(\"resource\").map((entry) => ({\n name: entry.name.split(\"/\").pop(),\n type: (entry as PerformanceResourceTiming).initiatorType,\n duration: entry.duration,\n size: (entry as PerformanceResourceTiming).transferSize,\n }));\n });\n\n // Find slow resources\n const slowResources = resources.filter((r) => r.duration > 1000);\n\n if (slowResources.length > 0) {\n console.warn(\"Slow resources:\", slowResources);\n }\n\n // Find large resources\n const largeResources = resources.filter((r) => r.size > 500000); // > 500KB\n\n expect(largeResources.length).toBe(0);\n});\n```\n\n### Memory Usage\n\n```typescript\ntest(\"memory usage is reasonable\", async ({ page }) => {\n await page.goto(\"/dashboard\");\n\n // Check memory (Chrome only)\n const memory = await page.evaluate(() => {\n if ((performance as any).memory) {\n return {\n usedJSHeapSize: (performance as any).memory.usedJSHeapSize,\n totalJSHeapSize: (performance as any).memory.totalJSHeapSize,\n };\n }\n return null;\n });\n\n if (memory) {\n const usedMB = memory.usedJSHeapSize / 1024 / 1024;\n console.log(`Memory usage: ${usedMB.toFixed(2)} MB`);\n\n // Assert reasonable memory usage\n expect(usedMB).toBeLessThan(100); // \u003c 100MB\n }\n});\n```\n\n## Performance Budgets\n\n### Define Budgets\n\n```typescript\n// performance-budgets.ts\nexport const budgets = {\n homepage: {\n lcp: 2500,\n cls: 0.1,\n fcp: 1800,\n ttfb: 600,\n totalSize: 1500000, // 1.5MB\n jsSize: 500000, // 500KB\n imageCount: 20,\n },\n dashboard: {\n lcp: 3000,\n cls: 0.1,\n fcp: 2000,\n ttfb: 800,\n totalSize: 2000000,\n jsSize: 800000,\n },\n};\n```\n\n### Test Against Budgets\n\n```typescript\nimport { budgets } from \"./performance-budgets\";\n\ntest(\"homepage meets performance budget\", async ({ page }) => {\n const budget = budgets.homepage;\n\n await page.goto(\"/\");\n await page.waitForLoadState(\"networkidle\");\n\n // Measure LCP\n const lcp = await page.evaluate(() => {\n return new Promise\u003cnumber>((resolve) => {\n new PerformanceObserver((list) => {\n const entries = list.getEntries();\n resolve(entries[entries.length - 1].startTime);\n }).observe({ type: \"largest-contentful-paint\", buffered: true });\n });\n });\n\n // Measure resources\n const resources = await page.evaluate(() => {\n const entries = performance.getEntriesByType(\n \"resource\",\n ) as PerformanceResourceTiming[];\n return {\n totalSize: entries.reduce((sum, e) => sum + (e.transferSize || 0), 0),\n jsSize: entries\n .filter((e) => e.initiatorType === \"script\")\n .reduce((sum, e) => sum + (e.transferSize || 0), 0),\n imageCount: entries.filter((e) => e.initiatorType === \"img\").length,\n };\n });\n\n // Assert budgets\n expect(lcp, \"LCP exceeds budget\").toBeLessThan(budget.lcp);\n expect(resources.totalSize, \"Total size exceeds budget\").toBeLessThan(\n budget.totalSize,\n );\n expect(resources.jsSize, \"JS size exceeds budget\").toBeLessThan(\n budget.jsSize,\n );\n expect(resources.imageCount, \"Too many images\").toBeLessThanOrEqual(\n budget.imageCount,\n );\n});\n```\n\n### Budget Fixture\n\n```typescript\n// fixtures/performance.fixture.ts\ntype PerformanceBudget = {\n lcp?: number;\n cls?: number;\n ttfb?: number;\n totalSize?: number;\n};\n\ntype PerformanceFixtures = {\n assertBudget: (budget: PerformanceBudget) => Promise\u003cvoid>;\n};\n\nexport const test = base.extend\u003cPerformanceFixtures>({\n assertBudget: async ({ page }, use) => {\n await use(async (budget) => {\n const metrics = await page.evaluate(() => {\n const nav = performance.getEntriesByType(\n \"navigation\",\n )[0] as PerformanceNavigationTiming;\n const resources = performance.getEntriesByType(\n \"resource\",\n ) as PerformanceResourceTiming[];\n\n return {\n ttfb: nav.responseStart - nav.requestStart,\n totalSize: resources.reduce(\n (sum, r) => sum + (r.transferSize || 0),\n 0,\n ),\n };\n });\n\n if (budget.ttfb) {\n expect(\n metrics.ttfb,\n `TTFB ${metrics.ttfb}ms exceeds budget ${budget.ttfb}ms`,\n ).toBeLessThan(budget.ttfb);\n }\n\n if (budget.totalSize) {\n expect(metrics.totalSize, `Total size exceeds budget`).toBeLessThan(\n budget.totalSize,\n );\n }\n });\n },\n});\n```\n\n## Lighthouse Integration\n\n### Using playwright-lighthouse\n\n```bash\nnpm install -D playwright-lighthouse lighthouse\n```\n\n```typescript\nimport { playAudit } from \"playwright-lighthouse\";\n\ntest(\"lighthouse audit\", async ({ page }) => {\n await page.goto(\"/\");\n\n // Run Lighthouse\n const audit = await playAudit({\n page,\n port: 9222, // Chrome debugging port\n thresholds: {\n performance: 80,\n accessibility: 90,\n \"best-practices\": 80,\n seo: 80,\n },\n });\n\n // Assertions\n expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(\n 80,\n );\n expect(audit.lhr.categories.accessibility.score * 100).toBeGreaterThanOrEqual(\n 90,\n );\n});\n```\n\n### Lighthouse with Config\n\n```typescript\ntest(\"lighthouse with custom config\", async ({ page }, testInfo) => {\n await page.goto(\"/\");\n\n const audit = await playAudit({\n page,\n port: 9222,\n thresholds: {\n performance: 70,\n },\n config: {\n extends: \"lighthouse:default\",\n settings: {\n onlyCategories: [\"performance\"],\n throttling: {\n rttMs: 40,\n throughputKbps: 10240,\n cpuSlowdownMultiplier: 1,\n },\n },\n },\n });\n\n // Save report\n const reportPath = testInfo.outputPath(\"lighthouse-report.html\");\n // Save audit.report to file\n\n // Attach to test report\n await testInfo.attach(\"lighthouse\", {\n body: JSON.stringify(audit.lhr),\n contentType: \"application/json\",\n });\n});\n```\n\n## CI Performance Monitoring\n\n### Track Performance Over Time\n\n```typescript\n// reporters/perf-reporter.ts\nimport { Reporter, TestResult } from \"@playwright/test/reporter\";\n\nclass PerfReporter implements Reporter {\n private metrics: any[] = [];\n\n onTestEnd(test: any, result: TestResult) {\n const perfAnnotation = test.annotations.find(\n (a: any) => a.type === \"performance\",\n );\n\n if (perfAnnotation) {\n this.metrics.push({\n test: test.title,\n ...JSON.parse(perfAnnotation.description),\n timestamp: new Date().toISOString(),\n });\n }\n }\n\n async onEnd() {\n // Send to metrics service\n if (process.env.METRICS_ENDPOINT) {\n await fetch(process.env.METRICS_ENDPOINT, {\n method: \"POST\",\n body: JSON.stringify({\n commit: process.env.GITHUB_SHA,\n branch: process.env.GITHUB_REF,\n metrics: this.metrics,\n }),\n });\n }\n }\n}\n\nexport default PerfReporter;\n```\n\n### Performance Regression Detection\n\n```typescript\ntest(\"no performance regression\", async ({ page }) => {\n await page.goto(\"/\");\n\n const metrics = await page.evaluate(() => {\n const nav = performance.getEntriesByType(\n \"navigation\",\n )[0] as PerformanceNavigationTiming;\n return {\n loadTime: nav.loadEventEnd - nav.startTime,\n };\n });\n\n // Compare against baseline (could be from file or API)\n const baseline = 2000; // ms\n const threshold = 1.1; // 10% regression allowed\n\n expect(\n metrics.loadTime,\n `Load time ${metrics.loadTime}ms is ${((metrics.loadTime / baseline - 1) * 100).toFixed(1)}% slower than baseline`,\n ).toBeLessThan(baseline * threshold);\n});\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| --------------------------- | ------------------------- | -------------------------------- |\n| Testing only once | Results vary | Run multiple times, use averages |\n| Ignoring network conditions | Unrealistic results | Test with throttling |\n| No baseline comparison | Can't detect regressions | Track metrics over time |\n| Testing in dev mode | Slow, not production-like | Test production builds |\n\n## Related References\n\n- **Performance Optimization**: See [performance.md](../infrastructure-ci-cd/performance.md) for test execution performance\n- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI integration\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12566,"content_sha256":"0faccd5ec785b4c66466f194ce9c8d1b9e257674e5be5c1ef5dd8864538bde5b"},{"filename":"testing-patterns/security-testing.md","content":"# Security Testing Basics\n\n## Table of Contents\n\n1. [XSS Prevention](#xss-prevention)\n2. [CSRF Protection](#csrf-protection)\n3. [Authentication Security](#authentication-security)\n4. [Authorization Testing](#authorization-testing)\n5. [Input Validation](#input-validation)\n6. [Security Headers](#security-headers)\n\n## XSS Prevention\n\n### Test Reflected XSS\n\n```typescript\ntest(\"input is properly escaped\", async ({ page }) => {\n const xssPayloads = [\n '\u003cscript>alert(\"xss\")\u003c/script>',\n '\u003cimg src=\"x\" onerror=\"alert(1)\">',\n '\">\u003cscript>alert(1)\u003c/script>',\n \"javascript:alert(1)\",\n '\u003csvg onload=\"alert(1)\">',\n ];\n\n for (const payload of xssPayloads) {\n await page.goto(`/search?q=${encodeURIComponent(payload)}`);\n\n // Verify script didn't execute\n const alertTriggered = await page.evaluate(() => {\n return (window as any).__xssTriggered === true;\n });\n expect(alertTriggered).toBe(false);\n\n // Verify payload is escaped in HTML\n const content = await page.content();\n expect(content).not.toContain(\"\u003cscript>alert\");\n expect(content).not.toContain(\"onerror=\");\n }\n});\n```\n\n### Test Stored XSS\n\n```typescript\ntest(\"user content is sanitized\", async ({ page }) => {\n await page.goto(\"/create-post\");\n\n // Try to inject script via form\n await page.getByLabel(\"Content\").fill('\u003cscript>alert(\"xss\")\u003c/script>Hello');\n await page.getByRole(\"button\", { name: \"Submit\" }).click();\n\n // View the post\n await page.goto(\"/posts/latest\");\n\n // Script should not be in page\n const scripts = await page.locator(\"script\").count();\n const pageContent = await page.content();\n\n // The script tag should be escaped or removed\n expect(pageContent).not.toContain(\"\u003cscript>alert\");\n\n // Text should still be visible (just sanitized)\n await expect(page.getByText(\"Hello\")).toBeVisible();\n});\n```\n\n### Monitor for XSS Execution\n\n```typescript\ntest(\"no XSS execution\", async ({ page }) => {\n // Set up XSS detection\n await page.addInitScript(() => {\n (window as any).__xssDetected = false;\n\n // Override alert/confirm/prompt\n window.alert = () => {\n (window as any).__xssDetected = true;\n };\n window.confirm = () => {\n (window as any).__xssDetected = true;\n return false;\n };\n window.prompt = () => {\n (window as any).__xssDetected = true;\n return null;\n };\n });\n\n // Perform test actions\n await page.goto(\"/vulnerable-page\");\n await page.getByLabel(\"Search\").fill('\">\u003cimg src=x onerror=alert(1)>');\n await page.getByLabel(\"Search\").press(\"Enter\");\n\n // Check if XSS triggered\n const xssDetected = await page.evaluate(() => (window as any).__xssDetected);\n expect(xssDetected).toBe(false);\n});\n```\n\n## CSRF Protection\n\n### Verify CSRF Token Present\n\n```typescript\ntest(\"forms include CSRF token\", async ({ page }) => {\n await page.goto(\"/settings\");\n\n // Check form has CSRF token\n const csrfInput = page.locator(\n 'input[name=\"_csrf\"], input[name=\"csrf_token\"]',\n );\n await expect(csrfInput).toBeAttached();\n\n const csrfValue = await csrfInput.getAttribute(\"value\");\n expect(csrfValue).toBeTruthy();\n expect(csrfValue!.length).toBeGreaterThan(20);\n});\n```\n\n### Test CSRF Token Validation\n\n```typescript\ntest(\"rejects requests without CSRF token\", async ({ page, request }) => {\n await page.goto(\"/settings\");\n\n // Try to submit without CSRF token\n const response = await request.post(\"/api/settings\", {\n data: { theme: \"dark\" },\n headers: {\n \"Content-Type\": \"application/json\",\n },\n });\n\n // Should be rejected\n expect(response.status()).toBe(403);\n});\n\ntest(\"rejects requests with invalid CSRF token\", async ({ page, request }) => {\n await page.goto(\"/settings\");\n\n const response = await request.post(\"/api/settings\", {\n data: { theme: \"dark\" },\n headers: {\n \"X-CSRF-Token\": \"invalid-token\",\n },\n });\n\n expect(response.status()).toBe(403);\n});\n```\n\n### Test CSRF with Valid Token\n\n```typescript\ntest(\"accepts requests with valid CSRF token\", async ({ page }) => {\n await page.goto(\"/settings\");\n\n // Get CSRF token from page\n const csrfToken = await page\n .locator('meta[name=\"csrf-token\"]')\n .getAttribute(\"content\");\n\n // Submit form normally\n await page.getByLabel(\"Theme\").selectOption(\"dark\");\n await page.getByRole(\"button\", { name: \"Save\" }).click();\n\n // Should succeed\n await expect(page.getByText(\"Settings saved\")).toBeVisible();\n});\n```\n\n## Authentication Security\n\n### Test Session Expiry\n\n```typescript\ntest(\"session expires after timeout\", async ({ page, context }) => {\n await page.goto(\"/login\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByLabel(\"Password\").fill(\"password\");\n await page.getByRole(\"button\", { name: \"Sign in\" }).click();\n\n await expect(page).toHaveURL(\"/dashboard\");\n\n // Simulate time passing (if using clock mocking)\n await page.clock.fastForward(\"02:00:00\"); // 2 hours\n\n // Try to access protected page\n await page.goto(\"/profile\");\n\n // Should redirect to login\n await expect(page).toHaveURL(/\\/login/);\n await expect(page.getByText(\"Session expired\")).toBeVisible();\n});\n```\n\n### Test Concurrent Sessions\n\n```typescript\ntest(\"handles concurrent session limit\", async ({ browser }) => {\n // Login from first browser\n const context1 = await browser.newContext();\n const page1 = await context1.newPage();\n\n await page1.goto(\"/login\");\n await page1.getByLabel(\"Email\").fill(\"[email protected]\");\n await page1.getByLabel(\"Password\").fill(\"password\");\n await page1.getByRole(\"button\", { name: \"Sign in\" }).click();\n await expect(page1).toHaveURL(\"/dashboard\");\n\n // Login from second browser (same user)\n const context2 = await browser.newContext();\n const page2 = await context2.newPage();\n\n await page2.goto(\"/login\");\n await page2.getByLabel(\"Email\").fill(\"[email protected]\");\n await page2.getByLabel(\"Password\").fill(\"password\");\n await page2.getByRole(\"button\", { name: \"Sign in\" }).click();\n\n // First session should be invalidated (or warning shown)\n await page1.reload();\n await expect(\n page1.getByText(/session.*another device|logged out/i),\n ).toBeVisible();\n\n await context1.close();\n await context2.close();\n});\n```\n\n### Test Password Reset Security\n\n```typescript\ntest(\"password reset token is single-use\", async ({ page, request }) => {\n // Request password reset\n await page.goto(\"/forgot-password\");\n await page.getByLabel(\"Email\").fill(\"[email protected]\");\n await page.getByRole(\"button\", { name: \"Reset\" }).click();\n\n // Get token (in test env, might be exposed or use email mock)\n const resetToken = \"mock-reset-token\";\n\n // Use token first time\n await page.goto(`/reset-password?token=${resetToken}`);\n await page.getByLabel(\"New Password\").fill(\"NewPassword123\");\n await page.getByRole(\"button\", { name: \"Reset\" }).click();\n\n await expect(page.getByText(\"Password updated\")).toBeVisible();\n\n // Try to use same token again\n await page.goto(`/reset-password?token=${resetToken}`);\n\n await expect(page.getByText(\"Invalid or expired token\")).toBeVisible();\n});\n```\n\n## Authorization Testing\n\n### Test Unauthorized Access\n\n```typescript\ntest.describe(\"authorization\", () => {\n test(\"cannot access admin routes as user\", async ({ browser }) => {\n const context = await browser.newContext({\n storageState: \".auth/user.json\", // Regular user\n });\n const page = await context.newPage();\n\n // Try to access admin page\n await page.goto(\"/admin/users\");\n\n // Should be denied\n await expect(page).not.toHaveURL(\"/admin/users\");\n expect(\n (await page.getByText(\"Access denied\").isVisible()) ||\n (await page.url()).includes(\"/login\") ||\n (await page.url()).includes(\"/403\"),\n ).toBe(true);\n\n await context.close();\n });\n\n test(\"cannot access other user's data\", async ({ page }) => {\n // Logged in as user 1, try to access user 2's profile\n await page.goto(\"/users/other-user-id/settings\");\n\n await expect(page.getByText(\"Access denied\")).toBeVisible();\n });\n});\n```\n\n### Test IDOR (Insecure Direct Object Reference)\n\n```typescript\ntest(\"cannot access other user resources by changing ID\", async ({\n page,\n request,\n}) => {\n // Get current user's order\n await page.goto(\"/orders/my-order-123\");\n await expect(page.getByText(\"Order #my-order-123\")).toBeVisible();\n\n // Try to access another user's order\n const response = await request.get(\"/api/orders/other-user-order-456\");\n\n // Should be forbidden\n expect(response.status()).toBe(403);\n});\n```\n\n## Input Validation\n\n### Test SQL Injection Prevention\n\n```typescript\ntest(\"SQL injection is prevented\", async ({ page }) => {\n const sqlPayloads = [\n \"'; DROP TABLE users; --\",\n \"1' OR '1'='1\",\n \"1; DELETE FROM orders\",\n \"' UNION SELECT * FROM users --\",\n ];\n\n for (const payload of sqlPayloads) {\n await page.goto(\"/search\");\n await page.getByLabel(\"Search\").fill(payload);\n await page.getByRole(\"button\", { name: \"Search\" }).click();\n\n // Should not error (injection blocked/escaped)\n await expect(page.getByText(\"Error\")).not.toBeVisible();\n\n // Should show no results or escaped text\n const hasError = await page\n .getByText(/database error|sql|syntax/i)\n .isVisible();\n expect(hasError).toBe(false);\n }\n});\n```\n\n### Test Input Length Limits\n\n```typescript\ntest(\"enforces input length limits\", async ({ page }) => {\n await page.goto(\"/profile\");\n\n // Try to submit very long input\n const longString = \"a\".repeat(10000);\n\n await page.getByLabel(\"Bio\").fill(longString);\n await page.getByRole(\"button\", { name: \"Save\" }).click();\n\n // Should show validation error or truncate\n const bioValue = await page.getByLabel(\"Bio\").inputValue();\n expect(bioValue.length).toBeLessThanOrEqual(500); // Expected max\n});\n```\n\n## Security Headers\n\n### Verify Security Headers\n\n```typescript\ntest(\"response includes security headers\", async ({ page }) => {\n const response = await page.goto(\"/\");\n\n const headers = response!.headers();\n\n // Content Security Policy\n expect(headers[\"content-security-policy\"]).toBeTruthy();\n\n // Prevent clickjacking\n expect(headers[\"x-frame-options\"]).toMatch(/DENY|SAMEORIGIN/);\n\n // Prevent MIME type sniffing\n expect(headers[\"x-content-type-options\"]).toBe(\"nosniff\");\n\n // XSS Protection (legacy but good to have)\n expect(headers[\"x-xss-protection\"]).toBeTruthy();\n\n // HTTPS enforcement\n if (!page.url().includes(\"localhost\")) {\n expect(headers[\"strict-transport-security\"]).toBeTruthy();\n }\n});\n```\n\n### Test CSP Violations\n\n```typescript\ntest(\"CSP blocks inline scripts\", async ({ page }) => {\n const cspViolations: string[] = [];\n\n // Listen for CSP violations via console\n page.on(\"console\", (msg) => {\n if (msg.text().includes(\"Content Security Policy\")) {\n cspViolations.push(msg.text());\n }\n });\n\n await page.goto(\"/\");\n\n // Try to inject inline script - CSP should block it\n await page.evaluate(() => {\n const script = document.createElement(\"script\");\n script.textContent = 'console.log(\"injected\")';\n document.body.appendChild(script);\n });\n\n expect(cspViolations.length).toBeGreaterThan(0);\n});\n```\n\n> **For comprehensive console monitoring** (fixtures, allowed patterns, fail on errors), see [console-errors.md](../debugging/console-errors.md).\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern | Problem | Solution |\n| -------------------------- | --------------------- | ----------------------------- |\n| Testing only happy path | Misses security holes | Test malicious inputs |\n| Hardcoded test credentials | Security risk | Use environment variables |\n| Skipping auth tests in dev | Bugs reach production | Test auth in all environments |\n| Not testing authorization | Access control bugs | Test all role combinations |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth fixtures\n- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for role-based testing\n- **Error Testing**: See [error-testing.md](../debugging/error-testing.md) for validation testing\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12145,"content_sha256":"c03fe6ead5ec78bfe20af45e05ab587d054f77f8e1a6d26dd7791f06be7cabd7"},{"filename":"testing-patterns/visual-regression.md","content":"# Visual Regression Testing\n\n## Table of Contents\n\n1. [Quick Reference](#quick-reference)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Detecting unintended visual changes—layout shifts, style regressions, broken responsive designs—that functional assertions miss.\n\n## Quick Reference\n\n```typescript\n// Element screenshot\nawait expect(page.getByTestId('product-card')).toHaveScreenshot();\n\n// Full page screenshot\nawait expect(page).toHaveScreenshot('landing-hero.png');\n\n// Threshold for minor pixel variance\nawait expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01 });\n\n// Mask volatile content\nawait expect(page).toHaveScreenshot({\n mask: [page.getByTestId('clock'), page.getByRole('img', { name: 'User photo' })],\n});\n\n// Disable CSS animations\nawait expect(page).toHaveScreenshot({ animations: 'disabled' });\n\n// Update baselines\nnpx playwright test --update-snapshots\n```\n\n## Patterns\n\n### Masking Volatile Content\n\n**Use when**: Page contains timestamps, avatars, ad slots, relative dates, random images, or A/B variants.\n\nThe `mask` option overlays a solid box over specified locators before capturing.\n\n```typescript\ntest('analytics panel with masked dynamic elements', async ({ page }) => {\n await page.goto('/analytics');\n\n await expect(page).toHaveScreenshot('analytics.png', {\n mask: [\n page.getByTestId('last-updated'),\n page.getByTestId('profile-avatar'),\n page.getByTestId('active-users'),\n page.locator('.promo-banner'),\n ],\n maskColor: '#FF00FF',\n });\n});\n\ntest('activity stream with relative times', async ({ page }) => {\n await page.goto('/activity');\n\n await expect(page).toHaveScreenshot('activity.png', {\n mask: [page.locator('time[datetime]')],\n });\n});\n```\n\n**Alternative: freeze content with JavaScript** when masking affects layout:\n\n```typescript\ntest('freeze timestamps before capture', async ({ page }) => {\n await page.goto('/analytics');\n\n await page.evaluate(() => {\n document.querySelectorAll('[data-testid=\"time-display\"]').forEach((el) => {\n el.textContent = 'Jan 1, 2025 12:00 PM';\n });\n });\n\n await expect(page).toHaveScreenshot('analytics-frozen.png');\n});\n```\n\n### Disabling Animations\n\n**Use when**: Always. CSS animations and transitions are the primary cause of flaky visual diffs.\n\n```typescript\ntest('renders without animation interference', async ({ page }) => {\n await page.goto('/');\n\n await expect(page).toHaveScreenshot('home.png', {\n animations: 'disabled',\n });\n});\n```\n\n**Set globally** in config:\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n expect: {\n toHaveScreenshot: {\n animations: 'disabled',\n },\n },\n});\n```\n\nWhen `animations: 'disabled'` is set, Playwright injects CSS forcing animation/transition duration to 0s, waits for running animations to finish, then captures.\n\nFor JavaScript-driven animations (GSAP, Framer Motion), wait for stability:\n\n```typescript\ntest('page with JS animations', async ({ page }) => {\n await page.goto('/animated-hero');\n\n const heroBanner = page.getByTestId('hero-banner');\n await heroBanner.waitFor({ state: 'visible' });\n \n // Wait for animation to complete by checking for stable state\n await expect(heroBanner).not.toHaveClass(/animating/);\n\n await expect(page).toHaveScreenshot('hero.png', {\n animations: 'disabled',\n });\n});\n```\n\n### Configuring Thresholds\n\n**Use when**: Minor rendering differences from anti-aliasing, font hinting, or sub-pixel rendering cause false failures.\n\n| Option | Controls | Typical Value |\n|---|---|---|\n| `maxDiffPixels` | Absolute pixel count that can differ | `100` for pages, `10` for components |\n| `maxDiffPixelRatio` | Fraction of total pixels (0-1) | `0.01` (1%) for pages |\n| `threshold` | Per-pixel color tolerance (0-1) | `0.2` for most UIs, `0.1` for design systems |\n\n```typescript\ntest('control panel allows minor variance', async ({ page }) => {\n await page.goto('/control-panel');\n\n await expect(page).toHaveScreenshot('control-panel.png', {\n maxDiffPixelRatio: 0.01,\n });\n});\n\ntest('brand logo renders pixel-perfect', async ({ page }) => {\n await page.goto('/brand');\n\n await expect(page.getByTestId('brand-logo')).toHaveScreenshot('brand-logo.png', {\n maxDiffPixels: 0,\n threshold: 0,\n });\n});\n\ntest('graph allows anti-aliasing differences', async ({ page }) => {\n await page.goto('/reports');\n\n await expect(page.getByTestId('sales-graph')).toHaveScreenshot('sales-graph.png', {\n threshold: 0.3,\n maxDiffPixels: 200,\n });\n});\n```\n\n**Global thresholds** in config:\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n expect: {\n toHaveScreenshot: {\n maxDiffPixelRatio: 0.01,\n threshold: 0.2,\n animations: 'disabled',\n },\n },\n});\n```\n\n### CI Configuration\n\n**Use when**: Running visual tests in CI. Consistent rendering is critical—the same test must produce identical screenshots every time.\n\n**The problem**: Font rendering and anti-aliasing differ across operating systems. macOS snapshots won't match Linux.\n\n**The solution**: Run visual tests in Docker using the official Playwright container. Generate and update snapshots from the same container.\n\n**GitHub Actions with Docker**\n\n```yaml\n# .github/workflows/visual-tests.yml\nname: Visual Regression Tests\non: [push, pull_request]\n\njobs:\n visual-tests:\n runs-on: ubuntu-latest\n container:\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: lts/*\n cache: npm\n\n - run: npm ci\n\n - name: Run visual tests\n run: npx playwright test --project=visual\n env:\n HOME: /root\n\n - uses: actions/upload-artifact@v4\n if: failure()\n with:\n name: visual-test-report\n path: playwright-report/\n retention-days: 14\n```\n\n**Updating snapshots locally using Docker**:\n\n```bash\ndocker run --rm -v $(pwd):/work -w /work \\\n mcr.microsoft.com/playwright:v1.48.0-noble \\\n npx playwright test --update-snapshots --project=visual\n```\n\n**Add script to `package.json`**:\n\n```json\n{\n \"scripts\": {\n \"test:visual\": \"npx playwright test --project=visual\",\n \"test:visual:update\": \"docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.48.0-noble npx playwright test --update-snapshots --project=visual\"\n }\n}\n```\n\n**Platform-agnostic snapshots** (requires Docker for generation):\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',\n projects: [\n {\n name: 'visual',\n testMatch: '**/*.visual.spec.ts',\n use: { ...devices['Desktop Chrome'] },\n },\n ],\n});\n```\n\n### Full Page vs Element Screenshots\n\n**Use when**: Deciding scope. Full page catches layout shifts. Element screenshots isolate components and are more stable.\n\n```typescript\ntest('full page captures layout shifts', async ({ page }) => {\n await page.goto('/');\n\n // Visible viewport\n await expect(page).toHaveScreenshot('home-viewport.png');\n\n // Entire scrollable page\n await expect(page).toHaveScreenshot('home-full.png', {\n fullPage: true,\n });\n});\n\ntest('element screenshot isolates component', async ({ page }) => {\n await page.goto('/catalog');\n\n await expect(page.getByRole('table')).toHaveScreenshot('catalog-table.png');\n await expect(page.getByTestId('featured-item')).toHaveScreenshot('featured-item.png');\n});\n```\n\n**Rule of thumb**: Element screenshots for independently changing components. Full page screenshots for key layouts where spacing matters.\n\n### Responsive Visual Testing\n\n**Use when**: Application has responsive breakpoints requiring verification at different viewport sizes.\n\n```typescript\nconst breakpoints = [\n { name: 'phone', width: 375, height: 812 },\n { name: 'tablet', width: 768, height: 1024 },\n { name: 'desktop', width: 1440, height: 900 },\n];\n\nfor (const bp of breakpoints) {\n test(`landing at ${bp.name} (${bp.width}x${bp.height})`, async ({ page }) => {\n await page.setViewportSize({ width: bp.width, height: bp.height });\n await page.goto('/');\n\n await expect(page).toHaveScreenshot(`landing-${bp.name}.png`, {\n animations: 'disabled',\n fullPage: true,\n });\n });\n}\n```\n\n**Alternative: use projects for responsive testing**:\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n projects: [\n {\n name: 'desktop',\n testMatch: '**/*.visual.spec.ts',\n use: {\n ...devices['Desktop Chrome'],\n viewport: { width: 1440, height: 900 },\n },\n },\n {\n name: 'tablet',\n testMatch: '**/*.visual.spec.ts',\n use: { ...devices['iPad (gen 7)'] },\n },\n {\n name: 'mobile',\n testMatch: '**/*.visual.spec.ts',\n use: { ...devices['iPhone 14'] },\n },\n ],\n});\n```\n\n### Component Visual Testing\n\n**Use when**: Testing individual UI components in isolation—buttons, cards, forms, modals. Faster and more stable than full-page screenshots.\n\n```typescript\ntest.describe('Button visual states', () => {\n test('primary button', async ({ page }) => {\n await page.goto('/storybook/iframe.html?id=button--primary');\n const btn = page.getByRole('button');\n await expect(btn).toHaveScreenshot('btn-primary.png', {\n animations: 'disabled',\n });\n });\n\n test('primary button hover', async ({ page }) => {\n await page.goto('/storybook/iframe.html?id=button--primary');\n const btn = page.getByRole('button');\n await btn.hover();\n await expect(btn).toHaveScreenshot('btn-primary-hover.png', {\n animations: 'disabled',\n });\n });\n\n test('button sizes', async ({ page }) => {\n for (const size of ['small', 'medium', 'large']) {\n await page.goto(`/storybook/iframe.html?id=button--${size}`);\n const btn = page.getByRole('button');\n await expect(btn).toHaveScreenshot(`btn-${size}.png`, {\n animations: 'disabled',\n });\n }\n });\n});\n```\n\n**Using a dedicated test harness** instead of Storybook:\n\n```typescript\ntest.describe('Card component', () => {\n test.beforeEach(async ({ page }) => {\n await page.goto('/test-harness/card');\n });\n\n test('default state', async ({ page }) => {\n await expect(page.getByTestId('card')).toHaveScreenshot('card-default.png', {\n animations: 'disabled',\n });\n });\n\n test('truncates long content', async ({ page }) => {\n await page.goto('/test-harness/card?content=long');\n await expect(page.getByTestId('card')).toHaveScreenshot('card-long.png', {\n animations: 'disabled',\n });\n });\n});\n```\n\n### Updating Snapshots\n\n**Use when**: Intentionally changed UI—design refresh, rebrand, new feature. Never update when diff is unexpected.\n\n```bash\n# Update all snapshots\nnpx playwright test --update-snapshots\n\n# Update for specific file\nnpx playwright test tests/landing.spec.ts --update-snapshots\n\n# Update for specific project\nnpx playwright test --project=chromium --update-snapshots\n```\n\n**Workflow for reviewing changes:**\n\n1. Run tests and view failures in HTML report:\n ```bash\n npx playwright test\n npx playwright show-report\n ```\n The report shows expected, actual, and diff images side-by-side.\n\n2. If changes are intentional, update:\n ```bash\n npx playwright test --update-snapshots\n ```\n\n3. Review updated snapshots before committing:\n ```bash\n git diff --name-only\n ```\n\n**Tag visual tests for selective updates:**\n\n```typescript\ntest('landing visual @visual', async ({ page }) => {\n await page.goto('/');\n await expect(page).toHaveScreenshot('landing.png', {\n animations: 'disabled',\n });\n});\n```\n\n```bash\nnpx playwright test --grep @visual --update-snapshots\n```\n\n### Cross-Browser Visual Testing\n\n**Use when**: Users span Chrome, Firefox, Safari and you need per-browser rendering verification.\n\nPlaywright separates snapshots by project name automatically. Each browser gets its own baseline—browsers render fonts and shadows differently.\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n expect: {\n toHaveScreenshot: {\n animations: 'disabled',\n maxDiffPixelRatio: 0.01,\n },\n },\n projects: [\n {\n name: 'chromium',\n use: { ...devices['Desktop Chrome'] },\n },\n {\n name: 'firefox',\n use: { ...devices['Desktop Firefox'] },\n },\n {\n name: 'webkit',\n use: { ...devices['Desktop Safari'] },\n },\n ],\n});\n```\n\n**Strategy**: Run visual tests in a single browser (Chromium on Linux in CI) to minimize snapshot count. Add other browsers only when you have actual cross-browser rendering bugs:\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n projects: [\n {\n name: 'visual',\n testMatch: '**/*.visual.spec.ts',\n use: { ...devices['Desktop Chrome'] },\n },\n {\n name: 'chromium',\n testIgnore: '**/*.visual.spec.ts',\n use: { ...devices['Desktop Chrome'] },\n },\n {\n name: 'firefox',\n testIgnore: '**/*.visual.spec.ts',\n use: { ...devices['Desktop Firefox'] },\n },\n ],\n});\n```\n\n## Decision Guide\n\n| Scenario | Approach | Rationale |\n|---|---|---|\n| Key landing/marketing pages | Full page, `fullPage: true` | Catches layout shifts, spacing, overall harmony |\n| Individual components | Element screenshot | Isolated, fast, immune to unrelated changes |\n| Page with dynamic content | Full page + `mask` | Covers layout while ignoring volatile content |\n| Design system library | Element per variant, zero threshold | Pixel-perfect enforcement |\n| Responsive verification | Screenshot per viewport | Catches breakpoint bugs |\n| Cross-browser consistency | Separate snapshots per browser | Browsers render differently |\n| CI pipeline | Docker container, Linux-only snapshots | Consistent rendering |\n| Threshold: design system | `threshold: 0`, `maxDiffPixels: 0` | Zero tolerance |\n| Threshold: content pages | `maxDiffPixelRatio: 0.01`, `threshold: 0.2` | Minor anti-aliasing variance |\n| Threshold: charts/graphs | `maxDiffPixels: 200`, `threshold: 0.3` | Anti-aliasing on curves varies |\n\n## Anti-Patterns\n\n| Don't | Problem | Do Instead |\n|---|---|---|\n| Visual test every page | Massive maintenance, constant false failures | Pick 5-10 key pages and critical components |\n| Skip masking dynamic content | Screenshots differ every run, permanently flaky | Use `mask` for all volatile elements |\n| Run across macOS, Linux, Windows | Font rendering differs, snapshots never match | Standardize on Linux via Docker |\n| Skip Docker in CI | OS updates shift rendering silently | Pin specific Playwright Docker image |\n| Blindly run `--update-snapshots` | Accepts unintentional regressions | Always review diff in HTML report first |\n| Skip `animations: 'disabled'` | CSS transitions create random diffs | Set globally in config |\n| Replace functional assertions with visual tests | Diffs don't tell you *what* broke | Visual tests complement, never replace |\n| Commit snapshots from different platforms | Tests fail for everyone | All team members use same Docker container |\n| Set threshold too high (`0.1`) | 10% pixel change passes, defeats purpose | Start with `0.01`, adjust per-test |\n| Full page on infinite scroll pages | Page height nondeterministic | Element screenshots on above-the-fold content |\n\n## Troubleshooting\n\n### \"Screenshot comparison failed\" on first CI run after local development\n\n**Cause**: Snapshots generated on macOS locally. CI runs on Linux. Font rendering differs.\n\n**Fix**: Generate snapshots using Docker:\n\n```bash\ndocker run --rm -v $(pwd):/work -w /work \\\n mcr.microsoft.com/playwright:v1.48.0-noble \\\n npx playwright test --update-snapshots --project=visual\n```\n\nCommit Linux-generated snapshots.\n\n### \"Expected screenshot to match but X pixels differ\"\n\n**Cause**: Anti-aliasing, font hinting, sub-pixel rendering differences.\n\n**Fix**: Add tolerance:\n\n```typescript\nawait expect(page).toHaveScreenshot('page.png', {\n maxDiffPixelRatio: 0.01,\n threshold: 0.2,\n});\n```\n\nCheck HTML report diff image to determine if it's regression or noise.\n\n### Visual tests pass locally but fail in CI (even with Docker)\n\n**Cause**: Different Playwright versions locally vs CI.\n\n**Fix**: Ensure `package.json` version matches Docker image tag:\n\n```json\n{\n \"devDependencies\": {\n \"@playwright/test\": \"latest\"\n }\n}\n```\n\n```yaml\ncontainer:\n image: mcr.microsoft.com/playwright:v1.48.0-noble\n```\n\n### Animations cause random diff failures\n\n**Cause**: CSS animations captured mid-frame.\n\n**Fix**: Set `animations: 'disabled'` globally:\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n expect: {\n toHaveScreenshot: {\n animations: 'disabled',\n },\n },\n});\n```\n\nFor JS animations, wait for stable state before capture.\n\n### Snapshot file names conflict between tests\n\n**Cause**: Two tests use same screenshot name without unique paths.\n\n**Fix**: Use explicit unique names:\n\n```typescript\nawait expect(page).toHaveScreenshot('auth-home.png');\nawait expect(page).toHaveScreenshot('public-home.png');\n```\n\nOr customize snapshot path template:\n\n```typescript\nexport default defineConfig({\n snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',\n});\n```\n\n### Too many snapshot files to maintain\n\n**Cause**: Visual tests for every page, browser, viewport.\n\n**Fix**: Be selective. Visual test only high-risk pages:\n- Landing and marketing pages\n- Design system components\n- Complex layouts (dashboards, data tables)\n- Pages after major refactor\n\nSkip pages where functional assertions cover key elements.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17765,"content_sha256":"b58d614c2d34f6b63df6a6aaa9d255a90dd4e4e5475db8227b6ea43c8a079a33"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Playwright Best Practices","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill provides comprehensive guidance for all aspects of Playwright test development, from writing new tests to debugging and maintaining existing test suites.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Activity-Based Reference Guide","type":"text"}]},{"type":"paragraph","content":[{"text":"Consult these references based on what you're doing:","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Writing New Tests","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Creating new test files, writing test cases, implementing test scenarios","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writing E2E tests","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]},{"text":", ","type":"text"},{"text":"locators.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/locators.md","title":null}}]},{"text":", ","type":"text"},{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writing component tests","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"component-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/component-testing.md","title":null}}]},{"text":", ","type":"text"},{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writing API tests","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"api-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/api-testing.md","title":null}}]},{"text":", ","type":"text"},{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writing GraphQL tests","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"graphql-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/graphql-testing.md","title":null}}]},{"text":", ","type":"text"},{"text":"api-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/api-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writing visual regression tests","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"visual-regression.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/visual-regression.md","title":null}}]},{"text":", ","type":"text"},{"text":"canvas-webgl.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/canvas-webgl.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Structuring test code with POM","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"page-object-model.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/page-object-model.md","title":null}}]},{"text":", ","type":"text"},{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Setting up test data/fixtures","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"fixtures-hooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/fixtures-hooks.md","title":null}}]},{"text":", ","type":"text"},{"text":"test-data.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-data.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Handling authentication","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"authentication.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/authentication.md","title":null}}]},{"text":", ","type":"text"},{"text":"authentication-flows.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/authentication-flows.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing date/time features","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"clock-mocking.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/clock-mocking.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing file upload/download","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"file-operations.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/file-operations.md","title":null}}]},{"text":", ","type":"text"},{"text":"file-upload-download.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/file-upload-download.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing forms/validation","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"forms-validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/forms-validation.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing drag and drop","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"drag-drop.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/drag-drop.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing accessibility","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"accessibility.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/accessibility.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing security (XSS, CSRF)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"security-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/security-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Using test annotations","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"annotations.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/annotations.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Using test tags","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-tags.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-tags.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing iframes","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"iframes.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/iframes.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing canvas/WebGL","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"canvas-webgl.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/canvas-webgl.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Internationalization (i18n)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"i18n.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/i18n.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing Electron apps","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"electron.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/electron.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing browser extensions","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"browser-extensions.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/browser-extensions.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Mobile & Responsive Testing","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Testing mobile devices, touch interactions, responsive layouts","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Device emulation","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"mobile-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/mobile-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Touch gestures (swipe, tap)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"mobile-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/mobile-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Viewport/breakpoint testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"mobile-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/mobile-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mobile-specific UI","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"mobile-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/mobile-testing.md","title":null}}]},{"text":", ","type":"text"},{"text":"locators.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/locators.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Real-Time & Browser APIs","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Testing WebSockets, geolocation, permissions, multi-tab flows","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"WebSocket/real-time testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"websockets.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/websockets.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Geolocation mocking","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"browser-apis.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/browser-apis.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Permission handling","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"browser-apis.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/browser-apis.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Clipboard testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"browser-apis.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/browser-apis.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Camera/microphone mocking","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"browser-apis.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/browser-apis.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Multi-tab/popup flows","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"multi-context.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/multi-context.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"OAuth popup handling","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"third-party.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/third-party.md","title":null}}]},{"text":", ","type":"text"},{"text":"multi-context.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/multi-context.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Debugging & Troubleshooting","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Test failures, element not found, timeouts, unexpected behavior","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Debugging test failures","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"debugging.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/debugging.md","title":null}}]},{"text":", ","type":"text"},{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fixing flaky tests","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"flaky-tests.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/flaky-tests.md","title":null}}]},{"text":", ","type":"text"},{"text":"debugging.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/debugging.md","title":null}}]},{"text":", ","type":"text"},{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Debugging flaky parallel runs","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"flaky-tests.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/flaky-tests.md","title":null}}]},{"text":", ","type":"text"},{"text":"performance.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/performance.md","title":null}}]},{"text":", ","type":"text"},{"text":"fixtures-hooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/fixtures-hooks.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ensuring test isolation / avoiding state leak","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"flaky-tests.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/flaky-tests.md","title":null}}]},{"text":", ","type":"text"},{"text":"fixtures-hooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/fixtures-hooks.md","title":null}}]},{"text":", ","type":"text"},{"text":"performance.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/performance.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fixing selector issues","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"locators.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/locators.md","title":null}}]},{"text":", ","type":"text"},{"text":"debugging.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/debugging.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Investigating timeout issues","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]},{"text":", ","type":"text"},{"text":"debugging.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/debugging.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Using trace viewer","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"debugging.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/debugging.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Debugging race conditions","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"flaky-tests.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/flaky-tests.md","title":null}}]},{"text":", ","type":"text"},{"text":"debugging.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/debugging.md","title":null}}]},{"text":", ","type":"text"},{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Debugging console/JS errors","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"console-errors.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/console-errors.md","title":null}}]},{"text":", ","type":"text"},{"text":"debugging.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/debugging.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Error & Edge Case Testing","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Testing error states, offline mode, network failures, validation","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Error boundary testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"error-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/error-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Network failure simulation","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"error-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/error-testing.md","title":null}}]},{"text":", ","type":"text"},{"text":"network-advanced.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/network-advanced.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Offline mode testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"error-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/error-testing.md","title":null}}]},{"text":", ","type":"text"},{"text":"service-workers.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/service-workers.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Service worker testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"service-workers.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/service-workers.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Loading state testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"error-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/error-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Form validation testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"error-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/error-testing.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Multi-User & Collaboration Testing","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Testing features involving multiple users, roles, or real-time collaboration","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Multiple users in one test","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"multi-user.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/multi-user.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Real-time collaboration","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"multi-user.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/multi-user.md","title":null}}]},{"text":", ","type":"text"},{"text":"websockets.md","type":"text","marks":[{"type":"link","attrs":{"href":"browser-apis/websockets.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Role-based access testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"multi-user.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/multi-user.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Concurrent action testing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"multi-user.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/multi-user.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Architecture Decisions","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Choosing test patterns, deciding between approaches, planning test architecture","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"POM vs fixtures decision","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pom-vs-fixtures.md","type":"text","marks":[{"type":"link","attrs":{"href":"architecture/pom-vs-fixtures.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test type selection","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-architecture.md","type":"text","marks":[{"type":"link","attrs":{"href":"architecture/test-architecture.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mock vs real services","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"when-to-mock.md","type":"text","marks":[{"type":"link","attrs":{"href":"architecture/when-to-mock.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test suite structure","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Framework-Specific Testing","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Testing React, Angular, Vue, or Next.js applications","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing React apps","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"react.md","type":"text","marks":[{"type":"link","attrs":{"href":"frameworks/react.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing Angular apps","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"angular.md","type":"text","marks":[{"type":"link","attrs":{"href":"frameworks/angular.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing Vue/Nuxt apps","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"vue.md","type":"text","marks":[{"type":"link","attrs":{"href":"frameworks/vue.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing Next.js apps","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"nextjs.md","type":"text","marks":[{"type":"link","attrs":{"href":"frameworks/nextjs.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Refactoring & Maintenance","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Improving existing tests, code review, reducing duplication","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Refactoring to Page Object Model","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"page-object-model.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/page-object-model.md","title":null}}]},{"text":", ","type":"text"},{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Improving test organization","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]},{"text":", ","type":"text"},{"text":"page-object-model.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/page-object-model.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Extracting common setup/teardown","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"fixtures-hooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/fixtures-hooks.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Replacing brittle selectors","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"locators.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/locators.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Removing explicit waits","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Creating test data factories","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-data.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-data.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Configuration setup","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"configuration.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/configuration.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Infrastructure & Configuration","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Setting up projects, configuring CI/CD, optimizing performance","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Configuring Playwright project","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"configuration.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/configuration.md","title":null}}]},{"text":", ","type":"text"},{"text":"projects-dependencies.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/projects-dependencies.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Setting up CI/CD pipelines","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ci-cd.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/ci-cd.md","title":null}}]},{"text":", ","type":"text"},{"text":"github-actions.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/github-actions.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GitHub Actions setup","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"github-actions.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/github-actions.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GitLab CI setup","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gitlab.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/gitlab.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Other CI providers","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"other-providers.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/other-providers.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Docker/container setup","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"docker.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/docker.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Global setup & teardown","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"global-setup.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/global-setup.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Project dependencies","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"projects-dependencies.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/projects-dependencies.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Optimizing test performance","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"performance.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/performance.md","title":null}}]},{"text":", ","type":"text"},{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Configuring parallel execution","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"parallel-sharding.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/parallel-sharding.md","title":null}}]},{"text":", ","type":"text"},{"text":"performance.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/performance.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Isolating test data between workers","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"fixtures-hooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/fixtures-hooks.md","title":null}}]},{"text":", ","type":"text"},{"text":"performance.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/performance.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test coverage","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-coverage.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/test-coverage.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test reporting/artifacts","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"reporting.md","type":"text","marks":[{"type":"link","attrs":{"href":"infrastructure-ci-cd/reporting.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Advanced Patterns","type":"text"}]},{"type":"paragraph","content":[{"text":"When to use","type":"text","marks":[{"type":"strong"}]},{"text":": Complex scenarios, API mocking, network interception","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Activity","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference Files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mocking API responses","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-suite-structure.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-suite-structure.md","title":null}}]},{"text":", ","type":"text"},{"text":"network-advanced.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/network-advanced.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Network interception","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"network-advanced.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/network-advanced.md","title":null}}]},{"text":", ","type":"text"},{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GraphQL mocking","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"network-advanced.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/network-advanced.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"HAR recording/playback","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"network-advanced.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/network-advanced.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Custom fixtures","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"fixtures-hooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/fixtures-hooks.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Advanced waiting strategies","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"assertions-waiting.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/assertions-waiting.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"OAuth/SSO mocking","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"third-party.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/third-party.md","title":null}}]},{"text":", ","type":"text"},{"text":"multi-context.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/multi-context.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Payment gateway mocking","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"third-party.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/third-party.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Email/SMS verification mocking","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"third-party.md","type":"text","marks":[{"type":"link","attrs":{"href":"advanced/third-party.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Failing on console errors","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"console-errors.md","type":"text","marks":[{"type":"link","attrs":{"href":"debugging/console-errors.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Security testing (XSS, CSRF)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"security-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/security-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Performance budgets & Web Vitals","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"performance-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/performance-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lighthouse integration","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"performance-testing.md","type":"text","marks":[{"type":"link","attrs":{"href":"testing-patterns/performance-testing.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test annotations (skip, fixme)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"annotations.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/annotations.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test tags (@smoke, @fast)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test-tags.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/test-tags.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Test steps for reporting","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"annotations.md","type":"text","marks":[{"type":"link","attrs":{"href":"core/annotations.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Decision Tree","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"What are you doing?\n│\n├─ Writing a new test?\n│ ├─ E2E test → core/test-suite-structure.md, core/locators.md, core/assertions-waiting.md\n│ ├─ Component test → testing-patterns/component-testing.md\n│ ├─ API test → testing-patterns/api-testing.md, core/test-suite-structure.md\n│ ├─ GraphQL test → testing-patterns/graphql-testing.md\n│ ├─ Visual regression → testing-patterns/visual-regression.md\n│ ├─ Visual/canvas test → testing-patterns/canvas-webgl.md, core/test-suite-structure.md\n│ ├─ Accessibility test → testing-patterns/accessibility.md\n│ ├─ Mobile/responsive test → advanced/mobile-testing.md\n│ ├─ i18n/locale test → testing-patterns/i18n.md\n│ ├─ Electron app test → testing-patterns/electron.md\n│ ├─ Browser extension test → testing-patterns/browser-extensions.md\n│ ├─ Multi-user test → advanced/multi-user.md\n│ ├─ Form validation test → testing-patterns/forms-validation.md\n│ └─ Drag and drop test → testing-patterns/drag-drop.md\n│\n├─ Testing specific features?\n│ ├─ File upload/download → testing-patterns/file-operations.md, testing-patterns/file-upload-download.md\n│ ├─ Date/time dependent → advanced/clock-mocking.md\n│ ├─ WebSocket/real-time → browser-apis/websockets.md\n│ ├─ Geolocation/permissions → browser-apis/browser-apis.md\n│ ├─ OAuth/SSO mocking → advanced/third-party.md, advanced/multi-context.md\n│ ├─ Payments/email/SMS → advanced/third-party.md\n│ ├─ iFrames → browser-apis/iframes.md\n│ ├─ Canvas/WebGL/charts → testing-patterns/canvas-webgl.md\n│ ├─ Service workers/PWA → browser-apis/service-workers.md\n│ ├─ i18n/localization → testing-patterns/i18n.md\n│ ├─ Security (XSS, CSRF) → testing-patterns/security-testing.md\n│ └─ Performance/Web Vitals → testing-patterns/performance-testing.md\n│\n├─ Architecture decisions?\n│ ├─ POM vs fixtures → architecture/pom-vs-fixtures.md\n│ ├─ Test type selection → architecture/test-architecture.md\n│ ├─ Mock vs real services → architecture/when-to-mock.md\n│ └─ Test suite structure → core/test-suite-structure.md\n│\n├─ Framework-specific testing?\n│ ├─ React app → frameworks/react.md\n│ ├─ Angular app → frameworks/angular.md\n│ ├─ Vue/Nuxt app → frameworks/vue.md\n│ └─ Next.js app → frameworks/nextjs.md\n│\n├─ Authentication testing?\n│ ├─ Basic auth patterns → advanced/authentication.md\n│ └─ Complex flows (MFA, reset) → advanced/authentication-flows.md\n│\n├─ Test is failing/flaky?\n│ ├─ Flaky test investigation → debugging/flaky-tests.md\n│ ├─ Element not found → core/locators.md, debugging/debugging.md\n│ ├─ Timeout issues → core/assertions-waiting.md, debugging/debugging.md\n│ ├─ Race conditions → debugging/flaky-tests.md, debugging/debugging.md\n│ ├─ Flaky only with multiple workers → debugging/flaky-tests.md, infrastructure-ci-cd/performance.md\n│ ├─ State leak / isolation → debugging/flaky-tests.md, core/fixtures-hooks.md\n│ ├─ Console/JS errors → debugging/console-errors.md, debugging/debugging.md\n│ └─ General debugging → debugging/debugging.md\n│\n├─ Testing error scenarios?\n│ ├─ Network failures → debugging/error-testing.md, advanced/network-advanced.md\n│ ├─ Offline (unexpected) → debugging/error-testing.md\n│ ├─ Offline-first/PWA → browser-apis/service-workers.md\n│ ├─ Error boundaries → debugging/error-testing.md\n│ └─ Form validation → testing-patterns/forms-validation.md, debugging/error-testing.md\n│\n├─ Refactoring existing code?\n│ ├─ Implementing POM → core/page-object-model.md\n│ ├─ Improving selectors → core/locators.md\n│ ├─ Extracting fixtures → core/fixtures-hooks.md\n│ ├─ Creating data factories → core/test-data.md\n│ └─ Configuration setup → core/configuration.md\n│\n├─ Setting up infrastructure?\n│ ├─ CI/CD → infrastructure-ci-cd/ci-cd.md\n│ ├─ GitHub Actions → infrastructure-ci-cd/github-actions.md\n│ ├─ GitLab CI → infrastructure-ci-cd/gitlab.md\n│ ├─ Other CI providers → infrastructure-ci-cd/other-providers.md\n│ ├─ Docker/containers → infrastructure-ci-cd/docker.md\n│ ├─ Sharding/parallel → infrastructure-ci-cd/parallel-sharding.md\n│ ├─ Reporting/artifacts → infrastructure-ci-cd/reporting.md\n│ ├─ Global setup/teardown → core/global-setup.md\n│ ├─ Project dependencies → core/projects-dependencies.md\n│ ├─ Test performance → infrastructure-ci-cd/performance.md\n│ ├─ Test coverage → infrastructure-ci-cd/test-coverage.md\n│ └─ Project config → core/configuration.md, core/projects-dependencies.md\n│\n├─ Organizing tests?\n│ ├─ Skip/fixme/slow tests → core/annotations.md\n│ ├─ Test tags (@smoke, @fast) → core/test-tags.md\n│ ├─ Filtering tests (--grep) → core/test-tags.md\n│ ├─ Test steps → core/annotations.md\n│ └─ Conditional execution → core/annotations.md\n│\n└─ Running subset of tests?\n ├─ By tag (@smoke, @critical) → core/test-tags.md\n ├─ Exclude slow/flaky tests → core/test-tags.md\n ├─ PR vs nightly tests → core/test-tags.md, infrastructure-ci-cd/ci-cd.md\n └─ Project-specific filtering → core/test-tags.md, core/configuration.md","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Test Validation Loop","type":"text"}]},{"type":"paragraph","content":[{"text":"After writing or modifying tests:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run tests","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"npx playwright test --reporter=list","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If tests fail","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Review error output and trace (","type":"text"},{"text":"npx playwright show-trace","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fix locators, waits, or assertions","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-run tests","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Only proceed when all tests pass","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run multiple times","type":"text","marks":[{"type":"strong"}]},{"text":" for critical tests: ","type":"text"},{"text":"npx playwright test --repeat-each=5","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"playwright-best-practices","author":"@skillopedia","source":{"stars":287,"repo_name":"playwright-best-practices-skill","origin_url":"https://github.com/currents-dev/playwright-best-practices-skill/blob/HEAD/SKILL.md","repo_owner":"currents-dev","body_sha256":"44737bda192d355b9c45593424e6ccc7084702132878fcaf2d69fb25da034486","cluster_key":"8fa651e9fb17ba021b0860c59d8175250e580c857ef0631fba0b14dc413ff251","clean_bundle":{"format":"clean-skill-bundle-v1","source":"currents-dev/playwright-best-practices-skill/SKILL.md","attachments":[{"id":"d3f0dc26-27f2-59ad-8293-9f4ff2ac5f3a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d3f0dc26-27f2-59ad-8293-9f4ff2ac5f3a/attachment.toml","path":".agnix.toml","size":35,"sha256":"3065a65058a2be51e48ad768b43b9468d0c1f3026402252a968b3a840d6ae08d","contentType":"text/plain; charset=utf-8"},{"id":"d1c59d76-4011-5589-9796-f03943d783fc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d1c59d76-4011-5589-9796-f03943d783fc/attachment.yml","path":".github/workflows/validate-skill.yml","size":312,"sha256":"d258f78240104676c9d4a8b8ca7033f285699e0a80447b0f6791f04bced37e80","contentType":"application/yaml; charset=utf-8"},{"id":"2f3072b3-b89c-56fe-bc3c-a2be4682817a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2f3072b3-b89c-56fe-bc3c-a2be4682817a/attachment.md","path":"README.md","size":10123,"sha256":"ab96dc47d58823c6878d96d2e8887a77d0fc3666b323e225066a8b159c7b7f28","contentType":"text/markdown; charset=utf-8"},{"id":"6816b501-4d4d-59c3-9b0d-2b8d652da868","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6816b501-4d4d-59c3-9b0d-2b8d652da868/attachment.md","path":"advanced/authentication-flows.md","size":10871,"sha256":"b6549421bf78de9304638480ee3e9eb8f65031ea2fba54aa7c69be7afb7001a0","contentType":"text/markdown; charset=utf-8"},{"id":"cd54be3e-10d0-510d-8ba4-31e7b78ef498","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cd54be3e-10d0-510d-8ba4-31e7b78ef498/attachment.md","path":"advanced/authentication.md","size":30857,"sha256":"058c3afa13ccc609f130ed9c7fd007b2f88dd9477d715b83ea132eb46deba755","contentType":"text/markdown; charset=utf-8"},{"id":"29b93da1-5dcf-5063-99e5-f9686f98b4fa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/29b93da1-5dcf-5063-99e5-f9686f98b4fa/attachment.md","path":"advanced/clock-mocking.md","size":9872,"sha256":"de7e78df2f8576e4e1ffe0a4993b8d5832cbbd63477daf511837872cd89afd9c","contentType":"text/markdown; charset=utf-8"},{"id":"5925237f-18c9-5b4e-be95-be2e29185858","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5925237f-18c9-5b4e-be95-be2e29185858/attachment.md","path":"advanced/mobile-testing.md","size":10993,"sha256":"2c1cadd409279925bf048475f2d1da0de9a704da21a367a98a3c24bac95f1921","contentType":"text/markdown; charset=utf-8"},{"id":"398b97e8-b9e6-5137-baaa-0e7038b843a6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/398b97e8-b9e6-5137-baaa-0e7038b843a6/attachment.md","path":"advanced/multi-context.md","size":8570,"sha256":"abe87f8f8d49fbf4b7a240105a51db7cbcd5622c86a8df31f481450903da4be2","contentType":"text/markdown; charset=utf-8"},{"id":"a06fac83-d6c0-5e08-b7a4-4d57bd0f323e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a06fac83-d6c0-5e08-b7a4-4d57bd0f323e/attachment.md","path":"advanced/multi-user.md","size":11490,"sha256":"fe102fd66a7c3f866dd6fe3fba638c71f9690971676ee2244f1cb43b53d07417","contentType":"text/markdown; charset=utf-8"},{"id":"a4b78e65-881b-5f0a-a910-7e985d5f31c0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a4b78e65-881b-5f0a-a910-7e985d5f31c0/attachment.md","path":"advanced/network-advanced.md","size":11106,"sha256":"49e5ec7411b5d388d700b36450fc54ab185a3f538088661fbd9a628df85073c0","contentType":"text/markdown; charset=utf-8"},{"id":"33efc96c-2f87-5d99-8e6f-d735c510b409","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/33efc96c-2f87-5d99-8e6f-d735c510b409/attachment.md","path":"advanced/third-party.md","size":12163,"sha256":"d9d07130ecb13e5e2548a59f9ed1fd76dfc58426fd5ab3de3d4589c8567313ee","contentType":"text/markdown; charset=utf-8"},{"id":"4b5e9267-dbf0-53ea-a7cb-5be4e4df2b0c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4b5e9267-dbf0-53ea-a7cb-5be4e4df2b0c/attachment.md","path":"architecture/pom-vs-fixtures.md","size":11261,"sha256":"6619745a7ddad8c3734433325b9a09825a539a695f532aeea62e112fd8d74699","contentType":"text/markdown; charset=utf-8"},{"id":"91d2c0f7-d4e9-5d5c-b991-d2f07d601c59","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/91d2c0f7-d4e9-5d5c-b991-d2f07d601c59/attachment.md","path":"architecture/test-architecture.md","size":14839,"sha256":"9f3b7e45ecbc3cadf21aaaaf3e0ce8feb0bd0a8b110f46fff1285b614f770ba2","contentType":"text/markdown; charset=utf-8"},{"id":"791465a9-67b5-543d-99cc-1c15863e2e1a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/791465a9-67b5-543d-99cc-1c15863e2e1a/attachment.md","path":"architecture/when-to-mock.md","size":11346,"sha256":"919f5b8c07a477b446988734809f109a404d008e9a4bfd7744bd0d521693f84f","contentType":"text/markdown; charset=utf-8"},{"id":"929dcb1c-534c-5332-9741-7c95279870bf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/929dcb1c-534c-5332-9741-7c95279870bf/attachment.md","path":"browser-apis/browser-apis.md","size":10717,"sha256":"c8eb673179ddee87566ad2bed11bc3060e00d72463a7554b9cd35c12551f65b4","contentType":"text/markdown; charset=utf-8"},{"id":"fcfe71d5-c659-5dba-bda7-578f94a2e18b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fcfe71d5-c659-5dba-bda7-578f94a2e18b/attachment.md","path":"browser-apis/iframes.md","size":11634,"sha256":"1cf746991a1431a5f1b42ce6dc89530d534a1d04d302c8bf2211130a4e3bb817","contentType":"text/markdown; charset=utf-8"},{"id":"fceda6bc-1b6e-56a7-a6cd-1e79d2d91e88","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fceda6bc-1b6e-56a7-a6cd-1e79d2d91e88/attachment.md","path":"browser-apis/service-workers.md","size":13813,"sha256":"c771ba9b5d26f18f1555fca583f365b9ecc2cdd26394bdbafe7e5bf0be0a635f","contentType":"text/markdown; charset=utf-8"},{"id":"eb8dbfe0-92e0-5801-a7ca-a329ebfe93e4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/eb8dbfe0-92e0-5801-a7ca-a329ebfe93e4/attachment.md","path":"browser-apis/websockets.md","size":10597,"sha256":"ed8a75b08e89720ea2c77d3adb4504530f9dfab2c140de423e29ae68a80f36c2","contentType":"text/markdown; charset=utf-8"},{"id":"a292b54e-d451-51be-82ec-87bfd0aa064f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a292b54e-d451-51be-82ec-87bfd0aa064f/attachment.md","path":"core/annotations.md","size":10864,"sha256":"f1aac6ca31db4b485a55b80fb69073ba5bb3a994c14868f25d87056e6bebbab6","contentType":"text/markdown; charset=utf-8"},{"id":"c714577d-29ab-5725-a86e-d086ed8a3907","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c714577d-29ab-5725-a86e-d086ed8a3907/attachment.md","path":"core/assertions-waiting.md","size":9459,"sha256":"8cdc5673d148aa3a187544aae13973996c690c8c125bd770a243c0ba5c6744cd","contentType":"text/markdown; charset=utf-8"},{"id":"b049da2b-3d20-5b41-8a8c-7be84dc4c7bf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b049da2b-3d20-5b41-8a8c-7be84dc4c7bf/attachment.md","path":"core/configuration.md","size":12611,"sha256":"d375dbb84d133a70cae42c660302323b0330346614986e5f50c190dc3da0d2d8","contentType":"text/markdown; charset=utf-8"},{"id":"241a5eef-d1c7-5adc-8503-5a434cbe0e40","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/241a5eef-d1c7-5adc-8503-5a434cbe0e40/attachment.md","path":"core/fixtures-hooks.md","size":11802,"sha256":"057018bd070b01e9ac1e469a80ff0df8de3e9a1ac2148be3e7c0fcb2256988e6","contentType":"text/markdown; charset=utf-8"},{"id":"04b9ae28-7553-543f-80c5-958d7b3f8934","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/04b9ae28-7553-543f-80c5-958d7b3f8934/attachment.md","path":"core/global-setup.md","size":12885,"sha256":"d59548fb70ee60f9270d4a1a2d68e89ae4cadffd0ec55b4c41a651e9ac70ed72","contentType":"text/markdown; charset=utf-8"},{"id":"150dd160-52de-50d3-9fc5-5661fe3991d8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/150dd160-52de-50d3-9fc5-5661fe3991d8/attachment.md","path":"core/locators.md","size":6528,"sha256":"888e6c2562aa28fe1a9efb36775c8cf0e77fdcc75b197c0fb66c4f0eda402378","contentType":"text/markdown; charset=utf-8"},{"id":"f0fc6cfe-012e-5ede-af07-091c55f797f1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f0fc6cfe-012e-5ede-af07-091c55f797f1/attachment.md","path":"core/page-object-model.md","size":8110,"sha256":"b1ceaba7a8ce019f4ec4bfe41b9c94b452ae93f87312a78c0ae71b4e1787357e","contentType":"text/markdown; charset=utf-8"},{"id":"d479e890-2994-504e-b5d8-2d4eab271cc8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d479e890-2994-504e-b5d8-2d4eab271cc8/attachment.md","path":"core/projects-dependencies.md","size":8949,"sha256":"d04ec97f78e1d4767c1ae20fdeab8f4407f574d182c3a313fb43a26146b05f19","contentType":"text/markdown; charset=utf-8"},{"id":"f7e8e259-cbb5-58ff-aaa5-3b5f4a0bfb58","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f7e8e259-cbb5-58ff-aaa5-3b5f4a0bfb58/attachment.md","path":"core/test-data.md","size":12082,"sha256":"a3ca72e9583de070bdc6808e0a59502601cb1455d180fdfe418ac9150fd7b62f","contentType":"text/markdown; charset=utf-8"},{"id":"ba20e8e8-ffdf-5e4b-ae07-c4b2be64daaa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ba20e8e8-ffdf-5e4b-ae07-c4b2be64daaa/attachment.md","path":"core/test-suite-structure.md","size":9550,"sha256":"638a72c8d01826a842096ca2a90bd6108d27a31a0d28d209833d14379abac9ee","contentType":"text/markdown; charset=utf-8"},{"id":"24961106-c12e-5397-b1b4-7950d46b957f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/24961106-c12e-5397-b1b4-7950d46b957f/attachment.md","path":"core/test-tags.md","size":7080,"sha256":"affc1e8550bfad113557670c97e050864ca5837df4fcebc322dd0b8ac6af5853","contentType":"text/markdown; charset=utf-8"},{"id":"e5c087a5-da81-5af8-856b-7f228f24384a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e5c087a5-da81-5af8-856b-7f228f24384a/attachment.md","path":"debugging/console-errors.md","size":10093,"sha256":"7332c92af9af3217516d012360621709750c1c191ecd588eaa77ecf1fd3c1450","contentType":"text/markdown; charset=utf-8"},{"id":"4545bbe5-0fb3-5422-a8b5-e3ab351fd988","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4545bbe5-0fb3-5422-a8b5-e3ab351fd988/attachment.md","path":"debugging/debugging.md","size":15097,"sha256":"ca751fe90673d51f7fdb28b3cab827bb7a76d745d006df21f6d7220ff5e4b112","contentType":"text/markdown; charset=utf-8"},{"id":"277f0970-c9cd-537c-a40e-333bc02056ed","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/277f0970-c9cd-537c-a40e-333bc02056ed/attachment.md","path":"debugging/error-testing.md","size":9694,"sha256":"3ad834d850816d9b5bffb456175a41fd88bd399fa2d7b098a4cb1bc12914d940","contentType":"text/markdown; charset=utf-8"},{"id":"86bd8403-4902-50b7-9108-adf3e390c1ab","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/86bd8403-4902-50b7-9108-adf3e390c1ab/attachment.md","path":"debugging/flaky-tests.md","size":15094,"sha256":"027dfe989ef2abf9fb8d1ab1e8b0602ee0c5026c93bb3ee69c3b2ffbbcd559f8","contentType":"text/markdown; charset=utf-8"},{"id":"23fa5aa7-b8dd-56b8-a8fd-31111ca36aed","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/23fa5aa7-b8dd-56b8-a8fd-31111ca36aed/attachment.md","path":"frameworks/angular.md","size":18606,"sha256":"4acd7095d3556dd09a486c7ef30e0a6af1bab1cbfe2645739cca32914ef93009","contentType":"text/markdown; charset=utf-8"},{"id":"cef04332-43d3-5a62-8d04-003182d1d79c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cef04332-43d3-5a62-8d04-003182d1d79c/attachment.md","path":"frameworks/nextjs.md","size":13273,"sha256":"1a6370ebb6f9b871e59027673e641fc968d24943d37ec73caad6e6b8463d7a4b","contentType":"text/markdown; charset=utf-8"},{"id":"e046c395-b2bb-5278-9cb1-6e8f023a9335","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e046c395-b2bb-5278-9cb1-6e8f023a9335/attachment.md","path":"frameworks/react.md","size":17650,"sha256":"67f248dfafd356c37354a9bc65c6306e9e48343f983c0352b78ffbc67a7d8391","contentType":"text/markdown; charset=utf-8"},{"id":"25c20338-1727-5894-a1e8-137bace75d00","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/25c20338-1727-5894-a1e8-137bace75d00/attachment.md","path":"frameworks/vue.md","size":17005,"sha256":"b35f8d561dd9b5ebe8b86be7408ff4dbf03e189649714d790943ce763abb6718","contentType":"text/markdown; charset=utf-8"},{"id":"26a2843d-05a2-5c54-8fb5-f693b24bdb79","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/26a2843d-05a2-5c54-8fb5-f693b24bdb79/attachment.md","path":"infrastructure-ci-cd/ci-cd.md","size":10006,"sha256":"a6951b34dbfc4eabaad53cc5ff63edea3ca7eb35c455a1b34a73992e9572757a","contentType":"text/markdown; charset=utf-8"},{"id":"c4677735-13ce-53aa-8a03-328e35b473a7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c4677735-13ce-53aa-8a03-328e35b473a7/attachment.md","path":"infrastructure-ci-cd/docker.md","size":6262,"sha256":"ba0318c1060fe7d2235899e03ab998e588581becc0da49c3562a78fdd71b6526","contentType":"text/markdown; charset=utf-8"},{"id":"1380eb67-460d-5635-a9d6-ed9dfcdd4283","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1380eb67-460d-5635-a9d6-ed9dfcdd4283/attachment.md","path":"infrastructure-ci-cd/github-actions.md","size":13383,"sha256":"1f243a1b43c3a25fd29484a3789f65e38a1725588149b88e9996b37e0e7c608f","contentType":"text/markdown; charset=utf-8"},{"id":"aa6b6afb-97e3-5185-9841-eaacc95d107c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aa6b6afb-97e3-5185-9841-eaacc95d107c/attachment.md","path":"infrastructure-ci-cd/gitlab.md","size":10647,"sha256":"91d73a21bfa8f238a719a04700390f01866f1de9dc70a064e09705ebc60f8b72","contentType":"text/markdown; charset=utf-8"},{"id":"431feaea-7183-5f69-9f4d-36e6970a07ae","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/431feaea-7183-5f69-9f4d-36e6970a07ae/attachment.md","path":"infrastructure-ci-cd/other-providers.md","size":13732,"sha256":"6aca1c5e00cb179853ee85b8f340481d59c2c938a49eea412f1d13a5e04a230a","contentType":"text/markdown; charset=utf-8"},{"id":"380d7afb-ac04-580b-863c-fc7ea715440b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/380d7afb-ac04-580b-863c-fc7ea715440b/attachment.md","path":"infrastructure-ci-cd/parallel-sharding.md","size":11579,"sha256":"81837bf4cc6e034053a0cbc00d9e7347f5cd573ceef61db49b0587ddbeba7a23","contentType":"text/markdown; charset=utf-8"},{"id":"a89b2257-305b-5675-aef1-70e65ef32527","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a89b2257-305b-5675-aef1-70e65ef32527/attachment.md","path":"infrastructure-ci-cd/performance.md","size":11867,"sha256":"01073c6bc622a7e2d347dbbea066dd8b05cb394c7717e2b2a8b26ea6f1e6e838","contentType":"text/markdown; charset=utf-8"},{"id":"b19b7334-3562-5330-81e0-9cb7ae7a1007","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b19b7334-3562-5330-81e0-9cb7ae7a1007/attachment.md","path":"infrastructure-ci-cd/reporting.md","size":10492,"sha256":"e7bd41103689230d3fe3d5f7019f95048548e5a1b8d70da7617cc3897236efd7","contentType":"text/markdown; charset=utf-8"},{"id":"e98bf542-0d59-5c30-be83-c451a36535f8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e98bf542-0d59-5c30-be83-c451a36535f8/attachment.md","path":"infrastructure-ci-cd/test-coverage.md","size":12725,"sha256":"b70eb2fa03e5cf3d4d7b98d4b342083fee1ea9092023d2c0c0cb685bfd8aa303","contentType":"text/markdown; charset=utf-8"},{"id":"ecf5e0a4-d29e-55e9-97eb-8e75c31b5432","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ecf5e0a4-d29e-55e9-97eb-8e75c31b5432/attachment.md","path":"testing-patterns/accessibility.md","size":9176,"sha256":"a52a8d1590c841f8470ee6bea8ea73c052010a36ae9a6afed7939d12bdbf800a","contentType":"text/markdown; charset=utf-8"},{"id":"a4cdc875-4c50-545c-9d7b-685774a6240a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a4cdc875-4c50-545c-9d7b-685774a6240a/attachment.md","path":"testing-patterns/api-testing.md","size":24927,"sha256":"b7b94a9015655e4d8f1826b440d5270fc8a7d969466d80aad4e7196faf60079f","contentType":"text/markdown; charset=utf-8"},{"id":"918bc13b-2ae7-5af7-9c28-5b362559bc74","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/918bc13b-2ae7-5af7-9c28-5b362559bc74/attachment.md","path":"testing-patterns/browser-extensions.md","size":13826,"sha256":"2f05f8f62f02ced311802c44e96198db3ceade61d2224501939ded091e952ee2","contentType":"text/markdown; charset=utf-8"},{"id":"f41f38e2-48a7-509b-84ad-cc222fdb20de","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f41f38e2-48a7-509b-84ad-cc222fdb20de/attachment.md","path":"testing-patterns/canvas-webgl.md","size":13183,"sha256":"39d62688c1e74a2e5af44efa03204be268fb2f4f62661d58aeffedfd3385998f","contentType":"text/markdown; charset=utf-8"},{"id":"2cb325ee-e67d-5c1f-aaac-91402996580c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2cb325ee-e67d-5c1f-aaac-91402996580c/attachment.md","path":"testing-patterns/component-testing.md","size":11701,"sha256":"2fb0d0d5053ccc0353022337beba2fa76b68389c6461b363cafacc32e8fa0c68","contentType":"text/markdown; charset=utf-8"},{"id":"e1ae69aa-69ff-551a-b24b-8a9e76fbd5ce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e1ae69aa-69ff-551a-b24b-8a9e76fbd5ce/attachment.md","path":"testing-patterns/drag-drop.md","size":18124,"sha256":"14c470974e736566e1c7a1c7f10c9d5d8b66085ec5c8179a6b3c33002252541d","contentType":"text/markdown; charset=utf-8"},{"id":"db69dcf2-6753-5cc4-a917-a1e40cf9f56a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/db69dcf2-6753-5cc4-a917-a1e40cf9f56a/attachment.md","path":"testing-patterns/electron.md","size":13512,"sha256":"093d00d665b96befa45d63385f32de34065785ef87f992526a333be91f83478c","contentType":"text/markdown; charset=utf-8"},{"id":"cac085f5-de2d-5a88-bbc4-b1125821cacb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cac085f5-de2d-5a88-bbc4-b1125821cacb/attachment.md","path":"testing-patterns/file-operations.md","size":10447,"sha256":"50f3818af9a2c5a9d1e83c832c7324c7bc94c0c93899c9cedb40d534e4335f60","contentType":"text/markdown; charset=utf-8"},{"id":"a56f640b-105c-5b43-a9a1-99d1398dadb9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a56f640b-105c-5b43-a9a1-99d1398dadb9/attachment.md","path":"testing-patterns/file-upload-download.md","size":17233,"sha256":"2aa6a50f0f3e8703f08f4365065d6e79350f1ef7ae4646662ab67d66e8e1d3c7","contentType":"text/markdown; charset=utf-8"},{"id":"f47bf8dc-de65-521b-af52-03dc1a95e8f1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f47bf8dc-de65-521b-af52-03dc1a95e8f1/attachment.md","path":"testing-patterns/forms-validation.md","size":22177,"sha256":"124cc991bd22106d202f6c0d7e15c79edb3dc4ca683b2fedfa700b6512df647a","contentType":"text/markdown; charset=utf-8"},{"id":"ae5f3a2b-c19c-5fb9-9064-514e312bf5b6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ae5f3a2b-c19c-5fb9-9064-514e312bf5b6/attachment.md","path":"testing-patterns/graphql-testing.md","size":8714,"sha256":"25706e8c8133a86ae01a7579fab4d17000d01d4e5d2c67c174da6bcd9dbf1a36","contentType":"text/markdown; charset=utf-8"},{"id":"6237d335-4810-5712-a80f-991c38ceba31","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6237d335-4810-5712-a80f-991c38ceba31/attachment.md","path":"testing-patterns/i18n.md","size":13134,"sha256":"348fd0817e83ce9ac1318ad2ff87f9ac5b52de6c21aecdbd99690538214c87f1","contentType":"text/markdown; charset=utf-8"},{"id":"97e7969d-4e3e-5129-8538-77581dff7af8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/97e7969d-4e3e-5129-8538-77581dff7af8/attachment.md","path":"testing-patterns/performance-testing.md","size":12566,"sha256":"0faccd5ec785b4c66466f194ce9c8d1b9e257674e5be5c1ef5dd8864538bde5b","contentType":"text/markdown; charset=utf-8"},{"id":"52934f13-413c-5905-8584-7d5d9ee79080","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/52934f13-413c-5905-8584-7d5d9ee79080/attachment.md","path":"testing-patterns/security-testing.md","size":12145,"sha256":"c03fe6ead5ec78bfe20af45e05ab587d054f77f8e1a6d26dd7791f06be7cabd7","contentType":"text/markdown; charset=utf-8"},{"id":"e174f5e6-9fcb-5009-9bf3-413371b15edf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e174f5e6-9fcb-5009-9bf3-413371b15edf/attachment.md","path":"testing-patterns/visual-regression.md","size":17765,"sha256":"b58d614c2d34f6b63df6a6aaa9d255a90dd4e4e5475db8227b6ea43c8a079a33","contentType":"text/markdown; charset=utf-8"}],"bundle_sha256":"3e2e9f37bca780de5b8409479876f4f69bc928ce23fbc31ceafad88ff9ca4eb3","attachment_count":61,"text_attachments":61,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"testing-qa","category_label":"Testing"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"testing-qa","metadata":{"author":"currents.dev","version":"1.1"},"import_tag":"clean-skills-v1","description":"Use when writing Playwright tests, fixing flaky tests, debugging failures, implementing Page Object Model, configuring CI/CD, optimizing performance, mocking APIs, handling authentication or OAuth, testing accessibility (axe-core), file uploads/downloads, date/time mocking, WebSockets, geolocation, permissions, multi-tab/popup flows, mobile/responsive layouts, touch gestures, GraphQL, error handling, offline mode, multi-user collaboration, third-party services (payments, email verification), console error monitoring, global setup/teardown, test annotations (skip, fixme, slow), test tags (@smoke, @fast, @critical, filtering with --grep), project dependencies, security testing (XSS, CSRF, auth), performance budgets (Web Vitals, Lighthouse), iframes, component testing, canvas/WebGL, service workers/PWA, test coverage, i18n/localization, Electron apps, or browser extension testing. Covers E2E, component, API, visual, accessibility, security, Electron, and extension testing."}},"renderedAt":1782981242820}

Playwright Best Practices This skill provides comprehensive guidance for all aspects of Playwright test development, from writing new tests to debugging and maintaining existing test suites. Activity-Based Reference Guide Consult these references based on what you're doing: Writing New Tests When to use : Creating new test files, writing test cases, implementing test scenarios | Activity | Reference Files | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Wr…