Skills로 돌아가기
currents-dev/playwright-best-practices-skill실행 전 동작 확인

SKILL DETAIL

playwright-best-practices

currents-dev/playwright-best-practices-skill/playwright-best-practices

This skill offers comprehensive guidance for Playwright test development, covering writing new 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. It covers E2E, component, API, visual, accessibility, security, Electron, and extension testing.

설치 수 · 322출처 보기

Installation

npx skills add https://github.com/currents-dev/playwright-best-practices-skill --skill playwright-best-practices

스킬 파일

SKILL.md

최근 동기화 · 2026. 8. 29.

advanced/authentication-flows.md
# Complex Authentication Flow Patterns

## Table of Contents

1. [Email Verification Flows](#email-verification-flows)
2. [Password Reset](#password-reset)
3. [Session Timeout](#session-timeout)
4. [Remember Me Persistence](#remember-me-persistence)
5. [Logout Patterns](#logout-patterns)
6. [Tips](#tips)
7. [Related](#related)

> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).

---

## Email Verification Flows

### Capturing Verification Tokens

Intercept API responses to capture verification tokens for testing:

```typescript
test('completes registration with email verification', async ({ page }) => {
  let capturedToken = '';

  await page.route('**/api/auth/register', async (route) => {
    const response = await route.fetch();
    const body = await response.json();
    capturedToken = body.verificationToken;
    await route.fulfill({ response });
  });

  await page.goto('/register');
  await page.getByLabel('Name').fill('New User');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password', { exact: true }).fill('SecurePass!');
  await page.getByLabel('Confirm password').fill('SecurePass!');
  await page.getByRole('button', { name: 'Create account' }).click();

  await expect(page.getByText('Check your inbox')).toBeVisible();

  expect(capturedToken).toBeTruthy();
  await page.goto(`/verify?token=${capturedToken}`);

  await expect(page.getByText('Email confirmed')).toBeVisible();
});
```

### Fully Mocked Verification

```typescript
test('verifies email with mocked endpoints', async ({ page }) => {
  const mockToken = 'test-verification-abc123';

  await page.route('**/api/auth/register', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }),
    });
  });

  await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ verified: true }),
    });
  });

  await page.goto('/register');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password', { exact: true }).fill('Password123!');
  await page.getByRole('button', { name: 'Sign up' }).click();

  await expect(page.getByText('Check your inbox')).toBeVisible();

  await page.goto(`/verify?token=${mockToken}`);
  await expect(page.getByText('Email confirmed')).toBeVisible();
});
```

---

## Password Reset

### Complete Reset Flow

```typescript
test('resets password through email link', async ({ page }) => {
  let resetToken = '';

  await page.route('**/api/auth/forgot-password', async (route) => {
    const response = await route.fetch();
    const body = await response.json();
    resetToken = body.resetToken;
    await route.fulfill({ response });
  });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByRole('button', { name: 'Send link' }).click();

  await expect(page.getByText('Reset email sent')).toBeVisible();

  expect(resetToken).toBeTruthy();
  await page.goto(`/reset-password?token=${resetToken}`);

  await page.getByLabel('New password', { exact: true }).fill('NewPassword456!');
  await page.getByLabel('Confirm password').fill('NewPassword456!');
  await page.getByRole('button', { name: 'Update password' }).click();

  await expect(page.getByText('Password updated')).toBeVisible();
});
```

### Expired Token Handling

```typescript
test('shows error for expired reset token', async ({ page }) => {
  await page.goto('/reset-password?token=expired-token');

  await page.getByLabel('New password', { exact: true }).fill('NewPass!');
  await page.getByLabel('Confirm password').fill('NewPass!');
  await page.getByRole('button', { name: 'Update password' }).click();

  await expect(page.getByRole('alert')).toContainText(/expired|invalid/i);
});
```

### Password Strength Validation

```typescript
test('enforces password requirements on reset', async ({ page }) => {
  await page.goto('/reset-password?token=valid-token');

  await page.getByLabel('New password', { exact: true }).fill('weak');
  await page.getByLabel('Confirm password').fill('weak');
  await page.getByRole('button', { name: 'Update password' }).click();

  await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
});
```

---

## Session Timeout

### Detecting Expired Sessions

```typescript
test('redirects to signin after session expires', async ({ page, context }) => {
  await page.goto('/signin');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('Password!');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/home');

  const cookies = await context.cookies();
  const sessionCookie = cookies.find((c) => c.name.includes('session'));

  if (sessionCookie) {
    await context.clearCookies({ name: sessionCookie.name });
  }

  await page.goto('/profile');
  await expect(page).toHaveURL(/\/signin/);
  await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible();
});
```

### Session Extension Warning

```typescript
test('shows warning before session expires', async ({ page }) => {
  await page.route('**/api/auth/session', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ valid: true, expiresIn: 60 }),
    });
  });

  await page.goto('/home');

  await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
  await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible();
});
```

### Session Extension Action

```typescript
test('extends session when user clicks extend', async ({ page }) => {
  let sessionExtended = false;

  await page.route('**/api/auth/session', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ valid: true, expiresIn: 60 }),
    });
  });

  await page.route('**/api/auth/refresh', async (route) => {
    sessionExtended = true;
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ valid: true, expiresIn: 3600 }),
    });
  });

  await page.goto('/home');

  await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({
    timeout: 10000,
  });
  await page.getByRole('button', { name: /extend|stay signed in/i }).click();

  expect(sessionExtended).toBe(true);
  await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
});
```

---

## Remember Me Persistence

### Persistent Session

```typescript
test('persists session with remember me enabled', async ({ browser }) => {
  const ctx1 = await browser.newContext();
  const page1 = await ctx1.newPage();

  await page1.goto('/signin');
  await page1.getByLabel('Email').fill('[email protected]');
  await page1.getByLabel('Password').fill('Password!');
  await page1.getByLabel('Keep me signed in').check();
  await page1.getByRole('button', { name: 'Sign in' }).click();

  await expect(page1).toHaveURL('/home');

  const state = await ctx1.storageState();
  await ctx1.close();

  const ctx2 = await browser.newContext({ storageState: state });
  const page2 = await ctx2.newPage();

  await page2.goto('/home');
  await expect(page2).toHaveURL('/home');
  await expect(page2.getByText('Welcome')).toBeVisible();

  await ctx2.close();
});
```

### Session-Only Login

```typescript
test('session-only login does not persist across browser restarts', async ({ browser }) => {
  const ctx1 = await browser.newContext();
  const page1 = await ctx1.newPage();

  await page1.goto('/signin');
  await page1.getByLabel('Email').fill('[email protected]');
  await page1.getByLabel('Password').fill('Password!');
  // Leave "Remember me" unchecked
  await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked();
  await page1.getByRole('button', { name: 'Sign in' }).click();

  await expect(page1).toHaveURL('/home');

  // Only keep persistent cookies (filter out session cookies)
  const cookies = await ctx1.cookies();
  await ctx1.close();

  const persistentCookies = cookies.filter((c) => c.expires > 0);
  const ctx2 = await browser.newContext();
  await ctx2.addCookies(persistentCookies);
  const page2 = await ctx2.newPage();

  await page2.goto('/home');

  // Should redirect to login since session was not persisted
  await expect(page2).toHaveURL(/\/signin/);

  await ctx2.close();
});
```

---

## Logout Patterns

### Standard Logout with Session Cleanup

```typescript
test.use({ storageState: '.auth/user.json' });

test('logs out and clears session', async ({ page, context }) => {
  await page.goto('/home');

  await page.getByRole('button', { name: /account|menu/i }).click();
  await page.getByRole('menuitem', { name: 'Sign out' }).click();

  await expect(page).toHaveURL('/signin');

  const cookies = await context.cookies();
  const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token'));
  expect(sessionCookies).toHaveLength(0);

  await page.goto('/home');
  await expect(page).toHaveURL(/\/signin/);
});
```

### Logout from All Devices

```typescript
test('logs out from all devices', async ({ page }) => {
  let logoutAllCalled = false;

  await page.route('**/api/auth/logout-all', async (route) => {
    logoutAllCalled = true;
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'Logged out everywhere' }),
    });
  });

  await page.goto('/settings/security');

  await page.getByRole('button', { name: 'Sign out everywhere' }).click();
  await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();

  expect(logoutAllCalled).toBe(true);
  await expect(page).toHaveURL(/\/signin/);
});
```

---

## Tips

1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests
2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens
3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared
4. **Test the full flow end-to-end** — Password reset should verify login with new password works

---

## Related

- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login
- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures
- [third-party.md](./third-party.md) — Mocking external auth providers
advanced/authentication.md
# Authentication Testing

## Table of Contents

1. [Quick Reference](#quick-reference)
2. [Patterns](#patterns)
3. [Decision Guide](#decision-guide)
4. [Anti-Patterns](#anti-patterns)
5. [Troubleshooting](#troubleshooting)
6. [Related](#related)

> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.

## Quick Reference

```typescript
// Storage state reuse — the #1 pattern for fast auth
await page.goto("/login");
await page.getByLabel("Username").fill("[email protected]");
await page.getByLabel("Password").fill("secretPass123");
await page.getByRole("button", { name: "Log in" }).click();
await page.context().storageState({ path: ".auth/session.json" });

// Reuse in config — every test starts authenticated
{
  use: {
    storageState: ".auth/session.json"
  }
}

// API login — skip the UI entirely
const context = await browser.newContext();
const response = await context.request.post("/api/auth/login", {
  data: { email: "[email protected]", password: "secretPass123" },
});
await context.storageState({ path: ".auth/session.json" });
```

## Patterns

### Storage State Reuse

**Use when**: You need authenticated tests and want to avoid logging in before every test.
**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.

`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.

```typescript
// scripts/generate-auth.ts — run once to generate the state file
import { chromium } from "@playwright/test";

async function generateAuthState() {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto("http://localhost:4000/login");
  await page.getByLabel("Username").fill("[email protected]");
  await page.getByLabel("Password").fill("secretPass123");
  await page.getByRole("button", { name: "Log in" }).click();
  await page.waitForURL("/home");

  await context.storageState({ path: ".auth/session.json" });
  await browser.close();
}

generateAuthState();
```

```typescript
// playwright.config.ts — load saved state for all tests
import { defineConfig } from "@playwright/test";

export default defineConfig({
  use: {
    baseURL: "http://localhost:4000",
    storageState: ".auth/session.json",
  },
});
```

```typescript
// tests/home.spec.ts — test starts already logged in
import { test, expect } from "@playwright/test";

test("authenticated user sees home page", async ({ page }) => {
  await page.goto("/home");
  await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});
```

### Global Setup Authentication

**Use when**: You want to authenticate once before the entire test suite runs.
**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.

```typescript
// global-setup.ts
import { chromium, type FullConfig } from "@playwright/test";

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use;
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto(`${baseURL}/login`);
  await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole("button", { name: "Log in" }).click();
  await page.waitForURL("**/home");

  await context.storageState({ path: ".auth/session.json" });
  await browser.close();
}

export default globalSetup;
```

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  globalSetup: require.resolve("./global-setup"),
  use: {
    baseURL: "http://localhost:4000",
    storageState: ".auth/session.json",
  },
});
```

Add `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.

### Per-Worker Authentication

**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.
**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.

> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.

```typescript
// fixtures/auth.ts
import { test as base, type BrowserContext } from "@playwright/test";

type AuthFixtures = {
  authenticatedContext: BrowserContext;
};

export const test = base.extend<{}, AuthFixtures>({
  authenticatedContext: [
    async ({ browser }, use) => {
      const context = await browser.newContext();
      const page = await context.newPage();

      await page.goto("/login");
      await page
        .getByLabel("Username")
        .fill(`worker-${test.info().parallelIndex}@example.com`);
      await page.getByLabel("Password").fill("secretPass123");
      await page.getByRole("button", { name: "Log in" }).click();
      await page.waitForURL("/home");
      await page.close();

      await use(context);
      await context.close();
    },
    { scope: "worker" },
  ],
});

export { expect } from "@playwright/test";
```

```typescript
// tests/settings.spec.ts
import { test, expect } from "../fixtures/auth";

test("update display name", async ({ authenticatedContext }) => {
  const page = await authenticatedContext.newPage();
  await page.goto("/settings/profile");
  await page.getByLabel("Display name").fill("Updated Name");
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByText("Profile saved")).toBeVisible();
});
```

### Multiple Roles

**Use when**: Your app has role-based access control and you need to test different permission levels.
**Avoid when**: Your app has a single user role.

```typescript
// global-setup.ts — authenticate all roles
import { chromium, type FullConfig } from "@playwright/test";

const accounts = [
  {
    role: "admin",
    email: "[email protected]",
    password: process.env.ADMIN_PASSWORD!,
  },
  {
    role: "member",
    email: "[email protected]",
    password: process.env.MEMBER_PASSWORD!,
  },
  {
    role: "guest",
    email: "[email protected]",
    password: process.env.GUEST_PASSWORD!,
  },
];

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use;

  for (const { role, email, password } of accounts) {
    const browser = await chromium.launch();
    const context = await browser.newContext();
    const page = await context.newPage();

    await page.goto(`${baseURL}/login`);
    await page.getByLabel("Username").fill(email);
    await page.getByLabel("Password").fill(password);
    await page.getByRole("button", { name: "Log in" }).click();
    await page.waitForURL("**/home");

    await context.storageState({ path: `.auth/${role}.json` });
    await browser.close();
  }
}

export default globalSetup;
```

```typescript
// playwright.config.ts — one project per role
import { defineConfig } from "@playwright/test";

export default defineConfig({
  globalSetup: require.resolve("./global-setup"),
  projects: [
    {
      name: "admin",
      use: { storageState: ".auth/admin.json" },
      testMatch: "**/*.admin.spec.ts",
    },
    {
      name: "member",
      use: { storageState: ".auth/member.json" },
      testMatch: "**/*.member.spec.ts",
    },
    {
      name: "guest",
      use: { storageState: ".auth/guest.json" },
      testMatch: "**/*.guest.spec.ts",
    },
    {
      name: "anonymous",
      use: { storageState: { cookies: [], origins: [] } },
      testMatch: "**/*.anon.spec.ts",
    },
  ],
});
```

```typescript
// tests/admin-panel.admin.spec.ts
import { test, expect } from "@playwright/test";

test("admin can access user management", async ({ page }) => {
  await page.goto("/admin/users");
  await expect(
    page.getByRole("heading", { name: "User Management" })
  ).toBeVisible();
  await expect(page.getByRole("button", { name: "Remove user" })).toBeEnabled();
});
```

```typescript
// tests/admin-panel.guest.spec.ts
import { test, expect } from "@playwright/test";

test("guest cannot access admin panel", async ({ page }) => {
  await page.goto("/admin/users");
  await expect(page.getByText("Access denied")).toBeVisible();
});
```

**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.

```typescript
// fixtures/auth.ts — role-based fixture
import { test as base, type Page } from "@playwright/test";
import fs from "fs";

type RoleFixtures = {
  loginAs: (role: "admin" | "member" | "guest") => Promise<Page>;
};

export const test = base.extend<RoleFixtures>({
  loginAs: async ({ browser }, use) => {
    const pages: Page[] = [];

    await use(async (role) => {
      const statePath = `.auth/${role}.json`;
      if (!fs.existsSync(statePath)) {
        throw new Error(
          `Auth state for role "${role}" not found at ${statePath}`
        );
      }
      const context = await browser.newContext({ storageState: statePath });
      const page = await context.newPage();
      pages.push(page);
      return page;
    });

    for (const page of pages) {
      await page.context().close();
    }
  },
});

export { expect } from "@playwright/test";
```

```typescript
// tests/role-comparison.spec.ts
import { test, expect } from "../fixtures/auth";

test("admin sees remove button, guest does not", async ({ loginAs }) => {
  const adminPage = await loginAs("admin");
  await adminPage.goto("/admin/users");
  await expect(
    adminPage.getByRole("button", { name: "Remove user" })
  ).toBeVisible();

  const guestPage = await loginAs("guest");
  await guestPage.goto("/admin/users");
  await expect(guestPage.getByText("Access denied")).toBeVisible();
});
```

### OAuth/SSO Mocking

**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.
**Avoid when**: You have a dedicated test tenant on the OAuth provider.

A typical OAuth flow works like this:

1. User clicks "Sign in with Provider" → browser navigates to `https://accounts.provider.com/authorize?...`
2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)
3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page

In tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.

For cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.

```typescript
// tests/oauth-login.spec.ts — mock the callback route
import { test, expect } from "@playwright/test";

test("login via mocked OAuth flow", async ({ page }) => {
  await page.route("https://accounts.provider.com/**", async (route) => {
    const callbackUrl = new URL("http://localhost:4000/auth/callback");
    callbackUrl.searchParams.set("code", "mock-auth-code-xyz");
    callbackUrl.searchParams.set("state", "expected-state-value");
    await route.fulfill({
      status: 302,
      headers: { location: callbackUrl.toString() },
    });
  });

  await page.goto("/login");
  await page.getByRole("button", { name: "Sign in with Provider" }).click();

  await page.waitForURL("/home");
  await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});
```

```typescript
// tests/oauth-login.spec.ts — API-based session injection
import { test, expect } from "@playwright/test";

test("bypass OAuth entirely via API session injection", async ({
  page,
}) => {
  // Call a test-only endpoint that creates a session without OAuth
  const response = await page.request.post("/api/test/create-session", {
    data: {
      email: "[email protected]",
      provider: "provider",
      role: "member",
    },
  });
  expect(response.ok()).toBeTruthy();

  await page.context().storageState({ path: ".auth/oauth-user.json" });
  await page.goto("/home");
  await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});
```

**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.

### MFA Handling

**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).
**Avoid when**: MFA is optional and you can disable it for test accounts.

**Strategy 1**: Generate real TOTP codes from a shared secret.

```typescript
// helpers/totp.ts
import * as OTPAuth from "otpauth";

export function generateTOTP(secret: string): string {
  const totp = new OTPAuth.TOTP({
    secret: OTPAuth.Secret.fromBase32(secret),
    digits: 6,
    period: 30,
    algorithm: "SHA1",
  });
  return totp.generate();
}
```

```typescript
// tests/mfa-login.spec.ts
import { test, expect } from "@playwright/test";
import { generateTOTP } from "../helpers/totp";

test("login with TOTP two-factor auth", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Username").fill("[email protected]");
  await page.getByLabel("Password").fill("secretPass123");
  await page.getByRole("button", { name: "Log in" }).click();

  await expect(page.getByText("Enter your authentication code")).toBeVisible();

  const code = generateTOTP(process.env.MFA_TOTP_SECRET!);
  await page.getByLabel("Authentication code").fill(code);
  await page.getByRole("button", { name: "Verify" }).click();

  await page.waitForURL("/home");
  await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});
```

**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.

**Strategy 3**: Disable MFA for test accounts at the infrastructure level.

### Session Refresh

**Use when**: Your tokens expire during long test runs.
**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.

```typescript
// fixtures/auth-with-refresh.ts
import { test as base, type BrowserContext } from "@playwright/test";
import fs from "fs";

type AuthFixtures = {
  authenticatedPage: import("@playwright/test").Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ browser }, use) => {
    const statePath = ".auth/session.json";

    let context: BrowserContext;
    if (fs.existsSync(statePath)) {
      context = await browser.newContext({ storageState: statePath });
      const page = await context.newPage();

      const response = await page.request.get("/api/auth/me");
      if (response.ok()) {
        await use(page);
        await context.close();
        return;
      }
      await context.close();
    }

    context = await browser.newContext();
    const page = await context.newPage();
    await page.goto("/login");
    await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
    await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
    await page.getByRole("button", { name: "Log in" }).click();
    await page.waitForURL("/home");

    await context.storageState({ path: statePath });

    await use(page);
    await context.close();
  },
});

export { expect } from "@playwright/test";
```

### Login Page Object

**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.
**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.

```typescript
// page-objects/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test";

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;
  readonly forgotPasswordLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByLabel("Username");
    this.passwordInput = page.getByLabel("Password");
    this.loginButton = page.getByRole("button", { name: "Log in" });
    this.errorMessage = page.getByRole("alert");
    this.forgotPasswordLink = page.getByRole("link", {
      name: "Forgot password",
    });
  }

  async goto() {
    await this.page.goto("/login");
    await expect(this.loginButton).toBeVisible();
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async loginAndWaitForHome(username: string, password: string) {
    await this.login(username, password);
    await this.page.waitForURL("/home");
  }

  async expectError(message: string | RegExp) {
    await expect(this.errorMessage).toContainText(message);
  }

  async expectFieldError(field: "username" | "password", message: string) {
    const input =
      field === "username" ? this.usernameInput : this.passwordInput;
    await expect(input).toHaveAttribute("aria-invalid", "true");
    const errorId = await input.getAttribute("aria-describedby");
    if (errorId) {
      await expect(this.page.locator(`#${errorId}`)).toContainText(message);
    }
  }
}
```

```typescript
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../page-objects/LoginPage";

test.use({ storageState: { cookies: [], origins: [] } });

test.describe("login page", () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test("successful login redirects to home", async ({ page }) => {
    await loginPage.loginAndWaitForHome(
      "[email protected]",
      "secretPass123"
    );
    await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
  });

  test("wrong password shows error", async () => {
    await loginPage.login("[email protected]", "wrong-password");
    await loginPage.expectError("Invalid username or password");
  });

  test("empty fields show validation errors", async () => {
    await loginPage.loginButton.click();
    await loginPage.expectFieldError("username", "Username is required");
  });

  test("forgot password link navigates correctly", async ({ page }) => {
    await loginPage.forgotPasswordLink.click();
    await page.waitForURL("/forgot-password");
    await expect(
      page.getByRole("heading", { name: "Reset password" })
    ).toBeVisible();
  });
});
```

### API-Based Login

**Use when**: You want the fastest possible authentication without any browser interaction.
**Avoid when**: You are specifically testing the login UI.

API login is typically 5-10x faster than UI login.

```typescript
// global-setup.ts — API-based login (fastest)
import { request, type FullConfig } from "@playwright/test";

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use;

  const requestContext = await request.newContext({ baseURL });

  const response = await requestContext.post("/api/auth/login", {
    data: {
      email: process.env.TEST_USER_EMAIL!,
      password: process.env.TEST_USER_PASSWORD!,
    },
  });

  if (!response.ok()) {
    throw new Error(
      `API login failed: ${response.status()} ${await response.text()}`
    );
  }

  await requestContext.storageState({ path: ".auth/session.json" });
  await requestContext.dispose();
}

export default globalSetup;
```

```typescript
// fixtures/api-auth.ts — fixture version for per-test authentication
import { test as base } from "@playwright/test";

export const test = base.extend({
  authenticatedPage: async ({ browser, playwright }, use) => {
    const apiContext = await playwright.request.newContext({
      baseURL: "http://localhost:4000",
    });

    await apiContext.post("/api/auth/login", {
      data: {
        email: "[email protected]",
        password: "secretPass123",
      },
    });

    const state = await apiContext.storageState();
    const context = await browser.newContext({ storageState: state });
    const page = await context.newPage();

    await use(page);

    await context.close();
    await apiContext.dispose();
  },
});

export { expect } from "@playwright/test";
```

### Unauthenticated Tests

**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.
**Avoid when**: The test requires a logged-in user.

When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.

```typescript
// tests/public-pages.spec.ts
import { test, expect } from "@playwright/test";

test.use({ storageState: { cookies: [], origins: [] } });

test.describe("unauthenticated access", () => {
  test("homepage is accessible without login", async ({ page }) => {
    await page.goto("/");
    await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
    await expect(page.getByRole("link", { name: "Log in" })).toBeVisible();
  });

  test("protected route redirects to login", async ({ page }) => {
    await page.goto("/home");
    await page.waitForURL("**/login**");
    expect(page.url()).toContain("redirect=%2Fhome");
  });

  test("expired session shows re-login prompt", async ({ page, context }) => {
    await page.goto("/home");
    await context.clearCookies();

    await page.goto("/settings");
    await page.waitForURL("**/login**");
    await expect(page.getByText("Your session has expired")).toBeVisible();
  });

  test("signup flow creates account", async ({ page }) => {
    await page.goto("/signup");
    await page.getByLabel("Name").fill("New User");
    await page.getByLabel("Email").fill(`test-${Date.now()}@example.com`);
    await page.getByLabel("Password", { exact: true }).fill("secretPass123");
    await page.getByLabel("Confirm password").fill("secretPass123");
    await page.getByRole("button", { name: "Create account" }).click();

    await page.waitForURL("/onboarding");
    await expect(page.getByText("Welcome, New User")).toBeVisible();
  });
});
```

## Decision Guide

| Scenario                         | Approach                       | Speed    | Isolation      | When to Choose                                                 |
| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |
| Most tests need auth             | Global setup + `storageState`  | Fastest  | Shared session | Default for nearly every project                               |
| Tests modify user state          | Per-worker fixture             | Fast     | Per worker     | Tests update profile, change settings, or mutate data          |
| Multiple user roles              | Per-project `storageState`     | Fastest  | Per role       | App has admin/member/guest roles                               |
| Testing the login page           | No `storageState`              | N/A      | Full           | Use `test.use({ storageState: { cookies: [], origins: [] } })` |
| OAuth/SSO provider               | Mock the callback              | Fast     | Per test       | Never hit real OAuth providers in CI                           |
| MFA is required                  | TOTP generation or bypass      | Moderate | Per test       | Generate real TOTP codes or use a test-mode bypass             |
| Token expires mid-suite          | Session refresh fixture        | Fast     | Per check      | Fixture validates the session before use                       |
| Single test needs different user | `loginAs(role)` fixture        | Moderate | Per call       | Rare: prefer per-project roles                                 |
| API-first app (no login UI)      | API login via `request.post()` | Fastest  | Per test       | No browser needed for auth                                     |

### UI Login vs API Login vs Storage State

```text
Need to test the login page itself?
├── Yes → UI login with LoginPage POM, no storageState
└── No → Do you have a login API endpoint?
    ├── Yes → API login in global setup, save storageState (fastest)
    └── No → UI login in global setup, save storageState
              └── Tokens expire quickly?
                  ├── Yes → Add session refresh fixture
                  └── No → Standard storageState reuse is fine
```

## Anti-Patterns

| Don't Do This                                                             | Problem                                     | Do This Instead                                                           |
| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |
| Log in via UI before every test                                           | Adds 2-5 seconds per test                   | Use `storageState` to skip login entirely                                 |
| Share a single auth state file across parallel workers that mutate state  | Race conditions                             | Use per-worker fixtures with `{ scope: 'worker' }`                        |
| Hardcode credentials in test files                                        | Security risk                               | Use environment variables and `.env` files                                |
| Ignore token expiration                                                   | Tests fail intermittently with 401 errors   | Add a session validity check in your auth fixture                         |
| Hit real OAuth providers in CI                                            | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection                      |
| Use `page.waitForTimeout(2000)` after login                               | Arbitrary delay                             | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |
| Store `.auth/*.json` files in git                                         | Tokens in version control                   | Add `.auth/` to `.gitignore`                                              |
| Create one "god" test account with all permissions                        | Cannot test role-based access control       | Create separate accounts per role                                         |
| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated        | Pass `storageState` when creating the context                             |
| Test MFA by disabling it everywhere                                       | You never test the MFA flow                 | Use TOTP generation for at least one test                                 |

## Troubleshooting

### Global setup fails with "Target page, context or browser has been closed"

**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.

**Fix**:

- Add `await page.waitForURL()` after the login action
- Check that `baseURL` in your config matches the actual server URL and protocol
- Add error handling to global setup:

```typescript
const response = await page.waitForResponse("**/api/auth/**");
if (!response.ok()) {
  throw new Error(
    `Login failed in global setup: ${response.status()} ${await response.text()}`
  );
}
```

### Tests fail with 401 Unauthorized after running for a while

**Cause**: The session token saved in `storageState` has expired.

**Fix**:

- Use the session refresh fixture pattern
- Increase token expiry in test environment configuration
- Switch to API-based login in a worker-scoped fixture

### `storageState` file is empty or contains no cookies

**Cause**: `storageState()` was called before the login response set cookies.

**Fix**:

- Wait for the post-login page to load: `await page.waitForURL('/home')`
- Verify cookies exist before saving:

```typescript
const cookies = await context.cookies();
if (cookies.length === 0) {
  throw new Error("No cookies found after login");
}
await context.storageState({ path: ".auth/session.json" });
```

### Different browsers get different cookies

**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.

**Fix**:

- Generate separate auth state files per browser project
- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:

```typescript
projects: [
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },
  },
  {
    name: 'firefox',
    use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },
  },
],
```

### Parallel tests interfere with each other's sessions

**Cause**: Multiple workers share the same test account and one worker's actions affect others.

**Fix**:

- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`
- Use the per-worker authentication fixture pattern
- Make tests idempotent

### OAuth mock does not work — still redirects to real provider

**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.

**Fix**:

- Register route handlers before any navigation: call `page.route()` before `page.goto()`
- Log the actual redirect URL to verify the pattern:

```typescript
page.on("request", (req) => {
  if (req.url().includes("oauth") || req.url().includes("accounts.provider")) {
    console.log("OAuth request:", req.url());
  }
});
```

## Related

- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown
- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration
- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies
- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking
- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login
- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness
advanced/clock-mocking.md
# Date, Time & Clock Mocking

## Table of Contents

1. [Clock API Basics](#clock-api-basics)
2. [Fixed Time Testing](#fixed-time-testing)
3. [Time Advancement](#time-advancement)
4. [Timezone Testing](#timezone-testing)
5. [Timer Mocking](#timer-mocking)

## Clock API Basics

### Install Clock

```typescript
test("mock current time", async ({ page }) => {
  // Install clock before navigating
  await page.clock.install({ time: new Date("2025-01-15T09:00:00") });

  await page.goto("/dashboard");

  // Page sees January 15, 2025 as current date
  await expect(page.getByText("January 15, 2025")).toBeVisible();
});
```

### Clock with Fixture

```typescript
// fixtures/clock.fixture.ts
import { test as base } from "@playwright/test";

type ClockFixtures = {
  mockTime: (date: Date | string) => Promise<void>;
};

export const test = base.extend<ClockFixtures>({
  mockTime: async ({ page }, use) => {
    await use(async (date) => {
      const time = typeof date === "string" ? new Date(date) : date;
      await page.clock.install({ time });
    });
  },
});

// Usage
test("subscription expiry", async ({ page, mockTime }) => {
  await mockTime("2025-12-31T23:59:00");
  await page.goto("/subscription");

  await expect(page.getByText("Expires today")).toBeVisible();
});
```

## Fixed Time Testing

### Test Date-Dependent Features

```typescript
test("show holiday banner in December", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-12-20T10:00:00") });

  await page.goto("/");

  await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible();
});

test("no holiday banner in January", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15T10:00:00") });

  await page.goto("/");

  await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden();
});
```

### Test Relative Time Display

```typescript
test("shows relative time correctly", async ({ page }) => {
  // Fix time to control "posted 2 hours ago" text
  await page.clock.install({ time: new Date("2025-06-15T14:00:00") });

  // Mock API to return post with known timestamp
  await page.route("**/api/posts/1", (route) =>
    route.fulfill({
      json: {
        id: 1,
        title: "Test Post",
        createdAt: "2025-06-15T12:00:00Z", // 2 hours before mock time
      },
    }),
  );

  await page.goto("/posts/1");

  await expect(page.getByText("2 hours ago")).toBeVisible();
});
```

### Test Date Boundaries

```typescript
test.describe("end of month billing", () => {
  test("shows billing on last day of month", async ({ page }) => {
    await page.clock.install({ time: new Date("2025-01-31T10:00:00") });
    await page.goto("/billing");

    await expect(page.getByText("Payment due today")).toBeVisible();
  });

  test("shows days remaining mid-month", async ({ page }) => {
    await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
    await page.goto("/billing");

    await expect(page.getByText("16 days until payment")).toBeVisible();
  });
});
```

## Time Advancement

### Advance Time Manually

```typescript
test("session timeout warning", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
  await page.goto("/dashboard");

  // Advance 25 minutes (session timeout at 30 min)
  await page.clock.fastForward("25:00");

  await expect(page.getByText("Session expires in 5 minutes")).toBeVisible();

  // Advance 5 more minutes
  await page.clock.fastForward("05:00");

  await expect(page.getByText("Session expired")).toBeVisible();
});
```

### Pause and Resume Time

```typescript
test("countdown timer", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
  await page.goto("/sale");

  // Initial state
  await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible();

  // Advance 1 hour
  await page.clock.fastForward("01:00:00");

  await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible();

  // Advance past end
  await page.clock.fastForward("01:00:01");

  await expect(page.getByText("Sale ended")).toBeVisible();
});
```

### Run Pending Timers

```typescript
test("debounced search", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
  await page.goto("/search");

  await page.getByLabel("Search").fill("playwright");

  // Search is debounced by 300ms, won't fire yet
  await expect(page.getByTestId("search-results")).toBeHidden();

  // Fast forward past debounce
  await page.clock.fastForward(300);

  // Now search should execute
  await expect(page.getByTestId("search-results")).toBeVisible();
});
```

## Timezone Testing

### Test Different Timezones

```typescript
test.describe("timezone display", () => {
  test("shows correct time in PST", async ({ browser }) => {
    const context = await browser.newContext({
      timezoneId: "America/Los_Angeles",
    });
    const page = await context.newPage();

    await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC

    await page.goto("/schedule");

    // Should show 9 AM PST
    await expect(page.getByText("9:00 AM")).toBeVisible();

    await context.close();
  });

  test("shows correct time in JST", async ({ browser }) => {
    const context = await browser.newContext({
      timezoneId: "Asia/Tokyo",
    });
    const page = await context.newPage();

    await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC

    await page.goto("/schedule");

    // Should show 2 AM next day JST
    await expect(page.getByText("2:00 AM")).toBeVisible();

    await context.close();
  });
});
```

### Timezone Fixture

```typescript
// fixtures/timezone.fixture.ts
import { test as base } from "@playwright/test";

type TimezoneFixtures = {
  pageInTimezone: (timezone: string) => Promise<Page>;
};

export const test = base.extend<TimezoneFixtures>({
  pageInTimezone: async ({ browser }, use) => {
    const pages: Page[] = [];

    await use(async (timezone) => {
      const context = await browser.newContext({ timezoneId: timezone });
      const page = await context.newPage();
      pages.push(page);
      return page;
    });

    // Cleanup
    for (const page of pages) {
      await page.context().close();
    }
  },
});
```

## Timer Mocking

### Mock setInterval

```typescript
test("auto-refresh data", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15T09:00:00") });

  let apiCalls = 0;
  await page.route("**/api/data", (route) => {
    apiCalls++;
    route.fulfill({ json: { value: apiCalls } });
  });

  await page.goto("/live-data"); // Sets up 30s refresh interval

  expect(apiCalls).toBe(1); // Initial load

  // Advance 30 seconds
  await page.clock.fastForward("00:30");
  expect(apiCalls).toBe(2); // First refresh

  // Advance another 30 seconds
  await page.clock.fastForward("00:30");
  expect(apiCalls).toBe(3); // Second refresh
});
```

### Mock setTimeout Chains

```typescript
test("notification queue", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
  await page.goto("/notifications");

  // Trigger 3 notifications that show sequentially
  await page.getByRole("button", { name: "Show All" }).click();

  // First notification appears immediately
  await expect(page.getByText("Notification 1")).toBeVisible();

  // Second appears after 2 seconds
  await page.clock.fastForward("00:02");
  await expect(page.getByText("Notification 2")).toBeVisible();

  // Third appears after 2 more seconds
  await page.clock.fastForward("00:02");
  await expect(page.getByText("Notification 3")).toBeVisible();
});
```

### Test Animation Frames

```typescript
test("animation completes", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
  await page.goto("/animation-demo");

  await page.getByRole("button", { name: "Animate" }).click();

  // Animation runs for 500ms
  const element = page.getByTestId("animated-box");
  await expect(element).toHaveCSS("opacity", "0");

  // Fast forward through animation
  await page.clock.fastForward(500);

  await expect(element).toHaveCSS("opacity", "1");
});
```

## Best Practices

### Always Install Clock Before Navigation

```typescript
// Good
test("date test", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-01-15") });
  await page.goto("/"); // Page loads with mocked time
});

// Bad - time already captured by page
test("date test", async ({ page }) => {
  await page.goto("/");
  await page.clock.install({ time: new Date("2025-01-15") }); // Too late!
});
```

### Use ISO Strings for Clarity

```typescript
// Good - explicit timezone
await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") });

// Ambiguous - uses local timezone
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
```

## Anti-Patterns to Avoid

| Anti-Pattern                             | Problem                         | Solution                               |
| ---------------------------------------- | ------------------------------- | -------------------------------------- |
| Installing clock after navigation        | Page already captured real time | Install clock before `goto()`          |
| Hardcoded relative dates                 | Tests break over time           | Use fixed dates with clock mock        |
| Not accounting for timezone              | Tests fail in different regions | Use explicit UTC times or set timezone |
| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers    | Use `fastForward` instead              |

## Related References

- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures
advanced/mobile-testing.md
# Mobile & Responsive Testing

## Table of Contents

1. [Device Emulation](#device-emulation)
2. [Touch Gestures](#touch-gestures)
3. [Viewport Testing](#viewport-testing)
4. [Mobile-Specific UI](#mobile-specific-ui)
5. [Responsive Breakpoints](#responsive-breakpoints)

## Device Emulation

### Use Built-in Devices

```typescript
import { test, devices } from "@playwright/test";

// Configure in playwright.config.ts
export default defineConfig({
  projects: [
    { name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
    { name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
    { name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },
    { name: "Tablet", use: { ...devices["iPad Pro 11"] } },
  ],
});
```

### Custom Device Configuration

```typescript
test.use({
  viewport: { width: 390, height: 844 },
  deviceScaleFactor: 3,
  isMobile: true,
  hasTouch: true,
  userAgent:
    "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
});

test("custom mobile device", async ({ page }) => {
  await page.goto("/");
  // Test runs with custom device settings
});
```

### Test Across Multiple Devices

```typescript
const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"];

for (const deviceName of mobileDevices) {
  test(`checkout on ${deviceName}`, async ({ browser }) => {
    const device = devices[deviceName];
    const context = await browser.newContext({ ...device });
    const page = await context.newPage();

    await page.goto("/checkout");
    await expect(page.getByRole("button", { name: "Pay" })).toBeVisible();

    await context.close();
  });
}
```

## Touch Gestures

### Tap

```typescript
test.use({ hasTouch: true });

test("tap to interact", async ({ page }) => {
  await page.goto("/gallery");

  // Tap is like click but for touch devices
  await page.getByRole("img", { name: "Photo 1" }).tap();

  await expect(page.getByRole("dialog")).toBeVisible();
});
```

### Swipe

```typescript
test("swipe carousel", async ({ page }) => {
  await page.goto("/carousel");

  const carousel = page.getByTestId("carousel");
  const box = await carousel.boundingBox();

  if (box) {
    // Swipe left
    await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2);
    await page.mouse.move(box.x + 50, box.y + box.height / 2);

    // Or use drag
    await carousel.dragTo(carousel, {
      sourcePosition: { x: box.width - 50, y: box.height / 2 },
      targetPosition: { x: 50, y: box.height / 2 },
    });
  }

  await expect(page.getByText("Slide 2")).toBeVisible();
});
```

### Swipe Fixture

```typescript
// fixtures/touch.fixture.ts
import { test as base, Page } from "@playwright/test";

type TouchFixtures = {
  swipe: (
    element: Locator,
    direction: "left" | "right" | "up" | "down",
  ) => Promise<void>;
};

export const test = base.extend<TouchFixtures>({
  swipe: async ({ page }, use) => {
    await use(async (element, direction) => {
      const box = await element.boundingBox();
      if (!box) throw new Error("Element not visible");

      const centerX = box.x + box.width / 2;
      const centerY = box.y + box.height / 2;
      const distance = 100;

      const moves = {
        left: {
          startX: centerX + distance,
          endX: centerX - distance,
          y: centerY,
        },
        right: {
          startX: centerX - distance,
          endX: centerX + distance,
          y: centerY,
        },
        up: {
          startX: centerX,
          endX: centerX,
          startY: centerY + distance,
          endY: centerY - distance,
        },
        down: {
          startX: centerX,
          endX: centerX,
          startY: centerY - distance,
          endY: centerY + distance,
        },
      };

      const move = moves[direction];
      await page.touchscreen.tap(move.startX, move.startY ?? move.y);
      await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 });
      await page.mouse.up();
    });
  },
});

// Usage
test("swipe to delete", async ({ page, swipe }) => {
  await page.goto("/inbox");

  const message = page.getByTestId("message-1");
  await swipe(message, "left");

  await expect(page.getByRole("button", { name: "Delete" })).toBeVisible();
});
```

### Long Press

```typescript
test("long press for context menu", async ({ page }) => {
  await page.goto("/files");

  const file = page.getByText("document.pdf");
  const box = await file.boundingBox();

  if (box) {
    // Touch down
    await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);

    // Hold for 500ms
    await page.waitForTimeout(500);

    // Context menu should appear
    await expect(page.getByRole("menu")).toBeVisible();
  }
});
```

### Pinch Zoom

```typescript
test("pinch to zoom image", async ({ page }) => {
  await page.goto("/map");

  // Pinch zoom requires two touch points
  // Playwright doesn't have native pinch support, so we simulate via evaluate
  await page.evaluate(() => {
    const element = document.querySelector("#map");
    if (element) {
      // Simulate wheel event as fallback for zoom
      element.dispatchEvent(
        new WheelEvent("wheel", {
          deltaY: -100, // Negative = zoom in
          ctrlKey: true, // Ctrl+wheel = pinch on many apps
        }),
      );
    }
  });

  // Or trigger the app's zoom function directly
  await page.evaluate(() => {
    (window as any).mapInstance?.setZoom(15);
  });
});
```

## Viewport Testing

### Test Different Sizes

```typescript
const viewports = [
  { name: "mobile", width: 375, height: 667 },
  { name: "tablet", width: 768, height: 1024 },
  { name: "desktop", width: 1920, height: 1080 },
];

for (const { name, width, height } of viewports) {
  test(`navigation on ${name}`, async ({ page }) => {
    await page.setViewportSize({ width, height });
    await page.goto("/");

    if (width < 768) {
      // Mobile: should have hamburger menu
      await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
    } else {
      // Desktop: should have visible nav links
      await expect(page.getByRole("link", { name: "Products" })).toBeVisible();
    }
  });
}
```

### Dynamic Viewport Changes

```typescript
test("responsive layout change", async ({ page }) => {
  await page.setViewportSize({ width: 1200, height: 800 });
  await page.goto("/dashboard");

  // Desktop: sidebar visible
  await expect(page.getByRole("complementary")).toBeVisible();

  // Resize to mobile
  await page.setViewportSize({ width: 375, height: 667 });

  // Mobile: sidebar hidden, hamburger visible
  await expect(page.getByRole("complementary")).toBeHidden();
  await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
});
```

## Mobile-Specific UI

### Hamburger Menu

```typescript
test("mobile navigation", async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 });
  await page.goto("/");

  // Open hamburger menu
  await page.getByRole("button", { name: "Menu" }).click();

  // Navigation drawer should appear
  const nav = page.getByRole("navigation");
  await expect(nav).toBeVisible();

  // Navigate via mobile menu
  await nav.getByRole("link", { name: "Products" }).click();

  await expect(page).toHaveURL("/products");
  // Menu should close after navigation
  await expect(nav).toBeHidden();
});
```

### Bottom Sheet

```typescript
test("bottom sheet interaction", async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 });
  await page.goto("/product/123");

  await page.getByRole("button", { name: "Add to Cart" }).click();

  // Bottom sheet appears
  const sheet = page.getByRole("dialog");
  await expect(sheet).toBeVisible();

  // Select options
  await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large");
  await sheet.getByRole("button", { name: "Confirm" }).click();

  await expect(page.getByText("Added to cart")).toBeVisible();
});
```

### Pull to Refresh

```typescript
test("pull to refresh", async ({ page }) => {
  await page.goto("/feed");

  const feed = page.getByTestId("feed");
  const initialFirstItem = await feed.locator("> *").first().textContent();

  // Simulate pull down
  const box = await feed.boundingBox();
  if (box) {
    await page.touchscreen.tap(box.x + box.width / 2, box.y + 50);
    await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 });
    await page.mouse.up();
  }

  // Wait for refresh
  await expect(page.getByTestId("loading")).toBeVisible();
  await expect(page.getByTestId("loading")).toBeHidden();

  // Content should be updated (in a real app)
});
```

## Responsive Breakpoints

### Test All Breakpoints

```typescript
const breakpoints = {
  xs: 320,
  sm: 640,
  md: 768,
  lg: 1024,
  xl: 1280,
  "2xl": 1536,
};

test.describe("responsive header", () => {
  for (const [name, width] of Object.entries(breakpoints)) {
    test(`header at ${name} (${width}px)`, async ({ page }) => {
      await page.setViewportSize({ width, height: 800 });
      await page.goto("/");

      if (width < 768) {
        await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
        await expect(page.getByTestId("desktop-nav")).toBeHidden();
      } else {
        await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
        await expect(page.getByTestId("desktop-nav")).toBeVisible();
      }
    });
  }
});
```

### Visual Regression at Breakpoints

```typescript
test.describe("visual regression", () => {
  const sizes = [
    { width: 375, height: 667, name: "mobile" },
    { width: 768, height: 1024, name: "tablet" },
    { width: 1440, height: 900, name: "desktop" },
  ];

  for (const { width, height, name } of sizes) {
    test(`homepage at ${name}`, async ({ page }) => {
      await page.setViewportSize({ width, height });
      await page.goto("/");

      await expect(page).toHaveScreenshot(`homepage-${name}.png`);
    });
  }
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                | Problem                   | Solution                         |
| --------------------------- | ------------------------- | -------------------------------- |
| Only testing one viewport   | Misses responsive bugs    | Test multiple breakpoints        |
| Ignoring touch events       | Features broken on mobile | Test tap, swipe, long press      |
| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()`     |
| Not testing orientation     | Landscape bugs missed     | Test both portrait and landscape |

## Related References

- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing
- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors
- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)
- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements
advanced/multi-context.md
# Multi-Tab, Window & Popup Testing

This file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).

## Table of Contents

1. [Popup Handling](#popup-handling)
2. [New Tab Navigation](#new-tab-navigation)
3. [OAuth Flows](#oauth-flows)
4. [Multiple Windows](#multiple-windows)
5. [Tab Coordination](#tab-coordination)

## Popup Handling

### Basic Popup

```typescript
test("handle popup window", async ({ page }) => {
  await page.goto("/");

  // Start waiting for popup before triggering it
  const popupPromise = page.waitForEvent("popup");
  await page.getByRole("button", { name: "Open Support Chat" }).click();
  const popup = await popupPromise;

  // Wait for popup to load
  await popup.waitForLoadState();

  // Interact with popup
  await popup.getByLabel("Message").fill("Need help");
  await popup.getByRole("button", { name: "Send" }).click();

  await expect(popup.getByText("Message sent")).toBeVisible();

  // Close popup
  await popup.close();
});
```

### Popup with Authentication

```typescript
test("popup login flow", async ({ page }) => {
  await page.goto("/dashboard");

  const popupPromise = page.waitForEvent("popup");
  await page.getByRole("button", { name: "Connect Account" }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();

  // Complete login in popup
  await popup.getByLabel("Email").fill("[email protected]");
  await popup.getByLabel("Password").fill("password123");
  await popup.getByRole("button", { name: "Log In" }).click();

  // Popup should close automatically after auth
  await popup.waitForEvent("close");

  // Main page should reflect connected state
  await expect(page.getByText("Account connected")).toBeVisible();
});
```

### Handle Blocked Popups

```typescript
test("handle popup blocker", async ({ page }) => {
  await page.goto("/share");

  // Listen for console messages about blocked popup
  page.on("console", (msg) => {
    if (msg.text().includes("popup blocked")) {
      console.log("Popup was blocked");
    }
  });

  const popupPromise = page.waitForEvent("popup").catch(() => null);
  await page.getByRole("button", { name: "Share to Twitter" }).click();
  const popup = await popupPromise;

  if (!popup) {
    // Popup blocked - app should show fallback
    await expect(page.getByText("Copy share link instead")).toBeVisible();
  }
});
```

## New Tab Navigation

### Link Opens in New Tab

```typescript
test("external link opens in new tab", async ({ page, context }) => {
  await page.goto("/resources");

  // Wait for new page in context
  const pagePromise = context.waitForEvent("page");
  await page.getByRole("link", { name: "Documentation" }).click();
  const newPage = await pagePromise;

  await newPage.waitForLoadState();

  expect(newPage.url()).toContain("docs.example.com");
  await expect(newPage.getByRole("heading", { level: 1 })).toBeVisible();

  // Original page still there
  expect(page.url()).toContain("/resources");

  await newPage.close();
});
```

### Intercept New Tab

```typescript
test("prevent new tab for testing", async ({ page }) => {
  await page.goto("/links");

  // Remove target="_blank" to keep navigation in same tab
  await page.evaluate(() => {
    document.querySelectorAll('a[target="_blank"]').forEach((a) => {
      a.removeAttribute("target");
    });
  });

  // Now link opens in same tab
  await page.getByRole("link", { name: "External Site" }).click();

  // Can test the destination page
  await expect(page).toHaveURL(/external-site\.com/);
});
```

## OAuth Flows

### Google OAuth Popup

```typescript
test("Google OAuth login", async ({ page }) => {
  await page.goto("/login");

  const popupPromise = page.waitForEvent("popup");
  await page.getByRole("button", { name: "Sign in with Google" }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();

  // Handle Google's OAuth flow
  await popup.getByLabel("Email or phone").fill("[email protected]");
  await popup.getByRole("button", { name: "Next" }).click();

  await popup.getByLabel("Enter your password").fill("password");
  await popup.getByRole("button", { name: "Next" }).click();

  // Wait for redirect back and popup close
  await popup.waitForEvent("close");

  // Verify logged in on main page
  await expect(page.getByText("Welcome, Test User")).toBeVisible();
});
```

### Mock OAuth (Recommended)

```typescript
test("mock OAuth flow", async ({ page, context }) => {
  // Mock the OAuth callback instead of real flow
  await page.route("**/auth/callback**", async (route) => {
    // Simulate successful OAuth
    const url = new URL(route.request().url());
    url.searchParams.set("code", "mock-auth-code");
    await route.fulfill({
      status: 302,
      headers: { Location: "/dashboard" },
    });
  });

  // Mock token exchange
  await page.route("**/api/auth/token", (route) =>
    route.fulfill({
      json: {
        access_token: "mock-token",
        user: { name: "Test User", email: "[email protected]" },
      },
    }),
  );

  await page.goto("/login");
  await page.getByRole("button", { name: "Sign in with Google" }).click();

  // Should redirect to dashboard without actual OAuth
  await expect(page).toHaveURL("/dashboard");
  await expect(page.getByText("Welcome, Test User")).toBeVisible();
});
```

### OAuth Fixture

> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.

## Multiple Windows

### Test Across Multiple Windows

```typescript
test("sync between windows", async ({ context }) => {
  // Open two pages
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  await page1.goto("/dashboard");
  await page2.goto("/dashboard");

  // Make change in first window
  await page1.getByRole("button", { name: "Add Item" }).click();
  await page1.getByLabel("Name").fill("New Item");
  await page1.getByRole("button", { name: "Save" }).click();

  // Should sync to second window (if app supports real-time sync)
  await expect(page2.getByText("New Item")).toBeVisible({ timeout: 10000 });
});
```

### Different Users in Different Windows

> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.

## Tab Coordination

### Switch Between Tabs

```typescript
test("manage multiple tabs", async ({ context }) => {
  const page1 = await context.newPage();
  await page1.goto("/editor");

  const page2 = await context.newPage();
  await page2.goto("/preview");

  // Edit in first tab
  await page1.bringToFront();
  await page1.getByLabel("Content").fill("Hello World");

  // Check preview in second tab
  await page2.bringToFront();
  await page2.reload(); // If preview needs refresh
  await expect(page2.getByText("Hello World")).toBeVisible();
});
```

### Close All Tabs Except One

```typescript
test("cleanup tabs after test", async ({ context }) => {
  const mainPage = await context.newPage();
  await mainPage.goto("/");

  // Open several popups during test
  for (let i = 0; i < 3; i++) {
    const popup = await context.newPage();
    await popup.goto(`/popup/${i}`);
  }

  // Close all except main page
  for (const page of context.pages()) {
    if (page !== mainPage) {
      await page.close();
    }
  }

  expect(context.pages()).toHaveLength(1);
});
```

## Anti-Patterns to Avoid

| Anti-Pattern            | Problem                        | Solution                                   |
| ----------------------- | ------------------------------ | ------------------------------------------ |
| Not waiting for popup   | Race condition                 | Use `waitForEvent("popup")` before trigger |
| Testing real OAuth      | Slow, flaky, needs credentials | Mock OAuth endpoints                       |
| Assuming popup opens    | May be blocked                 | Handle both open and blocked cases         |
| Not closing extra pages | Resource leak                  | Close pages in cleanup                     |

## Related References

- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth
advanced/multi-user.md
# Multi-User & Collaboration Testing

## Table of Contents

1. [Multiple Browser Contexts](#multiple-browser-contexts)
2. [Real-Time Collaboration](#real-time-collaboration)
3. [Role-Based Testing](#role-based-testing)
4. [Concurrent Actions](#concurrent-actions)
5. [Chat & Messaging](#chat--messaging)

## Multiple Browser Contexts

### Two Users in Same Test

```typescript
test("two users see each other's changes", async ({ browser }) => {
  // Create two isolated contexts (like two browsers)
  const userAContext = await browser.newContext();
  const userBContext = await browser.newContext();

  const userAPage = await userAContext.newPage();
  const userBPage = await userBContext.newPage();

  // Both users go to the same document
  await userAPage.goto("/doc/shared-123");
  await userBPage.goto("/doc/shared-123");

  // User A types
  await userAPage.getByLabel("Content").fill("Hello from User A");

  // User B should see the change
  await expect(userBPage.getByText("Hello from User A")).toBeVisible();

  // Cleanup
  await userAContext.close();
  await userBContext.close();
});
```

### Multiple Users with Auth States

```typescript
test("admin and user interaction", async ({ browser }) => {
  // Load different auth states
  const adminContext = await browser.newContext({
    storageState: ".auth/admin.json",
  });
  const userContext = await browser.newContext({
    storageState: ".auth/user.json",
  });

  const adminPage = await adminContext.newPage();
  const userPage = await userContext.newPage();

  // User submits request
  await userPage.goto("/support");
  await userPage.getByLabel("Message").fill("Need help!");
  await userPage.getByRole("button", { name: "Submit" }).click();

  // Admin sees and responds
  await adminPage.goto("/admin/tickets");
  await expect(adminPage.getByText("Need help!")).toBeVisible();
  await adminPage.getByRole("button", { name: "Reply" }).click();
  await adminPage.getByLabel("Response").fill("How can I help?");
  await adminPage.getByRole("button", { name: "Send" }).click();

  // User sees response
  await expect(userPage.getByText("How can I help?")).toBeVisible();

  await adminContext.close();
  await userContext.close();
});
```

### Multi-User Fixture

```typescript
// fixtures/multi-user.fixture.ts
import { test as base, Browser, BrowserContext, Page } from "@playwright/test";

type UserSession = {
  context: BrowserContext;
  page: Page;
};

type MultiUserFixtures = {
  createUser: (authState?: string) => Promise<UserSession>;
};

export const test = base.extend<MultiUserFixtures>({
  createUser: async ({ browser }, use) => {
    const sessions: UserSession[] = [];

    await use(async (authState) => {
      const context = await browser.newContext({
        storageState: authState,
      });
      const page = await context.newPage();
      sessions.push({ context, page });
      return { context, page };
    });

    // Cleanup all sessions
    for (const session of sessions) {
      await session.context.close();
    }
  },
});

// Usage
test("3 users collaborate", async ({ createUser }) => {
  const alice = await createUser(".auth/alice.json");
  const bob = await createUser(".auth/bob.json");
  const charlie = await createUser(".auth/charlie.json");

  // All navigate to same room
  await alice.page.goto("/room/123");
  await bob.page.goto("/room/123");
  await charlie.page.goto("/room/123");

  // Test interactions...
});
```

## Real-Time Collaboration

### Collaborative Document

```typescript
test("real-time collaborative editing", async ({ browser }) => {
  const user1 = await browser.newContext();
  const user2 = await browser.newContext();

  const page1 = await user1.newPage();
  const page2 = await user2.newPage();

  await page1.goto("/docs/shared");
  await page2.goto("/docs/shared");

  // User 1 types at the beginning
  const editor1 = page1.getByRole("textbox");
  await editor1.click();
  await editor1.press("Home");
  await editor1.type("User 1: ");

  // User 2 types at the end
  const editor2 = page2.getByRole("textbox");
  await editor2.click();
  await editor2.press("End");
  await editor2.type(" - User 2");

  // Both should see combined result
  await expect(page1.getByRole("textbox")).toContainText("User 1:");
  await expect(page1.getByRole("textbox")).toContainText("- User 2");
  await expect(page2.getByRole("textbox")).toContainText("User 1:");
  await expect(page2.getByRole("textbox")).toContainText("- User 2");

  await user1.close();
  await user2.close();
});
```

### Cursor Presence

```typescript
test("shows other user cursors", async ({ browser }) => {
  const ctx1 = await browser.newContext();
  const ctx2 = await browser.newContext();

  const page1 = await ctx1.newPage();
  const page2 = await ctx2.newPage();

  // Mock to identify users
  await page1.route("**/api/me", (route) =>
    route.fulfill({ json: { id: "user-1", name: "Alice" } }),
  );
  await page2.route("**/api/me", (route) =>
    route.fulfill({ json: { id: "user-2", name: "Bob" } }),
  );

  await page1.goto("/whiteboard/123");
  await page2.goto("/whiteboard/123");

  // Move cursor on page1
  await page1.mouse.move(200, 200);

  // Page2 should see Alice's cursor
  await expect(page2.getByTestId("cursor-user-1")).toBeVisible();
  await expect(page2.getByText("Alice")).toBeVisible();

  await ctx1.close();
  await ctx2.close();
});
```

## Role-Based Testing

### Test RBAC

```typescript
const roles = [
  { role: "admin", canDelete: true, canEdit: true, canView: true },
  { role: "editor", canDelete: false, canEdit: true, canView: true },
  { role: "viewer", canDelete: false, canEdit: false, canView: true },
];

for (const { role, canDelete, canEdit, canView } of roles) {
  test(`${role} permissions`, async ({ browser }) => {
    const context = await browser.newContext({
      storageState: `.auth/${role}.json`,
    });
    const page = await context.newPage();

    await page.goto("/document/123");

    // Check view permission
    if (canView) {
      await expect(page.getByTestId("content")).toBeVisible();
    } else {
      await expect(page.getByText("Access denied")).toBeVisible();
    }

    // Check edit permission
    const editButton = page.getByRole("button", { name: "Edit" });
    if (canEdit) {
      await expect(editButton).toBeEnabled();
    } else {
      await expect(editButton).toBeDisabled();
    }

    // Check delete permission
    const deleteButton = page.getByRole("button", { name: "Delete" });
    if (canDelete) {
      await expect(deleteButton).toBeVisible();
    } else {
      await expect(deleteButton).toBeHidden();
    }

    await context.close();
  });
}
```

### Permission Escalation Test

```typescript
test("cannot access admin routes as user", async ({ browser }) => {
  const userContext = await browser.newContext({
    storageState: ".auth/user.json",
  });
  const page = await userContext.newPage();

  // Try to access admin page directly
  await page.goto("/admin/users");

  // Should redirect or show error
  await expect(page).not.toHaveURL("/admin/users");
  await expect(page.getByText("Access denied")).toBeVisible();

  await userContext.close();
});
```

## Concurrent Actions

### Race Condition Testing

```typescript
test("handles concurrent edits", async ({ browser }) => {
  const ctx1 = await browser.newContext();
  const ctx2 = await browser.newContext();

  const page1 = await ctx1.newPage();
  const page2 = await ctx2.newPage();

  await page1.goto("/item/123");
  await page2.goto("/item/123");

  // Both click edit at the same time
  await Promise.all([
    page1.getByRole("button", { name: "Edit" }).click(),
    page2.getByRole("button", { name: "Edit" }).click(),
  ]);

  // Both try to save different values
  await page1.getByLabel("Name").fill("Value from User 1");
  await page2.getByLabel("Name").fill("Value from User 2");

  await Promise.all([
    page1.getByRole("button", { name: "Save" }).click(),
    page2.getByRole("button", { name: "Save" }).click(),
  ]);

  // One should succeed, one should get conflict error
  const page1HasConflict = await page1.getByText("Conflict").isVisible();
  const page2HasConflict = await page2.getByText("Conflict").isVisible();

  // Exactly one should have conflict
  expect(page1HasConflict || page2HasConflict).toBe(true);
  expect(page1HasConflict && page2HasConflict).toBe(false);

  await ctx1.close();
  await ctx2.close();
});
```

### Optimistic Locking Test

```typescript
test("optimistic locking prevents overwrites", async ({ browser }) => {
  const ctx1 = await browser.newContext();
  const ctx2 = await browser.newContext();

  const page1 = await ctx1.newPage();
  const page2 = await ctx2.newPage();

  // Both load the same version
  await page1.goto("/record/123");
  await page2.goto("/record/123");

  // User 1 edits and saves first
  await page1.getByRole("button", { name: "Edit" }).click();
  await page1.getByLabel("Value").fill("Updated by User 1");
  await page1.getByRole("button", { name: "Save" }).click();
  await expect(page1.getByText("Saved")).toBeVisible();

  // User 2 tries to save with stale version
  await page2.getByRole("button", { name: "Edit" }).click();
  await page2.getByLabel("Value").fill("Updated by User 2");
  await page2.getByRole("button", { name: "Save" }).click();

  // Should fail with version conflict
  await expect(page2.getByText("Someone else modified this")).toBeVisible();
  await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible();

  await ctx1.close();
  await ctx2.close();
});
```

## Chat & Messaging

### Real-Time Chat

```typescript
test("chat messages sync between users", async ({ browser }) => {
  const aliceCtx = await browser.newContext();
  const bobCtx = await browser.newContext();

  const alicePage = await aliceCtx.newPage();
  const bobPage = await bobCtx.newPage();

  // Setup user identities
  await alicePage.route("**/api/me", (r) =>
    r.fulfill({ json: { name: "Alice" } }),
  );
  await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } }));

  await alicePage.goto("/chat/room-1");
  await bobPage.goto("/chat/room-1");

  // Alice sends message
  await alicePage.getByLabel("Message").fill("Hi Bob!");
  await alicePage.getByRole("button", { name: "Send" }).click();

  // Bob sees it
  await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible();

  // Bob replies
  await bobPage.getByLabel("Message").fill("Hey Alice!");
  await bobPage.getByRole("button", { name: "Send" }).click();

  // Alice sees it
  await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible();

  await aliceCtx.close();
  await bobCtx.close();
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                  | Problem                       | Solution                     |
| ----------------------------- | ----------------------------- | ---------------------------- |
| Sharing context between users | State leaks, not isolated     | Create separate contexts     |
| Not closing contexts          | Memory leak, browser overload | Always close in cleanup      |
| Hardcoded timing for sync     | Flaky tests                   | Use `expect().toBeVisible()` |
| Testing only single user      | Misses collaboration bugs     | Test multi-user scenarios    |

## Related References

- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup
- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
advanced/network-advanced.md
# Advanced Network Interception

## Table of Contents

1. [Request Modification](#request-modification)
2. [GraphQL Mocking](#graphql-mocking)
3. [HAR Recording & Playback](#har-recording--playback)
4. [Conditional Mocking](#conditional-mocking)
5. [Network Throttling](#network-throttling)

## Request Modification

### Modify Request Headers

```typescript
test("add auth header to requests", async ({ page }) => {
  await page.route("**/api/**", (route) => {
    const headers = {
      ...route.request().headers(),
      Authorization: "Bearer test-token",
      "X-Test-Header": "test-value",
    };
    route.continue({ headers });
  });

  await page.goto("/dashboard");
});
```

### Modify Request Body

```typescript
test("modify POST body", async ({ page }) => {
  await page.route("**/api/orders", async (route) => {
    if (route.request().method() === "POST") {
      const postData = route.request().postDataJSON();

      // Add test metadata
      const modifiedData = {
        ...postData,
        testMode: true,
        testTimestamp: Date.now(),
      };

      await route.continue({
        postData: JSON.stringify(modifiedData),
      });
    } else {
      await route.continue();
    }
  });

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Place Order" }).click();
});
```

### Transform Response

```typescript
test("modify API response", async ({ page }) => {
  await page.route("**/api/products", async (route) => {
    // Fetch real response
    const response = await route.fetch();
    const json = await response.json();

    // Modify response
    const modified = json.map((product: any) => ({
      ...product,
      price: product.price * 0.9, // 10% discount
      testMode: true,
    }));

    await route.fulfill({
      response,
      json: modified,
    });
  });

  await page.goto("/products");
});
```

## GraphQL Mocking

### Mock by Operation Name

```typescript
test("mock GraphQL query", async ({ page }) => {
  await page.route("**/graphql", async (route) => {
    const postData = route.request().postDataJSON();

    if (postData.operationName === "GetUser") {
      return route.fulfill({
        json: {
          data: {
            user: {
              id: "1",
              name: "Test User",
              email: "[email protected]",
            },
          },
        },
      });
    }

    if (postData.operationName === "GetProducts") {
      return route.fulfill({
        json: {
          data: {
            products: [
              { id: "1", name: "Product A", price: 29.99 },
              { id: "2", name: "Product B", price: 49.99 },
            ],
          },
        },
      });
    }

    // Pass through unmocked operations
    return route.continue();
  });

  await page.goto("/dashboard");
});
```

### GraphQL Mock Fixture

```typescript
// fixtures/graphql.fixture.ts
type GraphQLMock = {
  operation: string;
  variables?: Record<string, any>;
  response: { data?: any; errors?: any[] };
};

type GraphQLFixtures = {
  mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>;
};

export const test = base.extend<GraphQLFixtures>({
  mockGraphQL: async ({ page }, use) => {
    await use(async (mocks) => {
      await page.route("**/graphql", async (route) => {
        const postData = route.request().postDataJSON();

        const mock = mocks.find((m) => {
          if (m.operation !== postData.operationName) return false;

          // Optionally match variables
          if (m.variables) {
            return (
              JSON.stringify(m.variables) === JSON.stringify(postData.variables)
            );
          }
          return true;
        });

        if (mock) {
          return route.fulfill({ json: mock.response });
        }

        return route.continue();
      });
    });
  },
});

// Usage
test("dashboard with mocked GraphQL", async ({ page, mockGraphQL }) => {
  await mockGraphQL([
    {
      operation: "GetDashboardStats",
      response: {
        data: { stats: { users: 100, revenue: 50000 } },
      },
    },
    {
      operation: "GetUser",
      variables: { id: "1" },
      response: {
        data: { user: { id: "1", name: "John" } },
      },
    },
  ]);

  await page.goto("/dashboard");
  await expect(page.getByText("100 users")).toBeVisible();
});
```

### Mock GraphQL Mutations

```typescript
test("mock GraphQL mutation", async ({ page }) => {
  await page.route("**/graphql", async (route) => {
    const postData = route.request().postDataJSON();

    if (postData.operationName === "CreateOrder") {
      const { input } = postData.variables;

      return route.fulfill({
        json: {
          data: {
            createOrder: {
              id: "order-123",
              status: "PENDING",
              items: input.items,
              total: input.items.reduce(
                (sum: number, item: any) => sum + item.price * item.quantity,
                0,
              ),
            },
          },
        },
      });
    }

    return route.continue();
  });

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Place Order" }).click();

  await expect(page.getByText("Order #order-123")).toBeVisible();
});
```

## HAR Recording & Playback

### Record HAR File

```typescript
// Record network traffic
test("record HAR", async ({ page, context }) => {
  // Start recording
  await context.routeFromHAR("./recordings/checkout.har", {
    update: true, // Create/update HAR file
    url: "**/api/**",
  });

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Place Order" }).click();

  // HAR file is saved automatically
});
```

### Playback HAR File

```typescript
// Use recorded HAR for offline testing
test("playback HAR", async ({ page, context }) => {
  await context.routeFromHAR("./recordings/checkout.har", {
    url: "**/api/**",
    update: false, // Don't update, just playback
  });

  await page.goto("/checkout");

  // All API calls served from HAR file
  await expect(page.getByText("Order confirmed")).toBeVisible();
});
```

### HAR with Fallback

```typescript
test("HAR with live fallback", async ({ page, context }) => {
  await context.routeFromHAR("./recordings/api.har", {
    url: "**/api/**",
    update: false,
    notFound: "fallback", // Use real network if not in HAR
  });

  await page.goto("/dashboard");
});
```

## Conditional Mocking

### Mock Based on Request Body

```typescript
test("conditional mock by body", async ({ page }) => {
  await page.route("**/api/search", async (route) => {
    const body = route.request().postDataJSON();

    if (body.query === "error") {
      return route.fulfill({
        status: 500,
        json: { error: "Search failed" },
      });
    }

    if (body.query === "empty") {
      return route.fulfill({
        json: { results: [] },
      });
    }

    // Default response
    return route.fulfill({
      json: {
        results: [{ id: 1, title: `Result for: ${body.query}` }],
      },
    });
  });

  await page.goto("/search");

  // Test different scenarios
  await page.getByLabel("Search").fill("error");
  await page.getByLabel("Search").press("Enter");
  await expect(page.getByText("Search failed")).toBeVisible();
});
```

### Mock Nth Request

```typescript
test("different response on retry", async ({ page }) => {
  let callCount = 0;

  await page.route("**/api/status", (route) => {
    callCount++;

    if (callCount < 3) {
      return route.fulfill({
        status: 503,
        json: { error: "Service unavailable" },
      });
    }

    // Succeed on 3rd attempt
    return route.fulfill({
      json: { status: "ok" },
    });
  });

  await page.goto("/dashboard");

  // App should retry and eventually succeed
  await expect(page.getByText("Connected")).toBeVisible();
});
```

### Mock with Delay

```typescript
test("slow network simulation", async ({ page }) => {
  await page.route("**/api/data", async (route) => {
    // Simulate 2 second delay
    await new Promise((resolve) => setTimeout(resolve, 2000));

    return route.fulfill({
      json: { data: "loaded" },
    });
  });

  await page.goto("/dashboard");

  // Loading state should appear
  await expect(page.getByText("Loading...")).toBeVisible();

  // Then data appears
  await expect(page.getByText("loaded")).toBeVisible();
});
```

## Network Throttling

### Slow 3G Simulation

```typescript
test("slow network experience", async ({ page, context }) => {
  // Create CDP session for network throttling
  const client = await context.newCDPSession(page);

  await client.send("Network.emulateNetworkConditions", {
    offline: false,
    downloadThroughput: (500 * 1024) / 8, // 500 Kbps
    uploadThroughput: (500 * 1024) / 8,
    latency: 400, // 400ms
  });

  await page.goto("/");

  // Test loading states appear
  await expect(page.getByTestId("skeleton-loader")).toBeVisible();
});
```

### Offline Mode

Use `context.setOffline(true/false)` to simulate network connectivity changes.

> **For comprehensive offline testing patterns:**
>
> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)
> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)

### Network Throttling Fixture

```typescript
// fixtures/network.fixture.ts
type NetworkCondition = "slow3g" | "fast3g" | "offline";

const conditions = {
  slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 },
  fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 },
};

type NetworkFixtures = {
  setNetworkCondition: (condition: NetworkCondition) => Promise<void>;
};

export const test = base.extend<NetworkFixtures>({
  setNetworkCondition: async ({ page, context }, use) => {
    const client = await context.newCDPSession(page);

    await use(async (condition) => {
      if (condition === "offline") {
        await context.setOffline(true);
      } else {
        await client.send("Network.emulateNetworkConditions", {
          offline: false,
          ...conditions[condition],
        });
      }
    });

    // Reset
    await context.setOffline(false);
  },
});
```

## Anti-Patterns to Avoid

| Anti-Pattern             | Problem                        | Solution                         |
| ------------------------ | ------------------------------ | -------------------------------- |
| Mocking all requests     | Tests don't reflect reality    | Mock only what's necessary       |
| No cleanup of routes     | Routes persist across tests    | Use fixtures with cleanup        |
| Ignoring request method  | Mock applies to wrong requests | Check `route.request().method()` |
| Hardcoded mock responses | Brittle, hard to maintain      | Use factories for mock data      |

## Related References

- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking
- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
advanced/third-party.md
# Third-Party Service Mocking

## Table of Contents

1. [OAuth/SSO Mocking](#oauthsso-mocking)
2. [Payment Gateway Mocking](#payment-gateway-mocking)
3. [Email Verification](#email-verification)
4. [SMS Verification](#sms-verification)
5. [Analytics & Tracking](#analytics--tracking)

## OAuth/SSO Mocking

### Mock Google OAuth

```typescript
test("Google OAuth login", async ({ page }) => {
  // Mock the OAuth callback
  await page.route("**/auth/google/callback**", (route) => {
    const url = new URL(route.request().url());
    // Simulate successful OAuth by redirecting with token
    route.fulfill({
      status: 302,
      headers: {
        Location: "/dashboard?token=mock-jwt-token",
      },
    });
  });

  // Mock the token verification endpoint
  await page.route("**/api/auth/verify", (route) =>
    route.fulfill({
      json: {
        valid: true,
        user: {
          id: "123",
          email: "[email protected]",
          name: "Test User",
        },
      },
    }),
  );

  await page.goto("/login");
  await page.getByRole("button", { name: "Sign in with Google" }).click();

  await expect(page.getByText("Welcome, Test User")).toBeVisible();
});
```

### OAuth Fixture

```typescript
// fixtures/oauth.fixture.ts
type OAuthProvider = "google" | "github" | "microsoft";

type OAuthUser = {
  id: string;
  email: string;
  name: string;
  avatar?: string;
};

type OAuthFixtures = {
  mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise<void>;
};

export const test = base.extend<OAuthFixtures>({
  mockOAuth: async ({ page }, use) => {
    await use(async (provider, user) => {
      // Mock callback redirect
      await page.route(`**/auth/${provider}/callback**`, (route) =>
        route.fulfill({
          status: 302,
          headers: { Location: `/auth/success?provider=${provider}` },
        }),
      );

      // Mock session/user endpoint
      await page.route("**/api/auth/session", (route) =>
        route.fulfill({
          json: { user, provider, authenticated: true },
        }),
      );

      // Mock user info endpoint
      await page.route("**/api/me", (route) => route.fulfill({ json: user }));
    });
  },
});

// Usage
test("login with GitHub", async ({ page, mockOAuth }) => {
  await mockOAuth("github", {
    id: "gh-123",
    email: "[email protected]",
    name: "GitHub User",
  });

  await page.goto("/login");
  await page.getByRole("button", { name: "Sign in with GitHub" }).click();

  await expect(page.getByText("Welcome, GitHub User")).toBeVisible();
});
```

### Mock SAML SSO

```typescript
test("SAML SSO login", async ({ page }) => {
  // Mock SAML assertion consumer service
  await page.route("**/saml/acs", async (route) => {
    route.fulfill({
      status: 302,
      headers: {
        Location: "/dashboard",
        "Set-Cookie": "session=mock-saml-session; Path=/; HttpOnly",
      },
    });
  });

  // Mock session validation
  await page.route("**/api/session", (route) =>
    route.fulfill({
      json: {
        user: { email: "[email protected]", name: "SSO User" },
        provider: "saml",
      },
    }),
  );

  await page.goto("/login");
  await page.getByRole("button", { name: "SSO Login" }).click();

  await expect(page).toHaveURL("/dashboard");
});
```

## Payment Gateway Mocking

### Mock Stripe

```typescript
test("Stripe checkout", async ({ page }) => {
  // Mock Stripe.js
  await page.addInitScript(() => {
    (window as any).Stripe = () => ({
      elements: () => ({
        create: () => ({
          mount: () => {},
          on: () => {},
          destroy: () => {},
        }),
      }),
      confirmCardPayment: async () => ({
        paymentIntent: { status: "succeeded", id: "pi_mock_123" },
      }),
      createPaymentMethod: async () => ({
        paymentMethod: { id: "pm_mock_123" },
      }),
    });
  });

  // Mock backend payment endpoint
  await page.route("**/api/create-payment-intent", (route) =>
    route.fulfill({
      json: { clientSecret: "pi_mock_123_secret_mock" },
    }),
  );

  await page.route("**/api/confirm-payment", (route) =>
    route.fulfill({
      json: { success: true, orderId: "order-123" },
    }),
  );

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Pay $99.99" }).click();

  await expect(page.getByText("Payment successful")).toBeVisible();
});
```

### Mock PayPal

```typescript
test("PayPal checkout", async ({ page }) => {
  // Mock PayPal SDK
  await page.addInitScript(() => {
    (window as any).paypal = {
      Buttons: () => ({
        render: () => Promise.resolve(),
        isEligible: () => true,
      }),
      FUNDING: { PAYPAL: "paypal", CARD: "card" },
    };
  });

  // Mock PayPal order creation
  await page.route("**/api/paypal/create-order", (route) =>
    route.fulfill({
      json: { orderId: "PAYPAL-ORDER-123" },
    }),
  );

  // Mock PayPal capture
  await page.route("**/api/paypal/capture", (route) =>
    route.fulfill({
      json: { success: true, transactionId: "TXN-123" },
    }),
  );

  await page.goto("/checkout");

  // Simulate PayPal approval callback
  await page.evaluate(() => {
    (window as any).onPayPalApprove?.({ orderID: "PAYPAL-ORDER-123" });
  });

  await expect(page.getByText("Order confirmed")).toBeVisible();
});
```

### Payment Fixture

```typescript
// fixtures/payment.fixture.ts
type PaymentFixtures = {
  mockStripe: (options?: { failPayment?: boolean }) => Promise<void>;
};

export const test = base.extend<PaymentFixtures>({
  mockStripe: async ({ page }, use) => {
    await use(async (options = {}) => {
      await page.addInitScript(
        ([shouldFail]) => {
          (window as any).Stripe = () => ({
            elements: () => ({
              create: () => ({
                mount: () => {},
                on: (event: string, handler: Function) => {
                  if (event === "ready") setTimeout(handler, 100);
                },
                destroy: () => {},
              }),
            }),
            confirmCardPayment: async () => {
              if (shouldFail) {
                return { error: { message: "Card declined" } };
              }
              return { paymentIntent: { status: "succeeded" } };
            },
          });
        },
        [options.failPayment],
      );
    });
  },
});

// Usage
test("handles declined card", async ({ page, mockStripe }) => {
  await mockStripe({ failPayment: true });

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Pay" }).click();

  await expect(page.getByText("Card declined")).toBeVisible();
});
```

## Email Verification

### Mock Email API

```typescript
test("email verification flow", async ({ page, request }) => {
  let verificationToken: string;

  // Capture the verification email
  await page.route("**/api/send-verification", async (route) => {
    const body = route.request().postDataJSON();
    verificationToken = `mock-token-${Date.now()}`;

    // Don't actually send email, just store token
    route.fulfill({
      json: { sent: true, messageId: "msg-123" },
    });
  });

  // Mock token verification
  await page.route("**/api/verify-email**", (route) => {
    const url = new URL(route.request().url());
    const token = url.searchParams.get("token");

    if (token === verificationToken) {
      route.fulfill({ json: { verified: true } });
    } else {
      route.fulfill({ status: 400, json: { error: "Invalid token" } });
    }
  });

  await page.goto("/signup");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByRole("button", { name: "Sign Up" }).click();

  await expect(page.getByText("Check your email")).toBeVisible();

  // Simulate clicking email link
  await page.goto(`/verify?token=${verificationToken}`);

  await expect(page.getByText("Email verified")).toBeVisible();
});
```

### Use Mailinator/Temp Mail

```typescript
// fixtures/email.fixture.ts
type EmailFixtures = {
  getVerificationEmail: (inbox: string) => Promise<{ link: string }>;
};

export const test = base.extend<EmailFixtures>({
  getVerificationEmail: async ({ request }, use) => {
    await use(async (inbox) => {
      // Poll Mailinator API for new email
      const response = await request.get(
        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,
        {
          headers: {
            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
          },
        },
      );

      const messages = await response.json();
      const latest = messages.msgs[0];

      // Get full message
      const msgResponse = await request.get(
        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,
        {
          headers: {
            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
          },
        },
      );

      const message = await msgResponse.json();

      // Extract verification link from HTML
      const linkMatch = message.parts[0].body.match(
        /href="([^"]*verify[^"]*)"/,
      );
      return { link: linkMatch?.[1] || "" };
    });
  },
});
```

## SMS Verification

### Mock SMS API

```typescript
test("SMS verification", async ({ page }) => {
  let smsCode: string;

  // Capture SMS send
  await page.route("**/api/send-sms", (route) => {
    smsCode = Math.random().toString().slice(2, 8); // 6-digit code

    route.fulfill({
      json: { sent: true, messageId: "sms-123" },
    });
  });

  // Mock code verification
  await page.route("**/api/verify-sms", (route) => {
    const body = route.request().postDataJSON();

    if (body.code === smsCode) {
      route.fulfill({ json: { verified: true } });
    } else {
      route.fulfill({ status: 400, json: { error: "Invalid code" } });
    }
  });

  await page.goto("/verify-phone");
  await page.getByLabel("Phone").fill("+1234567890");
  await page.getByRole("button", { name: "Send Code" }).click();

  // Enter the code
  await page.getByLabel("Verification Code").fill(smsCode);
  await page.getByRole("button", { name: "Verify" }).click();

  await expect(page.getByText("Phone verified")).toBeVisible();
});
```

## Analytics & Tracking

### Block Analytics in Tests

```typescript
test.beforeEach(async ({ page }) => {
  // Block all analytics/tracking
  await page.route(
    /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,
    (route) => route.abort(),
  );
});
```

### Mock Analytics for Verification

```typescript
test("tracks purchase event", async ({ page }) => {
  const analyticsEvents: any[] = [];

  // Capture analytics calls
  await page.route("**/api/analytics/**", (route) => {
    analyticsEvents.push(route.request().postDataJSON());
    route.fulfill({ status: 200 });
  });

  // Mock analytics SDK
  await page.addInitScript(() => {
    (window as any).analytics = {
      track: (event: string, props: any) => {
        fetch("/api/analytics/track", {
          method: "POST",
          body: JSON.stringify({ event, props }),
        });
      },
    };
  });

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Complete Purchase" }).click();

  // Verify analytics event was sent
  expect(analyticsEvents).toContainEqual(
    expect.objectContaining({
      event: "Purchase Completed",
      props: expect.objectContaining({ amount: expect.any(Number) }),
    }),
  );
});
```

## Anti-Patterns to Avoid

| Anti-Pattern              | Problem                        | Solution                |
| ------------------------- | ------------------------------ | ----------------------- |
| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints    |
| Real payment processing   | Charges real money, slow       | Use test mode or mock   |
| Waiting for real emails   | Very slow, unreliable          | Mock email API          |
| Not mocking analytics     | Pollutes analytics data        | Block or mock analytics |

## Related References

- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
architecture/pom-vs-fixtures.md
# Organizing Reusable Test Code

## Table of Contents

1. [Pattern Comparison](#pattern-comparison)
2. [Selection Flowchart](#selection-flowchart)
3. [Page Objects](#page-objects)
4. [Custom Fixtures](#custom-fixtures)
5. [Helper Functions](#helper-functions)
6. [Combined Project Structure](#combined-project-structure)
7. [Anti-Patterns](#anti-patterns)

Use all three patterns together. Most projects benefit from a hybrid approach:

- **Page objects** for UI interaction (pages/components with 5+ interactions)
- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)
- **Helper functions** for stateless utilities (generate data, format values, simple waits)

If only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.

## Pattern Comparison

| Aspect | Page Objects | Custom Fixtures | Helper Functions |
|---|---|---|---|
| **Purpose** | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities |
| **Lifecycle** | Manual (constructor/methods) | Built-in (`use()` with automatic teardown) | None |
| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions |
| **Best for** | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects |

## Selection Flowchart

```text
What kind of reusable code?
|
+-- Interacts with browser page/component?
|   |
|   +-- Has 5+ interactions (fill, click, navigate, assert)?
|   |   +-- YES: Used in 3+ test files?
|   |   |   +-- YES --> PAGE OBJECT
|   |   |   +-- NO --> Inline or small helper
|   |   +-- NO --> HELPER FUNCTION
|   |
|   +-- Needs setup before AND cleanup after test?
|       +-- YES --> CUSTOM FIXTURE
|       +-- NO --> PAGE OBJECT method or HELPER
|
+-- Manages resource with lifecycle (create/destroy)?
|   +-- Examples: auth state, DB connection, API client, test user
|   +-- YES --> CUSTOM FIXTURE (always)
|
+-- Stateless utility? (no browser, no side effects)
|   +-- Examples: random email, format date, build URL, parse response
|   +-- YES --> HELPER FUNCTION
|
+-- Not sure?
    +-- Start with HELPER FUNCTION
    +-- Promote to PAGE OBJECT when interactions grow
    +-- Promote to FIXTURE when lifecycle needed
```

## Page Objects

Best for pages/components with 5+ interactions appearing in 3+ test files.

```typescript
// page-objects/booking.page.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class BookingPage {
  readonly page: Page;
  readonly dateField: Locator;
  readonly guestCount: Locator;
  readonly roomType: Locator;
  readonly reserveBtn: Locator;
  readonly totalPrice: Locator;

  constructor(page: Page) {
    this.page = page;
    this.dateField = page.getByLabel('Check-in date');
    this.guestCount = page.getByLabel('Guests');
    this.roomType = page.getByLabel('Room type');
    this.reserveBtn = page.getByRole('button', { name: 'Reserve' });
    this.totalPrice = page.getByTestId('total-price');
  }

  async goto() {
    await this.page.goto('/booking');
  }

  async fillDetails(opts: { date: string; guests: number; room: string }) {
    await this.dateField.fill(opts.date);
    await this.guestCount.fill(String(opts.guests));
    await this.roomType.selectOption(opts.room);
  }

  async reserve() {
    await this.reserveBtn.click();
    await this.page.waitForURL('**/confirmation');
  }

  async expectPrice(amount: string) {
    await expect(this.totalPrice).toHaveText(amount);
  }
}
```

```typescript
// tests/booking/reservation.spec.ts
import { test, expect } from '@playwright/test';
import { BookingPage } from '../page-objects/booking.page';

test('complete reservation with standard room', async ({ page }) => {
  const booking = new BookingPage(page);
  await booking.goto();
  await booking.fillDetails({ date: '2026-03-15', guests: 2, room: 'standard' });
  await booking.reserve();
  await expect(page.getByText('Reservation confirmed')).toBeVisible();
});
```

**Page object principles:**
- One class per logical page/component, not per URL
- Constructor takes `Page`
- Locators as `readonly` properties in constructor
- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks
- Navigation methods (`goto`) belong on the page object

## Custom Fixtures

Best for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.

```typescript
// fixtures/base.fixture.ts
import { test as base, expect } from '@playwright/test';
import { BookingPage } from '../page-objects/booking.page';
import { generateMember } from '../helpers/data';

type Fixtures = {
  bookingPage: BookingPage;
  member: { email: string; password: string; id: string };
  loggedInPage: import('@playwright/test').Page;
};

export const test = base.extend<Fixtures>({
  bookingPage: async ({ page }, use) => {
    await use(new BookingPage(page));
  },

  member: async ({ request }, use) => {
    const data = generateMember();
    const res = await request.post('/api/test/members', { data });
    const member = await res.json();
    await use(member);
    await request.delete(`/api/test/members/${member.id}`);
  },

  loggedInPage: async ({ page, member }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(member.email);
    await page.getByLabel('Password').fill(member.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page).toHaveURL('/dashboard');
    await use(page);
  },
});

export { expect } from '@playwright/test';
```

```typescript
// tests/dashboard/overview.spec.ts
import { test, expect } from '../../fixtures/base.fixture';

test('member sees dashboard widgets', async ({ loggedInPage }) => {
  await expect(loggedInPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible();
});

test('new member sees welcome prompt', async ({ loggedInPage, member }) => {
  await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible();
});
```

**Fixture principles:**
- Use `test.extend()` — never module-level variables
- `use()` callback separates setup from teardown
- Teardown runs even if test fails
- Fixtures compose: one can depend on another
- Fixtures are lazy: created only when requested
- Wrap page objects in fixtures for lifecycle management

## Helper Functions

Best for stateless utilities — generating test data, formatting values, building URLs, parsing responses.

```typescript
// helpers/data.ts
import { randomUUID } from 'node:crypto';

export function generateEmail(prefix = 'user'): string {
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`;
}

export function generateMember(overrides: Partial<Member> = {}): Member {
  return {
    email: generateEmail(),
    password: 'SecurePass456!',
    name: 'Test Member',
    ...overrides,
  };
}

interface Member {
  email: string;
  password: string;
  name: string;
}

export function formatPrice(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}
```

```typescript
// helpers/assertions.ts
import { type Page, expect } from '@playwright/test';

export async function expectNotification(page: Page, message: string): Promise<void> {
  const notification = page.getByRole('alert').filter({ hasText: message });
  await expect(notification).toBeVisible();
  await expect(notification).toBeHidden({ timeout: 10000 });
}
```

```typescript
// tests/settings/account.spec.ts
import { test, expect } from '@playwright/test';
import { generateEmail } from '../../helpers/data';
import { expectNotification } from '../../helpers/assertions';

test('update account email', async ({ page }) => {
  const newEmail = generateEmail('updated');
  await page.goto('/settings/account');
  await page.getByLabel('Email').fill(newEmail);
  await page.getByRole('button', { name: 'Save' }).click();
  await expectNotification(page, 'Account updated');
  await expect(page.getByLabel('Email')).toHaveValue(newEmail);
});
```

**Helper principles:**
- Pure functions with no side effects
- No browser state — take `page` as parameter if needed
- Promote to fixture if setup/teardown needed
- Promote to page object if many page interactions grow
- Keep small and focused

## Combined Project Structure

```text
tests/
+-- fixtures/
|   +-- auth.fixture.ts
|   +-- db.fixture.ts
|   +-- base.fixture.ts
+-- page-objects/
|   +-- login.page.ts
|   +-- booking.page.ts
|   +-- components/
|       +-- data-table.component.ts
+-- helpers/
|   +-- data.ts
|   +-- assertions.ts
+-- e2e/
|   +-- auth/
|   |   +-- login.spec.ts
|   +-- booking/
|       +-- reservation.spec.ts
playwright.config.ts
```

**Layer responsibilities:**

| Layer | Pattern | Responsibility |
|---|---|---|
| **Test file** | `test()` | Describes behavior, orchestrates layers |
| **Fixtures** | `test.extend()` | Resource lifecycle — setup, provide, teardown |
| **Page objects** | Classes | UI interaction — navigation, actions, locators |
| **Helpers** | Functions | Utilities — data generation, formatting, assertions |

## Anti-Patterns

### Page object managing resources

```typescript
// BAD: page object handling API calls and database
class LoginPage {
  async createUser() { /* API call */ }
  async deleteUser() { /* API call */ }
  async signIn(email: string, password: string) { /* UI */ }
}
```

Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.

### Locator-only page objects

```typescript
// BAD: no methods, just locators
class LoginPage {
  emailInput = this.page.getByLabel('Email');
  passwordInput = this.page.getByLabel('Password');
  submitBtn = this.page.getByRole('button', { name: 'Sign in' });
  constructor(private page: Page) {}
}
```

Add intent-revealing methods or skip the page object entirely.

### Monolithic fixtures

```typescript
// BAD: one fixture doing everything
test.extend({
  everything: async ({ page, request }, use) => {
    const user = await createUser(request);
    const products = await seedProducts(request, 50);
    await setupPayment(request, user.id);
    await page.goto('/dashboard');
    await use({ user, products, page });
    // massive teardown...
  },
});
```

Break into small, composable fixtures. Each fixture does one thing.

### Helpers with side effects

```typescript
// BAD: module-level state
let createdUserId: string;

export async function createTestUser(request: APIRequestContext) {
  const res = await request.post('/api/users', { data: { email: '[email protected]' } });
  const user = await res.json();
  createdUserId = user.id; // shared across tests!
  return user;
}
```

Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.

### Over-abstracting simple operations

```typescript
// BAD: helper for one-liner
export async function clickButton(page: Page, name: string) {
  await page.getByRole('button', { name }).click();
}
```

Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions).
architecture/test-architecture.md
# Choosing Test Types: E2E, Component, or API

## Table of Contents

1. [Decision Matrix](#decision-matrix)
2. [API Tests](#api-tests)
3. [Component Tests](#component-tests)
4. [E2E Tests](#e2e-tests)
5. [Layering Test Types](#layering-test-types)
6. [Common Mistakes](#common-mistakes)
7. [Related](#related)

> **When to use**: Deciding which test type to write for a feature. Ask: "What's the cheapest test that gives confidence this works?"

## Decision Matrix

| Scenario                    | Recommended Type | Rationale                                     |
| --------------------------- | ---------------- | --------------------------------------------- |
| Login / auth flow           | E2E              | Cross-page, cookies, redirects, session state |
| Form submission             | Component        | Isolated validation logic, error states       |
| CRUD operations             | API              | Data integrity matters more than UI           |
| Search with results UI      | Component + API  | API for query logic; component for rendering  |
| Cross-page navigation       | E2E              | Routing, history, deep linking                |
| API error handling          | API              | Status codes, error shapes, edge cases        |
| UI error feedback           | Component        | Toast, banner, inline error rendering         |
| Accessibility               | Component        | ARIA roles, keyboard nav per-component        |
| Responsive layout           | Component        | Viewport-specific rendering without full app  |
| API contract validation     | API              | Response shapes, headers, auth                |
| WebSocket/real-time         | E2E              | Requires full browser environment             |
| Payment / checkout          | E2E              | Multi-step, third-party iframes               |
| Onboarding wizard           | E2E              | Multi-step, state persists across pages       |
| Widget behavior             | Component        | Toggle, accordion, date picker, modal         |
| Permissions / authorization | API              | Role-based access is backend logic            |

## API Tests

**Ideal for**:

- CRUD operations (create, read, update, delete)
- Input validation and error responses (400, 422)
- Permission and authorization checks
- Data integrity and business rules
- API contract verification
- Edge cases expensive to reproduce through UI
- Test data setup/teardown for E2E tests

**Avoid for**:

- Testing how errors display to users
- Browser-specific behavior (cookies, redirects)
- Visual layout or responsive design
- Flows requiring JavaScript execution or DOM interaction
- Third-party iframe interactions

```typescript
import { test, expect } from "@playwright/test";

test.describe("Products API", () => {
  let token: string;

  test.beforeAll(async ({ request }) => {
    const res = await request.post("/api/auth/token", {
      data: { email: "[email protected]", password: "mgr-secret" },
    });
    token = (await res.json()).accessToken;
  });

  test("creates product with valid payload", async ({ request }) => {
    const res = await request.post("/api/products", {
      headers: { Authorization: `Bearer ${token}` },
      data: { name: "Widget Pro", sku: "WGT-100", price: 29.99 },
    });

    expect(res.status()).toBe(201);
    const product = await res.json();
    expect(product).toMatchObject({ name: "Widget Pro", sku: "WGT-100" });
    expect(product).toHaveProperty("id");
  });

  test("rejects duplicate SKU with 409", async ({ request }) => {
    const res = await request.post("/api/products", {
      headers: { Authorization: `Bearer ${token}` },
      data: { name: "Duplicate", sku: "WGT-100", price: 19.99 },
    });

    expect(res.status()).toBe(409);
    expect((await res.json()).message).toContain("already exists");
  });

  test("returns 422 for missing required fields", async ({ request }) => {
    const res = await request.post("/api/products", {
      headers: { Authorization: `Bearer ${token}` },
      data: { name: "Incomplete" },
    });

    expect(res.status()).toBe(422);
    const err = await res.json();
    expect(err.errors).toContainEqual(
      expect.objectContaining({ field: "sku" })
    );
  });

  test("staff role cannot delete products", async ({ request }) => {
    const staffLogin = await request.post("/api/auth/token", {
      data: { email: "[email protected]", password: "staff-pass" },
    });
    const staffToken = (await staffLogin.json()).accessToken;

    const res = await request.delete("/api/products/123", {
      headers: { Authorization: `Bearer ${staffToken}` },
    });

    expect(res.status()).toBe(403);
  });

  test("lists products with pagination", async ({ request }) => {
    const res = await request.get("/api/products", {
      headers: { Authorization: `Bearer ${token}` },
      params: { page: "1", limit: "20" },
    });

    expect(res.status()).toBe(200);
    const body = await res.json();
    expect(body.items).toBeInstanceOf(Array);
    expect(body.items.length).toBeLessThanOrEqual(20);
    expect(body).toHaveProperty("totalCount");
  });
});
```

## Component Tests

**Ideal for**:

- Form validation (required fields, format rules, error messages)
- Interactive widgets (modals, dropdowns, accordions, date pickers)
- Conditional rendering (show/hide, loading states, empty states)
- Accessibility per-component (ARIA attributes, keyboard navigation)
- Responsive layout at different viewports
- Visual states (hover, focus, disabled, selected)

**Avoid for**:

- Testing routing or navigation between pages
- Flows requiring real cookies, sessions, or server-side state
- Data persistence or API contract validation
- Third-party iframe interactions
- Anything requiring multiple pages or browser contexts

```typescript
import { test, expect } from "@playwright/experimental-ct-react";
import { ContactForm } from "../src/components/ContactForm";

test.describe("ContactForm component", () => {
  test("displays validation errors on empty submit", async ({ mount }) => {
    const component = await mount(<ContactForm onSubmit={() => {}} />);

    await component.getByRole("button", { name: "Send message" }).click();

    await expect(component.getByText("Name is required")).toBeVisible();
    await expect(component.getByText("Email is required")).toBeVisible();
  });

  test("rejects malformed email", async ({ mount }) => {
    const component = await mount(<ContactForm onSubmit={() => {}} />);

    await component.getByLabel("Name").fill("Alex");
    await component.getByLabel("Email").fill("invalid-email");
    await component.getByLabel("Message").fill("Hello");
    await component.getByRole("button", { name: "Send message" }).click();

    await expect(component.getByText("Enter a valid email")).toBeVisible();
  });

  test("invokes onSubmit with form data", async ({ mount }) => {
    const submissions: Array<{ name: string; email: string; message: string }> =
      [];
    const component = await mount(
      <ContactForm onSubmit={(data) => submissions.push(data)} />
    );

    await component.getByLabel("Name").fill("Alex");
    await component.getByLabel("Email").fill("[email protected]");
    await component.getByLabel("Message").fill("Inquiry about pricing");
    await component.getByRole("button", { name: "Send message" }).click();

    expect(submissions).toHaveLength(1);
    expect(submissions[0]).toEqual({
      name: "Alex",
      email: "[email protected]",
      message: "Inquiry about pricing",
    });
  });

  test("disables button during submission", async ({ mount }) => {
    const component = await mount(
      <ContactForm onSubmit={() => {}} submitting={true} />
    );

    await expect(
      component.getByRole("button", { name: "Sending..." })
    ).toBeDisabled();
  });

  test("associates labels with inputs for accessibility", async ({ mount }) => {
    const component = await mount(<ContactForm onSubmit={() => {}} />);

    await expect(
      component.getByRole("textbox", { name: "Name" })
    ).toBeVisible();
    await expect(
      component.getByRole("textbox", { name: "Email" })
    ).toBeVisible();
  });
});
```

## E2E Tests

**Ideal for**:

- Critical user flows that generate revenue (checkout, signup)
- Authentication flows (login, SSO, MFA, password reset)
- Multi-page workflows where state carries across navigation
- Flows involving third-party iframes (payment widgets)
- Smoke tests validating the entire stack
- Real-time collaboration requiring multiple browser contexts

**Avoid for**:

- Testing every form validation permutation
- CRUD operations where UI is a thin wrapper
- Verifying individual component states
- Testing API response shapes or error codes
- Responsive layout at every breakpoint
- Edge cases that only affect the backend

```typescript
import { test, expect } from "@playwright/test";

test.describe("subscription flow", () => {
  test.beforeEach(async ({ page }) => {
    await page.request.post("/api/test/seed-account", {
      data: { plan: "free", email: "[email protected]" },
    });
    await page.goto("/account/upgrade");
  });

  test("upgrades to premium plan", async ({ page }) => {
    await test.step("select plan", async () => {
      await expect(
        page.getByRole("heading", { name: "Choose Your Plan" })
      ).toBeVisible();
      await page.getByRole("button", { name: "Select Premium" }).click();
    });

    await test.step("enter billing details", async () => {
      await page.getByLabel("Cardholder name").fill("Sam Johnson");
      await page.getByLabel("Billing address").fill("456 Oak Ave");
      await page.getByLabel("City").fill("Seattle");
      await page.getByRole("combobox", { name: "State" }).selectOption("WA");
      await page.getByLabel("Postal code").fill("98101");
      await page.getByRole("button", { name: "Continue" }).click();
    });

    await test.step("complete payment", async () => {
      const paymentFrame = page.frameLocator('iframe[title="Secure Payment"]');
      await paymentFrame.getByLabel("Card number").fill("5555555555554444");
      await paymentFrame.getByLabel("Expiry").fill("09/29");
      await paymentFrame.getByLabel("CVV").fill("456");
      await page.getByRole("button", { name: "Subscribe now" }).click();
    });

    await test.step("verify success", async () => {
      await page.waitForURL("**/account/subscription/success**");
      await expect(
        page.getByRole("heading", { name: "Welcome to Premium" })
      ).toBeVisible();
      await expect(page.getByText(/Subscription #\d+/)).toBeVisible();
    });
  });
});
```

## Layering Test Types

Effective test suites combine all three types. Example for an "inventory management" feature:

### API Layer (60% of tests)

Cover every backend logic permutation. Cheap to run and maintain.

```
tests/api/inventory.spec.ts
  - creates item with valid data (201)
  - rejects duplicate SKU (409)
  - rejects invalid quantity format (422)
  - rejects missing required fields (422)
  - warehouse-staff cannot delete items (403)
  - unauthenticated request returns 401
  - lists items with pagination
  - filters items by category
  - updates item stock level
  - archives an item
  - prevents archiving items with pending orders
```

### Component Layer (30% of tests)

Cover every visual state and interaction.

```
tests/components/InventoryForm.spec.tsx
  - shows validation errors on empty submit
  - shows inline error for invalid SKU format
  - disables submit while saving
  - calls onSubmit with form data
  - resets form after successful save

tests/components/InventoryTable.spec.tsx
  - renders item rows from props
  - shows empty state when no items
  - handles archive confirmation modal
  - sorts by column header click
  - shows stock level badges with correct colors
```

### E2E Layer (10% of tests)

Cover only critical paths proving full stack works.

```
tests/e2e/inventory.spec.ts
  - manager creates item and sees it in list
  - manager updates item stock level
  - warehouse-staff cannot access admin settings
```

### Execution Profile

For this feature:

- **11 API tests** — ~2 seconds total, no browser
- **10 component tests** — ~5 seconds total, real browser but no server
- **3 E2E tests** — ~15 seconds total, full stack

Total: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).

## Common Mistakes

| Anti-Pattern                              | Problem                                                  | Better Approach                                                |
| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |
| E2E for every validation rule             | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display  |
| No API tests, all E2E                     | Slow suite, flaky from UI timing, hard to diagnose       | API tests for data/logic, E2E for critical paths only          |
| Component tests mocking everything        | Tests pass but app broken because mocks drift            | Mock only external boundaries; API tests verify real contracts |
| Same assertion in API, component, AND E2E | Triple maintenance cost                                  | Each layer tests what it uniquely verifies                     |
| E2E creating test data via UI             | 2-minute test where 90 seconds is setup                  | Seed via API in `beforeEach`, test actual flow                 |
| Testing third-party behavior              | Testing that Stripe validates cards (Stripe's job)       | Mock Stripe; trust their contract                              |
| Skipping API layer                        | Can't tell if bug is frontend or backend                 | API tests isolate backend; component tests isolate frontend    |
| One giant E2E for entire feature          | 5-minute test failing somewhere with no clear cause      | Focused E2E per critical path; use `test.step()`               |

## Related

- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming
- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing
- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests
- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`
- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services
- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic
architecture/when-to-mock.md
# Mocking Strategy: Real vs Mock Services

## Table of Contents

1. [Core Principle](#core-principle)
2. [Decision Matrix](#decision-matrix)
3. [Decision Flowchart](#decision-flowchart)
4. [Mocking Techniques](#mocking-techniques)
5. [Real Service Strategies](#real-service-strategies)
6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)
7. [Validating Mock Accuracy](#validating-mock-accuracy)
8. [Anti-Patterns](#anti-patterns)

> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.

## Core Principle

**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.

## Decision Matrix

| Scenario | Mock? | Strategy |
| --- | --- | --- |
| Your own REST/GraphQL API | Never | Hit real API against staging or local dev |
| Your database (through your API) | Never | Seed via API or fixtures |
| Authentication (your auth system) | Mostly no | Use `storageState` to skip login in most tests |
| Stripe / payment gateway | Always | `route.fulfill()` with expected responses |
| SendGrid / email service | Always | Mock the API call, verify request payload |
| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler |
| Analytics (Segment, Mixpanel) | Always | `route.abort()` or `route.fulfill()` |
| Maps / geocoding APIs | Always | Mock with static responses |
| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states |
| CDN / static assets | Never | Let them load normally |
| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment |
| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config |

## Decision Flowchart

```text
Is this service part of YOUR codebase?
├── YES → Do NOT mock. Test the real integration.
│   ├── Is it slow? → Optimize the service, not the test.
│   └── Is it flaky? → Fix the service. Flaky infra is a bug.
└── NO → It's a third-party service.
    ├── Is it paid per call? → ALWAYS mock.
    ├── Is it rate-limited? → ALWAYS mock.
    ├── Is it slow or unreliable? → ALWAYS mock.
    └── Is it a complex multi-step flow? → Mock with HAR recording.
```

## Mocking Techniques

### Blocking Unwanted Requests

Block third-party scripts that slow tests and add no coverage:

```typescript
test.beforeEach(async ({ page }) => {
  await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {
    route.abort();
  });
});

test('dashboard renders without tracking scripts', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
```

### Full Mock (route.fulfill)

Completely replace a third-party API response:

```typescript
test('order flow with mocked payment service', async ({ page }) => {
  await page.route('**/api/charge', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        transactionId: 'txn_mock_abc',
        status: 'completed',
      }),
    });
  });

  await page.goto('/order/confirm');
  await page.getByRole('button', { name: 'Complete Purchase' }).click();
  await expect(page.getByText('Order confirmed')).toBeVisible();
});

test('display error on payment decline', async ({ page }) => {
  await page.route('**/api/charge', (route) => {
    route.fulfill({
      status: 402,
      contentType: 'application/json',
      body: JSON.stringify({
        error: { code: 'insufficient_funds', message: 'Card declined.' },
      }),
    });
  });

  await page.goto('/order/confirm');
  await page.getByRole('button', { name: 'Complete Purchase' }).click();
  await expect(page.getByRole('alert')).toContainText('Card declined');
});
```

### Partial Mock (Modify Responses)

Let the real API call happen but tweak the response:

```typescript
test('display low inventory warning', async ({ page }) => {
  await page.route('**/api/inventory/*', async (route) => {
    const response = await route.fetch();
    const data = await response.json();

    data.quantity = 1;
    data.lowStock = true;

    await route.fulfill({
      response,
      body: JSON.stringify(data),
    });
  });

  await page.goto('/products/widget-pro');
  await expect(page.getByText('Only 1 remaining')).toBeVisible();
});

test('inject test notification into real response', async ({ page }) => {
  await page.route('**/api/alerts', async (route) => {
    const response = await route.fetch();
    const data = await response.json();

    data.items.push({
      id: 'test-alert',
      text: 'Report generated',
      category: 'info',
    });

    await route.fulfill({
      response,
      body: JSON.stringify(data),
    });
  });

  await page.goto('/home');
  await expect(page.getByText('Report generated')).toBeVisible();
});
```

### Record and Replay (HAR Files)

For complex API sequences (OAuth flows, multi-step wizards):

**Recording:**

```typescript
test('capture API traffic for admin panel', async ({ page }) => {
  await page.routeFromHAR('tests/fixtures/admin-panel.har', {
    url: '**/api/**',
    update: true,
  });

  await page.goto('/admin');
  await page.getByRole('tab', { name: 'Reports' }).click();
  await page.getByRole('tab', { name: 'Settings' }).click();
});
```

**Replaying:**

```typescript
test('admin panel loads with recorded data', async ({ page }) => {
  await page.routeFromHAR('tests/fixtures/admin-panel.har', {
    url: '**/api/**',
    update: false,
  });

  await page.goto('/admin');
  await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
});
```

**HAR maintenance:**

- Record against a known-good staging environment
- Commit `.har` files to version control
- Re-record when APIs change
- Scope HAR to specific URL patterns

## Real Service Strategies

### Local Dev Server

```typescript
// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },
  use: {
    baseURL: 'http://localhost:3000',
  },
});
```

### Staging Environment

```typescript
// playwright.config.ts
export default defineConfig({
  use: {
    baseURL: process.env.CI
      ? 'https://staging.example.com'
      : 'http://localhost:3000',
  },
});
```

### Test Containers

```typescript
// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'docker compose -f docker-compose.test.yml up --wait',
    url: 'http://localhost:3000/health',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
  globalTeardown: './tests/global-teardown.ts',
});
```

```typescript
// tests/global-teardown.ts
import { execSync } from 'child_process';

export default function globalTeardown() {
  if (process.env.CI) {
    execSync('docker compose -f docker-compose.test.yml down -v');
  }
}
```

## Hybrid Approach: Fixture-Based Mock Control

Create fixtures that let individual tests opt into mocking specific services:

```typescript
// tests/fixtures/service-mocks.ts
import { test as base } from '@playwright/test';

type MockConfig = {
  mockPayments: boolean;
  mockNotifications: boolean;
  mockAnalytics: boolean;
};

export const test = base.extend<MockConfig>({
  mockPayments: [true, { option: true }],
  mockNotifications: [true, { option: true }],
  mockAnalytics: [true, { option: true }],

  page: async ({ page, mockPayments, mockNotifications, mockAnalytics }, use) => {
    if (mockPayments) {
      await page.route('**/api/billing/**', (route) => {
        route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({ status: 'paid', id: 'inv_mock_789' }),
        });
      });
    }

    if (mockNotifications) {
      await page.route('**/api/notify', (route) => {
        route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({ delivered: true }),
        });
      });
    }

    if (mockAnalytics) {
      await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {
        route.abort();
      });
    }

    await use(page);
  },
});

export { expect } from '@playwright/test';
```

```typescript
// tests/billing.spec.ts
import { test, expect } from './fixtures/service-mocks';

test('subscription renewal sends notification', async ({ page }) => {
  await page.goto('/account/billing');
  await page.getByRole('button', { name: 'Renew Now' }).click();
  await expect(page.getByText('Subscription renewed')).toBeVisible();
});

test.describe('integration suite', () => {
  test.use({ mockPayments: false });

  test('real billing flow against test gateway', async ({ page }) => {
    await page.goto('/account/billing');
    await page.getByRole('button', { name: 'Renew Now' }).click();
    await expect(page.getByText('Subscription renewed')).toBeVisible();
  });
});
```

### Environment-Based Test Projects

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'ci-fast',
      testMatch: '**/*.spec.ts',
      use: { baseURL: 'http://localhost:3000' },
    },
    {
      name: 'nightly-full',
      testMatch: '**/*.integration.spec.ts',
      use: { baseURL: 'https://staging.example.com' },
      timeout: 120_000,
    },
  ],
});
```

## Validating Mock Accuracy

Guard against mock drift from real APIs:

```typescript
test.describe('contract validation', () => {
  test('billing mock matches real API shape', async ({ request }) => {
    const realResponse = await request.post('/api/billing/charge', {
      data: { amount: 5000, currency: 'usd' },
    });
    const realBody = await realResponse.json();

    const mockBody = {
      status: 'paid',
      id: 'inv_mock_789',
    };

    expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort());

    for (const key of Object.keys(mockBody)) {
      expect(typeof mockBody[key]).toBe(typeof realBody[key]);
    }
  });
});
```

## Anti-Patterns

| Don't Do This | Problem | Do This Instead |
| --- | --- | --- |
| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. |
| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. |
| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. |
| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. |
| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation. | Use `page.route()` which intercepts at network layer. |
| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. |
| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. |
browser-apis/browser-apis.md
# Browser APIs: Geolocation, Permissions & More

## Table of Contents

1. [Geolocation](#geolocation)
2. [Permissions](#permissions)
3. [Clipboard](#clipboard)
4. [Notifications](#notifications)
5. [Camera & Microphone](#camera--microphone)

## Geolocation

### Mock Location

```typescript
test("shows nearby stores", async ({ context }) => {
  // Grant permission and set location
  await context.grantPermissions(["geolocation"]);
  await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); // San Francisco

  const page = await context.newPage();
  await page.goto("/store-finder");
  await page.getByRole("button", { name: "Find Nearby" }).click();

  await expect(page.getByText("San Francisco")).toBeVisible();
});
```

### Geolocation Fixture

```typescript
// fixtures/geolocation.fixture.ts
import { test as base } from "@playwright/test";

type Coordinates = { latitude: number; longitude: number; accuracy?: number };

type GeoFixtures = {
  setLocation: (coords: Coordinates) => Promise<void>;
};

export const test = base.extend<GeoFixtures>({
  setLocation: async ({ context }, use) => {
    await context.grantPermissions(["geolocation"]);

    await use(async (coords) => {
      await context.setGeolocation({
        latitude: coords.latitude,
        longitude: coords.longitude,
        accuracy: coords.accuracy ?? 100,
      });
    });
  },
});

// Usage
test("delivery zone check", async ({ page, setLocation }) => {
  await setLocation({ latitude: 40.7128, longitude: -74.006 }); // NYC

  await page.goto("/delivery");

  await expect(page.getByText("Delivery available")).toBeVisible();
});
```

### Test Location Changes

```typescript
test("tracks location updates", async ({ context }) => {
  await context.grantPermissions(["geolocation"]);

  const page = await context.newPage();
  await page.goto("/tracking");

  // Initial location
  await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
  await page.getByRole("button", { name: "Start Tracking" }).click();

  await expect(page.getByTestId("location")).toContainText("37.7749");

  // Move to new location
  await context.setGeolocation({ latitude: 37.8044, longitude: -122.2712 });

  // Trigger location update
  await page.evaluate(() => {
    navigator.geolocation.getCurrentPosition(() => {});
  });

  await expect(page.getByTestId("location")).toContainText("37.8044");
});
```

### Test Geolocation Denial

```typescript
test("handles location denied", async ({ browser }) => {
  // Create context without geolocation permission
  const context = await browser.newContext({
    permissions: [], // No permissions
  });

  const page = await context.newPage();
  await page.goto("/store-finder");
  await page.getByRole("button", { name: "Find Nearby" }).click();

  await expect(page.getByText("Location access denied")).toBeVisible();
  await expect(page.getByLabel("Enter ZIP code")).toBeVisible();

  await context.close();
});
```

## Permissions

### Grant Permissions

```typescript
test("notifications with permission", async ({ context }) => {
  await context.grantPermissions(["notifications"]);

  const page = await context.newPage();
  await page.goto("/alerts");

  // Notification API should work
  const permission = await page.evaluate(() => Notification.permission);
  expect(permission).toBe("granted");
});
```

### Test Permission Denied

```typescript
test("handles notification permission denied", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: [], // Deny all
  });

  const page = await context.newPage();
  await page.goto("/notifications");

  await page.getByRole("button", { name: "Enable Notifications" }).click();

  await expect(page.getByText("Please enable notifications")).toBeVisible();

  await context.close();
});
```

### Multiple Permissions

```typescript
test("video call with permissions", async ({ context }) => {
  await context.grantPermissions(["camera", "microphone", "notifications"]);

  const page = await context.newPage();
  await page.goto("/video-call");

  // All permissions should be granted
  const permissions = await page.evaluate(async () => ({
    camera: await navigator.permissions.query({
      name: "camera" as PermissionName,
    }),
    microphone: await navigator.permissions.query({
      name: "microphone" as PermissionName,
    }),
  }));

  expect(permissions.camera.state).toBe("granted");
  expect(permissions.microphone.state).toBe("granted");
});
```

## Clipboard

### Test Copy to Clipboard

```typescript
test("copy button works", async ({ page, context }) => {
  // Grant clipboard permissions
  await context.grantPermissions(["clipboard-read", "clipboard-write"]);

  await page.goto("/share");

  await page.getByRole("button", { name: "Copy Link" }).click();

  // Read clipboard content
  const clipboardContent = await page.evaluate(() =>
    navigator.clipboard.readText(),
  );

  expect(clipboardContent).toContain("https://example.com/share/");
});
```

### Test Paste from Clipboard

```typescript
test("paste from clipboard", async ({ page, context }) => {
  await context.grantPermissions(["clipboard-read", "clipboard-write"]);

  await page.goto("/editor");

  // Write to clipboard
  await page.evaluate(() => navigator.clipboard.writeText("Pasted content"));

  // Trigger paste
  await page.getByLabel("Content").focus();
  await page.keyboard.press("Control+V");

  await expect(page.getByLabel("Content")).toHaveValue("Pasted content");
});
```

### Clipboard Fixture

```typescript
// fixtures/clipboard.fixture.ts
import { test as base } from "@playwright/test";

type ClipboardFixtures = {
  clipboard: {
    write: (text: string) => Promise<void>;
    read: () => Promise<string>;
  };
};

export const test = base.extend<ClipboardFixtures>({
  clipboard: async ({ page, context }, use) => {
    await context.grantPermissions(["clipboard-read", "clipboard-write"]);

    await use({
      write: async (text) => {
        await page.evaluate((t) => navigator.clipboard.writeText(t), text);
      },
      read: async () => {
        return page.evaluate(() => navigator.clipboard.readText());
      },
    });
  },
});
```

## Notifications

### Mock Notification API

```typescript
test("shows browser notification", async ({ page }) => {
  const notifications: any[] = [];

  // Mock Notification constructor
  await page.addInitScript(() => {
    (window as any).__notifications = [];
    (window as any).Notification = class {
      constructor(title: string, options?: NotificationOptions) {
        (window as any).__notifications.push({ title, ...options });
      }
      static permission = "granted";
      static requestPermission = async () => "granted";
    };
  });

  await page.goto("/alerts");
  await page.getByRole("button", { name: "Notify Me" }).click();

  // Check notification was created
  const created = await page.evaluate(() => (window as any).__notifications);
  expect(created).toHaveLength(1);
  expect(created[0].title).toBe("New Alert");
});
```

### Test Notification Click

```typescript
test("notification click handler", async ({ page }) => {
  await page.addInitScript(() => {
    (window as any).Notification = class {
      onclick: (() => void) | null = null;
      constructor(title: string) {
        // Simulate click after creation
        setTimeout(() => this.onclick?.(), 100);
      }
      static permission = "granted";
      static requestPermission = async () => "granted";
    };
  });

  await page.goto("/messages");
  await page.evaluate(() => {
    new Notification("New Message");
  });

  // Should navigate to messages when notification clicked
  await expect(page).toHaveURL(/\/messages/);
});
```

## Camera & Microphone

### Mock Media Devices

```typescript
test("video preview works", async ({ page, context }) => {
  await context.grantPermissions(["camera"]);

  // Mock getUserMedia
  await page.addInitScript(() => {
    navigator.mediaDevices.getUserMedia = async () => {
      const canvas = document.createElement("canvas");
      canvas.width = 640;
      canvas.height = 480;
      return canvas.captureStream();
    };
  });

  await page.goto("/video-settings");
  await page.getByRole("button", { name: "Start Camera" }).click();

  await expect(page.getByTestId("video-preview")).toBeVisible();
});
```

### Test Media Device Selection

```typescript
test("switch camera", async ({ page }) => {
  await page.addInitScript(() => {
    navigator.mediaDevices.enumerateDevices = async () =>
      [
        {
          deviceId: "cam1",
          kind: "videoinput",
          label: "Front Camera",
          groupId: "1",
        },
        {
          deviceId: "cam2",
          kind: "videoinput",
          label: "Back Camera",
          groupId: "2",
        },
      ] as MediaDeviceInfo[];

    navigator.mediaDevices.getUserMedia = async () => {
      const canvas = document.createElement("canvas");
      return canvas.captureStream();
    };
  });

  await page.goto("/camera");

  // Should show camera options
  await expect(page.getByRole("combobox", { name: "Camera" })).toBeVisible();
  await expect(page.getByText("Front Camera")).toBeVisible();
  await expect(page.getByText("Back Camera")).toBeVisible();
});
```

### Test Media Errors

```typescript
test("handles camera access error", async ({ page }) => {
  await page.addInitScript(() => {
    navigator.mediaDevices.getUserMedia = async () => {
      throw new DOMException("Permission denied", "NotAllowedError");
    };
  });

  await page.goto("/video-call");
  await page.getByRole("button", { name: "Join Call" }).click();

  await expect(page.getByText("Camera access denied")).toBeVisible();
  await expect(
    page.getByRole("button", { name: "Join Audio Only" }),
  ).toBeVisible();
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                  | Problem                           | Solution                            |
| ----------------------------- | --------------------------------- | ----------------------------------- |
| Not granting permissions      | Tests fail with permission errors | Use `context.grantPermissions()`    |
| Testing real geolocation      | Flaky, environment-dependent      | Mock with `setGeolocation()`        |
| Not testing permission denial | Misses error handling             | Test both granted and denied states |
| Using real camera/mic         | CI has no devices                 | Mock `getUserMedia`                 |

## Related References

- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures
- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation
browser-apis/iframes.md
# iFrame Testing

## Table of Contents

1. [Basic iFrame Access](#basic-iframe-access)
2. [Cross-Origin iFrames](#cross-origin-iframes)
3. [Nested iFrames](#nested-iframes)
4. [Dynamic iFrames](#dynamic-iframes)
5. [iFrame Navigation](#iframe-navigation)
6. [Common Patterns](#common-patterns)

## Basic iFrame Access

### Using frameLocator

```typescript
// Access iframe by selector
const frame = page.frameLocator("iframe#payment");
await frame.getByRole("button", { name: "Pay" }).click();

// Access by name attribute
const namedFrame = page.frameLocator('iframe[name="checkout"]');
await namedFrame.getByLabel("Card number").fill("4242424242424242");

// Access by title
const titledFrame = page.frameLocator('iframe[title="Payment Form"]');

// Access by src (partial match)
const srcFrame = page.frameLocator('iframe[src*="stripe.com"]');
```

### Frame vs FrameLocator

```typescript
// frameLocator - for locator-based operations (recommended)
const frameLocator = page.frameLocator("#my-iframe");
await frameLocator.getByRole("button").click();

// frame() - for Frame object operations (navigation, evaluation)
const frame = page.frame({ name: "my-frame" });
if (frame) {
  await frame.goto("https://example.com");
  const title = await frame.title();
}

// Get all frames
const frames = page.frames();
for (const f of frames) {
  console.log("Frame URL:", f.url());
}
```

### Waiting for iFrame Content

```typescript
// Wait for iframe to load
const frame = page.frameLocator("#dynamic-iframe");

// Wait for element inside iframe
await expect(frame.getByRole("heading")).toBeVisible({ timeout: 10000 });

// Wait for iframe src to change
await page.waitForFunction(() => {
  const iframe = document.querySelector("iframe#my-frame") as HTMLIFrameElement;
  return iframe?.src.includes("loaded");
});
```

## Cross-Origin iFrames

### Accessing Cross-Origin Content

```typescript
// Cross-origin iframes work seamlessly with frameLocator
const thirdPartyFrame = page.frameLocator('iframe[src*="third-party.com"]');

// Interact with elements inside cross-origin iframe
await thirdPartyFrame.getByRole("textbox").fill("[email protected]");
await thirdPartyFrame.getByRole("button", { name: "Submit" }).click();

// Wait for cross-origin iframe to be ready
await expect(thirdPartyFrame.locator("body")).toBeVisible();
```

### Payment Provider iFrames (Stripe, PayPal)

```typescript
test("Stripe payment iframe", async ({ page }) => {
  await page.goto("/checkout");

  // Stripe uses multiple iframes for each field
  const cardFrame = page
    .frameLocator('iframe[name*="__privateStripeFrame"]')
    .first();

  // Wait for Stripe to initialize
  await expect(cardFrame.locator('[placeholder="Card number"]')).toBeVisible({
    timeout: 15000,
  });

  // Fill card details
  await cardFrame
    .locator('[placeholder="Card number"]')
    .fill("4242424242424242");
  await cardFrame.locator('[placeholder="MM / YY"]').fill("12/30");
  await cardFrame.locator('[placeholder="CVC"]').fill("123");
});
```

### Handling OAuth in iFrames

```typescript
test("OAuth iframe flow", async ({ page }) => {
  await page.goto("/login");
  await page.getByRole("button", { name: "Sign in with Google" }).click();

  // If OAuth opens in iframe instead of popup
  const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]');

  // Wait for OAuth form
  await expect(oauthFrame.getByLabel("Email")).toBeVisible({ timeout: 10000 });
  await oauthFrame.getByLabel("Email").fill("[email protected]");
});
```

## Nested iFrames

### Accessing Nested Frames

```typescript
// Parent iframe contains child iframe
const parentFrame = page.frameLocator("#outer-frame");
const childFrame = parentFrame.frameLocator("#inner-frame");

// Interact with deeply nested content
await childFrame.getByRole("button", { name: "Submit" }).click();

// Multiple levels of nesting
const level1 = page.frameLocator("#level1");
const level2 = level1.frameLocator("#level2");
const level3 = level2.frameLocator("#level3");
await level3.getByText("Deep content").click();
```

### Finding Elements Across Frame Hierarchy

```typescript
// Helper to search all frames for an element
async function findInAnyFrame(
  page: Page,
  selector: string,
): Promise<Locator | null> {
  // Check main page first
  const mainCount = await page.locator(selector).count();
  if (mainCount > 0) return page.locator(selector);

  // Check all frames
  for (const frame of page.frames()) {
    const count = await frame.locator(selector).count();
    if (count > 0) {
      return frame.locator(selector);
    }
  }
  return null;
}

test("find element in any frame", async ({ page }) => {
  await page.goto("/complex-page");
  const element = await findInAnyFrame(page, '[data-testid="submit-btn"]');
  if (element) await element.click();
});
```

## Dynamic iFrames

### iFrames Created at Runtime

```typescript
test("handle dynamically created iframe", async ({ page }) => {
  await page.goto("/dashboard");

  // Click button that creates iframe
  await page.getByRole("button", { name: "Open Widget" }).click();

  // Wait for iframe to appear in DOM
  await page.waitForSelector("iframe#widget-frame");

  // Now access the frame
  const widgetFrame = page.frameLocator("#widget-frame");
  await expect(widgetFrame.getByText("Widget Loaded")).toBeVisible();
});
```

### iFrames with Changing src

```typescript
test("iframe src changes", async ({ page }) => {
  await page.goto("/multi-step");

  const frame = page.frameLocator("#step-frame");

  // Step 1
  await expect(frame.getByText("Step 1")).toBeVisible();
  await frame.getByRole("button", { name: "Next" }).click();

  // Wait for iframe to reload with new content
  await expect(frame.getByText("Step 2")).toBeVisible({ timeout: 10000 });
  await frame.getByRole("button", { name: "Next" }).click();

  // Step 3
  await expect(frame.getByText("Step 3")).toBeVisible({ timeout: 10000 });
});
```

### Lazy-Loaded iFrames

```typescript
test("lazy loaded iframe", async ({ page }) => {
  await page.goto("/page-with-lazy-iframe");

  // Scroll to trigger lazy load
  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));

  // Wait for iframe to load
  const lazyFrame = page.frameLocator("#lazy-iframe");
  await expect(lazyFrame.locator("body")).not.toBeEmpty({ timeout: 15000 });

  // Interact with content
  await lazyFrame.getByRole("button").click();
});
```

## iFrame Navigation

### Navigating Within iFrame

```typescript
test("iframe internal navigation", async ({ page }) => {
  await page.goto("/app");

  // Get frame object for navigation control
  const frame = page.frame({ name: "content-frame" });
  if (!frame) throw new Error("Frame not found");

  // Navigate within iframe
  await frame.goto("https://embedded-app.com/page2");

  // Wait for navigation
  await frame.waitForURL("**/page2");

  // Verify content
  await expect(frame.getByRole("heading")).toHaveText("Page 2");
});
```

### Handling Frame Navigation Events

```typescript
test("track iframe navigation", async ({ page }) => {
  const navigations: string[] = [];

  // Listen to frame navigation
  page.on("framenavigated", (frame) => {
    if (frame.parentFrame()) {
      // This is an iframe navigation
      navigations.push(frame.url());
    }
  });

  await page.goto("/with-iframe");
  await page
    .frameLocator("#nav-frame")
    .getByRole("link", { name: "Page 2" })
    .click();

  // Verify navigation occurred
  expect(navigations.some((url) => url.includes("page2"))).toBe(true);
});
```

## Common Patterns

### iFrame Fixture

```typescript
// fixtures.ts
import { test as base, FrameLocator } from "@playwright/test";

export const test = base.extend<{ paymentFrame: FrameLocator }>({
  paymentFrame: async ({ page }, use) => {
    await page.goto("/checkout");

    // Wait for payment iframe to be ready
    const frame = page.frameLocator('iframe[src*="payment"]');
    await expect(frame.locator("body")).toBeVisible({ timeout: 15000 });

    await use(frame);
  },
});

// test file
test("complete payment", async ({ paymentFrame }) => {
  await paymentFrame.getByLabel("Card").fill("4242424242424242");
  await paymentFrame.getByRole("button", { name: "Pay" }).click();
});
```

### Debugging iFrame Issues

```typescript
test("debug iframe content", async ({ page }) => {
  await page.goto("/page-with-iframes");

  // List all frames
  console.log("All frames:");
  for (const frame of page.frames()) {
    console.log(`  - ${frame.name() || "(unnamed)"}: ${frame.url()}`);
  }

  // Screenshot specific iframe content
  const frame = page.frame({ name: "target-frame" });
  if (frame) {
    const body = frame.locator("body");
    await body.screenshot({ path: "iframe-content.png" });
  }

  // Get iframe HTML for debugging
  const frameContent = page.frameLocator("#my-frame");
  const html = await frameContent.locator("body").innerHTML();
  console.log("iFrame HTML:", html.substring(0, 500));
});
```

### Handling iFrame Load Failures

```typescript
test("handle iframe load failure", async ({ page }) => {
  await page.goto("/page-with-unreliable-iframe");

  const frame = page.frameLocator("#unreliable-frame");

  try {
    // Try to interact with iframe content
    await expect(frame.getByRole("button")).toBeVisible({ timeout: 5000 });
    await frame.getByRole("button").click();
  } catch (error) {
    // Fallback: refresh iframe
    await page.evaluate(() => {
      const iframe = document.querySelector(
        "#unreliable-frame",
      ) as HTMLIFrameElement;
      if (iframe) iframe.src = iframe.src;
    });

    // Retry
    await expect(frame.getByRole("button")).toBeVisible({ timeout: 10000 });
    await frame.getByRole("button").click();
  }
});
```

### Mocking iFrame Content

```typescript
test("mock iframe response", async ({ page }) => {
  // Intercept iframe src request
  await page.route("**/embedded-widget**", (route) => {
    route.fulfill({
      contentType: "text/html",
      body: `
        <!DOCTYPE html>
        <html>
          <body>
            <h1>Mocked Widget</h1>
            <button>Mocked Button</button>
          </body>
        </html>
      `,
    });
  });

  await page.goto("/page-with-widget");

  const frame = page.frameLocator("#widget-frame");
  await expect(frame.getByRole("heading")).toHaveText("Mocked Widget");
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                          | Problem                           | Solution                                           |
| ------------------------------------- | --------------------------------- | -------------------------------------------------- |
| Using `page.frame()` for interactions | Less reliable than frameLocator   | Use `page.frameLocator()` for element interactions |
| Hardcoding iframe index               | Fragile if DOM order changes      | Use name, id, or src attribute selectors           |
| Not waiting for iframe load           | Race conditions                   | Wait for element inside iframe to be visible       |
| Assuming same-origin                  | Cross-origin has different timing | Always wait for iframe content explicitly          |
| Ignoring nested iframes               | Element not found                 | Chain frameLocator calls for nested frames         |

## Related References

- **Locators**: See [locators.md](../core/locators.md) for selector strategies
- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues
browser-apis/service-workers.md
# Service Worker Testing

## Table of Contents

1. [Service Worker Basics](#service-worker-basics)
2. [Registration & Lifecycle](#registration--lifecycle)
3. [Cache Testing](#cache-testing)
4. [Offline Testing](#offline-testing)
5. [Push Notifications](#push-notifications)
6. [Background Sync](#background-sync)

## Service Worker Basics

### Waiting for Service Worker Registration

```typescript
test("service worker registers", async ({ page }) => {
  await page.goto("/pwa-app");

  // Wait for SW to register
  const swRegistered = await page.evaluate(async () => {
    if (!("serviceWorker" in navigator)) return false;

    const registration = await navigator.serviceWorker.ready;
    return !!registration.active;
  });

  expect(swRegistered).toBe(true);
});
```

### Getting Service Worker State

```typescript
test("check SW state", async ({ page }) => {
  await page.goto("/");

  const swState = await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.getRegistration();
    if (!registration) return null;

    return {
      installing: !!registration.installing,
      waiting: !!registration.waiting,
      active: !!registration.active,
      scope: registration.scope,
    };
  });

  expect(swState?.active).toBe(true);
  expect(swState?.scope).toContain(page.url());
});
```

### Service Worker Context

```typescript
test("access service worker", async ({ context, page }) => {
  await page.goto("/pwa-app");

  // Get all service workers in context
  const workers = context.serviceWorkers();

  // Wait for service worker if not yet available
  if (workers.length === 0) {
    await context.waitForEvent("serviceworker");
  }

  const sw = context.serviceWorkers()[0];
  expect(sw.url()).toContain("sw.js");
});
```

## Registration & Lifecycle

### Testing SW Update Flow

```typescript
test("service worker updates", async ({ page }) => {
  await page.goto("/pwa-app");

  // Check for update
  const hasUpdate = await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    await registration.update();

    return new Promise<boolean>((resolve) => {
      if (registration.waiting) {
        resolve(true);
      } else {
        registration.addEventListener("updatefound", () => {
          resolve(true);
        });
        // Timeout if no update
        setTimeout(() => resolve(false), 5000);
      }
    });
  });

  // If update found, test skip waiting flow
  if (hasUpdate) {
    await page.evaluate(async () => {
      const registration = await navigator.serviceWorker.ready;
      registration.waiting?.postMessage({ type: "SKIP_WAITING" });
    });

    // Wait for controller change
    await page.evaluate(() => {
      return new Promise<void>((resolve) => {
        navigator.serviceWorker.addEventListener("controllerchange", () => {
          resolve();
        });
      });
    });
  }
});
```

### Testing SW Installation

```typescript
test("verify SW install event", async ({ context, page }) => {
  // Listen for service worker before navigating
  const swPromise = context.waitForEvent("serviceworker");

  await page.goto("/pwa-app");

  const sw = await swPromise;

  // Evaluate in SW context
  const swVersion = await sw.evaluate(() => {
    // Access SW globals
    return (self as any).SW_VERSION || "unknown";
  });

  expect(swVersion).toBe("1.0.0");
});
```

### Unregistering Service Workers

```typescript
test.beforeEach(async ({ page }) => {
  await page.goto("/");

  // Unregister all service workers for clean state
  await page.evaluate(async () => {
    const registrations = await navigator.serviceWorker.getRegistrations();
    await Promise.all(registrations.map((r) => r.unregister()));
  });

  // Clear caches
  await page.evaluate(async () => {
    const cacheNames = await caches.keys();
    await Promise.all(cacheNames.map((name) => caches.delete(name)));
  });
});
```

## Cache Testing

### Verifying Cached Resources

```typescript
test("assets are cached", async ({ page }) => {
  await page.goto("/pwa-app");

  // Wait for SW to cache assets
  await page.evaluate(async () => {
    await navigator.serviceWorker.ready;
  });

  // Check cache contents
  const cachedUrls = await page.evaluate(async () => {
    const cache = await caches.open("app-cache-v1");
    const requests = await cache.keys();
    return requests.map((r) => r.url);
  });

  expect(cachedUrls).toContain(expect.stringContaining("/styles.css"));
  expect(cachedUrls).toContain(expect.stringContaining("/app.js"));
});
```

### Testing Cache Strategies

```typescript
test("cache-first strategy", async ({ page }) => {
  await page.goto("/pwa-app");

  // Wait for initial cache
  await page.waitForFunction(async () => {
    const cache = await caches.open("app-cache-v1");
    const keys = await cache.keys();
    return keys.length > 0;
  });

  // Block network for cached resources
  await page.route("**/styles.css", (route) => route.abort());

  // Reload - should work from cache
  await page.reload();

  // Verify page still styled (CSS loaded from cache)
  const hasStyles = await page.evaluate(() => {
    const body = document.body;
    const styles = window.getComputedStyle(body);
    return styles.fontFamily !== ""; // Has custom font from CSS
  });

  expect(hasStyles).toBe(true);
});
```

### Testing Cache Updates

```typescript
test("cache updates on new version", async ({ page }) => {
  await page.goto("/pwa-app");

  // Get initial cache
  const initialCacheKeys = await page.evaluate(async () => {
    const cache = await caches.open("app-cache-v1");
    const keys = await cache.keys();
    return keys.map((r) => r.url);
  });

  // Simulate app update by mocking SW response
  await page.route("**/sw.js", (route) => {
    route.fulfill({
      contentType: "application/javascript",
      body: `
        const VERSION = 'v2';
        self.addEventListener('install', (e) => {
          e.waitUntil(caches.open('app-cache-v2'));
          self.skipWaiting();
        });
      `,
    });
  });

  // Trigger update
  await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    await reg.update();
  });

  // Verify new cache exists
  await page.waitForFunction(async () => {
    return await caches.has("app-cache-v2");
  });
});
```

## Offline Testing

This section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).

### Simulating Offline Mode

```typescript
test("app works offline", async ({ page, context }) => {
  await page.goto("/pwa-app");

  // Ensure SW is active and content cached
  await page.evaluate(async () => {
    await navigator.serviceWorker.ready;
  });
  await page.waitForTimeout(1000); // Allow caching to complete

  // Go offline
  await context.setOffline(true);

  // Navigate to cached page
  await page.reload();

  // Verify content loads
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();

  // Verify offline indicator
  await expect(page.locator(".offline-badge")).toBeVisible();

  // Go back online
  await context.setOffline(false);
  await expect(page.locator(".offline-badge")).not.toBeVisible();
});
```

### Testing Offline Fallback

```typescript
test("shows offline page for uncached routes", async ({ page, context }) => {
  await page.goto("/pwa-app");
  await page.evaluate(() => navigator.serviceWorker.ready);

  // Go offline
  await context.setOffline(true);

  // Navigate to uncached page
  await page.goto("/uncached-page");

  // Should show offline fallback
  await expect(page.getByText("You are offline")).toBeVisible();
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
```

### Testing Offline Form Submission

```typescript
test("queues form submission offline", async ({ page, context }) => {
  await page.goto("/pwa-app/form");

  // Go offline
  await context.setOffline(true);

  // Submit form
  await page.getByLabel("Message").fill("Offline message");
  await page.getByRole("button", { name: "Send" }).click();

  // Should show queued status
  await expect(page.getByText("Queued for sync")).toBeVisible();

  // Go online
  await context.setOffline(false);

  // Trigger sync (or wait for automatic)
  await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    // Manually trigger sync for testing
    await (reg as any).sync?.register("form-sync");
  });

  // Verify submission completed
  await expect(page.getByText("Message sent")).toBeVisible({ timeout: 10000 });
});
```

## Push Notifications

### Mocking Push Subscription

```typescript
test("handles push subscription", async ({ page, context }) => {
  // Grant notification permission
  await context.grantPermissions(["notifications"]);

  await page.goto("/pwa-app");

  // Subscribe to push
  const subscription = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    const sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: "test-key",
    });
    return sub.toJSON();
  });

  expect(subscription.endpoint).toBeDefined();
});
```

### Testing Push Message Handling

```typescript
test("handles push notification", async ({ context, page }) => {
  await context.grantPermissions(["notifications"]);
  await page.goto("/pwa-app");

  // Wait for SW
  const swPromise = context.waitForEvent("serviceworker");
  const sw = await swPromise;

  // Simulate push message to service worker
  await sw.evaluate(async () => {
    // Dispatch push event
    const pushEvent = new PushEvent("push", {
      data: new PushMessageData(
        JSON.stringify({ title: "Test", body: "Push message" }),
      ),
    });
    self.dispatchEvent(pushEvent);
  });

  // Note: Actual notification display testing is limited in Playwright
  // Focus on verifying the SW handles the push correctly
});
```

### Testing Notification Click

```typescript
test("notification click opens page", async ({ context, page }) => {
  await context.grantPermissions(["notifications"]);
  await page.goto("/pwa-app");

  // Store notification URL target
  let notificationUrl = "";

  // Listen for new pages (notification click opens new page)
  context.on("page", (newPage) => {
    notificationUrl = newPage.url();
  });

  // Trigger notification via SW
  await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    await reg.showNotification("Test", {
      body: "Click me",
      data: { url: "/notification-target" },
    });
  });

  // Simulate clicking notification (via SW)
  const sw = context.serviceWorkers()[0];
  await sw.evaluate(() => {
    self.dispatchEvent(
      new NotificationEvent("notificationclick", {
        notification: { data: { url: "/notification-target" } } as any,
      }),
    );
  });

  // Verify navigation occurred
  await page.waitForTimeout(1000);
  // Check if new page opened or current page navigated
});
```

## Background Sync

### Testing Background Sync Registration

```typescript
test("registers background sync", async ({ page }) => {
  await page.goto("/pwa-app");

  // Register sync
  const syncRegistered = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    if (!("sync" in reg)) return false;

    await (reg as any).sync.register("my-sync");
    return true;
  });

  expect(syncRegistered).toBe(true);
});
```

### Testing Sync Event

```typescript
test("sync event fires when online", async ({ context, page }) => {
  await page.goto("/pwa-app");

  // Queue data while offline
  await context.setOffline(true);

  await page.evaluate(async () => {
    // Store data in IndexedDB for sync
    const db = await openDB();
    await db.put("sync-queue", { id: 1, data: "test" });

    // Register sync
    const reg = await navigator.serviceWorker.ready;
    await (reg as any).sync.register("data-sync");
  });

  // Track sync completion
  await page.evaluate(() => {
    window.syncCompleted = false;
    navigator.serviceWorker.addEventListener("message", (e) => {
      if (e.data.type === "SYNC_COMPLETE") {
        window.syncCompleted = true;
      }
    });
  });

  // Go online
  await context.setOffline(false);

  // Wait for sync to complete
  await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 });
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                   | Problem                 | Solution                                     |
| ------------------------------ | ----------------------- | -------------------------------------------- |
| Not clearing SW between tests  | Tests affect each other | Unregister SW in beforeEach                  |
| Not waiting for SW ready       | Race conditions         | Always await `navigator.serviceWorker.ready` |
| Testing in isolation only      | Misses real SW behavior | Test with actual caching                     |
| Hardcoded timeouts for caching | Flaky tests             | Wait for cache to populate                   |
| Ignoring SW update cycle       | Missing update bugs     | Test install, activate, update flows         |

## Related References

- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns
- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions
- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception
- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns
browser-apis/websockets.md
# WebSocket & Real-Time Testing

## Table of Contents

1. [WebSocket Basics](#websocket-basics)
2. [Mocking WebSocket Messages](#mocking-websocket-messages)
3. [Testing Real-Time Features](#testing-real-time-features)
4. [Server-Sent Events](#server-sent-events)
5. [Reconnection Testing](#reconnection-testing)

## WebSocket Basics

### Wait for WebSocket Connection

```typescript
test("chat connects via websocket", async ({ page }) => {
  // Listen for WebSocket connection
  const wsPromise = page.waitForEvent("websocket");

  await page.goto("/chat");

  const ws = await wsPromise;
  expect(ws.url()).toContain("/ws/chat");

  // Wait for connection to be established
  await ws.waitForEvent("framesent");
});
```

### Monitor WebSocket Messages

```typescript
test("receives real-time updates", async ({ page }) => {
  const messages: string[] = [];

  // Set up listener before navigation
  page.on("websocket", (ws) => {
    ws.on("framereceived", (frame) => {
      messages.push(frame.payload as string);
    });
  });

  await page.goto("/dashboard");

  // Wait for some messages
  await expect.poll(() => messages.length).toBeGreaterThan(0);

  // Verify message format
  const data = JSON.parse(messages[0]);
  expect(data).toHaveProperty("type");
});
```

### Capture Sent Messages

```typescript
test("sends correct message format", async ({ page }) => {
  const sentMessages: string[] = [];

  page.on("websocket", (ws) => {
    ws.on("framesent", (frame) => {
      sentMessages.push(frame.payload as string);
    });
  });

  await page.goto("/chat");
  await page.getByLabel("Message").fill("Hello!");
  await page.getByRole("button", { name: "Send" }).click();

  // Verify sent message
  await expect.poll(() => sentMessages.length).toBeGreaterThan(0);

  const sent = JSON.parse(sentMessages[sentMessages.length - 1]);
  expect(sent).toEqual({
    type: "message",
    content: "Hello!",
  });
});
```

## Mocking WebSocket Messages

### Inject Messages via Page Evaluate

```typescript
test("displays incoming chat message", async ({ page }) => {
  await page.goto("/chat");

  // Wait for WebSocket to be ready
  await page.waitForFunction(
    () => (window as any).chatSocket?.readyState === 1,
  );

  // Simulate incoming message
  await page.evaluate(() => {
    const event = new MessageEvent("message", {
      data: JSON.stringify({
        type: "message",
        from: "Alice",
        content: "Hello there!",
      }),
    });
    (window as any).chatSocket.dispatchEvent(event);
  });

  await expect(page.getByText("Alice: Hello there!")).toBeVisible();
});
```

### Mock WebSocket with Route Handler

```typescript
test("mock websocket entirely", async ({ page, context }) => {
  // Intercept the WebSocket upgrade
  await context.route("**/ws/**", async (route) => {
    // For WebSocket routes, we can't fulfill directly
    // Instead, use page.evaluate to mock the client-side
  });

  // Alternative: Mock at application level
  await page.addInitScript(() => {
    const OriginalWebSocket = window.WebSocket;
    (window as any).WebSocket = function (url: string) {
      const ws = {
        readyState: 1,
        send: (data: string) => {
          console.log("WS Send:", data);
        },
        close: () => {},
        addEventListener: () => {},
        removeEventListener: () => {},
      };
      setTimeout(() => ws.onopen?.(), 100);
      return ws;
    };
  });

  await page.goto("/chat");
});
```

### WebSocket Mock Fixture

```typescript
// fixtures/websocket.fixture.ts
import { test as base, Page } from "@playwright/test";

type WsMessage = { type: string; [key: string]: any };

type WebSocketFixtures = {
  mockWebSocket: {
    injectMessage: (message: WsMessage) => Promise<void>;
    getSentMessages: () => Promise<WsMessage[]>;
  };
};

export const test = base.extend<WebSocketFixtures>({
  mockWebSocket: async ({ page }, use) => {
    const sentMessages: WsMessage[] = [];

    // Capture sent messages
    await page.addInitScript(() => {
      (window as any).__wsSent = [];
      const OriginalWebSocket = window.WebSocket;
      window.WebSocket = function (url: string) {
        const ws = new OriginalWebSocket(url);
        const originalSend = ws.send.bind(ws);
        ws.send = (data: string) => {
          (window as any).__wsSent.push(JSON.parse(data));
          originalSend(data);
        };
        (window as any).__ws = ws;
        return ws;
      } as any;
    });

    await use({
      injectMessage: async (message) => {
        await page.evaluate((msg) => {
          const event = new MessageEvent("message", {
            data: JSON.stringify(msg),
          });
          (window as any).__ws?.dispatchEvent(event);
        }, message);
      },
      getSentMessages: async () => {
        return page.evaluate(() => (window as any).__wsSent || []);
      },
    });
  },
});

// Usage
test("chat with mocked websocket", async ({ page, mockWebSocket }) => {
  await page.goto("/chat");

  // Inject incoming message
  await mockWebSocket.injectMessage({
    type: "message",
    from: "Bob",
    content: "Hi!",
  });

  await expect(page.getByText("Bob: Hi!")).toBeVisible();

  // Send a reply
  await page.getByLabel("Message").fill("Hello Bob!");
  await page.getByRole("button", { name: "Send" }).click();

  // Verify sent message
  const sent = await mockWebSocket.getSentMessages();
  expect(sent).toContainEqual(
    expect.objectContaining({ content: "Hello Bob!" }),
  );
});
```

## Testing Real-Time Features

### Live Notifications

```typescript
test("displays live notification", async ({ page }) => {
  await page.goto("/dashboard");

  // Simulate notification via WebSocket
  await page.evaluate(() => {
    const event = new MessageEvent("message", {
      data: JSON.stringify({
        type: "notification",
        title: "New Order",
        message: "Order #123 received",
      }),
    });
    (window as any).notificationSocket.dispatchEvent(event);
  });

  await expect(page.getByRole("alert")).toContainText("Order #123 received");
});
```

### Live Data Updates

```typescript
test("updates stock price in real-time", async ({ page }) => {
  await page.goto("/stocks/AAPL");

  const priceElement = page.getByTestId("stock-price");
  const initialPrice = await priceElement.textContent();

  // Simulate price update
  await page.evaluate(() => {
    const event = new MessageEvent("message", {
      data: JSON.stringify({
        type: "price_update",
        symbol: "AAPL",
        price: 150.25,
      }),
    });
    (window as any).stockSocket.dispatchEvent(event);
  });

  await expect(priceElement).not.toHaveText(initialPrice!);
  await expect(priceElement).toContainText("150.25");
});
```

### Collaborative Editing

```typescript
test("shows collaborator cursor", async ({ page }) => {
  await page.goto("/document/123");

  // Simulate another user's cursor position
  await page.evaluate(() => {
    const event = new MessageEvent("message", {
      data: JSON.stringify({
        type: "cursor",
        userId: "user-456",
        userName: "Alice",
        position: { x: 100, y: 200 },
      }),
    });
    (window as any).docSocket.dispatchEvent(event);
  });

  await expect(page.getByTestId("cursor-user-456")).toBeVisible();
  await expect(page.getByText("Alice")).toBeVisible();
});
```

## Server-Sent Events

### Test SSE Updates

```typescript
test("receives SSE updates", async ({ page }) => {
  // Mock SSE endpoint
  await page.route("**/api/events", (route) => {
    route.fulfill({
      status: 200,
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
      body: `data: {"type":"update","value":42}\n\n`,
    });
  });

  await page.goto("/live-data");

  await expect(page.getByTestId("value")).toHaveText("42");
});
```

### Simulate Multiple SSE Events

```typescript
test("handles multiple SSE events", async ({ page }) => {
  await page.route("**/api/events", async (route) => {
    const encoder = new TextEncoder();
    const events = [
      `data: {"count":1}\n\n`,
      `data: {"count":2}\n\n`,
      `data: {"count":3}\n\n`,
    ];

    route.fulfill({
      status: 200,
      headers: { "Content-Type": "text/event-stream" },
      body: events.join(""),
    });
  });

  await page.goto("/counter");

  // Should receive all events
  await expect(page.getByTestId("count")).toHaveText("3");
});
```

## Reconnection Testing

### Test Connection Loss

```typescript
test("handles connection loss gracefully", async ({ page }) => {
  await page.goto("/chat");

  // Simulate connection close
  await page.evaluate(() => {
    (window as any).chatSocket.close();
  });

  // Should show disconnected state
  await expect(page.getByText("Reconnecting...")).toBeVisible();
});
```

### Test Reconnection

```typescript
test("reconnects after connection loss", async ({ page }) => {
  await page.goto("/chat");

  // Simulate disconnect
  await page.evaluate(() => {
    (window as any).chatSocket.close();
  });

  await expect(page.getByText("Reconnecting...")).toBeVisible();

  // Simulate reconnection
  await page.evaluate(() => {
    const event = new Event("open");
    (window as any).chatSocket = { readyState: 1 };
    (window as any).chatSocket.dispatchEvent?.(event);
  });

  // Force component to re-check connection
  await page.evaluate(() => {
    window.dispatchEvent(new Event("online"));
  });

  await expect(page.getByText("Connected")).toBeVisible();
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                          | Problem                       | Solution                           |
| ------------------------------------- | ----------------------------- | ---------------------------------- |
| Not waiting for WebSocket ready       | Messages sent too early       | Wait for `readyState === 1`        |
| Testing against real WebSocket server | Flaky, timing-dependent       | Mock WebSocket messages            |
| Ignoring connection state             | Tests pass but feature broken | Test connected/disconnected states |
| No cleanup of listeners               | Memory leaks in tests         | Clean up event listeners           |

## Related References

- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns
- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users
core/annotations.md
# Test Annotations & Organization

## Table of Contents

1. [Skip Annotations](#skip-annotations)
2. [Fixme & Fail Annotations](#fixme--fail-annotations)
3. [Slow Tests](#slow-tests)
4. [Test Steps](#test-steps)
5. [Custom Annotations](#custom-annotations)
6. [Conditional Annotations](#conditional-annotations)

## Skip Annotations

### Basic Skip

```typescript
// Skip unconditionally
test.skip("feature not implemented", async ({ page }) => {
  // This test won't run
});

// Skip with reason
test("payment flow", async ({ page }) => {
  test.skip(true, "Payment gateway in maintenance");
  // Test body won't execute
});
```

### Conditional Skip

```typescript
test("webkit-specific feature", async ({ page, browserName }) => {
  test.skip(browserName !== "webkit", "This feature only works in WebKit");

  await page.goto("/webkit-feature");
});

test("production only", async ({ page }) => {
  test.skip(process.env.ENV !== "production", "Only runs against production");

  await page.goto("/prod-feature");
});
```

### Skip by Platform

```typescript
test("windows-specific", async ({ page }) => {
  test.skip(process.platform !== "win32", "Windows only");
});

test("not on CI", async ({ page }) => {
  test.skip(!!process.env.CI, "Skipped in CI environment");
});
```

### Skip Describe Block

```typescript
test.describe("Admin features", () => {
  test.skip(
    ({ browserName }) => browserName === "firefox",
    "Firefox admin bug",
  );

  test("admin dashboard", async ({ page }) => {
    // Skipped in Firefox
  });

  test("admin settings", async ({ page }) => {
    // Skipped in Firefox
  });
});
```

## Fixme & Fail Annotations

### Fixme - Known Issues

```typescript
// Mark test as needing fix (skips the test)
test.fixme("broken after refactor", async ({ page }) => {
  // Test won't run but is tracked
});

// Conditional fixme
test("flaky on CI", async ({ page }) => {
  test.fixme(!!process.env.CI, "Investigate CI flakiness - ticket #123");

  await page.goto("/flaky-feature");
});
```

### Fail - Expected Failures

```typescript
// Test is expected to fail (runs but expects failure)
test("known bug", async ({ page }) => {
  test.fail();

  await page.goto("/buggy-page");
  // If this passes, the test fails (bug was fixed!)
  await expect(page.getByText("Working")).toBeVisible();
});

// Conditional fail
test("fails on webkit", async ({ page, browserName }) => {
  test.fail(browserName === "webkit", "WebKit rendering bug #456");

  await page.goto("/render-test");
  await expect(page.getByTestId("element")).toHaveCSS("width", "100px");
});
```

### Difference Between Skip, Fixme, Fail

| Annotation     | Runs? | Use Case                         |
| -------------- | ----- | -------------------------------- |
| `test.skip()`  | No    | Feature not applicable           |
| `test.fixme()` | No    | Known bug, needs investigation   |
| `test.fail()`  | Yes   | Expected to fail, tracking a bug |

## Slow Tests

### Mark Slow Tests

```typescript
// Triple the default timeout
test("large data import", async ({ page }) => {
  test.slow();

  await page.goto("/import");
  await page.setInputFiles("#file", "large-file.csv");
  await page.getByRole("button", { name: "Import" }).click();

  await expect(page.getByText("Import complete")).toBeVisible();
});

// Conditional slow
test("video processing", async ({ page, browserName }) => {
  test.slow(browserName === "webkit", "WebKit video processing is slow");

  await page.goto("/video-editor");
});
```

### Custom Timeout

```typescript
test("very long operation", async ({ page }) => {
  // Set specific timeout (in milliseconds)
  test.setTimeout(120000); // 2 minutes

  await page.goto("/long-operation");
});

// Timeout for describe block
test.describe("Integration tests", () => {
  test.describe.configure({ timeout: 60000 });

  test("test 1", async ({ page }) => {
    // Has 60 second timeout
  });
});
```

## Test Steps

### Basic Steps

```typescript
test("checkout flow", async ({ page }) => {
  await test.step("Add item to cart", async () => {
    await page.goto("/products");
    await page.getByRole("button", { name: "Add to Cart" }).click();
  });

  await test.step("Go to checkout", async () => {
    await page.getByRole("link", { name: "Cart" }).click();
    await page.getByRole("button", { name: "Checkout" }).click();
  });

  await test.step("Fill shipping info", async () => {
    await page.getByLabel("Address").fill("123 Test St");
    await page.getByLabel("City").fill("Test City");
  });

  await test.step("Complete payment", async () => {
    await page.getByLabel("Card").fill("4242424242424242");
    await page.getByRole("button", { name: "Pay" }).click();
  });

  await expect(page.getByText("Order confirmed")).toBeVisible();
});
```

### Nested Steps

```typescript
test("user registration", async ({ page }) => {
  await test.step("Fill registration form", async () => {
    await page.goto("/register");

    await test.step("Personal info", async () => {
      await page.getByLabel("Name").fill("John Doe");
      await page.getByLabel("Email").fill("[email protected]");
    });

    await test.step("Security", async () => {
      await page.getByLabel("Password").fill("SecurePass123");
      await page.getByLabel("Confirm Password").fill("SecurePass123");
    });
  });

  await test.step("Submit and verify", async () => {
    await page.getByRole("button", { name: "Register" }).click();
    await expect(page.getByText("Welcome")).toBeVisible();
  });
});
```

### Steps with Return Values

```typescript
test("verify order", async ({ page }) => {
  const orderId = await test.step("Create order", async () => {
    await page.goto("/checkout");
    await page.getByRole("button", { name: "Place Order" }).click();

    // Return value from step
    return await page.getByTestId("order-id").textContent();
  });

  await test.step("Verify order details", async () => {
    await page.goto(`/orders/${orderId}`);
    await expect(page.getByText(`Order #${orderId}`)).toBeVisible();
  });
});
```

### Step in Page Object

```typescript
// pages/checkout.page.ts
export class CheckoutPage {
  async fillShippingInfo(address: string, city: string) {
    await test.step("Fill shipping information", async () => {
      await this.page.getByLabel("Address").fill(address);
      await this.page.getByLabel("City").fill(city);
    });
  }

  async completePayment(cardNumber: string) {
    await test.step("Complete payment", async () => {
      await this.page.getByLabel("Card").fill(cardNumber);
      await this.page.getByRole("button", { name: "Pay" }).click();
    });
  }
}
```

## Custom Annotations

### Add Annotations

```typescript
test("important feature", async ({ page }, testInfo) => {
  // Add custom annotation
  testInfo.annotations.push({
    type: "priority",
    description: "high",
  });

  testInfo.annotations.push({
    type: "ticket",
    description: "JIRA-123",
  });

  await page.goto("/feature");
});
```

### Annotation Fixture

```typescript
// fixtures/annotations.fixture.ts
import { test as base, TestInfo } from "@playwright/test";

type AnnotationFixtures = {
  annotate: {
    ticket: (id: string) => void;
    priority: (level: "low" | "medium" | "high") => void;
    owner: (name: string) => void;
  };
};

export const test = base.extend<AnnotationFixtures>({
  annotate: async ({}, use, testInfo) => {
    await use({
      ticket: (id) => {
        testInfo.annotations.push({ type: "ticket", description: id });
      },
      priority: (level) => {
        testInfo.annotations.push({ type: "priority", description: level });
      },
      owner: (name) => {
        testInfo.annotations.push({ type: "owner", description: name });
      },
    });
  },
});

// Usage
test("critical feature", async ({ page, annotate }) => {
  annotate.ticket("JIRA-456");
  annotate.priority("high");
  annotate.owner("Alice");

  await page.goto("/critical");
});
```

### Read Annotations in Reporter

```typescript
// reporters/annotation-reporter.ts
import { Reporter, TestCase, TestResult } from "@playwright/test/reporter";

class AnnotationReporter implements Reporter {
  onTestEnd(test: TestCase, result: TestResult) {
    const ticket = test.annotations.find((a) => a.type === "ticket");
    const priority = test.annotations.find((a) => a.type === "priority");

    if (ticket) {
      console.log(`Test linked to: ${ticket.description}`);
    }

    if (priority?.description === "high" && result.status === "failed") {
      console.log(`HIGH PRIORITY FAILURE: ${test.title}`);
    }
  }
}

export default AnnotationReporter;
```

## Conditional Annotations

### Annotation Helper

```typescript
// helpers/test-annotations.ts
import { test } from "@playwright/test";

export function skipInCI(reason = "Skipped in CI") {
  test.skip(!!process.env.CI, reason);
}

export function skipInBrowser(browser: string, reason: string) {
  test.beforeEach(({ browserName }) => {
    test.skip(browserName === browser, reason);
  });
}

export function onlyInEnv(env: string) {
  test.skip(process.env.ENV !== env, `Only runs in ${env}`);
}
```

```typescript
// tests/feature.spec.ts
import { skipInCI, onlyInEnv } from "../helpers/test-annotations";

test("local only feature", async ({ page }) => {
  skipInCI("Uses local resources");

  await page.goto("/local-feature");
});

test("production check", async ({ page }) => {
  onlyInEnv("production");

  await page.goto("/prod-only");
});
```

### Describe-Level Conditions

```typescript
test.describe("Mobile features", () => {
  test.beforeEach(({ isMobile }) => {
    test.skip(!isMobile, "Mobile only tests");
  });

  test("touch gestures", async ({ page }) => {
    // Only runs on mobile
  });
});

test.describe("Desktop features", () => {
  test.beforeEach(({ isMobile }) => {
    test.skip(isMobile, "Desktop only tests");
  });

  test("hover interactions", async ({ page }) => {
    // Only runs on desktop
  });
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                | Problem                | Solution                         |
| --------------------------- | ---------------------- | -------------------------------- |
| Skipping without reason     | Hard to track why      | Always provide description       |
| Too many skipped tests      | Test debt accumulates  | Review and clean up regularly    |
| Using skip instead of fixme | Loses intent           | Use fixme for bugs, skip for N/A |
| Not using steps             | Hard to debug failures | Group logical actions in steps   |

## Related References

- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`
- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting
core/assertions-waiting.md
# Assertions & Waiting

## Table of Contents

1. [Web-First Assertions](#web-first-assertions)
2. [Generic Assertions](#generic-assertions)
3. [Soft Assertions](#soft-assertions)
4. [Waiting Strategies](#waiting-strategies)
5. [Polling & Retrying](#polling--retrying)
6. [Custom Matchers](#custom-matchers)

## Web-First Assertions

Auto-retry until condition is met or timeout. Always prefer these over generic assertions.

### Locator Assertions

```typescript
import { expect } from "@playwright/test";

// Visibility
await expect(page.getByRole("button")).toBeVisible();
await expect(page.getByRole("button")).toBeHidden();
await expect(page.getByRole("button")).not.toBeVisible();

// Enabled/Disabled
await expect(page.getByRole("button")).toBeEnabled();
await expect(page.getByRole("button")).toBeDisabled();

// Text content
await expect(page.getByRole("heading")).toHaveText("Welcome");
await expect(page.getByRole("heading")).toHaveText(/welcome/i);
await expect(page.getByRole("heading")).toContainText("Welcome");

// Count
await expect(page.getByRole("listitem")).toHaveCount(5);

// Attributes
await expect(page.getByRole("link")).toHaveAttribute("href", "/home");
await expect(page.getByRole("img")).toHaveAttribute("alt", /logo/i);

// CSS
await expect(page.getByRole("button")).toHaveClass(/primary/);
await expect(page.getByRole("button")).toHaveCSS("color", "rgb(0, 0, 255)");

// Input values
await expect(page.getByLabel("Email")).toHaveValue("[email protected]");
await expect(page.getByLabel("Email")).toBeEmpty();

// Focus
await expect(page.getByLabel("Email")).toBeFocused();

// Checked state
await expect(page.getByRole("checkbox")).toBeChecked();
await expect(page.getByRole("checkbox")).not.toBeChecked();

// Editable state
await expect(page.getByLabel("Name")).toBeEditable();
```

### Page Assertions

```typescript
// URL
await expect(page).toHaveURL("/dashboard");
await expect(page).toHaveURL(/\/dashboard/);

// Title
await expect(page).toHaveTitle("Dashboard - MyApp");
await expect(page).toHaveTitle(/dashboard/i);
```

### Response Assertions

```typescript
const response = await page.request.get("/api/users");
await expect(response).toBeOK();
await expect(response).not.toBeOK();
```

## Generic Assertions

Use for non-UI values. Do NOT retry - execute immediately.

```typescript
// Equality
expect(value).toBe(5);
expect(object).toEqual({ name: "Test" });
expect(array).toContain("item");

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Numbers
expect(value).toBeGreaterThan(5);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(5.5, 1);

// Strings
expect(string).toMatch(/pattern/);
expect(string).toContain("substring");

// Arrays/Objects
expect(array).toHaveLength(3);
expect(object).toHaveProperty("key", "value");

// Exceptions
expect(() => fn()).toThrow();
expect(() => fn()).toThrow("error message");
await expect(asyncFn()).rejects.toThrow();
```

## Soft Assertions

Continue test execution after failure, report all failures at end.

```typescript
test("check multiple elements", async ({ page }) => {
  await page.goto("/dashboard");

  // Won't stop on first failure
  await expect.soft(page.getByRole("heading")).toHaveText("Dashboard");
  await expect.soft(page.getByRole("button", { name: "Save" })).toBeEnabled();
  await expect.soft(page.getByText("Welcome")).toBeVisible();

  // Test continues; all failures reported at end
});
```

### Soft Assertions with Early Exit

```typescript
test("check form", async ({ page }) => {
  await expect.soft(page.getByRole("form")).toBeVisible();

  // Exit early if form not visible (pointless to check fields)
  if (expect.soft.hasFailures()) {
    return;
  }

  await expect.soft(page.getByLabel("Name")).toBeVisible();
  await expect.soft(page.getByLabel("Email")).toBeVisible();
});
```

## Waiting Strategies

### Auto-Waiting (Default)

Actions automatically wait for:

- Element to be attached to DOM
- Element to be visible
- Element to be stable (no animations)
- Element to be enabled
- Element to receive events

```typescript
// These auto-wait
await page.click("button");
await page.fill("input", "text");
await page.getByRole("button").click();
```

### Wait for Navigation

```typescript
// Wait for URL change
await page.waitForURL("/dashboard");
await page.waitForURL(/\/dashboard/);

// Wait for navigation after action
await Promise.all([
  page.waitForURL("**/dashboard"),
  page.click('a[href="/dashboard"]'),
]);

// Or without Promise.all
const urlPromise = page.waitForURL("**/dashboard");
await page.click("a");
await urlPromise;
```

### Wait for Network

```typescript
// Wait for specific response
const responsePromise = page.waitForResponse("**/api/users");
await page.click("button");
const response = await responsePromise;
expect(response.status()).toBe(200);

// Wait for request
const requestPromise = page.waitForRequest("**/api/submit");
await page.click("button");
const request = await requestPromise;

// Wait for no network activity
await page.waitForLoadState("networkidle");
```

### Wait for Element State

```typescript
// Wait for element to appear
await page.getByRole("dialog").waitFor({ state: "visible" });

// Wait for element to disappear
await page.getByText("Loading...").waitFor({ state: "hidden" });

// Wait for element to be attached
await page.getByTestId("result").waitFor({ state: "attached" });

// Wait for element to be detached
await page.getByTestId("modal").waitFor({ state: "detached" });
```

### Wait for Function

```typescript
// Wait for arbitrary condition
await page.waitForFunction(() => {
  return document.querySelector(".loaded") !== null;
});

// With arguments
await page.waitForFunction(
  (selector) => document.querySelector(selector)?.textContent === "Ready",
  ".status",
);
```

## Polling & Retrying

### toPass() for Polling

Retry until block passes or times out:

```typescript
await expect(async () => {
  const response = await page.request.get("/api/status");
  expect(response.status()).toBe(200);

  const data = await response.json();
  expect(data.ready).toBe(true);
}).toPass({
  intervals: [1000, 2000, 5000], // Retry intervals
  timeout: 30000,
});
```

### expect.poll()

Poll a function until assertion passes:

```typescript
// Poll API until condition met
await expect
  .poll(
    async () => {
      const response = await page.request.get("/api/job/123");
      return (await response.json()).status;
    },
    {
      intervals: [1000, 2000, 5000],
      timeout: 30000,
    },
  )
  .toBe("completed");

// Poll DOM value
await expect.poll(() => page.getByTestId("counter").textContent()).toBe("10");
```

## Custom Matchers

```typescript
// playwright.config.ts or fixtures
import { expect } from "@playwright/test";

expect.extend({
  async toHaveDataLoaded(page: Page) {
    const locator = page.getByTestId("data-container");
    let pass = false;
    let message = "";

    try {
      await expect(locator).toBeVisible();
      await expect(locator).not.toContainText("Loading");
      pass = true;
    } catch (e) {
      message = `Expected data to be loaded but found loading state`;
    }

    return { pass, message: () => message };
  },
});

// Extend TypeScript types
declare global {
  namespace PlaywrightTest {
    interface Matchers<R> {
      toHaveDataLoaded(): Promise<R>;
    }
  }
}

// Usage
await expect(page).toHaveDataLoaded();
```

## Timeouts

### Configure Timeouts

```typescript
// playwright.config.ts
export default defineConfig({
  timeout: 30000, // Test timeout
  expect: {
    timeout: 5000, // Assertion timeout
  },
});

// Per-test timeout
test("long test", async ({ page }) => {
  test.setTimeout(60000);
  // ...
});

// Per-assertion timeout
await expect(page.getByRole("button")).toBeVisible({ timeout: 10000 });
```

## Best Practices

| Do                             | Don't                          |
| ------------------------------ | ------------------------------ |
| Use web-first assertions       | Use generic assertions for DOM |
| Let auto-waiting work          | Add unnecessary explicit waits |
| Use `toPass()` for polling     | Write manual retry loops       |
| Configure appropriate timeouts | Use `waitForTimeout()`         |
| Check specific conditions      | Wait for arbitrary time        |

## Anti-Patterns to Avoid

| Anti-Pattern                                              | Problem                       | Solution                                     |
| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |
| `await page.waitForTimeout(5000)`                         | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse`        |
| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above                 | Use `waitForResponse` or element state waits |
| Generic assertions on DOM elements                        | No auto-retry, flaky          | Use web-first assertions with `expect()`     |

## Related References

- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions
- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking
core/configuration.md
# Playwright Configuration

## Table of Contents

1. [CLI Quick Reference](#cli-quick-reference)
2. [Decision Guide](#decision-guide)
3. [Production-Ready Config](#production-ready-config)
4. [Patterns](#patterns)
5. [Anti-Patterns](#anti-patterns)
6. [Troubleshooting](#troubleshooting)
7. [Related](#related)

> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.

## CLI Quick Reference

```bash
npx playwright init                           # scaffold config + first test
npx playwright test --config=custom.config.ts # use alternate config
npx playwright test --project=chromium        # run single project
npx playwright test --reporter=html           # override reporter
npx playwright test --grep @smoke             # run tests tagged @smoke
npx playwright test --grep-invert @slow       # exclude @slow tests
npx playwright show-report                    # open last HTML report
DEBUG=pw:api npx playwright test              # verbose logging
```

## Decision Guide

### Timeout Selection

| Symptom | Setting | Default | Recommended |
|---------|---------|---------|-------------|
| Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) |
| Assertion retries too long/short | `expect.timeout` | 5s | 5-10s |
| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s |
| `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s |
| Dev server slow to start | `webServer.timeout` | 60s | 60-180s |

### Server Management

| Scenario | Approach |
|----------|----------|
| App in same repo | `webServer` with `reuseExistingServer: !process.env.CI` |
| Separate repos | Manual start or Docker Compose |
| Testing deployed environment | No `webServer`; set `baseURL` via env |
| Multiple services | Array of `webServer` entries |

### Single vs Multi-Project

| Scenario | Approach |
|----------|----------|
| Early development | Single project (chromium only) |
| Pre-release validation | Multi-project: chromium + firefox + webkit |
| Mobile-responsive app | Add mobile projects alongside desktop |
| Auth + non-auth tests | Setup project with dependencies |
| Tight CI budget | Chromium on PRs; all browsers on main |

### globalSetup vs Setup Projects vs Fixtures

| Need | Use |
|------|-----|
| One-time DB seed | `globalSetup` |
| Shared browser auth | Setup project with `dependencies` |
| Per-test isolated state | Custom fixture via `test.extend()` |
| Cleanup after all tests | `globalTeardown` |

## Production-Ready Config

```ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';

dotenv.config({ path: path.resolve(__dirname, '.env') });

export default defineConfig({
  testDir: './e2e',
  testMatch: '**/*.spec.ts',

  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? '50%' : undefined,

  reporter: process.env.CI
    ? [['html', { open: 'never' }], ['github']]
    : [['html', { open: 'on-failure' }]],

  timeout: 30_000,
  expect: { timeout: 5_000 },

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:4000',
    actionTimeout: 10_000,
    navigationTimeout: 15_000,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    locale: 'en-US',
    timezoneId: 'America/Los_Angeles',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: 'npm run start',
    url: 'http://localhost:4000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    stdout: 'pipe',
    stderr: 'pipe',
  },
});
```

## Patterns

### Environment-Specific Configuration

**Use when**: Tests run against dev, staging, and production environments.

```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';

const ENV = process.env.TEST_ENV || 'local';
dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) });

const envConfig: Record<string, { baseURL: string; retries: number }> = {
  local:   { baseURL: 'http://localhost:4000',      retries: 0 },
  staging: { baseURL: 'https://staging.myapp.com',  retries: 2 },
  prod:    { baseURL: 'https://myapp.com',          retries: 2 },
};

export default defineConfig({
  testDir: './e2e',
  retries: envConfig[ENV].retries,
  use: { baseURL: envConfig[ENV].baseURL },
});
```

```bash
TEST_ENV=staging npx playwright test
TEST_ENV=prod npx playwright test --grep @smoke
```

### Setup Project with Dependencies

**Use when**: Tests need shared authentication state before running.

```ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  projects: [
    {
      name: 'setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/session.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/session.json',
      },
      dependencies: ['setup'],
    },
  ],
});
```

```ts
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/session.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Username').fill('[email protected]');
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
  await page.context().storageState({ path: authFile });
});
```

### webServer with Build Step

**Use when**: Tests need a running application server managed by Playwright.

```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: { baseURL: 'http://localhost:4000' },
  webServer: {
    command: process.env.CI
      ? 'npm run build && npm run preview'
      : 'npm run dev',
    url: 'http://localhost:4000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    env: {
      NODE_ENV: 'test',
      DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',
    },
  },
});
```

### globalSetup / globalTeardown

**Use when**: One-time non-browser work like seeding a database. Runs once per test run.

```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  globalSetup: './e2e/setup.ts',
  globalTeardown: './e2e/teardown.ts',
});
```

```ts
// e2e/setup.ts
import { FullConfig } from '@playwright/test';

export default async function globalSetup(config: FullConfig) {
  const { execSync } = await import('child_process');
  execSync('npx prisma db seed', { stdio: 'inherit' });
  process.env.TEST_RUN_ID = `run-${Date.now()}`;
}
```

```ts
// e2e/teardown.ts
import { FullConfig } from '@playwright/test';

export default async function globalTeardown(config: FullConfig) {
  const { execSync } = await import('child_process');
  execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
}
```

### Environment Variables with .env

**Use when**: Managing secrets, URLs, or feature flags without hardcoding.

```bash
# .env.example (commit this)
BASE_URL=http://localhost:4000
TEST_PASSWORD=
API_KEY=

# .env.local (gitignored)
BASE_URL=http://localhost:4000
TEST_PASSWORD=secret123
API_KEY=dev-key-abc

# .env.staging (gitignored)
BASE_URL=https://staging.myapp.com
TEST_PASSWORD=staging-pass
API_KEY=staging-key-xyz
```

```bash
# .gitignore
.env
.env.local
.env.staging
.env.production
playwright/.auth/
```

Install dotenv:

```bash
npm install -D dotenv
```

### Tag-Based Test Filtering

**Use when**: Running subsets of tests in different CI stages (PR vs nightly).

```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',

  // Filter by tags in CI
  grep: process.env.CI ? /@smoke|@critical/ : undefined,
  grepInvert: process.env.CI ? /@flaky/ : undefined,
});
```

**Project-specific filtering:**

```ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  projects: [
    {
      name: 'smoke',
      grep: /@smoke/,
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'regression',
      grepInvert: /@smoke/,
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'critical-only',
      grep: /@critical/,
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});
```

```bash
# Run specific project
npx playwright test --project=smoke
npx playwright test --project=regression
```

### Artifact Collection Strategy

| Setting | Local | CI | Reason |
|---------|-------|-----|--------|
| `trace` | `'off'` | `'on-first-retry'` | Traces are large; collect on failure only |
| `screenshot` | `'off'` | `'only-on-failure'` | Useful for CI debugging |
| `video` | `'off'` | `'retain-on-failure'` | Recording slows tests |

```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    trace: process.env.CI ? 'on-first-retry' : 'off',
    screenshot: process.env.CI ? 'only-on-failure' : 'off',
    video: process.env.CI ? 'retain-on-failure' : 'off',
  },
});
```

## Anti-Patterns

| Don't | Problem | Do Instead |
|-------|---------|------------|
| `timeout: 300_000` globally | Masks flaky tests; slow CI | Fix root cause; keep 30s default |
| Hardcoded URLs: `page.goto('http://localhost:4000/login')` | Breaks in other environments | Use `baseURL` + relative paths |
| All browsers on every PR | 3x CI time | Chromium on PRs; all on main |
| `trace: 'on'` always | Huge artifacts, slow uploads | `trace: 'on-first-retry'` |
| `video: 'on'` always | Massive storage; slow tests | `video: 'retain-on-failure'` |
| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent | Define once in project config |
| `retries: 3` locally | Hides flakiness | `retries: 0` local, `retries: 2` CI |
| No `forbidOnly` in CI | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI` |
| `globalSetup` for browser auth | No browser context available | Use setup project with dependencies |
| Committing `.env` with credentials | Security risk | Commit `.env.example` only |

## Troubleshooting

### baseURL Not Working

**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.

```ts
// Wrong - ignores baseURL
await page.goto('http://localhost:4000/dashboard');

// Correct - uses baseURL
await page.goto('/dashboard');
```

### webServer Starts But Tests Get Connection Refused

**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.

```ts
webServer: {
  command: 'npm run dev',
  url: 'http://localhost:4000/api/health',  // use real endpoint
  reuseExistingServer: !process.env.CI,
  timeout: 120_000,
},
```

### Tests Pass Locally But Timeout in CI

**Cause**: CI machines are slower. Increase timeouts and reduce workers:

```ts
export default defineConfig({
  workers: process.env.CI ? '50%' : undefined,
  use: {
    navigationTimeout: process.env.CI ? 30_000 : 15_000,
    actionTimeout: process.env.CI ? 15_000 : 10_000,
  },
});
```

### "Target page, context or browser has been closed"

**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.

**Fix**: Don't increase global timeout. Find slow step using trace:

```bash
npx playwright test --trace on
npx playwright show-report
```

## Related

- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`
- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state
- [test-suite-structure.md](test-suite-structure.md) - file structure and naming
- [authentication.md](../advanced/authentication.md) - setup projects for shared auth
- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns
core/fixtures-hooks.md
# Fixtures & Hooks

## Table of Contents

1. [Built-in Fixtures](#built-in-fixtures)
2. [Custom Fixtures](#custom-fixtures)
3. [Fixture Scopes](#fixture-scopes)
4. [Hooks](#hooks)
5. [Authentication Patterns](#authentication-patterns)
6. [Database Fixtures](#database-fixtures)

## Built-in Fixtures

### Core Fixtures

```typescript
test("example", async ({
  page, // Isolated page instance
  context, // Browser context (cookies, localStorage)
  browser, // Browser instance
  browserName, // 'chromium', 'firefox', or 'webkit'
  request, // API request context
}) => {
  // Each test gets fresh instances
});
```

### Request Fixture

```typescript
test("API call", async ({ request }) => {
  const response = await request.get("/api/users");
  await expect(response).toBeOK();

  const users = await response.json();
  expect(users).toHaveLength(5);
});
```

## Custom Fixtures

### Basic Custom Fixture

```typescript
// fixtures.ts
import { test as base } from "@playwright/test";

// Declare fixture types
type MyFixtures = {
  todoPage: TodoPage;
  apiClient: ApiClient;
};

export const test = base.extend<MyFixtures>({
  // Fixture with setup and teardown
  todoPage: async ({ page }, use) => {
    const todoPage = new TodoPage(page);
    await todoPage.goto();

    await use(todoPage); // Test runs here

    // Teardown (optional)
    await todoPage.clearTodos();
  },

  // Simple fixture
  apiClient: async ({ request }, use) => {
    await use(new ApiClient(request));
  },
});

export { expect } from "@playwright/test";
```

### Fixture with Options

```typescript
type Options = {
  defaultUser: { email: string; password: string };
};

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Options & Fixtures>({
  // Define option with default
  defaultUser: [
    { email: "[email protected]", password: "pass123" },
    { option: true },
  ],

  // Use option in fixture
  authenticatedPage: async ({ page, defaultUser }, use) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill(defaultUser.email);
    await page.getByLabel("Password").fill(defaultUser.password);
    await page.getByRole("button", { name: "Sign in" }).click();
    await use(page);
  },
});

// Override in config
export default defineConfig({
  use: {
    defaultUser: { email: "[email protected]", password: "admin123" },
  },
});
```

### Automatic Fixtures

```typescript
export const test = base.extend<{}, { setupDb: void }>({
  // Auto-fixture runs for every test without explicit usage
  setupDb: [
    async ({}, use) => {
      await seedDatabase();
      await use();
      await cleanDatabase();
    },
    { auto: true },
  ],
});
```

## Fixture Scopes

### Test Scope (Default)

Created fresh for each test:

```typescript
test.extend({
  page: async ({ browser }, use) => {
    const page = await browser.newPage();
    await use(page);
    await page.close();
  },
});
```

### Worker Scope

Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):

```typescript
type WorkerFixtures = {
  sharedAccount: Account;
};

export const test = base.extend<{}, WorkerFixtures>({
  sharedAccount: [
    async ({ browser }, use) => {
      // Expensive setup - runs once per worker
      const account = await createTestAccount();
      await use(account);
      await deleteTestAccount(account);
    },
    { scope: "worker" },
  ],
});
```

### Isolate test data between parallel workers

When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:

```typescript
import { test as baseTest } from "@playwright/test";

type WorkerFixtures = {
  dbUserName: string;
};

export const test = baseTest.extend<{}, WorkerFixtures>({
  dbUserName: [
    async ({}, use, testInfo) => {
      const userName = `user-${testInfo.workerIndex}`;
      await createUserInTestDatabase(userName);
      await use(userName);
      await deleteUserFromTestDatabase(userName);
    },
    { scope: "worker" },
  ],
});
```

Then each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data.

## Hooks

### beforeEach / afterEach

```typescript
test.beforeEach(async ({ page }) => {
  // Runs before each test in file
  await page.goto("/");
});

test.afterEach(async ({ page }, testInfo) => {
  // Runs after each test
  if (testInfo.status !== "passed") {
    await page.screenshot({ path: `failed-${testInfo.title}.png` });
  }
});
```

### beforeAll / afterAll

```typescript
test.beforeAll(async ({ browser }) => {
  // Runs once before all tests in file
  // Note: Cannot use page fixture here
});

test.afterAll(async () => {
  // Runs once after all tests in file
});
```

### Describe-Level Hooks

```typescript
test.describe("User Management", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/users");
  });

  test("can list users", async ({ page }) => {
    // Starts at /users
  });

  test("can add user", async ({ page }) => {
    // Starts at /users
  });
});
```

## Authentication Patterns

### Global Setup with Storage State

```typescript
// auth.setup.ts
import { test as setup, expect } from "@playwright/test";

const authFile = ".auth/user.json";

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.TEST_EMAIL!);
  await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await page.context().storageState({ path: authFile });
});
```

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: ".auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
});
```

### Multiple Auth States

```typescript
// auth.setup.ts
setup("admin auth", async ({ page }) => {
  await login(page, "[email protected]", "adminpass");
  await page.context().storageState({ path: ".auth/admin.json" });
});

setup("user auth", async ({ page }) => {
  await login(page, "[email protected]", "userpass");
  await page.context().storageState({ path: ".auth/user.json" });
});
```

```typescript
// playwright.config.ts
projects: [
  {
    name: "admin tests",
    testMatch: /.*admin.*\.spec\.ts/,
    use: { storageState: ".auth/admin.json" },
    dependencies: ["setup"],
  },
  {
    name: "user tests",
    testMatch: /.*user.*\.spec\.ts/,
    use: { storageState: ".auth/user.json" },
    dependencies: ["setup"],
  },
];
```

### Auth Fixture

```typescript
// fixtures/auth.fixture.ts
export const test = base.extend<{ adminPage: Page; userPage: Page }>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: ".auth/admin.json",
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  userPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: ".auth/user.json",
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});
```

## Database Fixtures

This section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:

- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)

### Transaction Rollback Pattern

```typescript
import { test as base } from "@playwright/test";
import { db } from "../db";

export const test = base.extend<{ dbTransaction: Transaction }>({
  dbTransaction: async ({}, use) => {
    const transaction = await db.beginTransaction();

    await use(transaction);

    await transaction.rollback(); // Clean slate for next test
  },
});
```

### Seed Data Fixture

```typescript
type TestData = {
  testUser: User;
  testProducts: Product[];
};

export const test = base.extend<TestData>({
  testUser: async ({}, use) => {
    const user = await db.users.create({
      email: `test-${Date.now()}@example.com`,
      name: "Test User",
    });

    await use(user);

    await db.users.delete(user.id);
  },

  testProducts: async ({ testUser }, use) => {
    const products = await db.products.createMany([
      { name: "Product A", ownerId: testUser.id },
      { name: "Product B", ownerId: testUser.id },
    ]);

    await use(products);

    await db.products.deleteMany(products.map((p) => p.id));
  },
});
```

## Fixture Tips

| Tip                | Explanation                                 |
| ------------------ | ------------------------------------------- |
| Fixtures are lazy  | Only created when used                      |
| Compose fixtures   | Use other fixtures as dependencies          |
| Keep setup minimal | Do heavy lifting in worker-scoped fixtures  |
| Clean up resources | Use teardown in fixtures, not afterEach     |
| Avoid shared state | Each fixture instance should be independent |

## Anti-Patterns to Avoid

| Anti-Pattern                              | Problem                                                    | Solution                                                                                                                                                                              |
| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Shared mutable state between tests        | Race conditions, order dependencies                        | Use fixtures for isolation                                                                                                                                                            |
| Global variables in tests                 | Tests depend on execution order                            | Use fixtures or beforeEach for setup                                                                                                                                                  |
| Not cleaning up test data                 | Tests interfere with each other                            | Use fixtures with teardown or database transactions                                                                                                                                   |
| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |
| Backend/DB state shared across workers    | Tests in different workers collide on same data            | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker                                                                                                |

## Related References

- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure
- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
core/global-setup.md
# Global Setup & Teardown

## Table of Contents

1. [Global Setup](#global-setup)
2. [Global Teardown](#global-teardown)
3. [Database Patterns](#database-patterns)
4. [Environment Provisioning](#environment-provisioning)
5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)
6. [Parallel Execution Caveats](#parallel-execution-caveats)

## Global Setup

### Basic Global Setup

```typescript
// global-setup.ts
import { FullConfig } from "@playwright/test";

async function globalSetup(config: FullConfig) {
  console.log("Running global setup...");
  // Perform one-time setup: start services, run migrations, etc.
}

export default globalSetup;
```

### Configure Global Setup

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  globalSetup: require.resolve("./global-setup"),
  globalTeardown: require.resolve("./global-teardown"),
});
```

> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.

### Global Setup with Return Value

```typescript
// global-setup.ts
async function globalSetup(config: FullConfig): Promise<() => Promise<void>> {
  const server = await startTestServer();

  // Return cleanup function (alternative to globalTeardown)
  return async () => {
    await server.stop();
  };
}

export default globalSetup;
```

### Access Config in Global Setup

```typescript
// global-setup.ts
import { FullConfig } from "@playwright/test";

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use;
  console.log(`Setting up for ${baseURL}`);

  // Access custom config
  const workers = config.workers;
  const timeout = config.timeout;

  // Access environment
  const isCI = !!process.env.CI;
}

export default globalSetup;
```

## Global Teardown

### Basic Global Teardown

```typescript
// global-teardown.ts
import { FullConfig } from "@playwright/test";
import fs from "fs";

async function globalTeardown(config: FullConfig) {
  console.log("Running global teardown...");

  // Clean up auth files
  if (fs.existsSync(".auth")) {
    fs.rmSync(".auth", { recursive: true });
  }

  // Clean up test data
  await cleanupTestDatabase();

  // Stop services
  await stopTestServices();
}

export default globalTeardown;
```

### Conditional Teardown

```typescript
// global-teardown.ts
async function globalTeardown(config: FullConfig) {
  // Skip cleanup in CI (containers are discarded anyway)
  if (process.env.CI) {
    console.log("Skipping teardown in CI");
    return;
  }

  // Local cleanup
  await cleanupLocalTestData();
}

export default globalTeardown;
```

## Database Patterns

This section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:

- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)

### Database Migration in Setup

```typescript
// global-setup.ts
import { execSync } from "child_process";

async function globalSetup() {
  console.log("Running database migrations...");

  // Run migrations
  execSync("npx prisma migrate deploy", { stdio: "inherit" });

  // Seed test data
  execSync("npx prisma db seed", { stdio: "inherit" });
}

export default globalSetup;
```

### Database Snapshot Pattern

```typescript
// global-setup.ts
import { execSync } from "child_process";
import fs from "fs";

const SNAPSHOT_PATH = "./test-db-snapshot.sql";

async function globalSetup() {
  // Check if snapshot exists
  if (fs.existsSync(SNAPSHOT_PATH)) {
    console.log("Restoring database from snapshot...");
    execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, { stdio: "inherit" });
    return;
  }

  // First run: migrate and create snapshot
  console.log("Creating database snapshot...");
  execSync("npx prisma migrate deploy", { stdio: "inherit" });
  execSync("npx prisma db seed", { stdio: "inherit" });
  execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, { stdio: "inherit" });
}

export default globalSetup;
```

### Test Database per Worker

```typescript
// global-setup.ts
async function globalSetup(config: FullConfig) {
  const workerCount = config.workers || 1;

  // Create a database for each worker
  for (let i = 0; i < workerCount; i++) {
    const dbName = `test_db_worker_${i}`;
    await createDatabase(dbName);
    await runMigrations(dbName);
    await seedDatabase(dbName);
  }
}

// global-teardown.ts
async function globalTeardown(config: FullConfig) {
  const workerCount = config.workers || 1;

  for (let i = 0; i < workerCount; i++) {
    await dropDatabase(`test_db_worker_${i}`);
  }
}
```

## Environment Provisioning

### Start Services in Setup

```typescript
// global-setup.ts
import { execSync, spawn } from "child_process";

let serverProcess: any;

async function globalSetup() {
  // Start backend server
  serverProcess = spawn("npm", ["run", "start:test"], {
    stdio: "pipe",
    detached: true,
  });

  // Wait for server to be ready
  await waitForServer("http://localhost:3000/health", 30000);

  // Store PID for teardown
  process.env.SERVER_PID = serverProcess.pid.toString();
}

async function waitForServer(url: string, timeout: number) {
  const start = Date.now();

  while (Date.now() - start < timeout) {
    try {
      const response = await fetch(url);
      if (response.ok) return;
    } catch {
      // Server not ready yet
    }
    await new Promise((r) => setTimeout(r, 1000));
  }

  throw new Error(`Server did not start within ${timeout}ms`);
}

export default globalSetup;
```

### Docker Compose Setup

```typescript
// global-setup.ts
import { execSync } from "child_process";

async function globalSetup() {
  console.log("Starting Docker services...");

  execSync("docker-compose -f docker-compose.test.yml up -d", {
    stdio: "inherit",
  });

  // Wait for services to be healthy
  execSync("docker-compose -f docker-compose.test.yml exec -T db pg_isready", {
    stdio: "inherit",
  });
}

export default globalSetup;
```

```typescript
// global-teardown.ts
import { execSync } from "child_process";

async function globalTeardown() {
  console.log("Stopping Docker services...");

  execSync("docker-compose -f docker-compose.test.yml down -v", {
    stdio: "inherit",
  });
}

export default globalTeardown;
```

### Environment Variables Setup

```typescript
// global-setup.ts
import dotenv from "dotenv";
import path from "path";

async function globalSetup() {
  // Load test-specific environment
  const envFile = process.env.CI ? ".env.ci" : ".env.test";
  dotenv.config({ path: path.resolve(process.cwd(), envFile) });

  // Validate required variables
  const required = ["DATABASE_URL", "API_KEY", "TEST_EMAIL"];
  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(`Missing required environment variable: ${key}`);
    }
  }
}

export default globalSetup;
```

## Setup Projects vs Global Setup

### When to Use Each

| Use Global Setup                      | Use Setup Projects                       |
| ------------------------------------- | ---------------------------------------- |
| One-time setup (migrations, services) | Per-project setup (auth states)          |
| No access to Playwright fixtures      | Need page, request fixtures              |
| Runs once before all projects         | Can run per-project or have dependencies |
| Shared across all workers             | Can be parallelized                      |

### Setup Project Pattern

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup project
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
    },
    // Test projects depend on setup
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
      dependencies: ["setup"],
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
      dependencies: ["setup"],
    },
  ],
});
```

> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).

### Combining Both

```typescript
// playwright.config.ts
export default defineConfig({
  // Global: Start services, run migrations
  globalSetup: require.resolve("./global-setup"),
  globalTeardown: require.resolve("./global-teardown"),

  projects: [
    // Setup project: Create auth states
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: ".auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
});
```

## Parallel Execution Caveats

### Understanding Global Setup Execution

```
┌─────────────────────────────────────────────────────────────┐
│  globalSetup runs ONCE                                      │
│  ↓                                                          │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │
│  │ Worker 1│  │ Worker 2│  │ Worker 3│  │ Worker 4│        │
│  │ tests   │  │ tests   │  │ tests   │  │ tests   │        │
│  └─────────┘  └─────────┘  └─────────┘  └─────────┘        │
│  ↓                                                          │
│  globalTeardown runs ONCE                                   │
└─────────────────────────────────────────────────────────────┘
```

**Key implications:**

- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)
- State created in global setup is **shared** across all workers
- If tests **modify** shared state, they may conflict with parallel workers
- Global setup **cannot** react to individual test needs

### When to Prefer Worker-Scoped Fixtures

Use **worker-scoped fixtures** instead of globalSetup when:

| Scenario                             | Why Fixtures Are Better                              |
| ------------------------------------ | ---------------------------------------------------- |
| Each worker needs isolated resources | Fixtures can create per-worker databases, servers    |
| Setup needs Playwright APIs          | Fixtures have access to `page`, `request`, `browser` |
| Setup depends on test configuration  | Fixtures receive test context and options            |
| Resources need cleanup per worker    | Worker fixtures auto-cleanup when worker exits       |

### Common Parallel Pitfall

```typescript
// ❌ BAD: Global setup creates ONE user, all workers fight over it
async function globalSetup() {
  await createUser({ email: "[email protected]" }); // Shared!
}

// ✅ GOOD: Each worker gets its own user via worker-scoped fixture
// Uses workerInfo.workerIndex to create unique data per worker
```

> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).

## Anti-Patterns to Avoid

| Anti-Pattern                   | Problem                          | Solution                                   |
| ------------------------------ | -------------------------------- | ------------------------------------------ |
| Heavy setup in globalSetup     | Slow test startup                | Use setup projects for parallelizable work |
| Not cleaning up in teardown    | Leaks resources, flaky CI        | Always clean up or use containers          |
| Hardcoded URLs in setup        | Breaks in different environments | Use config.projects[0].use.baseURL         |
| No timeout on service wait     | Hangs forever if service fails   | Add timeout with clear error               |
| Shared mutable state           | Race conditions in parallel      | Use worker-scoped fixtures for isolation   |
| Global setup for per-test data | Tests conflict                   | Use test-scoped fixtures                   |

## Related References

- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration
core/locators.md
# Locator Strategies

## Table of Contents

1. [Priority Order](#priority-order)
2. [User-Facing Locators](#user-facing-locators)
3. [Filtering & Chaining](#filtering--chaining)
4. [Dynamic Content](#dynamic-content)
5. [Shadow DOM](#shadow-dom)
6. [Iframes](#iframes)

## Priority Order

Use locators in this order of preference:

1. **Role-based** (most resilient): `getByRole`
2. **Label-based**: `getByLabel`, `getByPlaceholder`
3. **Text-based**: `getByText`, `getByTitle`
4. **Test IDs** (when semantic locators aren't possible): `getByTestId`
5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`

## User-Facing Locators

### getByRole

Most robust approach - matches how users and assistive technology perceive the page.

```typescript
// Buttons
page.getByRole("button", { name: "Submit", exact: true }); // exact accessible name
page.getByRole("button", { name: /submit/i }); // flexible case-insensitive match

// Links
page.getByRole("link", { name: "Home" });

// Form elements
page.getByRole("textbox", { name: "Email" });
page.getByRole("checkbox", { name: "Remember me" });
page.getByRole("combobox", { name: "Country" });
page.getByRole("radio", { name: "Option A" });

// Headings
page.getByRole("heading", { name: "Welcome", level: 1 });

// Lists & items
page.getByRole("list").getByRole("listitem");

// Navigation & regions
page.getByRole("navigation");
page.getByRole("main");
page.getByRole("dialog");
page.getByRole("alert");
```

### getByLabel

For form elements with associated labels.

```typescript
// Input with <label for="email">
page.getByLabel("Email address");

// Input with aria-label
page.getByLabel("Search");

// Exact match
page.getByLabel("Email", { exact: true });
```

### getByPlaceholder

```typescript
page.getByPlaceholder("Enter your email");
page.getByPlaceholder(/email/i);
```

### getByText

```typescript
// Partial match (default)
page.getByText("Welcome");

// Exact match
page.getByText("Welcome to our site", { exact: true });

// Regex
page.getByText(/welcome/i);
```

### getByTestId

Configure custom test ID attribute in `playwright.config.ts`:

```typescript
use: {
  testIdAttribute: "data-testid"; // default
}
```

Usage:

```typescript
// HTML: <button data-testid="submit-btn">Submit</button>
page.getByTestId("submit-btn");
```

## Filtering & Chaining

### filter()

Narrow down locators:

```typescript
// Filter by text
page.getByRole("listitem").filter({ hasText: "Product" });

// Filter by NOT having text
page.getByRole("listitem").filter({ hasNotText: "Out of stock" });

// Filter by child locator
page.getByRole("listitem").filter({
  has: page.getByRole("button", { name: "Buy" }),
});

// Combine filters
page
  .getByRole("listitem")
  .filter({ hasText: "Product" })
  .filter({ has: page.getByText("$9.99") });
```

### Chaining

```typescript
// Navigate down the DOM tree
page.getByRole("article").getByRole("heading");

// Get parent/ancestor
page.getByText("Child").locator("..");
page.getByText("Child").locator("xpath=ancestor::article");
```

### nth() and first()/last()

```typescript
page.getByRole("listitem").first();
page.getByRole("listitem").last();
page.getByRole("listitem").nth(2); // 0-indexed
```

## Dynamic Content

### Waiting for Elements

Locators auto-wait for actionability by default. For explicit state waiting:

```typescript
await page.getByRole("button").waitFor({ state: "visible" });
await page.getByText("Loading").waitFor({ state: "hidden" });
```

> **For comprehensive waiting strategies** (element state, navigation, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).

### Lists with Dynamic Items

```typescript
// Wait for specific count
await expect(page.getByRole("listitem")).toHaveCount(5);

// Get all matching elements
const items = await page.getByRole("listitem").all();
for (const item of items) {
  await expect(item).toBeVisible();
}
```

## Shadow DOM

Playwright pierces shadow DOM by default:

```typescript
// Automatically finds elements inside shadow roots
page.getByRole("button", { name: "Shadow Button" });

// Explicit shadow DOM traversal (if needed)
page.locator("my-component").locator("internal:shadow=button");
```

## Iframes

```typescript
// By frame name or URL
const frame = page.frameLocator('iframe[name="content"]');
await frame.getByRole("button").click();

// By index
const frame = page.frameLocator("iframe").first();

// Nested iframes
const nestedFrame = page.frameLocator("#outer").frameLocator("#inner");
await nestedFrame.getByText("Content").click();
```

## Debugging Locators

```typescript
// Highlight element in headed mode
await page.getByRole("button").highlight();

// Count matches
const count = await page.getByRole("listitem").count();

// Check if exists without waiting
const exists = (await page.getByRole("button").count()) > 0;

// Use Playwright Inspector
// PWDEBUG=1 npx playwright test
```

## Common Issues & Solutions

| Issue                   | Solution                                         |
| ----------------------- | ------------------------------------------------ |
| Multiple elements match | Add filters or use `nth()`, `first()`, `last()`  |
| Element not found       | Check visibility, wait for load, verify selector |
| Stale element           | Locators are lazy; re-query if DOM changes       |
| Dynamic IDs             | Use stable attributes like role, text, test-id   |
| Hidden elements         | Use `{ force: true }` only when necessary        |

## Anti-Patterns to Avoid

| Anti-Pattern                      | Problem                           | Solution                                          |
| --------------------------------- | --------------------------------- | ------------------------------------------------- |
| `page.locator('.btn-primary')`    | Brittle, implementation-dependent | `page.getByRole('button', { name: 'Submit' })`    |
| `page.locator('#dynamic-id-123')` | Breaks when IDs change            | Use stable attributes like role, text, or test-id |
| Testing implementation details    | Breaks on refactoring             | Test user-visible behavior                        |

## Related References

- **Debugging selector issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
- **Waiting for elements**: See [assertions-waiting.md](assertions-waiting.md) for waiting strategies
- **Using in Page Objects**: See [page-object-model.md](page-object-model.md) for organizing locators
core/page-object-model.md
# Page Object Model (POM)

## Table of Contents

1. [Overview](#overview)
2. [Basic Structure](#basic-structure)
3. [Component Objects](#component-objects)
4. [Composition Patterns](#composition-patterns)
5. [Factory Functions](#factory-functions)
6. [Best Practices](#best-practices)

## Overview

Page Object Model encapsulates page structure and interactions, providing:

- **Maintainability**: Change selectors in one place
- **Reusability**: Share page interactions across tests
- **Readability**: Tests express intent, not implementation

## Basic Structure

### Page Class

```typescript
// pages/login.page.ts
import { Page, Locator, expect } from "@playwright/test";

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel("Email");
    this.passwordInput = page.getByLabel("Password");
    this.submitButton = page.getByRole("button", { name: "Sign in" });
    this.errorMessage = page.getByRole("alert");
  }

  async goto() {
    await this.page.goto("/login");
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}
```

### Usage in Tests

```typescript
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login.page";

test.describe("Login", () => {
  test("successful login redirects to dashboard", async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login("[email protected]", "password123");
    await expect(page).toHaveURL("/dashboard");
  });

  test("shows error for invalid credentials", async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login("[email protected]", "wrong");
    await loginPage.expectError("Invalid credentials");
  });
});
```

## Component Objects

For reusable UI components:

```typescript
// components/navbar.component.ts
import { Page, Locator } from "@playwright/test";

export class NavbarComponent {
  readonly container: Locator;
  readonly logo: Locator;
  readonly searchInput: Locator;
  readonly userMenu: Locator;

  constructor(page: Page) {
    this.container = page.getByRole("navigation");
    this.logo = this.container.getByRole("link", { name: "Home" });
    this.searchInput = this.container.getByRole("searchbox");
    this.userMenu = this.container.getByRole("button", { name: /user menu/i });
  }

  async search(query: string) {
    await this.searchInput.fill(query);
    await this.searchInput.press("Enter");
  }

  async openUserMenu() {
    await this.userMenu.click();
  }
}
```

```typescript
// components/modal.component.ts
import { Locator, expect } from "@playwright/test";

export class ModalComponent {
  readonly container: Locator;
  readonly title: Locator;
  readonly closeButton: Locator;
  readonly confirmButton: Locator;

  constructor(container: Locator) {
    this.container = container;
    this.title = container.getByRole("heading");
    this.closeButton = container.getByRole("button", { name: "Close" });
    this.confirmButton = container.getByRole("button", { name: "Confirm" });
  }

  async expectTitle(title: string) {
    await expect(this.title).toHaveText(title);
  }

  async close() {
    await this.closeButton.click();
  }

  async confirm() {
    await this.confirmButton.click();
  }
}
```

## Composition Patterns

### Page with Components

```typescript
// pages/dashboard.page.ts
import { Page, Locator } from "@playwright/test";
import { NavbarComponent } from "../components/navbar.component";
import { ModalComponent } from "../components/modal.component";

export class DashboardPage {
  readonly page: Page;
  readonly navbar: NavbarComponent;
  readonly newProjectButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.navbar = new NavbarComponent(page);
    this.newProjectButton = page.getByRole("button", { name: "New Project" });
  }

  async goto() {
    await this.page.goto("/dashboard");
  }

  async createProject() {
    await this.newProjectButton.click();
    return new ModalComponent(this.page.getByRole("dialog"));
  }
}
```

### Page Navigation

```typescript
// pages/base.page.ts
import { Page } from "@playwright/test";

export abstract class BasePage {
  constructor(readonly page: Page) {}

  abstract goto(): Promise<void>;

  async getTitle(): Promise<string> {
    return this.page.title();
  }
}
```

```typescript
// Return new page object on navigation
export class LoginPage extends BasePage {
  async login(email: string, password: string): Promise<DashboardPage> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    return new DashboardPage(this.page);
  }
}

// Usage
const loginPage = new LoginPage(page);
await loginPage.goto();
const dashboardPage = await loginPage.login("[email protected]", "pass");
await dashboardPage.expectWelcomeMessage();
```

## Factory Functions

Alternative to classes for simpler pages:

```typescript
// pages/login.page.ts
import { Page } from "@playwright/test";

export function createLoginPage(page: Page) {
  const emailInput = page.getByLabel("Email");
  const passwordInput = page.getByLabel("Password");
  const submitButton = page.getByRole("button", { name: "Sign in" });

  return {
    goto: () => page.goto("/login"),
    login: async (email: string, password: string) => {
      await emailInput.fill(email);
      await passwordInput.fill(password);
      await submitButton.click();
    },
    emailInput,
    passwordInput,
    submitButton,
  };
}

// Usage
const loginPage = createLoginPage(page);
await loginPage.goto();
await loginPage.login("[email protected]", "password");
```

## Best Practices

### Do

- **Keep locators in page objects** - Single source of truth
- **Return new page objects** when navigation occurs
- **Expose elements** for custom assertions in tests
- **Use descriptive method names** - `submitOrder()` not `clickButton()`
- **Keep methods focused** - One action per method

### Don't

- **Don't include assertions in page methods** (usually) - Keep in tests
- **Don't expose implementation details** - Hide complex interactions
- **Don't make page objects too large** - Split into components
- **Don't share state** between page object instances

### Directory Structure

```
tests/
├── pages/
│   ├── base.page.ts
│   ├── login.page.ts
│   ├── dashboard.page.ts
│   └── settings.page.ts
├── components/
│   ├── navbar.component.ts
│   ├── modal.component.ts
│   └── table.component.ts
├── fixtures/
│   └── pages.fixture.ts
└── specs/
    ├── login.spec.ts
    └── dashboard.spec.ts
```

### Using with Fixtures

```typescript
// fixtures/pages.fixture.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
import { DashboardPage } from "../pages/dashboard.page";

type Pages = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

// Usage in tests
test("can login", async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.login("[email protected]", "password");
});
```

## Related References

- **Locator strategies**: See [locators.md](locators.md) for selecting elements
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for advanced fixture patterns
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring test suites
core/projects-dependencies.md
# Projects & Dependencies

## Table of Contents

1. [Project Configuration](#project-configuration)
2. [Project Dependencies](#project-dependencies)
3. [Setup Projects](#setup-projects)
4. [Filtering & Running Projects](#filtering--running-projects)
5. [Sharing Configuration](#sharing-configuration)
6. [Advanced Patterns](#advanced-patterns)

## Project Configuration

### Basic Multi-Browser Setup

```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
    },
    {
      name: "webkit",
      use: { ...devices["Desktop Safari"] },
    },
  ],
});
```

### Environment-Based Projects

```typescript
export default defineConfig({
  projects: [
    {
      name: "staging",
      use: {
        baseURL: "https://staging.example.com",
      },
    },
    {
      name: "production",
      use: {
        baseURL: "https://example.com",
      },
    },
    {
      name: "local",
      use: {
        baseURL: "http://localhost:3000",
      },
    },
  ],
});
```

### Test Type Projects

```typescript
export default defineConfig({
  projects: [
    {
      name: "e2e",
      testDir: "./tests/e2e",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "api",
      testDir: "./tests/api",
      use: { baseURL: "http://localhost:3000" },
    },
    {
      name: "visual",
      testDir: "./tests/visual",
      use: {
        ...devices["Desktop Chrome"],
        viewport: { width: 1280, height: 720 },
      },
    },
  ],
});
```

## Project Dependencies

### Setup Dependency

```typescript
export default defineConfig({
  projects: [
    // Setup project runs first
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
    },

    // Browser projects depend on setup
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: ".auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
        storageState: ".auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
});
```

### Multiple Auth States

```typescript
export default defineConfig({
  projects: [
    // Auth setup projects
    {
      name: "setup-admin",
      testMatch: /admin\.setup\.ts/,
    },
    {
      name: "setup-user",
      testMatch: /user\.setup\.ts/,
    },

    // Admin tests
    {
      name: "admin-tests",
      testDir: "./tests/admin",
      use: { storageState: ".auth/admin.json" },
      dependencies: ["setup-admin"],
    },

    // User tests
    {
      name: "user-tests",
      testDir: "./tests/user",
      use: { storageState: ".auth/user.json" },
      dependencies: ["setup-user"],
    },

    // Tests that need both
    {
      name: "integration-tests",
      testDir: "./tests/integration",
      dependencies: ["setup-admin", "setup-user"],
    },
  ],
});
```

### Chained Dependencies

```typescript
export default defineConfig({
  projects: [
    // Step 1: Database setup
    {
      name: "db-setup",
      testMatch: /db\.setup\.ts/,
    },

    // Step 2: Auth setup (needs DB)
    {
      name: "auth-setup",
      testMatch: /auth\.setup\.ts/,
      dependencies: ["db-setup"],
    },

    // Step 3: Seed data (needs auth)
    {
      name: "seed-setup",
      testMatch: /seed\.setup\.ts/,
      dependencies: ["auth-setup"],
    },

    // Tests (need everything)
    {
      name: "tests",
      testDir: "./tests",
      dependencies: ["seed-setup"],
    },
  ],
});
```

## Setup Projects

### Authentication Setup

Setup projects are the recommended way to handle authentication. They run before your main test projects and can use Playwright fixtures.

> **For complete authentication patterns** (storage state, multiple auth states, auth fixtures), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).

### Data Seeding Setup

```typescript
// seed.setup.ts
import { test as setup } from "@playwright/test";

setup("seed test data", async ({ request }) => {
  // Create test data via API
  await request.post("/api/test/seed", {
    data: {
      users: 10,
      products: 50,
      orders: 100,
    },
  });
});
```

### Cleanup Setup

```typescript
// cleanup.setup.ts
import { test as setup } from "@playwright/test";

setup("cleanup previous run", async ({ request }) => {
  // Clean up data from previous test runs
  await request.delete("/api/test/cleanup");
});
```

## Filtering & Running Projects

### Run Specific Project

```bash
# Run single project
npx playwright test --project=chromium

# Run multiple projects
npx playwright test --project=chromium --project=firefox
```

### Run by Grep

```bash
# Run tests matching pattern
npx playwright test --grep @smoke

# Run project with grep
npx playwright test --project=chromium --grep @critical

# Exclude pattern
npx playwright test --grep-invert @slow
```

### Project-Specific Grep

```typescript
export default defineConfig({
  projects: [
    {
      name: "smoke",
      grep: /@smoke/,
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "regression",
      grepInvert: /@smoke/,
      use: { ...devices["Desktop Chrome"] },
    },
  ],
});
```

## Sharing Configuration

### Base Configuration

```typescript
// playwright.config.ts
const baseConfig = {
  timeout: 30000,
  expect: { timeout: 5000 },
  use: {
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
};

export default defineConfig({
  ...baseConfig,
  projects: [
    {
      name: "chromium",
      use: {
        ...baseConfig.use,
        ...devices["Desktop Chrome"],
      },
    },
    {
      name: "firefox",
      use: {
        ...baseConfig.use,
        ...devices["Desktop Firefox"],
      },
    },
  ],
});
```

### Shared Project Settings

```typescript
const sharedBrowserConfig = {
  timeout: 60000,
  retries: 2,
  use: {
    video: "on-first-retry",
    trace: "on-first-retry",
  },
};

export default defineConfig({
  projects: [
    {
      name: "chromium",
      ...sharedBrowserConfig,
      use: {
        ...sharedBrowserConfig.use,
        ...devices["Desktop Chrome"],
      },
    },
    {
      name: "firefox",
      ...sharedBrowserConfig,
      use: {
        ...sharedBrowserConfig.use,
        ...devices["Desktop Firefox"],
      },
    },
  ],
});
```

## Advanced Patterns

### Conditional Projects

```typescript
const projects = [
  {
    name: "chromium",
    use: { ...devices["Desktop Chrome"] },
  },
];

// Add Firefox only in CI
if (process.env.CI) {
  projects.push({
    name: "firefox",
    use: { ...devices["Desktop Firefox"] },
  });
}

// Add mobile only for specific test dirs
if (process.env.TEST_MOBILE) {
  projects.push({
    name: "mobile",
    use: { ...devices["iPhone 14"] },
  });
}

export default defineConfig({ projects });
```

### Project Metadata

```typescript
export default defineConfig({
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
      metadata: {
        platform: "desktop",
        browser: "chromium",
        priority: "high",
      },
    },
  ],
});

// Access in test
test("example", async ({ page }, testInfo) => {
  const { platform, priority } = testInfo.project.metadata;
  console.log(`Running on ${platform} with ${priority} priority`);
});
```

### Teardown Projects

```typescript
export default defineConfig({
  projects: [
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
      teardown: "teardown", // Run teardown after this completes
    },
    {
      name: "teardown",
      testMatch: /.*\.teardown\.ts/,
    },
    {
      name: "tests",
      dependencies: ["setup"],
    },
  ],
});
```

```typescript
// cleanup.teardown.ts
import { test as teardown } from "@playwright/test";

teardown("cleanup", async ({ request }) => {
  await request.delete("/api/test/data");
});
```

## Anti-Patterns to Avoid

| Anti-Pattern               | Problem                | Solution                            |
| -------------------------- | ---------------------- | ----------------------------------- |
| Too many browser projects  | Slow CI, expensive     | Focus on critical browsers          |
| Missing setup dependencies | Tests fail randomly    | Declare all dependencies explicitly |
| Duplicated configuration   | Hard to maintain       | Extract shared config               |
| Not using setup projects   | Repeated auth in tests | Use setup project + storageState    |

## Related References

- **Global Setup**: See [global-setup.md](global-setup.md) for globalSetup vs setup projects
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for authentication patterns
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for running projects in CI
core/test-data.md
# Test Data Factories & Generators

This file covers **reusable test data builders** (factories, Faker, data generators). For related topics:

- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)

## Table of Contents

1. [Factory Pattern](#factory-pattern)
2. [Faker Integration](#faker-integration)
3. [Data-Driven Testing](#data-driven-testing)
4. [Test Data Fixtures](#test-data-fixtures)
5. [Database Seeding](#database-seeding)

## Factory Pattern

### Basic Factory

```typescript
// factories/user.factory.ts
interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "user" | "guest";
  createdAt: Date;
}

let userIdCounter = 0;

export function createUser(overrides: Partial<User> = {}): User {
  userIdCounter++;
  return {
    id: `user-${userIdCounter}`,
    email: `user${userIdCounter}@test.com`,
    name: `Test User ${userIdCounter}`,
    role: "user",
    createdAt: new Date(),
    ...overrides,
  };
}

// Usage
const user = createUser();
const admin = createUser({ role: "admin", name: "Admin User" });
```

### Factory with Traits

```typescript
// factories/product.factory.ts
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
  featured: boolean;
}

type ProductTrait = "outOfStock" | "featured" | "expensive" | "sale";

const traits: Record<ProductTrait, Partial<Product>> = {
  outOfStock: { stock: 0 },
  featured: { featured: true },
  expensive: { price: 999.99 },
  sale: { price: 9.99 },
};

let productIdCounter = 0;

export function createProduct(
  overrides: Partial<Product> = {},
  ...traitNames: ProductTrait[]
): Product {
  productIdCounter++;

  const appliedTraits = traitNames.reduce(
    (acc, trait) => ({ ...acc, ...traits[trait] }),
    {},
  );

  return {
    id: `prod-${productIdCounter}`,
    name: `Product ${productIdCounter}`,
    price: 29.99,
    stock: 100,
    category: "General",
    featured: false,
    ...appliedTraits,
    ...overrides,
  };
}

// Usage
const product = createProduct();
const featuredProduct = createProduct({}, "featured");
const saleItem = createProduct({ name: "Sale Item" }, "sale", "featured");
const soldOut = createProduct({}, "outOfStock");
```

### Factory with Relationships

```typescript
// factories/order.factory.ts
import { createUser, User } from "./user.factory";
import { createProduct, Product } from "./product.factory";

interface OrderItem {
  product: Product;
  quantity: number;
}

interface Order {
  id: string;
  user: User;
  items: OrderItem[];
  total: number;
  status: "pending" | "paid" | "shipped" | "delivered";
}

let orderIdCounter = 0;

export function createOrder(overrides: Partial<Order> = {}): Order {
  orderIdCounter++;

  const user = overrides.user ?? createUser();
  const items = overrides.items ?? [{ product: createProduct(), quantity: 1 }];
  const total = items.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0,
  );

  return {
    id: `order-${orderIdCounter}`,
    user,
    items,
    total,
    status: "pending",
    ...overrides,
  };
}

// Usage
const order = createOrder();
const bigOrder = createOrder({
  items: [
    { product: createProduct({ price: 100 }), quantity: 5 },
    { product: createProduct({ price: 50 }), quantity: 2 },
  ],
});
```

## Faker Integration

### Setup Faker

```bash
npm install -D @faker-js/faker
```

```typescript
// factories/faker-user.factory.ts
import { faker } from "@faker-js/faker";

interface User {
  id: string;
  email: string;
  name: string;
  avatar: string;
  address: {
    street: string;
    city: string;
    country: string;
    zipCode: string;
  };
}

export function createFakeUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    avatar: faker.image.avatar(),
    address: {
      street: faker.location.streetAddress(),
      city: faker.location.city(),
      country: faker.location.country(),
      zipCode: faker.location.zipCode(),
    },
    ...overrides,
  };
}
```

### Seeded Faker for Reproducibility

```typescript
import { faker } from "@faker-js/faker";

// Set seed for reproducible data
faker.seed(12345);

export function createDeterministicUser(): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    // Same seed = same data every time
  };
}

// Or seed per test
test("user profile", async ({ page }) => {
  faker.seed(42); // Reset seed for this test
  const user = createFakeUser();
  // user will always have the same data
});
```

### Faker Fixture

```typescript
// fixtures/faker.fixture.ts
import { test as base } from "@playwright/test";
import { faker } from "@faker-js/faker";

type FakerFixtures = {
  fake: typeof faker;
};

export const test = base.extend<FakerFixtures>({
  fake: async ({}, use, testInfo) => {
    // Seed based on test name for reproducibility
    faker.seed(testInfo.title.length);
    await use(faker);
  },
});

// Usage
test("create user with fake data", async ({ page, fake }) => {
  await page.goto("/signup");

  await page.getByLabel("Name").fill(fake.person.fullName());
  await page.getByLabel("Email").fill(fake.internet.email());
  await page.getByLabel("Password").fill(fake.internet.password());

  await page.getByRole("button", { name: "Sign Up" }).click();
});
```

## Data-Driven Testing

### test.each with Arrays

```typescript
const loginScenarios = [
  { email: "[email protected]", password: "pass123", expected: "Dashboard" },
  { email: "[email protected]", password: "admin123", expected: "Admin Panel" },
  {
    email: "[email protected]",
    password: "wrong",
    expected: "Invalid credentials",
  },
];

for (const { email, password, expected } of loginScenarios) {
  test(`login with ${email}`, async ({ page }) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill(email);
    await page.getByLabel("Password").fill(password);
    await page.getByRole("button", { name: "Sign In" }).click();

    await expect(page.getByText(expected)).toBeVisible();
  });
}
```

### Parameterized Tests

```typescript
// data/checkout-scenarios.ts
export const checkoutScenarios = [
  {
    name: "standard shipping",
    shipping: "standard",
    expectedDays: "5-7 business days",
    expectedCost: "$5.99",
  },
  {
    name: "express shipping",
    shipping: "express",
    expectedDays: "2-3 business days",
    expectedCost: "$14.99",
  },
  {
    name: "overnight shipping",
    shipping: "overnight",
    expectedDays: "Next business day",
    expectedCost: "$29.99",
  },
];
```

```typescript
import { checkoutScenarios } from "./data/checkout-scenarios";

test.describe("shipping options", () => {
  for (const scenario of checkoutScenarios) {
    test(`checkout with ${scenario.name}`, async ({ page }) => {
      await page.goto("/checkout");

      await page.getByLabel(scenario.shipping, { exact: false }).check();

      await expect(page.getByText(scenario.expectedDays)).toBeVisible();
      await expect(page.getByText(scenario.expectedCost)).toBeVisible();
    });
  }
});
```

### CSV/JSON Data Source

```typescript
import fs from "fs";

interface TestCase {
  input: string;
  expected: string;
}

// Load test data from JSON
const testCases: TestCase[] = JSON.parse(
  fs.readFileSync("./data/search-tests.json", "utf-8"),
);

test.describe("search functionality", () => {
  for (const { input, expected } of testCases) {
    test(`search for "${input}"`, async ({ page }) => {
      await page.goto("/search");
      await page.getByLabel("Search").fill(input);
      await page.getByLabel("Search").press("Enter");

      await expect(page.getByText(expected)).toBeVisible();
    });
  }
});
```

## Test Data Fixtures

### Fixture with Factory

```typescript
// fixtures/data.fixture.ts
import { test as base } from "@playwright/test";
import { createUser, User } from "../factories/user.factory";
import { createProduct, Product } from "../factories/product.factory";

type DataFixtures = {
  testUser: User;
  testProducts: Product[];
};

export const test = base.extend<DataFixtures>({
  testUser: async ({}, use) => {
    const user = createUser({ name: "E2E Test User" });
    await use(user);
  },

  testProducts: async ({}, use) => {
    const products = [
      createProduct({ name: "Test Product 1" }),
      createProduct({ name: "Test Product 2" }),
      createProduct({ name: "Test Product 3" }),
    ];
    await use(products);
  },
});

// Usage
test("add product to cart", async ({ page, testUser, testProducts }) => {
  // Mock API with test data
  await page.route("**/api/user", (route) => route.fulfill({ json: testUser }));
  await page.route("**/api/products", (route) =>
    route.fulfill({ json: testProducts }),
  );

  await page.goto("/products");
  await expect(page.getByText(testProducts[0].name)).toBeVisible();
});
```

## Database Seeding

### API-Based Seeding

```typescript
// fixtures/seed.fixture.ts
import { test as base, APIRequestContext } from "@playwright/test";
import { createUser } from "../factories/user.factory";

type SeedFixtures = {
  seedUser: (overrides?: Partial<User>) => Promise<User>;
  cleanupUsers: string[];
};

export const test = base.extend<SeedFixtures>({
  cleanupUsers: [],

  seedUser: async ({ request, cleanupUsers }, use) => {
    await use(async (overrides = {}) => {
      const userData = createUser(overrides);

      const response = await request.post("/api/test/users", {
        data: userData,
      });
      const user = await response.json();

      cleanupUsers.push(user.id);
      return user;
    });
  },

  // Cleanup after test
  cleanupUsers: async ({ request }, use) => {
    const userIds: string[] = [];
    await use(userIds);

    // Delete all created users
    for (const id of userIds) {
      await request.delete(`/api/test/users/${id}`);
    }
  },
});

// Usage
test("user profile page", async ({ page, seedUser }) => {
  const user = await seedUser({ name: "John Doe" });

  await page.goto(`/users/${user.id}`);
  await expect(page.getByText("John Doe")).toBeVisible();
});
```

### Transaction Rollback Seeding

```typescript
// fixtures/db.fixture.ts
export const test = base.extend<{}, { db: DbTransaction }>({
  db: [
    async ({}, use) => {
      const client = await pool.connect();
      await client.query("BEGIN");

      await use({
        query: (sql: string, params?: any[]) => client.query(sql, params),
        seed: async (table: string, data: object) => {
          const keys = Object.keys(data);
          const values = Object.values(data);
          const placeholders = keys.map((_, i) => `$${i + 1}`);

          const result = await client.query(
            `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`,
            values,
          );
          return result.rows[0];
        },
      });

      await client.query("ROLLBACK");
      client.release();
    },
    { scope: "test" },
  ],
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                    | Problem                         | Solution                   |
| ------------------------------- | ------------------------------- | -------------------------- |
| Hardcoded test data             | Brittle, repetitive             | Use factories              |
| Random data without seed        | Non-reproducible failures       | Seed faker per test        |
| Shared mutable test data        | Tests interfere with each other | Create fresh data per test |
| Manual data creation everywhere | Duplication, maintenance burden | Centralize in factories    |

## Related References

- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for fixture patterns
- **API Testing**: See [test-suite-structure.md](test-suite-structure.md) for API mocking
core/test-suite-structure.md
# Test Suite Structure

## Table of Contents

1. [Configuration](#configuration)
2. [E2E Tests](#e2e-tests)
3. [Component Tests](#component-tests)
4. [API Tests](#api-tests)
5. [Visual Regression Tests](#visual-regression-tests)
6. [Directory Structure](#directory-structure)
7. [Tagging & Filtering](#tagging--filtering)

### Project Setup

```bash
npm init playwright@latest
```

## Configuration

### Essential Configuration

```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [["html"], ["list"]],
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
      dependencies: ["setup"],
    },
  ],
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});
```

## E2E Tests

Full user journey tests through the browser.

### Structure

```typescript
// tests/e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Checkout Flow", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/products");
  });

  test("complete purchase as guest", async ({ page }) => {
    // Add to cart
    await page.getByRole("button", { name: "Add to Cart" }).first().click();
    await expect(page.getByTestId("cart-count")).toHaveText("1");

    // Go to checkout
    await page.getByRole("link", { name: "Cart" }).click();
    await page.getByRole("button", { name: "Checkout" }).click();

    // Fill shipping
    await page.getByLabel("Email").fill("[email protected]");
    await page.getByLabel("Address").fill("123 Test St");
    await page.getByRole("button", { name: "Continue" }).click();

    // Payment
    await page.getByLabel("Card Number").fill("4242424242424242");
    await page.getByRole("button", { name: "Pay Now" }).click();

    // Confirmation
    await expect(page.getByRole("heading")).toHaveText("Order Confirmed");
  });

  test("apply discount code", async ({ page }) => {
    await page.getByRole("button", { name: "Add to Cart" }).first().click();
    await page.getByRole("link", { name: "Cart" }).click();

    await page.getByLabel("Discount Code").fill("SAVE10");
    await page.getByRole("button", { name: "Apply" }).click();

    await expect(page.getByText("10% discount applied")).toBeVisible();
  });
});
```

### Best Practices

- Test critical user journeys
- Keep tests independent
- Use realistic data
- Clean up test data in teardown

## Component Tests

Test individual components in isolation using Playwright Component Testing.

```bash
npm init playwright@latest -- --ct
```

For comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples (React, Vue, Svelte), see **[component-testing.md](../testing-patterns/component-testing.md)**.

## API Tests

Test backend APIs without browser.

### API Mocking Patterns

For E2E tests that need to mock API responses:

```typescript
// Mock single endpoint
test("displays mocked users", async ({ page }) => {
  await page.route("**/api/users", (route) =>
    route.fulfill({
      status: 200,
      json: [{ id: 1, name: "Test User" }],
    })
  );

  await page.goto("/users");
  await expect(page.getByText("Test User")).toBeVisible();
});

// Mock with different responses
test("handles API errors", async ({ page }) => {
  await page.route("**/api/users", (route) =>
    route.fulfill({
      status: 500,
      json: { error: "Server error" },
    })
  );

  await page.goto("/users");
  await expect(page.getByText("Server error")).toBeVisible();
});

// Conditional mocking
test("mocks based on request", async ({ page }) => {
  await page.route("**/api/users", (route, request) => {
    if (request.method() === "GET") {
      route.fulfill({ json: [{ id: 1, name: "User" }] });
    } else {
      route.continue();
    }
  });
});

// Mock with delay (simulate slow network)
test("handles slow API", async ({ page }) => {
  await page.route("**/api/data", (route) =>
    route.fulfill({
      json: { data: "test" },
      delay: 2000, // 2 second delay
    })
  );

  await page.goto("/dashboard");
  await expect(page.getByText("Loading...")).toBeVisible();
  await expect(page.getByText("test")).toBeVisible();
});
```

For advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see **[network-advanced.md](../advanced/network-advanced.md)**.

## Visual Regression Tests

Compare screenshots to detect visual changes.

### Basic Visual Test

```typescript
// tests/visual/homepage.spec.ts
import { test, expect } from "@playwright/test";

test("homepage visual", async ({ page }) => {
  await page.goto("/");
  await expect(page).toHaveScreenshot("homepage.png");
});

test("component visual", async ({ page }) => {
  await page.goto("/components");

  const button = page.getByRole("button", { name: "Primary" });
  await expect(button).toHaveScreenshot("primary-button.png");
});
```

### Visual Test Options

```typescript
test("dashboard visual", async ({ page }) => {
  await page.goto("/dashboard");

  await expect(page).toHaveScreenshot("dashboard.png", {
    fullPage: true, // Capture entire scrollable page
    maxDiffPixels: 100, // Allow up to 100 different pixels
    maxDiffPixelRatio: 0.01, // Or 1% difference
    threshold: 0.2, // Pixel comparison threshold
    animations: "disabled", // Disable animations
    mask: [page.getByTestId("date")], // Mask dynamic content
  });
});
```

### Handling Dynamic Content

```typescript
test("page with dynamic content", async ({ page }) => {
  await page.goto("/profile");

  // Mask elements that change
  await expect(page).toHaveScreenshot("profile.png", {
    mask: [
      page.getByTestId("timestamp"),
      page.getByTestId("avatar"),
      page.getByRole("img"),
    ],
  });
});

// Or hide elements via CSS
test("page hiding dynamic elements", async ({ page }) => {
  await page.goto("/profile");

  await page.addStyleTag({
    content: `
      .dynamic-content { visibility: hidden !important; }
      [data-testid="ad-banner"] { display: none !important; }
    `,
  });

  await expect(page).toHaveScreenshot("profile-stable.png");
});
```

### Visual Test Configuration

```typescript
// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,
      animations: "disabled",
    },
  },
  projects: [
    {
      name: "visual-chrome",
      use: {
        ...devices["Desktop Chrome"],
        viewport: { width: 1280, height: 720 },
      },
      testMatch: /.*visual.*\.spec\.ts/,
    },
  ],
});
```

### Update Snapshots

```bash
# Update all snapshots
npx playwright test --update-snapshots

# Update specific test
npx playwright test homepage.spec.ts --update-snapshots
```

## Directory Structure

```
tests/
├── e2e/                    # End-to-end tests
│   ├── auth.spec.ts
│   ├── checkout.spec.ts
│   └── dashboard.spec.ts
├── component/              # Component tests
│   ├── Button.spec.tsx
│   └── Modal.spec.tsx
├── api/                    # API tests
│   ├── users.spec.ts
│   └── products.spec.ts
├── visual/                 # Visual regression tests
│   └── homepage.spec.ts
├── fixtures/               # Custom fixtures
│   ├── auth.fixture.ts
│   └── api.fixture.ts
└── pages/                  # Page objects
    ├── login.page.ts
    └── dashboard.page.ts
```

## Anti-Patterns to Avoid

| Anti-Pattern                          | Problem                            | Solution                  |
| ------------------------------------- | ---------------------------------- | ------------------------- |
| Long test files                       | Hard to maintain, slow to navigate | Split by feature, use POM |
| Tests depend on execution order       | Flaky, hard to debug               | Keep tests independent    |
| Testing multiple features in one test | Hard to debug failures             | One feature per test      |

## Related References

- **Component Testing**: See [component-testing.md](../testing-patterns/component-testing.md) for comprehensive CT patterns
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project-based filtering
- **Page Objects**: See [page-object-model.md](page-object-model.md) for organizing page interactions
- **Test Data**: See [fixtures-hooks.md](fixtures-hooks.md) for managing test data

## Tagging & Filtering

### Using Tags

```typescript
test("user login @smoke @auth", async ({ page }) => {
  // ...
});

test("checkout flow @e2e @critical", async ({ page }) => {
  // ...
});

test.describe("API tests @api", () => {
  test("create user", async ({ request }) => {
    // ...
  });
});
```

### Running Tagged Tests

```bash
# Run smoke tests
npx playwright test --grep @smoke

# Run all except slow tests
npx playwright test --grep-invert @slow

# Combine tags
npx playwright test --grep "@smoke|@critical"
```

For project-based filtering and advanced project configuration, see **[projects-dependencies.md](projects-dependencies.md)**.
core/test-tags.md
# Test Tags

## Table of Contents

1. [Basic Tagging](#basic-tagging)
2. [Tagging Describe Blocks](#tagging-describe-blocks)
3. [Running Tagged Tests](#running-tagged-tests)
4. [Filtering by Tags](#filtering-by-tags)
5. [Configuration-Based Filtering](#configuration-based-filtering)
6. [Tag Organization Patterns](#tag-organization-patterns)
7. [Common Tag Categories](#common-tag-categories)
8. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
9. [Related References](#related-references)

## Basic Tagging

### Tag via Details Object

```typescript
import { test, expect } from "@playwright/test";

test(
  "test login page",
  {
    tag: "@fast",
  },
  async ({ page }) => {
    await page.goto("/login");
    await expect(page.getByRole("heading")).toBeVisible();
  }
);

test(
  "test dashboard",
  {
    tag: "@slow",
  },
  async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page.getByTestId("charts")).toBeVisible();
  }
);
```

### Tag via Title (not recommended)

```typescript
test("test full report @slow", async ({ page }) => {
  await page.goto("/reports/full");
  await expect(page.getByText("Report loaded")).toBeVisible();
});

test("quick validation @fast @smoke", async ({ page }) => {
  await page.goto("/");
  await expect(page.locator("body")).toBeVisible();
});
```

## Tagging Describe Blocks

### Tag All Tests in Group

```typescript
test.describe(
  "report tests",
  {
    tag: "@report",
  },
  () => {
    test("test report header", async ({ page }) => {
      // Inherits @report tag
    });

    test("test report footer", async ({ page }) => {
      // Inherits @report tag
    });
  }
);
```

### Combine Group and Test Tags

```typescript
test.describe(
  "admin features",
  {
    tag: "@admin",
  },
  () => {
    test("admin dashboard", async ({ page }) => {
      // Has @admin tag
    });

    test(
      "admin settings",
      {
        tag: ["@slow", "@critical"],
      },
      async ({ page }) => {
        // Has @admin, @slow, @critical tags
      }
    );
  }
);
```

## Running Tagged Tests

### Run Tests with Specific Tag

```bash
# Run all @fast tests
npx playwright test --grep @fast
```

### Exclude Tests with Tag

```bash
# Run all tests except @slow
npx playwright test --grep-invert @slow
```

## Filtering by Tags

### Logical OR (Either Tag)

```bash
# Run tests with @fast OR @smoke
npx playwright test --grep "@fast|@smoke"
```

### Logical AND (Both Tags)

```bash
# Run tests with both @fast AND @critical
npx playwright test --grep "(?=.*@fast)(?=.*@critical)"
```

### Complex Patterns

```bash
# Run @e2e tests that are also @critical
npx playwright test --grep "(?=.*@e2e)(?=.*@critical)"

# Run @api tests excluding @slow
npx playwright test --grep "@api" --grep-invert "@slow"
```

## Configuration-Based Filtering

### Filter in playwright.config.ts

```typescript
import { defineConfig } from "@playwright/test";

export default defineConfig({
  grep: /@smoke/,
  grepInvert: /@flaky/,
});
```

### Project-Specific Tags

```typescript
import { defineConfig } from "@playwright/test";

export default defineConfig({
  projects: [
    {
      name: "smoke",
      grep: /@smoke/,
    },
    {
      name: "regression",
      grepInvert: /@smoke/,
    },
    {
      name: "critical-only",
      grep: /@critical/,
    },
  ],
});
```

### Environment-Based Filtering

```typescript
import { defineConfig } from "@playwright/test";

const isCI = !!process.env.CI;

export default defineConfig({
  grep: isCI ? /@smoke|@critical/ : undefined,
  grepInvert: isCI ? /@flaky/ : undefined,
});
```

## Tag Organization Patterns

### By Test Type

```typescript
// Smoke tests - quick validation
test("homepage loads", { tag: "@smoke" }, async ({ page }) => {});
test("login works", { tag: "@smoke" }, async ({ page }) => {});

// Regression tests - comprehensive
test("full checkout flow", { tag: "@regression" }, async ({ page }) => {});
test("all payment methods", { tag: "@regression" }, async ({ page }) => {});

// E2E tests - user journeys
test("complete user journey", { tag: "@e2e" }, async ({ page }) => {});
```

### By Priority

```typescript
test(
  "payment processing",
  {
    tag: ["@critical", "@p0"],
  },
  async ({ page }) => {}
);

test(
  "user preferences",
  {
    tag: ["@p1"],
  },
  async ({ page }) => {}
);

test(
  "theme customization",
  {
    tag: ["@p2"],
  },
  async ({ page }) => {}
);
```

### By Feature Area

```typescript
test.describe(
  "authentication",
  {
    tag: "@auth",
  },
  () => {
    test("login @smoke", async ({ page }) => {});
    test("logout", async ({ page }) => {});
    test("password reset @slow", async ({ page }) => {});
  }
);

test.describe(
  "payments",
  {
    tag: "@payments",
  },
  () => {
    test("credit card @critical", async ({ page }) => {});
    test("paypal @critical", async ({ page }) => {});
  }
);
```

## Common Tag Categories

| Category        | Tags                                          | Purpose                       |
| --------------- | --------------------------------------------- | ----------------------------- |
| **Speed**       | `@fast`, `@slow`                              | Execution time classification |
| **Priority**    | `@critical`, `@p0`, `@p1`, `@p2`              | Business importance           |
| **Type**        | `@smoke`, `@regression`, `@e2e`               | Test suite categorization     |
| **Feature**     | `@auth`, `@payments`, `@settings`             | Feature area grouping         |
| **Pipeline**    | `@pr`, `@nightly`, `@release`                 | CI/CD execution timing        |
| **Status**      | `@flaky`, `@wip`, `@quarantine`               | Test health tracking          |
| **Environment** | `@local`, `@staging`, `@prod`                 | Target environment            |
| **Team**        | `@team-frontend`, `@team-backend`, `@team-qa` | Team assignment               |

## Anti-Patterns to Avoid

| Anti-Pattern             | Problem                  | Solution                                       |
| ------------------------ | ------------------------ | ---------------------------------------------- |
| Too many tags per test   | Hard to maintain         | Limit to 2-3 relevant tags                     |
| Inconsistent naming      | Confusing filtering      | Establish naming conventions                   |
| Missing `@` prefix       | Tags won't match filters | Always prefix with `@`                         |
| Overlapping tag meanings | Ambiguous categorization | Define clear tag semantics                     |
| Not using tags           | Can't selectively run    | Tag by type, priority, or feature              |
| Tags in test title       | Hard to parse/filter     | Use the details object for tags, not the title |

## Related References

- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
- **Annotations**: See [annotations.md](annotations.md) for skip, fixme, fail, slow
- **CI/CD Integration**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for pipeline setup
debugging/console-errors.md
# Browser Console & JavaScript Error Handling

## Table of Contents

1. [Capturing Console Messages](#capturing-console-messages)
2. [Failing on Console Errors](#failing-on-console-errors)
3. [JavaScript Error Detection](#javascript-error-detection)
4. [Monitoring Warnings](#monitoring-warnings)
5. [Console Fixtures](#console-fixtures)

## Capturing Console Messages

### Basic Console Capture

```typescript
test("capture console logs", async ({ page }) => {
  const logs: string[] = [];

  page.on("console", (msg) => {
    logs.push(`${msg.type()}: ${msg.text()}`);
  });

  await page.goto("/");

  // Check what was logged
  console.log("Captured logs:", logs);
});
```

### Capture by Type

```typescript
test("capture specific console types", async ({ page }) => {
  const errors: string[] = [];
  const warnings: string[] = [];
  const infos: string[] = [];

  page.on("console", (msg) => {
    switch (msg.type()) {
      case "error":
        errors.push(msg.text());
        break;
      case "warning":
        warnings.push(msg.text());
        break;
      case "info":
      case "log":
        infos.push(msg.text());
        break;
    }
  });

  await page.goto("/dashboard");

  expect(errors).toHaveLength(0);
  console.log("Warnings:", warnings);
});
```

### Capture with Stack Trace

```typescript
test("capture errors with location", async ({ page }) => {
  const errors: { message: string; location?: string }[] = [];

  page.on("console", async (msg) => {
    if (msg.type() === "error") {
      const location = msg.location();
      errors.push({
        message: msg.text(),
        location: location
          ? `${location.url}:${location.lineNumber}`
          : undefined,
      });
    }
  });

  await page.goto("/buggy-page");

  // Log errors with source location
  errors.forEach((e) => {
    console.log(`Error: ${e.message}`);
    if (e.location) console.log(`  at ${e.location}`);
  });
});
```

## Failing on Console Errors

### Fail Test on Any Error

```typescript
test("no console errors allowed", async ({ page }) => {
  const errors: string[] = [];

  page.on("console", (msg) => {
    if (msg.type() === "error") {
      errors.push(msg.text());
    }
  });

  await page.goto("/");
  await page.getByRole("button", { name: "Load Data" }).click();

  // Fail if any console errors
  expect(errors, `Console errors found:\n${errors.join("\n")}`).toHaveLength(0);
});
```

### Fail with Allowed Exceptions

```typescript
test("no unexpected console errors", async ({ page }) => {
  const allowedErrors = [
    /Failed to load resource.*favicon/,
    /ResizeObserver loop/,
  ];

  const unexpectedErrors: string[] = [];

  page.on("console", (msg) => {
    if (msg.type() === "error") {
      const text = msg.text();
      const isAllowed = allowedErrors.some((pattern) => pattern.test(text));
      if (!isAllowed) {
        unexpectedErrors.push(text);
      }
    }
  });

  await page.goto("/");

  expect(
    unexpectedErrors,
    `Unexpected console errors:\n${unexpectedErrors.join("\n")}`,
  ).toHaveLength(0);
});
```

### Auto-Fail Fixture

```typescript
// fixtures/console.fixture.ts
type ConsoleFixtures = {
  failOnConsoleError: void;
};

export const test = base.extend<ConsoleFixtures>({
  failOnConsoleError: [
    async ({ page }, use, testInfo) => {
      const errors: string[] = [];

      page.on("console", (msg) => {
        if (msg.type() === "error") {
          errors.push(msg.text());
        }
      });

      await use();

      // After test, check for errors
      if (errors.length > 0) {
        testInfo.annotations.push({
          type: "console-errors",
          description: errors.join("\n"),
        });
        throw new Error(`Console errors detected:\n${errors.join("\n")}`);
      }
    },
    { auto: true }, // Runs for every test
  ],
});
```

## JavaScript Error Detection

### Catch Uncaught Exceptions

```typescript
test("no uncaught exceptions", async ({ page }) => {
  const pageErrors: Error[] = [];

  page.on("pageerror", (error) => {
    pageErrors.push(error);
  });

  await page.goto("/");
  await page.getByRole("button", { name: "Trigger Action" }).click();

  expect(
    pageErrors,
    `Uncaught exceptions:\n${pageErrors.map((e) => e.message).join("\n")}`,
  ).toHaveLength(0);
});
```

### Capture Error Details

```typescript
test("capture JS error details", async ({ page }) => {
  const errors: { message: string; stack?: string }[] = [];

  page.on("pageerror", (error) => {
    errors.push({
      message: error.message,
      stack: error.stack,
    });
  });

  await page.goto("/error-page");

  if (errors.length > 0) {
    console.log("JavaScript errors:");
    errors.forEach((e) => {
      console.log(`  Message: ${e.message}`);
      console.log(`  Stack: ${e.stack}`);
    });
  }
});
```

### Test Error Boundary Triggers

```typescript
test("error boundary catches render error", async ({ page }) => {
  let errorCaught = false;

  page.on("pageerror", () => {
    // Note: React error boundaries catch errors before they become pageerrors
    // This would only fire for unhandled errors
    errorCaught = true;
  });

  // Trigger component error via props
  await page.route(
    "**/api/data",
    (route) => route.fulfill({ json: null }), // Will cause "cannot read property of null"
  );

  await page.goto("/dashboard");

  // Error boundary should show fallback, not crash
  await expect(page.getByText("Something went wrong")).toBeVisible();
  expect(errorCaught).toBe(false); // Error was caught by boundary
});
```

## Monitoring Warnings

### Capture Deprecation Warnings

```typescript
test("no deprecation warnings", async ({ page }) => {
  const deprecations: string[] = [];

  page.on("console", (msg) => {
    const text = msg.text();
    if (
      msg.type() === "warning" &&
      (text.includes("deprecated") || text.includes("Deprecation"))
    ) {
      deprecations.push(text);
    }
  });

  await page.goto("/");

  if (deprecations.length > 0) {
    console.warn("Deprecation warnings found:");
    deprecations.forEach((d) => console.warn(`  - ${d}`));
  }

  // Optionally fail
  // expect(deprecations).toHaveLength(0);
});
```

### React Development Warnings

```typescript
test("no React warnings", async ({ page }) => {
  const reactWarnings: string[] = [];

  page.on("console", (msg) => {
    const text = msg.text();
    if (
      msg.type() === "warning" &&
      (text.includes("Warning:") || text.includes("React"))
    ) {
      reactWarnings.push(text);
    }
  });

  await page.goto("/");

  // Common React warnings to check
  const criticalWarnings = reactWarnings.filter(
    (w) =>
      w.includes("Each child in a list should have a unique") ||
      w.includes("Cannot update a component") ||
      w.includes("Can't perform a React state update"),
  );

  expect(
    criticalWarnings,
    `React warnings:\n${criticalWarnings.join("\n")}`,
  ).toHaveLength(0);
});
```

## Console Fixtures

### Comprehensive Console Fixture

```typescript
// fixtures/console.fixture.ts
type ConsoleMessage = {
  type: string;
  text: string;
  location?: { url: string; line: number };
  timestamp: number;
};

type ConsoleFixtures = {
  consoleMessages: ConsoleMessage[];
  getConsoleErrors: () => ConsoleMessage[];
  getConsoleWarnings: () => ConsoleMessage[];
  assertNoErrors: (allowedPatterns?: RegExp[]) => void;
};

export const test = base.extend<ConsoleFixtures>({
  consoleMessages: async ({ page }, use) => {
    const messages: ConsoleMessage[] = [];

    page.on("console", (msg) => {
      const location = msg.location();
      messages.push({
        type: msg.type(),
        text: msg.text(),
        location: location
          ? { url: location.url, line: location.lineNumber }
          : undefined,
        timestamp: Date.now(),
      });
    });

    await use(messages);
  },

  getConsoleErrors: async ({ consoleMessages }, use) => {
    await use(() => consoleMessages.filter((m) => m.type === "error"));
  },

  getConsoleWarnings: async ({ consoleMessages }, use) => {
    await use(() => consoleMessages.filter((m) => m.type === "warning"));
  },

  assertNoErrors: async ({ getConsoleErrors }, use) => {
    await use((allowedPatterns = []) => {
      const errors = getConsoleErrors();
      const unexpected = errors.filter(
        (e) => !allowedPatterns.some((p) => p.test(e.text)),
      );

      if (unexpected.length > 0) {
        throw new Error(
          `Unexpected console errors:\n${unexpected.map((e) => e.text).join("\n")}`,
        );
      }
    });
  },
});

// Usage
test("page loads without errors", async ({ page, assertNoErrors }) => {
  await page.goto("/dashboard");
  await page.getByRole("button", { name: "Load" }).click();

  assertNoErrors([/favicon/]); // Allow favicon errors
});
```

### Attach Console to Report

```typescript
test("capture console for debugging", async ({ page }, testInfo) => {
  const logs: string[] = [];

  page.on("console", (msg) => {
    logs.push(`[${msg.type()}] ${msg.text()}`);
  });

  page.on("pageerror", (error) => {
    logs.push(`[EXCEPTION] ${error.message}`);
  });

  await page.goto("/");
  // ... test actions

  // Attach console log to test report
  await testInfo.attach("console-log", {
    body: logs.join("\n"),
    contentType: "text/plain",
  });
});
```

## Anti-Patterns to Avoid

| Anti-Pattern               | Problem                    | Solution                    |
| -------------------------- | -------------------------- | --------------------------- |
| Ignoring console errors    | Bugs go unnoticed          | Check for errors in tests   |
| Too strict error checking  | Tests fail on minor issues | Allow known/expected errors |
| Not capturing stack traces | Hard to debug              | Include location info       |
| Checking only at end       | Miss errors during actions | Capture continuously        |

## Related References

- **Debugging**: See [debugging.md](debugging.md) for troubleshooting
- **Error Testing**: See [error-testing.md](error-testing.md) for error scenarios
debugging/debugging.md
# Debugging & Troubleshooting

## Table of Contents

1. [Debug Tools](#debug-tools)
2. [Trace Viewer](#trace-viewer)
3. [Identifying Flaky Tests](#identifying-flaky-tests)
4. [Debugging Network Issues](#debugging-network-issues)
5. [Debugging in CI](#debugging-in-ci)
6. [Debugging Authentication](#debugging-authentication)
7. [Debugging Screenshots](#debugging-screenshots)
8. [Common Issues](#common-issues)
9. [Logging](#logging)

## Debug Tools

### Playwright Inspector

```bash
# Run with inspector
PWDEBUG=1 npx playwright test
# Or specific test
PWDEBUG=1 npx playwright test login.spec.ts
```

Features:

- Step through test actions
- Pick locators visually
- Inspect DOM state
- Edit and re-run

### Headed Mode

```bash
# Run with visible browser
npx playwright test --headed

# Interactive debugging (headed, paused, step-through)
npx playwright test --debug
```

You can also set `slowMo` to add an `N` ms delay per action, making test execution easier to follow while debugging.

```typescript
// playwright.config.ts
export default defineConfig({
  use: {
    launchOptions: {
      slowMo: 500,
    },
  },
});
```

### UI Mode

```bash
# Interactive test runner
npx playwright test --ui
```

Features:

- Watch mode
- Test timeline
- DOM snapshots
- Network logs
- Console logs

### Debug in Code

```typescript
test("debug example", async ({ page }) => {
  await page.goto("/");

  // Pause and open inspector
  await page.pause();

  // Continue test...
  await page.click("button");
});
```

## Trace Viewer

### Enable Traces

```typescript
// playwright.config.ts
export default defineConfig({
  use: {
    trace: "on-first-retry", // Record on retry
    // trace: 'on',                 // Always record
    // trace: 'retain-on-failure',  // Keep only failures
  },
});
```

### View Traces

```bash
# Open trace file
npx playwright show-trace trace.zip

# From test-results
npx playwright show-trace test-results/test-name/trace.zip
```

### Trace Contents

- Screenshots at each action
- DOM snapshots
- Network requests/responses
- Console logs
- Action timeline
- Source code

### Programmatic Traces

```typescript
test("manual trace", async ({ page, context }) => {
  await context.tracing.start({ screenshots: true, snapshots: true });

  await page.goto("/");
  await page.click("button");

  await context.tracing.stop({ path: "trace.zip" });
});
```

## Identifying Flaky Tests

If a test fails intermittently, it's likely flaky. Quick checks:

| Behavior                               | Likely Cause                  | Next Step                              |
| -------------------------------------- | ----------------------------- | -------------------------------------- |
| Fails sometimes, passes other times    | Flaky - timing/race condition | [flaky-tests.md](flaky-tests.md)       |
| Fails only with multiple workers       | Flaky - parallelism/isolation | [flaky-tests.md](flaky-tests.md)       |
| Fails only in CI                       | Environment difference        | [CI Debugging](#debugging-in-ci) below |
| Always fails                           | Bug in test or app            | Debug with tools above                 |
| Always passes locally, always fails CI | CI-specific issue             | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md)                   |

> **For flaky test detection commands, root cause analysis, and fixing strategies**, see [flaky-tests.md](flaky-tests.md).

## Debugging Network Issues

### Monitor All Requests

```typescript
test("debug network", async ({ page }) => {
  const requests: string[] = [];
  const failures: string[] = [];

  page.on("request", (req) => requests.push(`>> ${req.method()} ${req.url()}`));
  page.on("requestfinished", (req) => {
    const resp = req.response();
    requests.push(`<< ${resp?.status()} ${req.url()}`);
  });
  page.on("requestfailed", (req) => {
    failures.push(`FAILED: ${req.url()} - ${req.failure()?.errorText}`);
  });

  await page.goto("/dashboard");

  // Log summary
  console.log("Requests:", requests.length);
  if (failures.length) console.log("Failures:", failures);
});
```

### Wait for Specific API Response

When debugging network-dependent issues, wait for specific API responses instead of arbitrary timeouts.

```typescript
// Start waiting BEFORE triggering the request
const responsePromise = page.waitForResponse(
  (resp) => resp.url().includes("/api/data") && resp.status() === 200,
);
await page.getByRole("button", { name: "Load" }).click();
const response = await responsePromise;
console.log("Status:", response.status());
```

> **For comprehensive waiting patterns** (navigation, element state, network, polling), see [assertions-waiting.md](../core/assertions-waiting.md#waiting-strategies).

### Debug Slow Requests

```typescript
test("find slow requests", async ({ page }) => {
  page.on("requestfinished", (request) => {
    const timing = request.timing();
    const total = timing.responseEnd - timing.requestStart;
    if (total > 1000) {
      console.log(`SLOW (${total}ms): ${request.url()}`);
    }
  });

  await page.goto("/");
});
```

## Debugging in CI

### Simulate CI Locally

```bash
# Run in headless mode like CI
CI=true npx playwright test

# Match CI browser versions
npx playwright install --with-deps

# Run in Docker (same as CI)
docker run --rm -v $(pwd):/work -w /work \
  mcr.microsoft.com/playwright:v1.40.0-jammy \
  npx playwright test
```

### CI-Specific Configuration

```typescript
// playwright.config.ts
export default defineConfig({
  // More artifacts in CI for debugging
  use: {
    trace: process.env.CI ? "on-first-retry" : "off",
    video: process.env.CI ? "retain-on-failure" : "off",
    screenshot: process.env.CI ? "only-on-failure" : "off",
  },

  // More retries in CI (but investigate failures!)
  retries: process.env.CI ? 2 : 0,
});
```

### Debug CI Environment

```typescript
test("CI environment check", async ({ page }, testInfo) => {
  console.log("CI:", process.env.CI);
  console.log("Project:", testInfo.project.name);
  console.log("Worker:", testInfo.workerIndex);
  console.log("Retry:", testInfo.retry);
  console.log("Base URL:", testInfo.project.use.baseURL);

  // Check viewport
  const viewport = page.viewportSize();
  console.log("Viewport:", viewport);
});
```

## Debugging Authentication

```typescript
test("debug auth", async ({ page, context }) => {
  // Inspect current storage state
  const storage = await context.storageState();
  console.log(
    "Cookies:",
    storage.cookies.map((c) => c.name),
  );

  // Check if auth cookies are present
  const cookies = await context.cookies();
  const authCookie = cookies.find((c) => c.name.includes("session"));
  console.log("Auth cookie:", authCookie ? "present" : "MISSING");

  await page.goto("/protected");

  // Check if redirected to login (auth failed)
  if (page.url().includes("/login")) {
    console.error("Auth failed - redirected to login");
    // Save state for inspection
    await context.storageState({ path: "debug-auth.json" });
  }
});
```

## Debugging Screenshots

### Compare Visual State

```typescript
test("visual debug", async ({ page }, testInfo) => {
  await page.goto("/");

  // Screenshot before action
  await page.screenshot({
    path: testInfo.outputPath("before.png"),
    fullPage: true,
  });

  await page.getByRole("button", { name: "Open Menu" }).click();

  // Screenshot after action
  await page.screenshot({
    path: testInfo.outputPath("after.png"),
    fullPage: true,
  });

  // Attach to report
  await testInfo.attach("before", {
    path: testInfo.outputPath("before.png"),
    contentType: "image/png",
  });
});
```

### Screenshot Specific Element

```typescript
test("element screenshot", async ({ page }) => {
  await page.goto("/");

  const element = page.getByTestId("problem-area");

  // Screenshot just the element
  await element.screenshot({ path: "element-debug.png" });

  // Highlight element in full page screenshot
  await element.evaluate((el) => (el.style.border = "3px solid red"));
  await page.screenshot({ path: "highlighted.png" });
});
```

## Common Issues

### Element Not Found

```typescript
// Debug: Check if element exists
console.log(await page.getByRole("button").count());

// Debug: Log all buttons
const buttons = await page.getByRole("button").all();
for (const button of buttons) {
  console.log(await button.textContent());
}

// Debug: Screenshot before action
await page.screenshot({ path: "debug.png" });
await page.getByRole("button").click();
```

### Timeout Issues

```typescript
// Increase timeout for slow operations
await expect(page.getByText("Loaded")).toBeVisible({ timeout: 30000 });

// Global timeout increase
test.setTimeout(60000);

// Check what's blocking
test("debug timeout", async ({ page }) => {
  await page.goto("/slow-page");

  // Log network activity
  page.on("request", (request) => console.log(">>", request.url()));
  page.on("response", (response) =>
    console.log("<<", response.url(), response.status()),
  );
});
```

### Selector Issues

```typescript
// Debug: Highlight element
await page.getByRole("button").highlight();

// Debug: Evaluate selector in browser console
// Run in Inspector console:
// playwright.locator('button').first().highlight()

// Debug: Get element info
const element = page.getByRole("button");
console.log("Count:", await element.count());
console.log("Visible:", await element.isVisible());
console.log("Enabled:", await element.isEnabled());
```

### Frame Issues

```typescript
// Debug: List all frames
for (const frame of page.frames()) {
  console.log("Frame:", frame.url());
}

// Debug: Check if element is in iframe
const frame = page.frameLocator("iframe").first();
console.log(await frame.getByRole("button").count());
```

## Logging

### Capture Browser Console

```typescript
test("with logging", async ({ page }) => {
  page.on("console", (msg) => console.log("Browser:", msg.text()));
  page.on("pageerror", (error) => console.log("Page error:", error.message));
  await page.goto("/");
});
```

> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).

### Custom Test Attachments

```typescript
test("with attachments", async ({ page }, testInfo) => {
  // Attach screenshot to report
  const screenshot = await page.screenshot();
  await testInfo.attach("screenshot", {
    body: screenshot,
    contentType: "image/png",
  });

  // Attach logs or data
  await testInfo.attach("logs", {
    body: "Custom log data",
    contentType: "text/plain",
  });

  // Use testInfo for output paths
  const outputPath = testInfo.outputPath("debug-file.json");
});
```

## Troubleshooting Checklist

### By Symptom

| Symptom                                       | Common Causes                                                | Quick Fixes                                                         | Reference                                                                  |
| --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **Element not found**                         | Wrong selector, element not visible, in iframe, timing issue | Check locator with Inspector, wait for visibility, use frameLocator | [locators.md](../core/locators.md), [assertions-waiting.md](../core/assertions-waiting.md) |
| **Timeout errors**                            | Slow network, heavy page load, waiting for wrong condition   | Increase timeout, wait for specific response, check network tab     | [assertions-waiting.md](../core/assertions-waiting.md)                             |
| **Flaky tests**                               | Race conditions, shared state, timing dependencies           | See comprehensive flaky test guide                                  | [flaky-tests.md](flaky-tests.md)                                           |
| **Tests pass locally, fail in CI**            | Environment differences, missing dependencies, timing        | Simulate CI locally, check CI logs, verify environment vars         | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md), [flaky-tests.md](flaky-tests.md)                     |
| **Slow test execution**                       | Not parallelized, heavy network calls, unnecessary waits     | Enable parallelization, mock APIs, optimize waits                   | [performance.md](../infrastructure-ci-cd/performance.md)                                           |
| **Selector works in browser but not in test** | Element not attached, wrong context, dynamic content         | Use auto-waiting, check iframe, verify element state                | [locators.md](../core/locators.md)                                                 |
| **Test fails on retry**                       | Non-deterministic data, external dependencies                | Use test data fixtures, mock external services                      | [fixtures-hooks.md](../core/fixtures-hooks.md)                                     |

### Step-by-Step Debugging Process

1. **Reproduce the issue**

   ```bash
   # Run with trace enabled
   npx playwright test tests/failing.spec.ts --trace on

   # If intermittent, run multiple times
   npx playwright test --repeat-each=10
   ```

2. **Inspect the failure**

   ```bash
   # View trace
   npx playwright show-trace test-results/path-to-trace.zip

   # Run in headed mode to watch
   npx playwright test --headed

   # Use inspector for step-by-step
   PWDEBUG=1 npx playwright test
   ```

3. **Isolate the problem**

   ```typescript
   // Add debugging points
   await page.pause();

   // Log element state
   console.log("Element count:", await page.getByRole("button").count());
   console.log("Element visible:", await page.getByRole("button").isVisible());

   // Take screenshot at failure point
   await page.screenshot({ path: "debug.png" });
   ```

4. **Check related areas**
   - Network requests: Are API calls completing? (see [Debugging Network Issues](#debugging-network-issues))
   - Timing: Is auto-waiting working correctly?
   - State: Is the test isolated? (see [flaky-tests.md](flaky-tests.md))
   - Environment: Does it work locally but fail in CI? (see [Debugging in CI](#debugging-in-ci))

5. **Apply fix and verify**
   - Fix the root cause (not just symptoms)
   - Run multiple times to confirm stability: `--repeat-each=10`
   - Check related tests aren't affected

## Related References

- **Flaky tests**: See [flaky-tests.md](flaky-tests.md) for comprehensive flaky test guide
- **Locator issues**: See [locators.md](../core/locators.md) for selector strategies
- **Waiting problems**: See [assertions-waiting.md](../core/assertions-waiting.md) for waiting patterns
- **Test isolation**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for fixtures and isolation
- **CI issues**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI configuration
debugging/error-testing.md
# Error & Edge Case Testing

## Table of Contents

1. [Error Boundaries](#error-boundaries)
2. [Network Failures](#network-failures)
3. [Offline Testing](#offline-testing)
4. [Loading States](#loading-states)
5. [Form Validation](#form-validation)

## Error Boundaries

### Test Component Errors

```typescript
test("error boundary catches component error", async ({ page }) => {
  // Trigger error via mock
  await page.route("**/api/user", (route) => {
    route.fulfill({
      json: null, // Will cause component to throw
    });
  });

  await page.goto("/profile");

  // Error boundary should render fallback
  await expect(page.getByText("Something went wrong")).toBeVisible();
  await expect(page.getByRole("button", { name: "Try Again" })).toBeVisible();
});
```

### Test Error Recovery

```typescript
test("recover from error state", async ({ page }) => {
  let requestCount = 0;

  await page.route("**/api/data", (route) => {
    requestCount++;
    if (requestCount === 1) {
      return route.fulfill({ status: 500 });
    }
    return route.fulfill({
      json: { data: "success" },
    });
  });

  await page.goto("/dashboard");

  // Error state
  await expect(page.getByText("Failed to load")).toBeVisible();

  // Retry
  await page.getByRole("button", { name: "Retry" }).click();

  // Success state
  await expect(page.getByText("success")).toBeVisible();
});
```

### Test JavaScript Errors

```typescript
test("handles runtime error gracefully", async ({ page }) => {
  const errors: string[] = [];

  page.on("pageerror", (error) => {
    errors.push(error.message);
  });

  await page.goto("/buggy-page");

  // App should still be functional despite error
  await expect(page.getByRole("navigation")).toBeVisible();

  // Error was logged
  expect(errors.length).toBeGreaterThan(0);
});
```

## Network Failures

### Test API Errors

```typescript
test.describe("API error handling", () => {
  const errorCodes = [400, 401, 403, 404, 500, 502, 503];

  for (const status of errorCodes) {
    test(`handles ${status} error`, async ({ page }) => {
      await page.route("**/api/data", (route) =>
        route.fulfill({
          status,
          json: { error: `Error ${status}` },
        }),
      );

      await page.goto("/dashboard");

      // Appropriate error message shown
      await expect(page.getByRole("alert")).toBeVisible();
    });
  }
});
```

### Test Timeout

```typescript
test("handles request timeout", async ({ page }) => {
  await page.route("**/api/slow", async (route) => {
    // Never respond - simulates timeout
    await new Promise(() => {});
  });

  await page.goto("/slow-page");

  // Should show timeout message (app should have its own timeout)
  await expect(page.getByText("Request timed out")).toBeVisible({
    timeout: 15000,
  });
});
```

### Test Connection Reset

```typescript
test("handles connection failure", async ({ page }) => {
  await page.route("**/api/data", (route) => {
    route.abort("connectionfailed");
  });

  await page.goto("/dashboard");

  await expect(page.getByText("Connection failed")).toBeVisible();
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
```

### Test Mid-Request Failure

```typescript
test("handles failure during request", async ({ page }) => {
  let requestStarted = false;

  await page.route("**/api/upload", async (route) => {
    requestStarted = true;
    // Abort after small delay (mid-request)
    await new Promise((resolve) => setTimeout(resolve, 500));
    route.abort("failed");
  });

  await page.goto("/upload");
  await page.getByLabel("File").setInputFiles("./fixtures/large-file.pdf");
  await page.getByRole("button", { name: "Upload" }).click();

  // Should show failure, not hang
  await expect(page.getByText("Upload failed")).toBeVisible();
  expect(requestStarted).toBe(true);
});
```

## Offline Testing

This section covers **unexpected network failures** and error recovery. For **offline-first apps (PWAs)** with service workers, caching, and background sync, see [service-workers.md](service-workers.md#offline-testing).

### Go Offline During Session

```typescript
test("handles going offline", async ({ page, context }) => {
  await page.goto("/dashboard");
  await expect(page.getByTestId("data")).toBeVisible();

  // Go offline unexpectedly
  await context.setOffline(true);

  // Try to refresh data
  await page.getByRole("button", { name: "Refresh" }).click();

  // Should show offline indicator
  await expect(page.getByText("You're offline")).toBeVisible();

  // Go back online
  await context.setOffline(false);

  // Should recover
  await page.getByRole("button", { name: "Refresh" }).click();
  await expect(page.getByText("You're offline")).toBeHidden();
});
```

### Test Network Recovery

```typescript
test("recovers gracefully when connection returns", async ({
  page,
  context,
}) => {
  await page.goto("/dashboard");

  // Simulate connection drop
  await context.setOffline(true);

  // App should show degraded state
  await expect(page.getByRole("alert")).toContainText(/offline|connection/i);

  // Connection restored
  await context.setOffline(false);

  // Retry should work
  await page.getByRole("button", { name: "Retry" }).click();
  await expect(page.getByTestId("data")).toBeVisible();
});
```

## Loading States

### Test Skeleton Loaders

```typescript
test("shows skeleton during load", async ({ page }) => {
  // Add delay to API response
  await page.route("**/api/posts", async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    route.fulfill({
      json: [{ id: 1, title: "Post 1" }],
    });
  });

  await page.goto("/posts");

  // Skeleton should appear immediately
  await expect(page.getByTestId("skeleton")).toBeVisible();

  // Then content replaces skeleton
  await expect(page.getByText("Post 1")).toBeVisible();
  await expect(page.getByTestId("skeleton")).toBeHidden();
});
```

### Test Loading Indicators

```typescript
test("shows loading state for actions", async ({ page }) => {
  await page.route("**/api/save", async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 500));
    route.fulfill({ json: { success: true } });
  });

  await page.goto("/editor");
  await page.getByLabel("Content").fill("New content");

  const saveButton = page.getByRole("button", { name: "Save" });
  await saveButton.click();

  // Button should show loading state
  await expect(saveButton).toBeDisabled();
  await expect(page.getByTestId("spinner")).toBeVisible();

  // Then success state
  await expect(saveButton).toBeEnabled();
  await expect(page.getByText("Saved")).toBeVisible();
});
```

### Test Empty States

```typescript
test("shows empty state when no data", async ({ page }) => {
  await page.route("**/api/items", (route) => route.fulfill({ json: [] }));

  await page.goto("/items");

  await expect(page.getByText("No items yet")).toBeVisible();
  await expect(
    page.getByRole("button", { name: "Create First Item" }),
  ).toBeVisible();
});
```

## Form Validation

### Test Client-Side Validation

```typescript
test("validates required fields", async ({ page }) => {
  await page.goto("/signup");

  // Submit empty form
  await page.getByRole("button", { name: "Sign Up" }).click();

  // Should show validation errors
  await expect(page.getByText("Email is required")).toBeVisible();
  await expect(page.getByText("Password is required")).toBeVisible();

  // Form should not submit
  await expect(page).toHaveURL("/signup");
});
```

### Test Format Validation

```typescript
test("validates email format", async ({ page }) => {
  await page.goto("/signup");

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByLabel("Email").blur();

  await expect(page.getByText("Invalid email address")).toBeVisible();

  // Fix the error
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Email").blur();

  await expect(page.getByText("Invalid email address")).toBeHidden();
});
```

### Test Server-Side Validation

```typescript
test("handles server validation errors", async ({ page }) => {
  await page.route("**/api/register", (route) =>
    route.fulfill({
      status: 422,
      json: {
        errors: {
          email: "Email already exists",
          username: "Username is taken",
        },
      },
    }),
  );

  await page.goto("/signup");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Username").fill("takenuser");
  await page.getByLabel("Password").fill("password123");
  await page.getByRole("button", { name: "Sign Up" }).click();

  // Server errors should display
  await expect(page.getByText("Email already exists")).toBeVisible();
  await expect(page.getByText("Username is taken")).toBeVisible();
});
```

## Anti-Patterns to Avoid

| Anti-Pattern             | Problem                        | Solution                               |
| ------------------------ | ------------------------------ | -------------------------------------- |
| Only testing happy path  | Misses error handling bugs     | Test all error scenarios               |
| No network failure tests | App crashes on poor connection | Test offline/slow/failed requests      |
| Skipping loading states  | Janky UX not caught            | Assert loading UI appears              |
| Ignoring validation      | Form bugs slip through         | Test both client and server validation |

## Related References

- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for mock patterns
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for error assertions
debugging/flaky-tests.md
# Debugging and Managing Flaky Tests

## Table of Contents

1. [Understanding Flakiness Types](#understanding-flakiness-types)
2. [Detection and Reproduction](#detection-and-reproduction)
3. [Root Cause Analysis](#root-cause-analysis)
4. [Fixing Strategies by Type](#fixing-strategies-by-type)
5. [CI-Specific Flakiness](#ci-specific-flakiness)
6. [Quarantine and Management](#quarantine-and-management)
7. [Prevention Strategies](#prevention-strategies)

## Understanding Flakiness Types

### Categories of Flakiness

Most flaky tests fall into distinct categories requiring different remediation:

| Category                    | Symptoms                        | Common Causes                                          |
| --------------------------- | ------------------------------- | ------------------------------------------------------ |
| **UI-driven**               | Element not found, click missed | Missing waits, animations, dynamic rendering           |
| **Environment-driven**      | CI-only failures                | Slower CPU, memory limits, cold browser starts         |
| **Data/parallelism-driven** | Fails with multiple workers     | Shared backend data, reused accounts, state collisions |
| **Test-suite-driven**       | Fails when run with other tests | Leaked state, shared fixtures, order dependencies      |

### Flakiness Decision Tree

```
Test fails intermittently
├─ Fails locally too?
│  ├─ YES → Timing/async issue → Check waits and assertions
│  └─ NO → CI-specific → Check environment differences
│
├─ Fails only with multiple workers?
│  └─ YES → Parallelism issue → Check data isolation
│
├─ Fails only when run after specific tests?
│  └─ YES → State leak → Check fixtures and cleanup
│
└─ Fails randomly regardless of conditions?
   └─ External dependency → Check network/API stability
```

## Detection and Reproduction

### Confirming Flakiness

```bash
# Run test multiple times to confirm instability
npx playwright test tests/checkout.spec.ts --repeat-each=20

# Run with single worker to isolate parallelism issues
npx playwright test --workers=1

# Run in CI-like conditions locally
CI=true npx playwright test --repeat-each=10
```

### Reproduction Strategies

```typescript
// playwright.config.ts - Enable artifacts for flaky test investigation
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: "on-first-retry", // Capture trace on retry
    video: "retain-on-failure",
    screenshot: "only-on-failure",
  },
});
```

### Identify Flaky Tests Programmatically

```typescript
// Track test results across runs
test.afterEach(async ({}, testInfo) => {
  if (testInfo.retry > 0 && testInfo.status === "passed") {
    console.warn(`FLAKY: ${testInfo.title} passed on retry ${testInfo.retry}`);
    // Log to your tracking system
  }
});
```

## Root Cause Analysis

### Event Logging for Race Conditions

Add comprehensive event logging to expose timing issues:

```typescript
test.beforeEach(async ({ page }) => {
  page.on("console", (msg) =>
    console.log(`CONSOLE [${msg.type()}]:`, msg.text()),
  );
  page.on("pageerror", (err) => console.error("PAGE ERROR:", err.message));
  page.on("requestfailed", (req) =>
    console.error(`REQUEST FAILED: ${req.url()}`),
  );
});
```

> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).

### Network Timing Analysis

```typescript
// Capture slow or failed requests
test.beforeEach(async ({ page }) => {
  const slowRequests: string[] = [];

  page.on("requestfinished", (request) => {
    const timing = request.timing();
    const duration = timing.responseEnd - timing.requestStart;
    if (duration > 2000) {
      slowRequests.push(`${request.url()} took ${duration}ms`);
    }
  });

  page.on("requestfailed", (request) => {
    console.error(`Failed: ${request.url()} - ${request.failure()?.errorText}`);
  });
});
```

### Trace Analysis

```bash
# View trace from failed CI run
npx playwright show-trace path/to/trace.zip

# Generate trace for specific test
npx playwright test tests/flaky.spec.ts --trace on
```

## Fixing Strategies by Type

### UI-Driven Flakiness

**Problem: Element not ready when action executes**

```typescript
// ❌ BAD: No wait for element state
await page.click("#submit");
await page.fill("#username", "test"); // Element may not be ready

// ✅ GOOD: Actions + assertions pattern (auto-waiting built-in)
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
```

**Problem: Animations or transitions interfere**

```typescript
// ❌ BAD: Click during animation
await page.click(".menu-item");

// ✅ GOOD: Wait for animation to complete
await page.getByRole("menuitem", { name: "Settings" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
// Or disable animations entirely
await page.emulateMedia({ reducedMotion: "reduce" });
```

**Problem: Brittle selectors**

```typescript
// ❌ BAD: Fragile CSS chain
await page.click("div.container > div:nth-child(2) > button.btn-primary");

// ✅ GOOD: Semantic selectors
await page.getByRole("button", { name: "Continue" }).click();
await page.getByTestId("checkout-button").click();
await page.getByLabel("Email address").fill("[email protected]");
```

### Async/Timing Flakiness

**Problem: Race between test and application**

```typescript
// ❌ BAD: Arbitrary sleep
await page.click("#load-data");
await page.waitForTimeout(3000); // Hope data loads in 3s

// ✅ GOOD: Wait for specific condition
await page.click("#load-data");
await expect(page.locator(".data-row")).toHaveCount(10, { timeout: 10000 });

// ✅ BETTER: Wait for network response, then assert
const responsePromise = page.waitForResponse(
  (r) =>
    r.url().includes("/api/data") &&
    r.request().method() === "GET" &&
    r.ok(),
);
await page.click("#load-data");
await responsePromise;
await expect(page.locator(".data-row")).toHaveCount(10);
```

> **For comprehensive waiting strategies** (navigation, element state, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).

**Problem: Complex async state**

```typescript
// Custom wait for application-specific conditions
await page.waitForFunction(() => {
  const app = (window as any).__APP_STATE__;
  return app?.isReady && !app?.isLoading;
});

// Wait for multiple conditions
await Promise.all([
  page.waitForResponse("**/api/user"),
  page.waitForResponse("**/api/settings"),
  page.getByRole("button", { name: "Load" }).click(),
]);
```

### Data/Parallelism-Driven Flakiness

**Problem: Tests share backend data**

```typescript
// ❌ BAD: All workers use same user
const testUser = { email: "[email protected]", password: "pass123" };

// ✅ GOOD: Unique data per worker
import { test as base } from "@playwright/test";

export const test = base.extend<
  {},
  { testUser: { email: string; id: string } }
>({
  testUser: [
    async ({}, use, workerInfo) => {
      const email = `test-${workerInfo.workerIndex}-${Date.now()}@example.com`;
      const user = await createTestUser(email);
      await use(user);
      await deleteTestUser(user.id);
    },
    { scope: "worker" },
  ],
});
```

**Problem: Shared storageState across workers**

```typescript
// ❌ BAD: All workers share same auth state
use: {
  storageState: '.auth/user.json',
}

// ✅ GOOD: Per-worker auth state
export const test = base.extend<{}, { workerStorageState: string }>({
  workerStorageState: [
    async ({ browser }, use, workerInfo) => {
      const id = workerInfo.workerIndex;
      const fileName = `.auth/user-${id}.json`;

      if (!fs.existsSync(fileName)) {
        const page = await browser.newPage({ storageState: undefined });
        await authenticateUser(page, `worker${id}@test.com`);
        await page.context().storageState({ path: fileName });
        await page.close();
      }

      await use(fileName);
    },
    { scope: "worker" },
  ],
});
```

### Test-Suite-Driven Flakiness (State Leaks)

**Problem: Tests affect each other**

```typescript
// ❌ BAD: Module-level state persists across tests
let sharedPage: Page;

test.beforeAll(async ({ browser }) => {
  sharedPage = await browser.newPage(); // Shared across tests!
});

// ✅ GOOD: Use Playwright's default isolation (fresh context per test)
test("first test", async ({ page }) => {
  // Fresh page for this test
});

test("second test", async ({ page }) => {
  // Fresh page for this test
});
```

**Problem: Fixture cleanup not happening**

```typescript
// ✅ GOOD: Proper fixture with cleanup
export const test = base.extend<{ tempFile: string }>({
  tempFile: async ({}, use) => {
    const file = `/tmp/test-${Date.now()}.json`;
    fs.writeFileSync(file, "{}");

    await use(file);

    // Cleanup always runs, even on failure
    if (fs.existsSync(file)) {
      fs.unlinkSync(file);
    }
  },
});
```

## CI-Specific Flakiness

### Why Tests Fail Only in CI

| CI Condition       | Impact                                | Solution                                             |
| ------------------ | ------------------------------------- | ---------------------------------------------------- |
| Slower CPU         | Actions complete later than expected  | Use auto-waiting, not timeouts                       |
| Cold browser start | No cached assets, slower initial load | Add explicit waits for first navigation              |
| Headless mode      | Different rendering behavior          | Test locally in headless mode                        |
| Shared runners     | Resource contention                   | Reduce parallelism or use dedicated runners          |
| Network latency    | API calls slower                      | Mock external APIs, increase timeouts for real calls |

### Simulating CI Locally

```bash
# Run headless with CI environment variable
CI=true npx playwright test

# Limit CPU (Linux/Mac)
cpulimit -l 50 -- npx playwright test

# Run in Docker matching CI environment
docker run -it --rm \
  -v $(pwd):/work \
  -w /work \
  mcr.microsoft.com/playwright:v1.40.0-jammy \
  npx playwright test
```

### Consistent Viewport and Scale

```typescript
// playwright.config.ts - Match CI rendering exactly
export default defineConfig({
  use: {
    viewport: { width: 1280, height: 720 },
    deviceScaleFactor: 1,
  },
});
```

### Network Stubbing for External APIs

```typescript
// Eliminate external API flakiness
test.beforeEach(async ({ page }) => {
  // Stub unstable third-party APIs
  await page.route("**/api.analytics.com/**", (route) =>
    route.fulfill({ body: "" }),
  );
  await page.route("**/api.payment-provider.com/**", (route) =>
    route.fulfill({ json: { status: "ok" } }),
  );
});

// Test-specific stub
test("checkout with payment", async ({ page }) => {
  await page.route("**/api/payment", (route) =>
    route.fulfill({ json: { success: true, transactionId: "test-123" } }),
  );
  // Test proceeds with deterministic response
});
```

## Quarantine and Management

### Quarantine Pattern

```typescript
// playwright.config.ts - Separate flaky tests
export default defineConfig({
  projects: [
    {
      name: "stable",
      testIgnore: ["**/*.flaky.spec.ts"],
    },
    {
      name: "quarantine",
      testMatch: ["**/*.flaky.spec.ts"],
      retries: 3,
    },
  ],
});
```

### Annotation-Based Quarantine

```typescript
// Mark flaky tests with annotations
test("intermittent checkout issue", async ({ page }, testInfo) => {
  testInfo.annotations.push({
    type: "flaky",
    description: "Investigating payment API timing - JIRA-1234",
  });

  // Test implementation
});

// Skip flaky test conditionally
test("known CI flaky", async ({ page }) => {
  test.skip(!!process.env.CI, "Flaky in CI - investigating JIRA-5678");
  // Test implementation
});
```

## Prevention Strategies

### Test Burn-In

```bash
# Run new tests many times before merging
npx playwright test tests/new-feature.spec.ts --repeat-each=50

# Run in parallel to expose race conditions
npx playwright test tests/new-feature.spec.ts --repeat-each=20 --workers=4
```

### Isolation Checklist

```typescript
// ✅ Each test should be self-contained
test.describe("User profile", () => {
  test("can update name", async ({ page, testUser }) => {
    // Uses unique testUser fixture
    // No dependency on other tests
    // Cleanup handled by fixture
  });

  test("can update email", async ({ page, testUser }) => {
    // Independent of "can update name"
    // Own testUser, own state
  });
});
```

### Defensive Assertions

```typescript
// ❌ BAD: Single point of failure
await expect(page.locator(".items")).toHaveCount(5);

// ✅ GOOD: Progressive assertions that help diagnose
await expect(page.locator(".items-container")).toBeVisible();
await expect(page.locator(".loading")).not.toBeVisible();
await expect(page.locator(".items")).toHaveCount(5);
```

### Retry Budget

```typescript
// playwright.config.ts - Limit retries to avoid masking issues
export default defineConfig({
  retries: process.env.CI ? 2 : 0, // Only retry in CI
  expect: {
    timeout: 10000, // Reasonable assertion timeout
  },
  timeout: 60000, // Test timeout
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                              | Problem                             | Solution                                       |
| ----------------------------------------- | ----------------------------------- | ---------------------------------------------- |
| `waitForTimeout()` as primary wait        | Arbitrary, hides real timing issues | Use auto-waiting assertions                    |
| Increasing global timeout to "fix" flakes | Masks root cause, slows all tests   | Find and fix actual timing issue               |
| Retrying until pass                       | Hides systemic problems             | Fix root cause, use retries for diagnosis only |
| Shared test data across workers           | Race conditions, collisions         | Isolate data per worker                        |
| Testing real external APIs                | Network variability                 | Mock external dependencies                     |
| Module-level mutable state                | Leaks between tests                 | Use fixtures with proper cleanup               |
| Ignoring flaky tests                      | Problem compounds over time         | Quarantine and track for fixing                |

## Related References

- **Debugging**: See [debugging.md](debugging.md) for trace viewer and inspector
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for worker-scoped isolation
- **Performance**: See [performance.md](../infrastructure-ci-cd/performance.md) for parallel execution patterns
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for auto-waiting patterns
- **Global Setup**: See [global-setup.md](../core/global-setup.md) for setup vs fixtures decision
frameworks/angular.md
# Angular Testing with Playwright

## Table of Contents

1. [Configuration](#configuration)
2. [Locator Strategies](#locator-strategies)
3. [Reactive Forms](#reactive-forms)
4. [Angular Material Components](#angular-material-components)
5. [Router Navigation](#router-navigation)
6. [Lazy-Loaded Modules](#lazy-loaded-modules)
7. [Signals and Observables](#signals-and-observables)
8. [Zone.js and Change Detection](#zonejs-and-change-detection)
9. [SSR Testing](#ssr-testing)
10. [Protractor Migration Reference](#protractor-migration-reference)
11. [Build Configurations](#build-configurations)
12. [CDK Overlay Container](#cdk-overlay-container)
13. [Anti-Patterns](#anti-patterns)
14. [Related](#related)

> **When to use**: Testing Angular applications with reactive forms, Angular Material components, Router navigation, lazy-loaded modules, signals, observables, and Zone.js change detection.
> **Prerequisites**: [core/configuration.md](../core/configuration.md), [core/locators.md](../core/locators.md)

## Configuration

### Playwright Config

```typescript
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  testMatch: '**/*.spec.ts',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? '50%' : undefined,

  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: process.env.CI
      ? 'npx ng build && npx http-server dist/my-app/browser -p 4200 -s'
      : 'npx ng serve',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});
```

### Project Structure

```text
my-angular-app/
  src/
  e2e/
    tests/
      dashboard.spec.ts
      login.spec.ts
    fixtures/
      auth.fixture.ts
  playwright.config.ts
  angular.json
```

### Package Scripts

```json
{
  "scripts": {
    "e2e": "playwright test",
    "e2e:headed": "playwright test --headed",
    "e2e:debug": "playwright test --debug",
    "e2e:report": "playwright show-report"
  }
}
```

## Locator Strategies

Angular generates internal attributes (`_ngcontent-*`, `_nghost-*`, `ng-reflect-*`) that change every build. Always use semantic locators.

```typescript
test('use semantic locators for Angular apps', async ({ page }) => {
  await page.goto('/projects');

  // Role-based locators work with Angular Material and native HTML
  await page.getByRole('button', { name: 'New project' }).click();
  await expect(page.getByRole('heading', { name: 'Create Project' })).toBeVisible();

  // Label-based for form fields
  await page.getByLabel('Project title').fill('Alpha');

  // Test IDs for complex components without semantic roles
  const chart = page.getByTestId('metrics-chart');
  await expect(chart).toBeVisible();

  // Scope locators within component boundaries
  const projectTable = page.getByRole('table', { name: 'Projects' });
  const activeRow = projectTable.getByRole('row').filter({
    has: page.getByRole('cell', { name: 'Active' }),
  });
  await activeRow.getByRole('button', { name: 'Edit' }).click();
});
```

## Reactive Forms

Playwright interacts with the rendered DOM, so reactive forms (`FormGroup`, `FormControl`, `FormArray`) are transparent.

```typescript
test.describe('form validation', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/signup');
  });

  test('displays validation errors on blur', async ({ page }) => {
    const emailField = page.getByLabel('Email');
    await emailField.click();
    await emailField.blur();
    await expect(page.getByText('Email is required')).toBeVisible();

    await emailField.fill('invalid');
    await emailField.blur();
    await expect(page.getByText('Invalid email format')).toBeVisible();
  });

  test('validates password confirmation', async ({ page }) => {
    await page.getByLabel('Password', { exact: true }).fill('Secret123!');
    await page.getByLabel('Confirm password').fill('Mismatch');
    await page.getByLabel('Confirm password').blur();

    await expect(page.getByText('Passwords must match')).toBeVisible();

    await page.getByLabel('Confirm password').fill('Secret123!');
    await expect(page.getByText('Passwords must match')).toBeHidden();
  });

  test('handles FormArray add/remove', async ({ page }) => {
    await page.goto('/contacts/edit');

    await page.getByRole('button', { name: 'Add email' }).click();
    const emailInputs = page.getByLabel(/Email address/);
    await expect(emailInputs).toHaveCount(2);

    await emailInputs.nth(1).fill('[email protected]');
    await page.getByRole('button', { name: 'Remove email 1' }).click();

    await expect(emailInputs).toHaveCount(1);
    await expect(emailInputs.first()).toHaveValue('[email protected]');
  });

  test('disables submit until form is valid', async ({ page }) => {
    const submitBtn = page.getByRole('button', { name: 'Register' });
    await expect(submitBtn).toBeDisabled();

    await page.getByLabel('Name').fill('Alice');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password', { exact: true }).fill('Secret123!');
    await page.getByLabel('Confirm password').fill('Secret123!');
    await page.getByLabel('Accept terms').check();

    await expect(submitBtn).toBeEnabled();
  });

  test('shows async validator loading state', async ({ page }) => {
    await page.route('**/api/username-check*', async (route) => {
      await new Promise((r) => setTimeout(r, 800));
      await route.fulfill({ json: { available: true } });
    });

    await page.getByLabel('Username').fill('alice');
    await page.getByLabel('Username').blur();

    await expect(page.getByTestId('username-loading')).toBeVisible();
    await expect(page.getByTestId('username-loading')).toBeHidden();
    await expect(page.getByText('Username available')).toBeVisible();
  });
});
```

## Angular Material Components

Angular Material uses proper ARIA attributes. Use role-based locators instead of CSS classes like `.mat-mdc-button`.

```typescript
test.describe('Material components', () => {
  test('mat-select dropdown', async ({ page }) => {
    await page.goto('/preferences');

    await page.getByRole('combobox', { name: 'Language' }).click();
    await page.getByRole('option', { name: 'Spanish' }).click();

    await expect(page.getByRole('combobox', { name: 'Language' })).toContainText('Spanish');
  });

  test('mat-autocomplete suggestions', async ({ page }) => {
    await page.goto('/members/add');

    const roleField = page.getByRole('combobox', { name: 'Role' });
    await roleField.fill('dev');

    await expect(page.getByRole('option', { name: 'Developer' })).toBeVisible();
    await expect(page.getByRole('option', { name: 'DevOps' })).toBeVisible();

    await page.getByRole('option', { name: 'Developer' }).click();
    await expect(roleField).toHaveValue('Developer');
  });

  test('mat-dialog interaction', async ({ page }) => {
    await page.goto('/items');

    await page.getByRole('button', { name: 'Remove item' }).first().click();

    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible();
    await expect(dialog.getByText('Confirm deletion?')).toBeVisible();

    await dialog.getByRole('button', { name: 'Cancel' }).click();
    await expect(dialog).toBeHidden();
  });

  test('mat-table sorting', async ({ page }) => {
    await page.goto('/members');

    await page.getByRole('columnheader', { name: 'Name' }).click();
    const header = page.getByRole('columnheader', { name: 'Name' });
    await expect(header).toHaveAttribute('aria-sort', 'ascending');

    await page.getByRole('columnheader', { name: 'Name' }).click();
    await expect(header).toHaveAttribute('aria-sort', 'descending');
  });

  test('mat-paginator navigation', async ({ page }) => {
    await page.goto('/members');

    await expect(page.getByText('1 - 10 of 100')).toBeVisible();

    await page.getByRole('button', { name: 'Next page' }).click();
    await expect(page.getByText('11 - 20 of 100')).toBeVisible();

    await page.getByRole('combobox', { name: 'Items per page' }).click();
    await page.getByRole('option', { name: '50' }).click();
    await expect(page.getByText('1 - 50 of 100')).toBeVisible();
  });

  test('mat-snack-bar notification', async ({ page }) => {
    await page.goto('/preferences');

    await page.getByRole('button', { name: 'Save' }).click();
    await expect(page.getByText('Changes saved')).toBeVisible();

    await page.getByRole('button', { name: 'Close' }).click();
    await expect(page.getByText('Changes saved')).toBeHidden();
  });

  test('mat-stepper wizard', async ({ page }) => {
    await page.goto('/wizard');

    await expect(page.getByText('Step 1 of 3')).toBeVisible();
    await page.getByLabel('Name').fill('Bob');
    await page.getByRole('button', { name: 'Next' }).click();

    await expect(page.getByText('Step 2 of 3')).toBeVisible();
    await page.getByLabel('Organization').fill('Acme');
    await page.getByRole('button', { name: 'Next' }).click();

    await expect(page.getByText('Step 3 of 3')).toBeVisible();
    await expect(page.getByText('Bob')).toBeVisible();
    await expect(page.getByText('Acme')).toBeVisible();
  });
});
```

## Router Navigation

```typescript
test.describe('Angular Router', () => {
  test('lazy-loaded module loads on navigation', async ({ page }) => {
    await page.goto('/');

    await page.getByRole('link', { name: 'Reports' }).click();
    await page.waitForURL('/reports');

    await expect(page.getByRole('heading', { name: 'Reports Dashboard' })).toBeVisible();
  });

  test('route guard redirects unauthorized users', async ({ page }) => {
    await page.goto('/admin/settings');

    await expect(page).toHaveURL(/\/login/);
    await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
  });

  test('resolver prefetches data', async ({ page }) => {
    const resolverPromise = page.waitForResponse('**/api/items/*');
    await page.goto('/items/42');
    await resolverPromise;

    await expect(page.getByRole('heading', { level: 1 })).toContainText('Item');
  });

  test('nested router-outlet renders children', async ({ page }) => {
    await page.goto('/account/profile');

    await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();

    await page.getByRole('link', { name: 'Security' }).click();
    await page.waitForURL('/account/security');

    await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
  });

  test('query parameters drive filters', async ({ page }) => {
    await page.goto('/products?type=hardware&page=3');

    await expect(page.getByRole('heading', { name: 'Hardware' })).toBeVisible();
    await expect(page.getByText('Page 3')).toBeVisible();
  });

  test('browser back navigates history', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('/products');
    await page.getByRole('link', { name: 'About' }).click();
    await page.waitForURL('/about');

    await page.goBack();
    await expect(page).toHaveURL(/\/products/);

    await page.goBack();
    await expect(page).toHaveURL(/\/$/);
  });
});
```

## Lazy-Loaded Modules

```typescript
test('lazy module loads without chunk errors', async ({ page }) => {
  const consoleErrors: string[] = [];
  page.on('console', (msg) => {
    if (msg.type() === 'error') consoleErrors.push(msg.text());
  });

  await page.goto('/');

  const chunkRequest = page.waitForResponse((r) =>
    r.url().includes('.js') && r.status() === 200
  );
  await page.getByRole('link', { name: 'Analytics' }).click();
  await chunkRequest;

  await page.waitForURL('/analytics');
  await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();

  const chunkErrors = consoleErrors.filter(
    (e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
  );
  expect(chunkErrors).toEqual([]);
});
```

## Signals and Observables

Playwright cannot subscribe to observables or read signals directly. Test through the rendered output.

```typescript
test.describe('signals through UI', () => {
  test('signal-based counter updates DOM', async ({ page }) => {
    await page.goto('/counter');

    await expect(page.getByTestId('value')).toHaveText('0');

    await page.getByRole('button', { name: 'Increment' }).click();
    await expect(page.getByTestId('value')).toHaveText('1');

    await page.getByRole('button', { name: 'Reset' }).click();
    await expect(page.getByTestId('value')).toHaveText('0');
  });

  test('computed signal updates derived values', async ({ page }) => {
    await page.goto('/cart');
    await expect(page.getByTestId('total')).toHaveText('$0.00');

    await page.goto('/catalog');
    await page.getByRole('listitem')
      .filter({ hasText: '$19.99' })
      .getByRole('button', { name: 'Add' })
      .click();

    await page.getByRole('link', { name: 'Cart' }).click();
    await expect(page.getByTestId('total')).toHaveText('$19.99');
  });
});

test.describe('observables through UI', () => {
  test('debounced search batches API calls', async ({ page }) => {
    await page.goto('/search');

    const apiCalls: string[] = [];
    await page.route('**/api/search*', async (route) => {
      apiCalls.push(route.request().url());
      await route.continue();
    });

    await page.getByRole('textbox', { name: 'Search' }).pressSequentially('playwright', {
      delay: 50,
    });

    await expect(page.getByRole('listitem')).toHaveCount(5);
    expect(apiCalls.length).toBeLessThanOrEqual(2);
  });

  test('switchMap cancels stale requests', async ({ page }) => {
    await page.goto('/search');

    await page.getByRole('textbox', { name: 'Search' }).fill('initial');
    await page.getByRole('textbox', { name: 'Search' }).fill('final');

    await expect(page.getByRole('listitem').first()).toContainText(/final/i);
  });
});
```

## Zone.js and Change Detection

Angular uses Zone.js for change detection. Playwright does not depend on Zone.js and interacts with the DOM directly.

- **Change detection timing**: After interactions, Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this.
- **Zoneless Angular**: Angular 17+ supports zoneless change detection. Tests work identically since Playwright waits for DOM changes.
- **Long-running async**: `setInterval` or long-running observables keep Angular "not stable." This does not affect Playwright (unlike Protractor).

## SSR Testing

```typescript
// playwright.config.ts for SSR
webServer: {
  command: process.env.CI
    ? 'npx ng build --ssr && node dist/my-app/server/server.mjs'
    : 'npx ng serve --ssr',
  url: 'http://localhost:4200',
  reuseExistingServer: !process.env.CI,
  timeout: 180_000,
},
```

```typescript
test('no hydration errors', async ({ page }) => {
  const errors: string[] = [];
  page.on('console', (msg) => {
    if (msg.type() === 'error' && msg.text().includes('hydration')) {
      errors.push(msg.text());
    }
  });

  await page.goto('/');
  await page.getByRole('button', { name: 'Get started' }).click();

  expect(errors).toEqual([]);
});
```

## Protractor Migration Reference

| Protractor | Playwright |
|---|---|
| `element(by.css('.btn'))` | `page.getByRole('button', { name: '...' })` |
| `element(by.id('login'))` | `page.getByTestId('login')` |
| `element(by.buttonText('Submit'))` | `page.getByRole('button', { name: 'Submit' })` |
| `element(by.model('user.name'))` | `page.getByLabel('Name')` |
| `element(by.binding('user.name'))` | `page.getByText(expectedValue)` |
| `element(by.repeater('item in items'))` | `page.getByRole('listitem')` |
| `browser.waitForAngular()` | Not needed — Playwright auto-waits |
| `browser.sleep(3000)` | `await expect(locator).toBeVisible()` |
| `browser.get('/path')` | `await page.goto('/path')` |
| `protractor.ExpectedConditions` | `await expect(locator).toBeVisible()` |

## Build Configurations

| Scenario | Command | Notes |
|---|---|---|
| Local dev | `npx ng serve` | Fast rebuild, source maps |
| CI production | `npx ng build && npx http-server dist/app/browser -p 4200 -s` | Tests production bundle |
| CI SSR | `npx ng build --ssr && node dist/app/server/server.mjs` | Tests server-side rendering |
| Staging | No `webServer` | Point `baseURL` to staging URL |

The `-s` flag on `http-server` enables SPA fallback for Angular Router.

## CDK Overlay Container

Angular Material and CDK render overlays (dialogs, menus, selects) in a special container outside the component tree. Playwright sees these as regular DOM elements:

```typescript
const dialog = page.getByRole('dialog');
const menu = page.getByRole('menu');
const listbox = page.getByRole('listbox');
```

## Anti-Patterns

| Anti-Pattern | Problem | Solution |
|---|---|---|
| `page.locator('[_ngcontent-xyz]')` | Scoped style attributes change every build | Use `getByRole`, `getByLabel`, `getByTestId` |
| `page.locator('[ng-reflect-model]')` | Only exists in dev mode | Test rendered value: `expect(input).toHaveValue()` |
| `page.locator('app-my-component')` | Component selectors are implementation details | Target rendered content with semantic locators |
| `page.locator('.mat-mdc-button')` | Material classes change between versions | `page.getByRole('button', { name: '...' })` |
| `page.evaluate(() => window.ng)` | Not available in production builds | Test through the DOM |
| `await page.waitForTimeout(500)` | Zone.js timing varies | Use auto-retrying assertions |
| `browser.waitForAngular()` | Does not exist in Playwright | Remove entirely |
| `ng serve` in CI | Slower, includes debug code | Use `ng build && http-server` |

## Related

- [core/locators.md](../core/locators.md) — locator strategies for Angular Material
- [core/assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting assertions
- [core/forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns
- [architecture/test-architecture.md](../architecture/test-architecture.md) — E2E vs unit tests with TestBed
frameworks/nextjs.md
# Next.js Testing Patterns

## Table of Contents

1. [Setup](#setup)
2. [App Router Patterns](#app-router-patterns)
3. [Pages Router Patterns](#pages-router-patterns)
4. [Dynamic Routes](#dynamic-routes)
5. [API Routes](#api-routes)
6. [Middleware Testing](#middleware-testing)
7. [Hydration Testing](#hydration-testing)
8. [next/image Testing](#nextimage-testing)
9. [NextAuth.js Authentication](#nextauthjs-authentication)
10. [Tips](#tips)
11. [Anti-Patterns](#anti-patterns)
12. [Related](#related)

> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)

## Setup

### Configuration with webServer

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? '50%' : undefined,

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: process.env.CI
      ? 'npm run build && npm run start'
      : 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    env: {
      NODE_ENV: process.env.CI ? 'production' : 'test',
    },
  },
});
```

### Environment Variables

Next.js loads `.env.test` when `NODE_ENV=test`:

```bash
# .env.test (commit this)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
DATABASE_URL=postgresql://localhost:5432/test_db

# .env.test.local (gitignored)
NEXTAUTH_SECRET=test-secret-local
```

## App Router Patterns

### Server Component Content

```typescript
test('renders server component content', async ({ page }) => {
  await page.goto('/');

  await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
  await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
});
```

### Loading States with Streaming

```typescript
test('loading state during data streaming', async ({ page }) => {
  await page.route('**/api/stats', async (route) => {
    await new Promise((r) => setTimeout(r, 2000));
    await route.continue();
  });

  await page.goto('/dashboard');

  await expect(page.getByRole('progressbar')).toBeVisible();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByRole('progressbar')).toBeHidden();
});
```

### Nested Layouts

```typescript
test('layouts persist across navigation', async ({ page }) => {
  await page.goto('/dashboard/analytics');

  const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
  await expect(sidebar).toBeVisible();

  await sidebar.getByRole('link', { name: 'Settings' }).click();
  await page.waitForURL('/dashboard/settings');

  await expect(sidebar).toBeVisible();
  await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
```

## Pages Router Patterns

### SSR with getServerSideProps

```typescript
test('page with getServerSideProps renders data', async ({ page }) => {
  await page.goto('/blog');

  await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
  await expect(page.getByRole('article')).toHaveCount(10);
  await expect(page.getByRole('article').first()).toContainText(/\w+/);
});
```

### Static Generation with getStaticProps

```typescript
test('static page shows pre-rendered content', async ({ page }) => {
  await page.goto('/about');

  await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
  await expect(page.getByText('Founded in 2020')).toBeVisible();
});
```

## Dynamic Routes

### Slug Parameters

```typescript
test('dynamic [slug] renders correct content', async ({ page }) => {
  await page.goto('/blog/testing-guide');

  await expect(page.getByRole('heading', { level: 1 })).toContainText('Testing Guide');
  await expect(page.getByText('Page not found')).toBeHidden();
});

test('non-existent slug shows 404', async ({ page }) => {
  const response = await page.goto('/blog/nonexistent-post');

  expect(response?.status()).toBe(404);
  await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
});
```

### Catch-All Routes

```typescript
test('catch-all handles nested paths', async ({ page }) => {
  await page.goto('/docs/getting-started/installation');
  await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();

  await page.goto('/docs/api/configuration');
  await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
});
```

### Query Parameters

```typescript
test('query parameters filter content', async ({ page }) => {
  await page.goto('/products?category=electronics&sort=price-asc');

  await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();

  const prices = await page.getByTestId('product-price').allTextContents();
  const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
  expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
});
```

## API Routes

### Direct API Testing

```typescript
test('GET /api/products returns list', async ({ request }) => {
  const response = await request.get('/api/products');

  expect(response.ok()).toBeTruthy();
  const body = await response.json();
  expect(body.products).toBeInstanceOf(Array);
  expect(body.products[0]).toHaveProperty('id');
  expect(body.products[0]).toHaveProperty('name');
});

test('POST /api/products creates item', async ({ request }) => {
  const response = await request.post('/api/products', {
    data: { name: 'Test Product', price: 29.99 },
  });

  expect(response.status()).toBe(201);
  const body = await response.json();
  expect(body.product.name).toBe('Test Product');
});

test('POST /api/products validates fields', async ({ request }) => {
  const response = await request.post('/api/products', {
    data: { name: '' },
  });

  expect(response.status()).toBe(400);
  const body = await response.json();
  expect(body.error).toContainEqual(expect.objectContaining({ field: 'price' }));
});
```

### API Through UI

```typescript
test('form submission calls API', async ({ page }) => {
  await page.goto('/products/new');

  await page.getByLabel('Product name').fill('Widget');
  await page.getByLabel('Price').fill('19.99');
  await page.getByRole('button', { name: 'Create product' }).click();

  await expect(page.getByText('Product created successfully')).toBeVisible();
  await page.waitForURL('/products/**');
});
```

## Middleware Testing

### Auth Redirects

```typescript
test('unauthenticated user redirected to login', async ({ page }) => {
  await page.goto('/dashboard');

  expect(page.url()).toContain('/login');
  await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});

test('redirect preserves return URL', async ({ page }) => {
  await page.goto('/dashboard/settings');

  const url = new URL(page.url());
  expect(url.pathname).toBe('/login');
  expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
    .toContain('/dashboard/settings');
});
```

### Security Headers

```typescript
test('middleware sets security headers', async ({ page }) => {
  const response = await page.goto('/');

  const headers = response!.headers();
  expect(headers['x-frame-options']).toBe('DENY');
  expect(headers['x-content-type-options']).toBe('nosniff');
});
```

### Locale Rewrites

```typescript
test('middleware rewrites based on locale', async ({ page, context }) => {
  await context.setExtraHTTPHeaders({
    'Accept-Language': 'fr-FR,fr;q=0.9',
  });

  await page.goto('/');

  await expect(page.getByText('Bienvenue')).toBeVisible();
});
```

## Hydration Testing

### Console Error Detection

```typescript
test('no hydration errors in console', async ({ page }) => {
  const consoleErrors: string[] = [];
  page.on('console', (msg) => {
    if (msg.type() === 'error') {
      consoleErrors.push(msg.text());
    }
  });

  await page.goto('/');
  await page.getByRole('button', { name: 'Get started' }).click();

  const hydrationErrors = consoleErrors.filter(
    (e) =>
      e.includes('Hydration') ||
      e.includes('hydration') ||
      e.includes('did not match')
  );
  expect(hydrationErrors).toEqual([]);
});
```

### Interactive Elements After Hydration

```typescript
test('interactive elements work after hydration', async ({ page }) => {
  await page.goto('/');

  const counter = page.getByTestId('counter-value');
  await expect(counter).toHaveText('0');

  await page.getByRole('button', { name: 'Increment' }).click();
  await expect(counter).toHaveText('1');
});
```

## next/image Testing

```typescript
test('hero image loads with srcset', async ({ page }) => {
  await page.goto('/');

  const heroImage = page.getByRole('img', { name: 'Hero banner' });
  await expect(heroImage).toBeVisible();

  const srcset = await heroImage.getAttribute('srcset');
  expect(srcset).toBeTruthy();
  expect(srcset).toContain('w=');

  const loading = await heroImage.getAttribute('loading');
  expect(loading).not.toBe('lazy');
});

test('offscreen images lazy load', async ({ page }) => {
  await page.goto('/gallery');

  const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });

  await offscreenImage.scrollIntoViewIfNeeded();
  await expect(offscreenImage).toBeVisible();

  const naturalWidth = await offscreenImage.evaluate(
    (img: HTMLImageElement) => img.naturalWidth
  );
  expect(naturalWidth).toBeGreaterThan(0);
});
```

## NextAuth.js Authentication

### Setup Project

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'authenticated',
      use: { storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
    { name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },
  ],
});
```

### Auth Setup

```typescript
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate via credentials', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.waitForURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  await page.context().storageState({ path: authFile });
});
```

### Authenticated Tests

```typescript
test('authenticated user sees dashboard', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByText('[email protected]')).toBeVisible();
});
```

## Tips

### Dev Server vs Production Build

| Scenario | Command | Trade-off |
|---|---|---|
| Local development | `npm run dev` | Fast iteration, no production behavior |
| CI pipeline | `npm run build && npm run start` | Tests real production bundle |

### Turbopack

```typescript
webServer: {
  command: process.env.CI
    ? 'npm run build && npm run start'
    : 'npx next dev --turbopack',
  url: 'http://localhost:3000',
  reuseExistingServer: !process.env.CI,
},
```

### Multiple webServer Entries

```typescript
webServer: [
  {
    command: 'npm run dev:api',
    url: 'http://localhost:4000/health',
    reuseExistingServer: !process.env.CI,
  },
  {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
],
```

## Anti-Patterns

| Don't Do This | Problem | Do This Instead |
|---|---|---|
| `await page.waitForTimeout(3000)` | Arbitrary waits are fragile | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |
| Test `getServerSideProps` directly | Depends on req/res context | Navigate to page and verify rendered output |
| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |
| `page.goto('http://localhost:3000/path')` | Breaks when port changes | Use `page.goto('/path')` with `baseURL` |
| Run `npm run build` locally for every test | Extremely slow | Use `npm run dev` locally with `reuseExistingServer: true` |
| Test `next/image` by checking exact URLs | Paths change between dev/prod | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset` |
| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |

## Related

- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`
- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`
- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context
- [react.md](react.md) -- React patterns for Next.js client components
frameworks/react.md
# React Application Testing

## Table of Contents

1. [Patterns](#patterns)
2. [Setup](#setup)
3. [Framework Tips](#framework-tips)
4. [Anti-Patterns](#anti-patterns)
5. [Related](#related)

> **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.
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)

## Patterns

### Testing Context and Global State

**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.
**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.

```typescript
import { test, expect } from '@playwright/test';

test.describe('theme switching', () => {
  test('toggle applies dark mode across pages', async ({ page }) => {
    await page.goto('/preferences');

    const root = page.locator('html');
    await expect(root).not.toHaveClass(/dark-mode/);

    await page.getByRole('switch', { name: 'Enable dark theme' }).click();
    await expect(root).toHaveClass(/dark-mode/);

    await page.getByRole('link', { name: 'Dashboard' }).click();
    await expect(page.locator('html')).toHaveClass(/dark-mode/);
  });
});

test.describe('cart state persistence', () => {
  test('item count updates globally', async ({ page }) => {
    await page.goto('/catalog');

    const badge = page.getByTestId('cart-badge');

    await page.getByRole('listitem')
      .filter({ hasText: 'Wireless Headphones' })
      .getByRole('button', { name: 'Add' })
      .click();
    await expect(badge).toHaveText('1');

    await page.getByRole('link', { name: 'Contact' }).click();
    await expect(badge).toHaveText('1');
  });
});

test.describe('auth state', () => {
  test('login updates header across components', async ({ page }) => {
    await page.goto('/');

    await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();

    await page.getByRole('link', { name: 'Login' }).click();
    await page.getByLabel('Username').fill('testuser');
    await page.getByLabel('Password').fill('secret123');
    await page.getByRole('button', { name: 'Submit' }).click();

    await expect(page.getByRole('link', { name: 'Login' })).toBeHidden();
    await expect(page.getByText('testuser')).toBeVisible();
  });
});
```

### React Router Navigation

**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.
**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).

```typescript
import { test, expect } from '@playwright/test';

test.describe('client routing', () => {
  test('navigation preserves SPA state', async ({ page }) => {
    await page.goto('/');

    await page.evaluate(() => {
      (window as any).__spaMarker = 'active';
    });

    await page.getByRole('link', { name: 'Inventory' }).click();
    await page.waitForURL('/inventory');

    const marker = await page.evaluate(() => (window as any).__spaMarker);
    expect(marker).toBe('active');
  });

  test('query params filter content', async ({ page }) => {
    await page.goto('/items?type=books');

    await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible();

    await page.getByRole('link', { name: 'Music' }).click();
    await page.waitForURL('/items?type=music');

    await expect(page.getByRole('heading', { name: 'Music' })).toBeVisible();
  });

  test('nested routes render layouts', async ({ page }) => {
    await page.goto('/account/security');

    await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();

    await page.getByRole('link', { name: 'Privacy' }).click();
    await page.waitForURL('/account/privacy');

    await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Privacy', level: 2 })).toBeVisible();
  });

  test('history navigation works', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: 'Inventory' }).click();
    await page.waitForURL('/inventory');
    await page.getByRole('link', { name: 'Help' }).click();
    await page.waitForURL('/help');

    await page.goBack();
    await expect(page).toHaveURL(/\/inventory/);

    await page.goBack();
    await expect(page).toHaveURL(/\/$/);
  });

  test('protected route redirects', async ({ page }) => {
    await page.goto('/admin/users');

    await expect(page).toHaveURL(/\/login/);
  });

  test('unknown route shows 404', async ({ page }) => {
    await page.goto('/nonexistent-path');

    await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
  });
});
```

### Testing Hooks Through UI

**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.
**Avoid when**: Hook logic is pure computation—use unit tests instead.

```typescript
import { test, expect } from '@playwright/test';

test.describe('useDebounce via SearchBox', () => {
  test('batches rapid input', async ({ page }) => {
    await page.goto('/search');

    const apiCalls: string[] = [];
    await page.route('**/api/query*', async (route) => {
      apiCalls.push(route.request().url());
      await route.continue();
    });

    await page.getByRole('textbox', { name: 'Search' }).pressSequentially('testing', {
      delay: 40,
    });

    await expect(page.getByRole('listitem')).toHaveCount(3);
    expect(apiCalls.length).toBeLessThanOrEqual(2);
  });
});

test.describe('usePagination via DataGrid', () => {
  test('page controls work', async ({ page }) => {
    await page.goto('/records');

    await expect(page.getByText('Page 1 of 10')).toBeVisible();

    await page.getByRole('button', { name: 'Next' }).click();
    await expect(page.getByText('Page 2 of 10')).toBeVisible();

    await page.getByRole('button', { name: 'Previous' }).click();
    await expect(page.getByText('Page 1 of 10')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Previous' })).toBeDisabled();
  });
});
```

### Form Libraries (React Hook Form, Formik)

**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.

```typescript
import { test, expect } from '@playwright/test';

test.describe('signup form', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/signup');
  });

  test('validation on empty submit', async ({ page }) => {
    await page.getByRole('button', { name: 'Register' }).click();

    await expect(page.getByText('Email required')).toBeVisible();
    await expect(page.getByText('Password required')).toBeVisible();
  });

  test('inline validation on blur', async ({ page }) => {
    const email = page.getByLabel('Email');
    await email.fill('invalid');
    await email.blur();

    await expect(page.getByText('Invalid email format')).toBeVisible();
  });

  test('password strength indicator', async ({ page }) => {
    const pwd = page.getByLabel('Password', { exact: true });

    await pwd.fill('weak');
    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/);

    await pwd.fill('StrongPass1!');
    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/);
  });

  test('successful submission redirects', async ({ page }) => {
    await page.getByLabel('Name').fill('Alice');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password', { exact: true }).fill('Secure123!');
    await page.getByLabel('Confirm').fill('Secure123!');
    await page.getByLabel('Accept terms').check();

    await page.getByRole('button', { name: 'Register' }).click();

    await page.waitForURL('/welcome');
    await expect(page.getByText('Hello, Alice')).toBeVisible();
  });

  test('submit button disabled during request', async ({ page }) => {
    await page.route('**/api/signup', async (route) => {
      await new Promise((r) => setTimeout(r, 800));
      await route.fulfill({ status: 201, json: { id: 1 } });
    });

    await page.getByLabel('Name').fill('Bob');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password', { exact: true }).fill('Secure123!');
    await page.getByLabel('Confirm').fill('Secure123!');
    await page.getByLabel('Accept terms').check();

    await page.getByRole('button', { name: 'Register' }).click();

    await expect(page.getByRole('button', { name: /Registering|Loading/ })).toBeDisabled();
  });
});
```

### Portals (Modals, Tooltips, Dropdowns)

**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.

```typescript
import { test, expect } from '@playwright/test';

test.describe('portal components', () => {
  test('modal interaction', async ({ page }) => {
    await page.goto('/items');

    await page.getByRole('button', { name: 'Remove' }).first().click();

    const dialog = page.getByRole('dialog', { name: 'Confirm removal' });
    await expect(dialog).toBeVisible();
    await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();

    await dialog.getByRole('button', { name: 'Remove' }).click();
    await expect(dialog).toBeHidden();
  });

  test('escape closes modal', async ({ page }) => {
    await page.goto('/items');
    await page.getByRole('button', { name: 'Remove' }).first().click();

    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible();

    await page.keyboard.press('Escape');
    await expect(dialog).toBeHidden();
  });

  test('tooltip on hover', async ({ page }) => {
    await page.goto('/panel');

    await page.getByRole('button', { name: 'Help' }).hover();
    await expect(page.getByRole('tooltip')).toBeVisible();

    await page.mouse.move(0, 0);
    await expect(page.getByRole('tooltip')).toBeHidden();
  });

  test('dropdown menu', async ({ page }) => {
    await page.goto('/panel');

    await page.getByRole('button', { name: 'Actions' }).click();

    const menu = page.getByRole('menu');
    await expect(menu).toBeVisible();

    await menu.getByRole('menuitem', { name: 'Rename' }).click();
    await expect(menu).toBeHidden();
  });

  test('toast auto-dismisses', async ({ page }) => {
    await page.goto('/preferences');

    await page.getByRole('button', { name: 'Save' }).click();
    await expect(page.getByText('Preferences saved')).toBeVisible();

    await expect(page.getByText('Preferences saved')).toBeHidden({ timeout: 8000 });
  });
});
```

### Error Boundaries

**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.
**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.

```typescript
import { test, expect } from '@playwright/test';

test.describe('error boundary', () => {
  test('shows fallback on crash', async ({ page }) => {
    await page.route('**/api/widgets', (route) => {
      route.fulfill({
        status: 200,
        json: { widgets: null },
      });
    });

    await page.goto('/panel');

    await expect(page.getByText('Something went wrong')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
    await expect(page.getByRole('navigation')).toBeVisible();
  });

  test('retry recovers component', async ({ page }) => {
    let calls = 0;
    await page.route('**/api/widgets', (route) => {
      calls++;
      if (calls === 1) {
        route.fulfill({ status: 200, json: { widgets: null } });
      } else {
        route.fulfill({ status: 200, json: { widgets: [{ id: 1, name: 'Chart' }] } });
      }
    });

    await page.goto('/panel');

    await expect(page.getByText('Something went wrong')).toBeVisible();

    await page.getByRole('button', { name: 'Retry' }).click();

    await expect(page.getByText('Something went wrong')).toBeHidden();
    await expect(page.getByText('Chart')).toBeVisible();
  });
});
```

### Component Testing (Experimental)

**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.
**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.

```typescript
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './tests/components',
  testMatch: '**/*.ct.ts',
  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});
```

```typescript
// tests/components/Stepper.ct.ts
import { test, expect } from '@playwright/experimental-ct-react';
import Stepper from '../../src/components/Stepper';

test('increments on click', async ({ mount }) => {
  const component = await mount(<Stepper initial={0} />);

  await expect(component.getByText('Value: 0')).toBeVisible();
  await component.getByRole('button', { name: '+' }).click();
  await expect(component.getByText('Value: 1')).toBeVisible();
});

test('fires onChange callback', async ({ mount }) => {
  const values: number[] = [];
  const component = await mount(
    <Stepper initial={0} onChange={(v) => values.push(v)} />
  );

  await component.getByRole('button', { name: '+' }).click();
  await component.getByRole('button', { name: '+' }).click();

  expect(values).toEqual([1, 2]);
});

test('respects min boundary', async ({ mount }) => {
  const component = await mount(<Stepper initial={0} min={0} />);

  await expect(component.getByRole('button', { name: '-' })).toBeDisabled();
});
```

## Setup

### E2E Config (Vite)

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? '50%' : undefined,

  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});
```

### CRA vs Vite Differences

| Aspect | Create React App | Vite |
|---|---|---|
| Default port | `3000` | `5173` |
| Build output | `build/` | `dist/` |
| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |
| Env var prefix | `REACT_APP_*` | `VITE_*` |

## Framework Tips

### Strict Mode Double Effects

React Strict Mode runs effects twice in development. Tests should be resilient:

- Don't assert exact API call counts in dev mode
- Run against production build for call count assertions, or account for double invocations

### Suspense and Lazy Components

```typescript
test('lazy route loads content', async ({ page }) => {
  await page.goto('/');

  await page.getByRole('link', { name: 'Analytics' }).click();

  await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
});
```

### Detecting Memory Leaks

```typescript
test('no unmounted state warnings', async ({ page }) => {
  const warnings: string[] = [];
  page.on('console', (msg) => {
    if (msg.type() === 'warning' && msg.text().includes('unmounted')) {
      warnings.push(msg.text());
    }
  });

  await page.goto('/panel');
  await page.getByRole('link', { name: 'Settings' }).click();
  await page.goBack();
  await page.getByRole('link', { name: 'Profile' }).click();

  expect(warnings).toEqual([]);
});
```

## Anti-Patterns

| Don't | Problem | Do Instead |
|---|---|---|
| `page.evaluate(() => store.getState())` | Couples tests to implementation | Assert on UI: `expect(badge).toHaveText('3')` |
| Import components in E2E tests | E2E runs in Node, not browser | Use `@playwright/experimental-ct-react` for components |
| `page.waitForTimeout(500)` after state changes | Timing varies across machines | `expect(locator).toHaveText('value')` auto-retries |
| `page.locator('.MuiButton-root')` | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |
| Test every component with CT | Overhead for simple components | CT for complex widgets, unit tests for logic, E2E for flows |
| Skip keyboard navigation tests | Accessibility regressions common | Test Tab, Enter, Escape, Arrow interactions |
| Assert on `__REACT_FIBER__` internals | Not stable across versions | Only interact with rendered DOM |

## Related

- [locators.md](../core/locators.md) — locator strategies for any React component library
- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes
- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns
- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing
- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions
- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR
frameworks/vue.md
# Vue and Nuxt Testing

## Table of Contents

1. [Commands](#commands)
2. [Configuration](#configuration)
3. [Patterns](#patterns)
4. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)
5. [Component Testing Dependencies](#component-testing-dependencies)
6. [Testing v-model](#testing-v-model)
7. [Capturing Vue Warnings](#capturing-vue-warnings)
8. [Anti-Patterns](#anti-patterns)

> **When to use**: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.

## Commands

```bash
npm init playwright@latest
npm install -D @playwright/experimental-ct-vue
npx playwright test
npx playwright test -c playwright-ct.config.ts
```

## Configuration

### Vue with Vite

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  testMatch: '**/*.spec.ts',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? '50%' : undefined,

  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: process.env.CI
      ? 'npm run build && npx vite preview --port 5173'
      : 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});
```

### Nuxt 3

Nuxt uses port 3000 and requires a build step before testing.

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  testMatch: '**/*.spec.ts',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],

  webServer: {
    command: process.env.CI
      ? 'npx nuxi build && npx nuxi preview'
      : 'npx nuxi dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    env: {
      NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',
    },
  },
});
```

### Component Testing

```typescript
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-vue';

export default defineConfig({
  testDir: './tests/components',
  testMatch: '**/*.ct.ts',

  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});
```

## Patterns

### Component Testing with Experimental CT

**Use when**: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).

**Avoid when**: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.

```typescript
// tests/components/Stepper.ct.ts
import { test, expect } from '@playwright/experimental-ct-vue';
import Stepper from '../../src/components/Stepper.vue';

test('increments value on button click', async ({ mount }) => {
  const component = await mount(Stepper, {
    props: { value: 0 },
  });

  await expect(component.getByText('Value: 0')).toBeVisible();
  await component.getByRole('button', { name: '+' }).click();
  await expect(component.getByText('Value: 1')).toBeVisible();
});

test('emits change event', async ({ mount }) => {
  const changes: number[] = [];
  const component = await mount(Stepper, {
    props: { value: 10 },
    on: {
      change: (val: number) => changes.push(val),
    },
  });

  await component.getByRole('button', { name: '+' }).click();
  await component.getByRole('button', { name: '+' }).click();

  expect(changes).toEqual([11, 12]);
});

test('renders slot content', async ({ mount }) => {
  const component = await mount(Stepper, {
    props: { value: 0 },
    slots: {
      default: '<span class="label">Quantity</span>',
    },
  });

  await expect(component.getByText('Quantity')).toBeVisible();
});
```

### Pinia Store Testing Through UI

**Use when**: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.

**Avoid when**: Testing pure store logic with no UI side effect—use unit tests with Vitest.

```typescript
import { test, expect } from '@playwright/test';

test.describe('shopping cart store', () => {
  test('adding products updates cart badge', async ({ page }) => {
    await page.goto('/shop');

    const badge = page.getByTestId('cart-badge');
    await expect(badge).toHaveText('0');

    await page.getByRole('listitem')
      .filter({ hasText: 'Hoodie' })
      .getByRole('button', { name: 'Add' })
      .click();
    await expect(badge).toHaveText('1');

    await page.getByRole('listitem')
      .filter({ hasText: 'Cap' })
      .getByRole('button', { name: 'Add' })
      .click();
    await expect(badge).toHaveText('2');

    await page.getByRole('link', { name: 'Cart' }).click();
    await page.waitForURL('/cart');

    await expect(page.getByText('Hoodie')).toBeVisible();
    await expect(page.getByText('Cap')).toBeVisible();
  });

  test('persisted state survives reload', async ({ page }) => {
    await page.goto('/shop');

    await page.getByRole('listitem')
      .filter({ hasText: 'Hoodie' })
      .getByRole('button', { name: 'Add' })
      .click();

    await page.reload();

    await expect(page.getByTestId('cart-badge')).toHaveText('1');
  });
});
```

### Vue Router Navigation

**Use when**: Testing client-side routing, navigation guards, URL parameters, browser history.

```typescript
import { test, expect } from '@playwright/test';

test.describe('router navigation', () => {
  test('client-side navigation preserves state', async ({ page }) => {
    await page.goto('/');

    await page.evaluate(() => {
      (window as any).__marker = 'spa';
    });

    await page.getByRole('link', { name: 'Shop' }).click();
    await page.waitForURL('/shop');

    const marker = await page.evaluate(() => (window as any).__marker);
    expect(marker).toBe('spa');
  });

  test('dynamic route params render content', async ({ page }) => {
    await page.goto('/items/99');

    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
    await expect(page.getByText('Item #99')).toBeVisible();
  });

  test('navigation guard redirects unauthorized users', async ({ page }) => {
    await page.goto('/admin/dashboard');

    await expect(page).toHaveURL(/\/login/);
    await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
  });

  test('browser history navigation works', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: 'Shop' }).click();
    await page.waitForURL('/shop');
    await page.getByRole('link', { name: 'Contact' }).click();
    await page.waitForURL('/contact');

    await page.goBack();
    await expect(page).toHaveURL(/\/shop/);

    await page.goBack();
    await expect(page).toHaveURL(/\/$/);

    await page.goForward();
    await expect(page).toHaveURL(/\/shop/);
  });

  test('query params update reactive state', async ({ page }) => {
    await page.goto('/items?sort=price&type=clothing');

    await expect(page.getByRole('heading', { name: 'Clothing' })).toBeVisible();

    await page.getByRole('combobox', { name: 'Sort' }).selectOption('name');
    await expect(page).toHaveURL(/sort=name/);
  });

  test('catch-all route shows 404', async ({ page }) => {
    await page.goto('/nonexistent-page');

    await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
  });
});
```

### Teleport Components

**Use when**: Testing components rendered via `<Teleport>` (modals, notifications, overlay menus).

```typescript
import { test, expect } from '@playwright/test';

test.describe('teleported elements', () => {
  test('modal is visible and interactive', async ({ page }) => {
    await page.goto('/items');

    await page.getByRole('button', { name: 'Remove' }).first().click();

    const dialog = page.getByRole('dialog', { name: 'Confirm' });
    await expect(dialog).toBeVisible();

    await dialog.getByRole('button', { name: 'Cancel' }).click();
    await expect(dialog).toBeHidden();
  });

  test('notification auto-dismisses', async ({ page }) => {
    await page.goto('/profile');

    await page.getByRole('button', { name: 'Update' }).click();

    const alert = page.getByRole('alert');
    await expect(alert).toBeVisible();
    await expect(alert).toContainText('Saved');

    await expect(alert).toBeHidden({ timeout: 10_000 });
  });

  test('dropdown closes on outside click', async ({ page }) => {
    await page.goto('/home');

    await page.getByRole('button', { name: 'Menu' }).click();

    const menu = page.getByRole('menu');
    await expect(menu).toBeVisible();

    await page.locator('body').click({ position: { x: 10, y: 10 } });
    await expect(menu).toBeHidden();
  });
});
```

### Transitions and Animations

**Use when**: Verifying `<Transition>` and `<TransitionGroup>` work correctly. Focus on end state, not animation details.

```typescript
import { test, expect } from '@playwright/test';

test.describe('transitions', () => {
  test('item appears after add', async ({ page }) => {
    await page.goto('/tasks');

    await page.getByRole('textbox', { name: 'Task' }).fill('Write tests');
    await page.getByRole('button', { name: 'Add' }).click();

    await expect(page.getByText('Write tests')).toBeVisible();
  });

  test('item disappears after delete', async ({ page }) => {
    await page.goto('/tasks');

    await page.getByRole('textbox', { name: 'Task' }).fill('Temp item');
    await page.getByRole('button', { name: 'Add' }).click();
    await expect(page.getByText('Temp item')).toBeVisible();

    await page.getByRole('listitem')
      .filter({ hasText: 'Temp item' })
      .getByRole('button', { name: 'Remove' })
      .click();

    await expect(page.getByText('Temp item')).toBeHidden();
  });

  test('disable animations for faster tests', async ({ page }) => {
    await page.addStyleTag({
      content: `
        *, *::before, *::after {
          animation-duration: 0s !important;
          animation-delay: 0s !important;
          transition-duration: 0s !important;
          transition-delay: 0s !important;
        }
      `,
    });

    await page.goto('/tasks');

    await page.getByRole('textbox', { name: 'Task' }).fill('Quick task');
    await page.getByRole('button', { name: 'Add' }).click();

    await expect(page.getByText('Quick task')).toBeVisible();
  });
});
```

### Composition API Components

**Use when**: Testing components with `<script setup>` or `setup()`. From Playwright's perspective, Composition API and Options API are identical.

```typescript
import { test, expect } from '@playwright/test';

test.describe('composition API', () => {
  test('computed properties update reactively', async ({ page }) => {
    await page.goto('/pricing');

    await page.getByLabel('Amount').fill('50');
    await page.getByLabel('Qty').fill('4');

    await expect(page.getByTestId('sum')).toHaveText('$200.00');

    await page.getByLabel('Discount').fill('20');
    await expect(page.getByTestId('sum')).toHaveText('$160.00');
  });

  test('watcher triggers on change', async ({ page }) => {
    await page.goto('/preferences');

    await page.getByRole('combobox', { name: 'Locale' }).selectOption('de');

    await expect(page.getByRole('heading', { name: 'Einstellungen' })).toBeVisible();
  });

  test('composable provides debounced search', async ({ page }) => {
    await page.goto('/shop');

    const input = page.getByRole('textbox', { name: 'Search' });
    await input.pressSequentially('hoodie', { delay: 50 });

    await expect(page.getByRole('listitem')).toHaveCount(2);
    await expect(page.getByText('Black Hoodie')).toBeVisible();
  });

  test('provide/inject updates all consumers', async ({ page }) => {
    await page.goto('/home');

    await page.getByRole('switch', { name: 'Dark theme' }).click();

    await expect(page.locator('body')).toHaveClass(/dark/);
  });
});
```

### Nuxt-Specific Patterns

**Use when**: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.

```typescript
import { test, expect } from '@playwright/test';

test.describe('nuxt features', () => {
  test('SSR renders server-fetched data', async ({ page }) => {
    await page.goto('/posts');

    await expect(page.getByRole('article')).toHaveCount(10);
    await expect(page.getByRole('article').first()).toContainText(/\w+/);
  });

  test('server route returns data', async ({ request }) => {
    const response = await request.get('/api/items');

    expect(response.ok()).toBeTruthy();
    const data = await response.json();
    expect(data).toBeInstanceOf(Array);
    expect(data[0]).toHaveProperty('id');
  });

  test('middleware redirects unauthorized', async ({ page }) => {
    await page.goto('/admin');

    await expect(page).toHaveURL(/\/login/);
  });

  test('NuxtLink enables SPA navigation', async ({ page }) => {
    await page.goto('/');

    await page.evaluate(() => {
      (window as any).__marker = 'spa';
    });

    await page.getByRole('link', { name: 'Posts' }).click();
    await page.waitForURL('/posts');

    const marker = await page.evaluate(() => (window as any).__marker);
    expect(marker).toBe('spa');
  });

  test('useHead sets meta tags', async ({ page }) => {
    await page.goto('/posts/hello-world');

    const title = await page.title();
    expect(title).toContain('Hello World');

    const desc = await page.locator('meta[name="description"]').getAttribute('content');
    expect(desc).toBeTruthy();
    expect(desc!.length).toBeGreaterThan(50);
  });
});
```

## Vue vs Nuxt Differences

| Aspect | Vue 3 (Vite) | Nuxt 3 |
| --- | --- | --- |
| Default port | `5173` | `3000` |
| Dev command | `npm run dev` | `npx nuxi dev` |
| Build + preview | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview` |
| SSR | Optional | Built-in |
| API routes | External backend | `/server/api/` built-in |
| Env variables | `VITE_*` prefix | `NUXT_PUBLIC_*` (client), `NUXT_*` (server) |
| File-based routing | No | Yes |

## Component Testing Dependencies

Components depending on Pinia or Vue Router need these provided:

```typescript
// playwright/index.ts
import { beforeMount } from '@playwright/experimental-ct-vue/hooks';
import { createPinia } from 'pinia';
import { createMemoryHistory, createRouter } from 'vue-router';

beforeMount(async ({ app, hooksConfig }) => {
  const pinia = createPinia();
  app.use(pinia);

  if (hooksConfig?.routes) {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: hooksConfig.routes,
    });
    app.use(router);
  }
});
```

## Testing v-model

`v-model` works through standard HTML events. Playwright methods trigger correct events automatically:

```typescript
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('checkbox', { name: 'Subscribe' }).check();
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
```

## Capturing Vue Warnings

```typescript
test('no Vue warnings during render', async ({ page }) => {
  const warnings: string[] = [];
  page.on('console', (msg) => {
    if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {
      warnings.push(msg.text());
    }
  });

  await page.goto('/home');
  await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();

  expect(warnings).toEqual([]);
});
```

## Anti-Patterns

| Avoid | Problem | Instead |
| --- | --- | --- |
| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades | Assert on UI that state produces |
| `page.locator('[data-v-abc123]')` | Scoped style hashes change on every build | Use `getByRole`, `getByText`, `getByTestId` |
| Import `.vue` files in E2E tests | E2E tests run in Node.js; `.vue` needs compilation | Use `@playwright/experimental-ct-vue` for component tests |
| `page.waitForTimeout(300)` for transitions | Arbitrary waits are fragile | `await expect(locator).toBeVisible()` auto-waits |
| Mock Pinia by patching `window.__pinia` | Fragile; may not trigger reactivity | Control state through UI or mock API responses |
| Test composables via `page.evaluate` | Composables need Vue's setup context | Test through components or unit test with Vitest |
| `page.locator('.v-btn')` for Vuetify | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |
| Run Nuxt dev server in CI | Dev mode is slower with hot reload overhead | Use `npx nuxi build && npx nuxi preview` |
infrastructure-ci-cd/ci-cd.md
# CI/CD Integration

## Table of Contents

1. [GitHub Actions](#github-actions)
2. [Docker](#docker)
3. [Reporting](#reporting)
4. [Sharding](#sharding)
5. [Environment Management](#environment-management)
6. [Caching](#caching)

## GitHub Actions

### Basic Workflow

```yaml
# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
```

### With Sharding

```yaml
name: Playwright Tests

on:
  push:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

      - name: Upload blob report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: blob-report-${{ matrix.shardIndex }}
          path: blob-report
          retention-days: 1

  merge-reports:
    if: ${{ !cancelled() }}
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Download blob reports
        uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true

      - name: Merge reports
        run: npx playwright merge-reports --reporter html ./all-blob-reports

      - name: Upload HTML report
        uses: actions/upload-artifact@v4
        with:
          name: html-report
          path: playwright-report
          retention-days: 14
```

### With Container

```yaml
jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    container:
      # Use latest or more appropriate playwright version (match package.json)
      image: mcr.microsoft.com/playwright:v1.40.0-jammy
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npx playwright test
        env:
          HOME: /root
```

## Docker

### Dockerfile

```dockerfile
FROM mcr.microsoft.com/playwright:v1.40.0-jammy

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

CMD ["npx", "playwright", "test"]
```

### Docker Compose

```yaml
# docker-compose.yml
version: "3.8"

services:
  playwright:
    build: .
    volumes:
      - ./playwright-report:/app/playwright-report
      - ./test-results:/app/test-results
    environment:
      - CI=true
      - BASE_URL=http://app:3000
    depends_on:
      - app

  app:
    build: ./app
    ports:
      - "3000:3000"
```

### Run with Docker

```bash
# Build and run
docker build -t playwright-tests .
docker run --rm -v $(pwd)/playwright-report:/app/playwright-report playwright-tests

# With docker-compose
docker-compose run --rm playwright
```

## Reporting

### Configuration

```typescript
// playwright.config.ts
export default defineConfig({
  reporter: [
    // Always generate
    ["html", { outputFolder: "playwright-report" }],

    // Console output
    ["list"],

    // CI-friendly
    ["github"], // GitHub Actions annotations

    // JUnit for CI integration
    ["junit", { outputFile: "results.xml" }],

    // JSON for custom processing
    ["json", { outputFile: "results.json" }],

    // Blob for merging shards
    ["blob", { outputDir: "blob-report" }],
  ],
});
```

### CI-Specific Reporter

```typescript
export default defineConfig({
  reporter: process.env.CI
    ? [["github"], ["blob"], ["html"]]
    : [["list"], ["html"]],
});
```

## Sharding

### Command Line

```bash
# Split into 4 shards, run shard 1
npx playwright test --shard=1/4

# Run shard 2
npx playwright test --shard=2/4
```

### Configuration

```typescript
// playwright.config.ts
export default defineConfig({
  // Evenly distribute tests across shards
  fullyParallel: true,

  // For blob reporter to merge later
  reporter: process.env.CI ? [["blob"]] : [["html"]],
});
```

### Merge Sharded Reports

```bash
# After all shards complete, merge blob reports
npx playwright merge-reports --reporter html ./all-blob-reports
```

## Environment Management

### Environment Variables

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import dotenv from "dotenv";

// Load env file based on environment
dotenv.config({ path: `.env.${process.env.NODE_ENV || "development"}` });

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || "http://localhost:3000",
  },
});
```

### Multiple Environments

```yaml
# .github/workflows/playwright.yml
jobs:
  test:
    strategy:
      matrix:
        environment: [staging, production]
    steps:
      - name: Run tests
        run: npx playwright test
        env:
          BASE_URL: ${{ matrix.environment == 'staging' && 'https://staging.example.com' || 'https://example.com' }}
          TEST_USER: ${{ secrets[format('TEST_USER_{0}', matrix.environment)] }}
```

### Secrets Management

```yaml
# GitHub Actions secrets
- name: Run tests
  run: npx playwright test
  env:
    TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
    TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
```

```typescript
// tests use environment variables
test("login", async ({ page }) => {
  await page.getByLabel("Email").fill(process.env.TEST_EMAIL!);
  await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
});
```

## Caching

### Cache Playwright Browsers

```yaml
- name: Cache Playwright browsers
  uses: actions/cache@v4
  id: playwright-cache
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
  if: steps.playwright-cache.outputs.cache-hit != 'true'
  run: npx playwright install --with-deps

- name: Install system deps only
  if: steps.playwright-cache.outputs.cache-hit == 'true'
  run: npx playwright install-deps
```

### Cache Node Modules

```yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "npm"

- name: Install dependencies
  run: npm ci
```

## Tag-Based Test Filtering

### Run Specific Tags in CI

```yaml
# Run smoke tests on PR
- name: Run smoke tests
  run: npx playwright test --grep @smoke

# Run full regression nightly
- name: Run regression
  run: npx playwright test --grep @regression

# Exclude flaky tests
- name: Run stable tests
  run: npx playwright test --grep-invert @flaky
```

### PR vs Nightly Strategy

```yaml
# .github/workflows/pr.yml - Fast feedback
- name: Run critical tests
  run: npx playwright test --grep "@smoke|@critical"

# .github/workflows/nightly.yml - Full coverage
- name: Run all tests
  run: npx playwright test --grep-invert @flaky
```

### Tag Filtering in Config

```typescript
// playwright.config.ts
export default defineConfig({
  grep: process.env.CI ? /@smoke|@critical/ : undefined,
  grepInvert: process.env.CI ? /@flaky/ : undefined,
});
```

### Project-Based Tag Filtering

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: "smoke",
      grep: /@smoke/,
    },
    {
      name: "regression",
      grepInvert: /@smoke/,
    },
  ],
});
```

## Best Practices

| Practice                      | Benefit                   |
| ----------------------------- | ------------------------- |
| Use `npm ci`                  | Deterministic installs    |
| Run headless in CI            | Faster, no display needed |
| Set retries in CI only        | Handle flakiness          |
| Upload artifacts on failure   | Debug failures            |
| Use sharding for large suites | Faster execution          |
| Cache browsers                | Faster setup              |
| Use blob reporter for shards  | Merge reports correctly   |
| Use tags for PR vs nightly    | Fast feedback + coverage  |
| Exclude @flaky in CI          | Stable pipeline           |

## CI Configuration Reference

```typescript
// playwright.config.ts - CI optimized
export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI
    ? [["github"], ["blob"], ["html"]]
    : [["list"], ["html"]],
  use: {
    baseURL: process.env.BASE_URL || "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "on-first-retry",
  },
});
```

## Related References

- **Test tags**: See [test-tags.md](../core/test-tags.md) for tagging and filtering patterns
- **Performance optimization**: See [performance.md](performance.md) for sharding and parallelization
- **Debugging CI failures**: See [debugging.md](../debugging/debugging.md) for troubleshooting
- **Test reporting**: See [debugging.md](../debugging/debugging.md) for trace viewer usage
infrastructure-ci-cd/docker.md
# Container-Based Testing

## Table of Contents

1. [Patterns](#patterns)
2. [Decision Guide](#decision-guide)
3. [Anti-Patterns](#anti-patterns)
4. [Troubleshooting](#troubleshooting)

> **When to use**: Running tests in containers for reproducible environments, CI pipelines, or consistent browser versions across team machines.

## Patterns

### Official Image Usage

Run tests without building a custom image:

```bash
docker run --rm \
  -v $(pwd):/app \
  -w /app \
  -e CI=true \
  -e BASE_URL=http://host.docker.internal:3000 \
  mcr.microsoft.com/playwright:v1.48.0-noble \
  bash -c "npm ci && npx playwright test"
```

Extract reports with bind mounts:

```bash
docker run --rm \
  -v $(pwd):/app \
  -v $(pwd)/playwright-report:/app/playwright-report \
  -v $(pwd)/test-results:/app/test-results \
  -w /app \
  mcr.microsoft.com/playwright:v1.48.0-noble \
  bash -c "npm ci && npx playwright test"
```

### Custom Dockerfile

Build a custom image when you need additional dependencies or pre-installed packages:

```dockerfile
FROM mcr.microsoft.com/playwright:v1.48.0-noble

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

CMD ["npx", "playwright", "test"]
```

Chromium-only slim image:

```dockerfile
FROM node:latest-slim

RUN npx playwright install --with-deps chromium

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

CMD ["npx", "playwright", "test", "--project=chromium"]
```

### Docker Compose Stack

Full application stack with database, cache, and test runner:

```yaml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/test
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:latest-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    tmpfs:
      - /var/lib/postgresql/data

  e2e:
    image: mcr.microsoft.com/playwright:v1.48.0-noble
    working_dir: /app
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - CI=true
      - BASE_URL=http://app:3000
    depends_on:
      - app
    command: bash -c "npm ci && npx playwright test"
    profiles:
      - test
```

Run commands:

```bash
docker compose --profile test up --abort-on-container-exit --exit-code-from e2e

docker compose --profile test down -v
```

### CI Container Jobs

**GitHub Actions:**

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.48.0-noble
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test
        env:
          HOME: /root
```

**GitLab CI:**

```yaml
test:
  image: mcr.microsoft.com/playwright:v1.48.0-noble
  script:
    - npm ci
    - npx playwright test
```

**Jenkins:**

```groovy
pipeline {
    agent {
        docker {
            image 'mcr.microsoft.com/playwright:v1.48.0-noble'
            args '-u root'
        }
    }
    stages {
        stage('Test') {
            steps {
                sh 'npm ci'
                sh 'npx playwright test'
            }
        }
    }
}
```

### Dev Container Setup

VS Code Dev Container or GitHub Codespaces configuration:

```json
{
  "name": "Playwright Dev",
  "image": "mcr.microsoft.com/playwright:v1.48.0-noble",
  "features": {
    "ghcr.io/devcontainers/features/node:latest": {
      "version": "20"
    }
  },
  "postCreateCommand": "npm ci",
  "customizations": {
    "vscode": {
      "extensions": ["ms-playwright.playwright"]
    }
  },
  "forwardPorts": [3000, 9323],
  "remoteUser": "root"
}
```

## Decision Guide

| Scenario | Approach |
|---|---|
| Simple CI pipeline | Official image as CI container |
| Tests need database + cache | Docker Compose with app, db, e2e services |
| Team needs identical environments | Dev Container or custom Dockerfile |
| Only testing Chromium | Slim image with `install --with-deps chromium` |
| Cross-browser testing | Official image (all browsers pre-installed) |
| Local development | Run directly on host for faster iteration |

## Anti-Patterns

| Anti-Pattern | Problem | Solution |
|---|---|---|
| Installing browsers at runtime | Wastes 60-90 seconds per run | Use official image or bake browsers into custom image |
| Running as non-root without sandbox config | Chromium sandbox permission errors | Run as root or disable sandbox |
| Bind-mounting `node_modules` from host | Platform-specific binary crashes | Use anonymous volume: `-v /app/node_modules` |
| No health checks on dependent services | Tests start before database ready | Add `healthcheck` with `depends_on: condition: service_healthy` |
| Building application inside Playwright container | Large image, slow builds | Separate app and e2e containers |

## Troubleshooting

### "browserType.launch: Executable doesn't exist"

Playwright version mismatch with Docker image. Ensure `@playwright/test` version matches image tag:

```bash
npm ls @playwright/test
docker pull mcr.microsoft.com/playwright:v<matching-version>-noble
```

### "net::ERR_CONNECTION_REFUSED" in docker-compose

Tests trying to reach `localhost` instead of service name. Configure `baseURL`:

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
  },
});
```

```yaml
e2e:
  environment:
    - BASE_URL=http://app:3000
```

### Permission denied on mounted volumes

Match user IDs or run as root:

```bash
docker run --rm -u $(id -u):$(id -g) \
  -v $(pwd):/app -w /app \
  mcr.microsoft.com/playwright:v1.48.0-noble \
  npx playwright test
```

### Slow container tests on macOS/Windows

Docker Desktop I/O overhead. Copy files instead of mounting:

```dockerfile
FROM mcr.microsoft.com/playwright:v1.48.0-noble
WORKDIR /app
COPY . .
RUN npm ci
CMD ["npx", "playwright", "test"]
```

Or use delegated mount:

```bash
docker run --rm \
  -v $(pwd):/app:delegated \
  -w /app \
  mcr.microsoft.com/playwright:v1.48.0-noble \
  bash -c "npm ci && npx playwright test"
```
infrastructure-ci-cd/github-actions.md
# GitHub Actions for Playwright

## Table of Contents

1. [CLI Commands](#cli-commands)
2. [Workflow Patterns](#workflow-patterns)
3. [Scenario Guide](#scenario-guide)
4. [Common Mistakes](#common-mistakes)
5. [Troubleshooting](#troubleshooting)
6. [Related](#related)

> **When to use**: Automating Playwright tests on pull requests, main branch merges, or scheduled runs.

## CLI Commands

```bash
npx playwright install --with-deps    # browsers + OS dependencies
npx playwright test --shard=1/4       # run shard 1 of 4
npx playwright test --reporter=github # PR annotations
npx playwright merge-reports ./blob-report  # combine shard reports
```

## Workflow Patterns

### Basic Workflow

**Use when**: Starting a new project or running a small test suite.

```yaml
# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: e2e-${{ github.ref }}
  cancel-in-progress: true

env:
  CI: true

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - run: npm ci

      - name: Cache browsers
        id: browser-cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install browsers
        if: steps.browser-cache.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps

      - name: Install OS dependencies
        if: steps.browser-cache.outputs.cache-hit == 'true'
        run: npx playwright install-deps

      - run: npx playwright test

      - name: Upload report
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: test-report
          path: playwright-report/
          retention-days: 14

      - name: Upload traces
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: traces
          path: test-results/
          retention-days: 7
```

### Sharded Execution

**Use when**: Test suite exceeds 10 minutes. Sharding cuts wall-clock time significantly.
**Avoid when**: Suite runs under 5 minutes—sharding overhead negates benefits.

```yaml
# .github/workflows/e2e-sharded.yml
name: E2E Tests (Sharded)

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: e2e-${{ github.ref }}
  cancel-in-progress: true

env:
  CI: true

jobs:
  test:
    timeout-minutes: 20
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]

    steps:
      - uses: actions/checkout@v4

      - run: npm ci

      - name: Cache browsers
        id: browser-cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install browsers
        if: steps.browser-cache.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps

      - name: Install OS dependencies
        if: steps.browser-cache.outputs.cache-hit == 'true'
        run: npx playwright install-deps

      - name: Run tests (shard ${{ matrix.shard }})
        run: npx playwright test --shard=${{ matrix.shard }}

      - name: Upload blob report
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: blob-${{ strategy.job-index }}
          path: blob-report/
          retention-days: 1

  merge:
    if: ${{ !cancelled() }}
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - run: npm ci

      - name: Download blob reports
        uses: actions/download-artifact@v4
        with:
          path: all-blobs
          pattern: blob-*
          merge-multiple: true

      - name: Merge reports
        run: npx playwright merge-reports --reporter=html ./all-blobs

      - name: Upload merged report
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: playwright-report/
          retention-days: 14
```

**Config for sharding**—enable blob reporter:

```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: process.env.CI
    ? [['blob'], ['github']]
    : [['html', { open: 'on-failure' }]],
});
```

### Container-Based Execution

**Use when**: Reproducible environment matching local Docker setup, or runner OS dependencies cause issues.
**Avoid when**: Standard `ubuntu-latest` with `--with-deps` works fine.

```yaml
# .github/workflows/e2e-container.yml
name: E2E Tests (Container)

on:
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.48.0-noble

    steps:
      - uses: actions/checkout@v4

      - run: npm ci

      - name: Run tests
        run: npx playwright test
        env:
          HOME: /root

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: test-report
          path: playwright-report/
          retention-days: 14
```

### Environment Secrets

**Use when**: Tests target staging/production with credentials.
**Avoid when**: Tests only run against local dev server.

```yaml
# .github/workflows/e2e-staging.yml
name: Staging Tests

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    environment: staging

    env:
      CI: true
      BASE_URL: ${{ vars.STAGING_URL }}
      TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
      API_TOKEN: ${{ secrets.API_TOKEN }}

    steps:
      - uses: actions/checkout@v4

      - run: npm ci

      - name: Cache browsers
        id: browser-cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install browsers
        if: steps.browser-cache.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps

      - name: Install OS dependencies
        if: steps.browser-cache.outputs.cache-hit == 'true'
        run: npx playwright install-deps

      - name: Run smoke tests
        run: npx playwright test --grep @smoke

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: staging-report
          path: playwright-report/
          retention-days: 14
```

### Scheduled Runs

**Use when**: Full regression suite is too slow for every PR—run nightly instead.
**Avoid when**: Suite runs under 15 minutes and can run on every PR.

```yaml
# .github/workflows/nightly.yml
name: Nightly Regression

on:
  schedule:
    - cron: '0 3 * * 1-5'
  workflow_dispatch:

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    env:
      CI: true
      BASE_URL: ${{ vars.STAGING_URL }}

    steps:
      - uses: actions/checkout@v4

      - run: npm ci

      - name: Install browsers
        run: npx playwright install --with-deps

      - name: Run full regression
        run: npx playwright test --grep @regression

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: nightly-${{ github.run_number }}
          path: playwright-report/
          retention-days: 30

      - name: Notify on failure
        if: failure()
        uses: slackapi/slack-github-action@latest
        with:
          payload: |
            {
              "text": "Nightly regression failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
```

### Reusable Workflow

**Use when**: Multiple repositories share the same Playwright setup.
**Avoid when**: Single repo with one workflow.

```yaml
# .github/workflows/pw-reusable.yml
name: Playwright Reusable

on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: 'lts/*'
      test-command:
        type: string
        default: 'npx playwright test'
    secrets:
      BASE_URL:
        required: false
      TEST_PASSWORD:
        required: false

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest

    env:
      CI: true
      BASE_URL: ${{ secrets.BASE_URL }}
      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm

      - run: npm ci

      - name: Cache browsers
        id: browser-cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install browsers
        if: steps.browser-cache.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps

      - name: Install OS dependencies
        if: steps.browser-cache.outputs.cache-hit == 'true'
        run: npx playwright install-deps

      - name: Run tests
        run: ${{ inputs.test-command }}

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: test-report
          path: playwright-report/
          retention-days: 14
```

**Calling the reusable workflow:**

```yaml
# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    uses: ./.github/workflows/pw-reusable.yml
    with:
      node-version: 'lts/*'
    secrets:
      BASE_URL: ${{ secrets.STAGING_URL }}
      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
```

## Scenario Guide

| Scenario | Approach |
|---|---|
| Small suite (< 5 min) | Single job, no sharding |
| Medium suite (5-20 min) | 2-4 shards with matrix |
| Large suite (20+ min) | 4-8 shards + blob merge |
| Cross-browser on PRs | Chromium only on PRs; all browsers on main |
| Staging/prod smoke tests | Separate workflow with `environment:` |
| Nightly full regression | `schedule` trigger + `workflow_dispatch` |
| Multiple repos, same setup | Reusable workflow with `workflow_call` |
| Reproducible env needed | Container job with Playwright image |

## Common Mistakes

| Mistake | Problem | Fix |
|---|---|---|
| No `concurrency` group | Duplicate runs waste minutes | Add `concurrency: { group: ..., cancel-in-progress: true }` |
| `fail-fast: true` with sharding | One failure cancels others | Set `fail-fast: false` |
| No browser caching | 60-90 seconds wasted per run | Cache `~/.cache/ms-playwright` |
| No `timeout-minutes` | Stuck jobs run for 6 hours | Set explicit timeout: 20-30 minutes |
| Artifacts only on failure | No report when tests pass | Use `if: ${{ !cancelled() }}` |
| Hardcoded secrets | Security risk | Use GitHub Secrets and Environments |
| All browsers on every PR | 3x CI cost | Chromium on PR; cross-browser on main |
| No artifact retention | Default 90-day fills storage | Set `retention-days: 7-14` |
| Missing `--with-deps` | Browser launch failures | Always use `npx playwright install --with-deps` |

## Troubleshooting

### Browser launch fails: "Missing dependencies"

**Cause**: Browsers restored from cache but OS dependencies weren't cached.

**Fix**: Run `npx playwright install-deps` on cache hit:

```yaml
- name: Install OS dependencies
  if: steps.browser-cache.outputs.cache-hit == 'true'
  run: npx playwright install-deps
```

### Tests pass locally but timeout in CI

**Cause**: CI runners have fewer resources than dev machines.

**Fix**: Reduce workers and increase timeouts:

```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  workers: process.env.CI ? '50%' : undefined,
  use: {
    actionTimeout: process.env.CI ? 15_000 : 10_000,
    navigationTimeout: process.env.CI ? 30_000 : 15_000,
  },
});
```

### Sharded reports incomplete

**Cause**: Artifact names collide or `merge-multiple` not set.

**Fix**: Unique names per shard and enable merge:

```yaml
# Upload in each shard
- uses: actions/upload-artifact@v4
  with:
    name: blob-${{ strategy.job-index }}
    path: blob-report/

# Download in merge job
- uses: actions/download-artifact@v4
  with:
    path: all-blobs
    pattern: blob-*
    merge-multiple: true
```

### `webServer` fails: "port already in use"

**Cause**: Zombie process from previous run.

**Fix**: Kill stale processes before starting:

```yaml
- name: Kill stale processes
  run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true
```

### No PR annotations

**Cause**: `github` reporter not configured.

**Fix**: Add `github` reporter for CI:

```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: process.env.CI
    ? [['html', { open: 'never' }], ['github']]
    : [['html', { open: 'on-failure' }]],
});
```

## Related

- [test-tags.md](../core/test-tags.md) — tagging and filtering tests
- [parallel-sharding.md](parallel-sharding.md) — sharding strategies
- [reporting.md](reporting.md) — reporter configuration
- [docker.md](docker.md) — container images
- [gitlab.md](gitlab.md) — GitLab CI equivalent
- [other-providers.md](other-providers.md) — CircleCI, Azure DevOps, Jenkins
infrastructure-ci-cd/gitlab.md
# GitLab CI/CD Configuration

## Table of Contents

1. [Key Commands](#key-commands)
2. [Patterns](#patterns)
3. [Decision Guide](#decision-guide)
4. [Anti-Patterns](#anti-patterns)
5. [Troubleshooting](#troubleshooting)

> **When to use**: Running Playwright tests in GitLab pipelines on merge requests, merges to main, or scheduled pipelines.

## Key Commands

```bash
npx playwright install --with-deps    # install browsers + OS deps
npx playwright test --shard=1/4       # run 1 of 4 parallel shards
npx playwright merge-reports ./blob-report  # merge shard results
npx playwright test --reporter=dot    # minimal output for CI logs
```

## Patterns

### Basic Pipeline Configuration

**Use when**: Any GitLab project with Playwright tests.

```yaml
# .gitlab-ci.yml
image: mcr.microsoft.com/playwright:v1.48.0-noble

stages:
  - install
  - test
  - report

variables:
  CI: "true"
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/
    - node_modules/

setup:
  stage: install
  script:
    - npm ci
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

e2e:
  stage: test
  needs: [setup]
  script:
    - npx playwright test
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/
    expire_in: 14 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
```

### Sharded Parallel Execution

**Use when**: Test suite exceeds 10 minutes. GitLab's `parallel` keyword splits across jobs automatically.
**Avoid when**: Suite runs under 5 minutes.

```yaml
image: mcr.microsoft.com/playwright:v1.48.0-noble

stages:
  - install
  - test
  - report

variables:
  CI: "true"
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/
    - node_modules/

setup:
  stage: install
  script:
    - npm ci
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

e2e:
  stage: test
  needs: [setup]
  parallel: 4
  script:
    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths:
      - blob-report/
    expire_in: 1 hour
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

combine-reports:
  stage: report
  needs: [e2e]
  when: always
  script:
    - npx playwright merge-reports --reporter=html ./blob-report
  artifacts:
    when: always
    paths:
      - playwright-report/
    expire_in: 14 days
```

**Config for sharded pipelines:**

```typescript
// playwright.config.ts
export default defineConfig({
  reporter: process.env.CI
    ? [["blob"], ["dot"]]
    : [["html", { open: "on-failure" }]],
});
```

### Environment Variables and Secrets

**Use when**: Tests need secrets (API keys, passwords) and should only run on merge requests or the default branch.

```yaml
image: mcr.microsoft.com/playwright:v1.48.0-noble

stages:
  - test

variables:
  CI: "true"

e2e:staging:
  stage: test
  variables:
    BASE_URL: $STAGING_URL
    TEST_PASSWORD: $TEST_PASSWORD
    API_KEY: $API_KEY
  before_script:
    - npm ci
  script:
    - npx playwright test
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/
    expire_in: 14 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - when: manual
      allow_failure: true
```

**Setting variables in GitLab:**
Navigate to **Settings > CI/CD > Variables** and add:

- `STAGING_URL` -- not masked, not protected
- `TEST_PASSWORD` -- masked, protected
- `API_KEY` -- masked, protected

### Multi-Browser Matrix

**Use when**: Running Chromium on MRs and all browsers on the default branch.

```yaml
image: mcr.microsoft.com/playwright:v1.48.0-noble

stages:
  - install
  - test

variables:
  CI: "true"

setup:
  stage: install
  script:
    - npm ci
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

e2e:chromium:
  stage: test
  needs: [setup]
  script:
    - npx playwright test --project=chromium
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/
    expire_in: 14 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

e2e:all-browsers:
  stage: test
  needs: [setup]
  parallel:
    matrix:
      - PROJECT: [chromium, firefox, webkit]
  script:
    - npx playwright test --project=$PROJECT
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/
    expire_in: 14 days
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
```

### Services Integration (Database, Cache)

**Use when**: Tests need the application running alongside Playwright, or you need external services.

```yaml
stages:
  - test

e2e:integration:
  stage: test
  image: mcr.microsoft.com/playwright:v1.48.0-noble
  services:
    - name: postgres:latest
      alias: db
    - name: redis:latest
      alias: cache
  variables:
    CI: "true"
    DATABASE_URL: "postgresql://postgres:postgres@db:5432/testdb"
    REDIS_URL: "redis://cache:6379"
    POSTGRES_PASSWORD: "postgres"
    POSTGRES_DB: "testdb"
  before_script:
    - npm ci
    - npx prisma db push
    - npx prisma db seed
  script:
    - npx playwright test
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/
    expire_in: 14 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
```

### Scheduled Nightly Regression

**Use when**: Full regression is too slow for every MR.

```yaml
e2e:nightly:
  stage: test
  image: mcr.microsoft.com/playwright:v1.48.0-noble
  before_script:
    - npm ci
  script:
    - npx playwright test --grep @regression
  artifacts:
    when: always
    paths:
      - playwright-report/
    expire_in: 30 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
```

Set up the schedule in **CI/CD > Schedules**: `0 3 * * 1-5` (3 AM UTC, weekdays).

## Decision Guide

| Scenario                             | Approach                                               | Why                                                 |
| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |
| Simple project, < 5 min suite        | Single `test` job using Playwright Docker image        | No sharding overhead; artifacts capture report      |
| Suite > 10 min                       | `parallel: N` with `--shard`                           | GitLab auto-assigns `CI_NODE_INDEX`/`CI_NODE_TOTAL` |
| Merge request fast feedback          | Chromium only on MRs; all browsers on main             | 3x fewer pipeline minutes on MRs                    |
| External services needed (DB, Redis) | `services:` keyword with Postgres/Redis images         | GitLab manages service lifecycle                    |
| Secrets for staging environment      | GitLab CI/CD Variables (masked + protected)            | Never hardcode secrets in `.gitlab-ci.yml`          |
| Full nightly regression              | Pipeline schedule (`CI_PIPELINE_SOURCE == "schedule"`) | Avoids blocking MR pipelines                        |
| Report browsing                      | `artifacts:` with `paths: [playwright-report/]`        | Browse directly in GitLab job artifacts UI          |

## Anti-Patterns

| Anti-Pattern                                         | Problem                                                            | Do This Instead                                                           |
| ---------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| 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                   |
| `artifacts: when: on_failure` only                   | No report when tests pass; can't verify results                    | Use `when: always` to capture reports regardless                          |
| No `expire_in` on artifacts                          | Artifacts accumulate and consume storage                           | Set `expire_in: 14 days` for reports, `1 hour` for intermediate artifacts |
| Hardcoding `CI_NODE_TOTAL` in shard flag             | Breaks when you change `parallel:` value                           | Use `--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL`                               |
| Skipping `needs:` between stages                     | Jobs wait for all previous stage jobs, not just their dependencies | Use `needs:` for precise dependency graphs                                |
| Large `cache:` including `node_modules/` without key | Stale cache causes version conflicts                               | Key cache on `package-lock.json` hash                                     |

## Troubleshooting

### Browser launch fails: "Failed to launch browser"

**Cause**: Not using the Playwright Docker image, or using a version that doesn't match your `@playwright/test` version.

**Fix**: Match the Docker image tag to your Playwright version:

```yaml
# Check your version: npm ls @playwright/test
image: mcr.microsoft.com/playwright:v1.48.0-noble
```

### Tests hang in GitLab runner: "Navigation timeout exceeded"

**Cause**: GitLab shared runners may have limited resources.

**Fix**: Reduce workers and increase timeouts:

```typescript
export default defineConfig({
  workers: process.env.CI ? 2 : undefined,
  use: {
    navigationTimeout: process.env.CI ? 30_000 : 15_000,
  },
});
```

### Pipeline runs on every push, not just merge requests

**Cause**: Missing `rules:` configuration.

**Fix**: Add explicit rules:

```yaml
rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
```

### Services (Postgres/Redis) not reachable from tests

**Cause**: Using `localhost` instead of the service alias.

**Fix**: Use the service alias as hostname:

```yaml
services:
  - name: postgres:latest
    alias: db

variables:
  DATABASE_URL: "postgresql://postgres:postgres@db:5432/testdb"
```

### Merged report is empty after sharded run

**Cause**: Each shard job needs the `blob` reporter, not `html`.

**Fix**: Configure blob reporter for CI:

```typescript
export default defineConfig({
  reporter: process.env.CI
    ? [["blob"], ["dot"]]
    : [["html", { open: "on-failure" }]],
});
```
infrastructure-ci-cd/other-providers.md
# CI: CircleCI, Azure DevOps, and Jenkins

> **When to use**: Running Playwright tests in CI platforms other than GitHub Actions or GitLab.

## Table of Contents

1. [Common Commands](#common-commands)
2. [Jenkins](#jenkins)
3. [CircleCI](#circleci)
4. [Azure DevOps](#azure-devops)
5. [JUnit Reporter Config](#junit-reporter-config)
6. [Platform Comparison](#platform-comparison)
7. [Troubleshooting](#troubleshooting)
8. [Anti-Patterns](#anti-patterns)

---

## Common Commands

```bash
npx playwright install --with-deps    # browsers + OS dependencies
npx playwright test --shard=1/4       # parallel sharding
npx playwright merge-reports ./blob-report  # combine shard results
npx playwright test --reporter=dot,html     # multiple reporters
```

## Jenkins

### Declarative Pipeline

```groovy
// Jenkinsfile
pipeline {
    agent {
        docker {
            image 'mcr.microsoft.com/playwright:v1.48.0-noble'
            args '-u root'
        }
    }

    environment {
        CI = 'true'
        HOME = '/root'
        npm_config_cache = "${WORKSPACE}/.npm"
    }

    options {
        timeout(time: 30, unit: 'MINUTES')
        disableConcurrentBuilds()
    }

    stages {
        stage('Install') {
            steps {
                sh 'npm ci'
            }
        }

        stage('Test') {
            steps {
                sh 'npx playwright test'
            }
            post {
                always {
                    junit allowEmptyResults: true,
                         testResults: 'results/junit.xml'
                    archiveArtifacts artifacts: 'pw-report/**',
                                     allowEmptyArchive: true
                    archiveArtifacts artifacts: 'results/**',
                                     allowEmptyArchive: true
                }
            }
        }
    }

    post {
        failure {
            echo 'Tests failed!'
        }
        cleanup {
            cleanWs()
        }
    }
}
```

### Parallel Shards

```groovy
// Jenkinsfile (sharded)
pipeline {
    agent none

    environment {
        CI = 'true'
        HOME = '/root'
    }

    options {
        timeout(time: 30, unit: 'MINUTES')
    }

    stages {
        stage('Test') {
            parallel {
                stage('Shard 1') {
                    agent {
                        docker {
                            image 'mcr.microsoft.com/playwright:v1.48.0-noble'
                            args '-u root'
                        }
                    }
                    steps {
                        sh 'npm ci'
                        sh 'npx playwright test --shard=1/4'
                    }
                    post {
                        always {
                            archiveArtifacts artifacts: 'blob-report/**',
                                             allowEmptyArchive: true
                        }
                    }
                }
                stage('Shard 2') {
                    agent {
                        docker {
                            image 'mcr.microsoft.com/playwright:v1.48.0-noble'
                            args '-u root'
                        }
                    }
                    steps {
                        sh 'npm ci'
                        sh 'npx playwright test --shard=2/4'
                    }
                    post {
                        always {
                            archiveArtifacts artifacts: 'blob-report/**',
                                             allowEmptyArchive: true
                        }
                    }
                }
                stage('Shard 3') {
                    agent {
                        docker {
                            image 'mcr.microsoft.com/playwright:v1.48.0-noble'
                            args '-u root'
                        }
                    }
                    steps {
                        sh 'npm ci'
                        sh 'npx playwright test --shard=3/4'
                    }
                    post {
                        always {
                            archiveArtifacts artifacts: 'blob-report/**',
                                             allowEmptyArchive: true
                        }
                    }
                }
                stage('Shard 4') {
                    agent {
                        docker {
                            image 'mcr.microsoft.com/playwright:v1.48.0-noble'
                            args '-u root'
                        }
                    }
                    steps {
                        sh 'npm ci'
                        sh 'npx playwright test --shard=4/4'
                    }
                    post {
                        always {
                            archiveArtifacts artifacts: 'blob-report/**',
                                             allowEmptyArchive: true
                        }
                    }
                }
            }
        }
    }
}
```

## CircleCI

### Basic Pipeline

```yaml
# .circleci/config.yml
version: 2.1

executors:
  pw:
    docker:
      - image: mcr.microsoft.com/playwright:v1.48.0-noble
    working_directory: ~/app

jobs:
  install:
    executor: pw
    steps:
      - checkout
      - restore_cache:
          keys:
            - deps-{{ checksum "package-lock.json" }}
      - run: npm ci
      - save_cache:
          key: deps-{{ checksum "package-lock.json" }}
          paths:
            - node_modules
      - persist_to_workspace:
          root: .
          paths:
            - node_modules

  test:
    executor: pw
    parallelism: 4
    steps:
      - checkout
      - attach_workspace:
          at: .
      - run:
          name: Run tests
          command: |
            npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
      - store_artifacts:
          path: pw-report
          destination: pw-report
      - store_artifacts:
          path: results
          destination: results
      - store_test_results:
          path: results/junit.xml

workflows:
  test:
    jobs:
      - install
      - test:
          requires:
            - install
```

### Using Orbs

```yaml
# .circleci/config.yml
version: 2.1

orbs:
  node: circleci/node@latest

executors:
  pw:
    docker:
      - image: mcr.microsoft.com/playwright:v1.48.0-noble

jobs:
  e2e:
    executor: pw
    parallelism: 4
    steps:
      - checkout
      - node/install-packages
      - run:
          name: Run tests
          command: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
      - store_artifacts:
          path: pw-report
      - store_test_results:
          path: results/junit.xml

workflows:
  main:
    jobs:
      - e2e
```

## Azure DevOps

### Basic Pipeline

```yaml
# azure-pipelines.yml
trigger:
  branches:
    include:
      - main

pr:
  branches:
    include:
      - main

pool:
  vmImage: "ubuntu-latest"

variables:
  CI: "true"
  npm_config_cache: $(Pipeline.Workspace)/.npm

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: "20.x"
    displayName: "Install Node.js"

  - task: Cache@2
    inputs:
      key: 'npm | "$(Agent.OS)" | package-lock.json'
      restoreKeys: |
        npm | "$(Agent.OS)"
      path: $(npm_config_cache)
    displayName: "Cache npm"

  - script: npm ci
    displayName: "Install dependencies"

  - script: npx playwright install --with-deps
    displayName: "Install browsers"

  - script: npx playwright test
    displayName: "Run tests"

  - task: PublishTestResults@2
    condition: always()
    inputs:
      testResultsFormat: "JUnit"
      testResultsFiles: "results/junit.xml"
      mergeTestResults: true
      testRunTitle: "E2E Tests"
    displayName: "Publish results"

  - task: PublishPipelineArtifact@1
    condition: always()
    inputs:
      targetPath: pw-report
      artifact: pw-report
      publishLocation: "pipeline"
    displayName: "Upload report"
```

### With Sharding

```yaml
# azure-pipelines.yml
trigger:
  branches:
    include:
      - main

pr:
  branches:
    include:
      - main

variables:
  CI: "true"

stages:
  - stage: Test
    jobs:
      - job: E2E
        pool:
          vmImage: "ubuntu-latest"
        strategy:
          matrix:
            shard1:
              SHARD: "1/4"
            shard2:
              SHARD: "2/4"
            shard3:
              SHARD: "3/4"
            shard4:
              SHARD: "4/4"
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: "20.x"

          - script: npm ci
            displayName: "Install dependencies"

          - script: npx playwright install --with-deps
            displayName: "Install browsers"

          - script: npx playwright test --shard=$(SHARD)
            displayName: "Run tests (shard $(SHARD))"

          - task: PublishPipelineArtifact@1
            condition: always()
            inputs:
              targetPath: blob-report
              artifact: blob-report-$(System.JobPositionInPhase)
            displayName: "Upload blob report"

  - stage: Report
    dependsOn: Test
    condition: always()
    jobs:
      - job: MergeReports
        pool:
          vmImage: "ubuntu-latest"
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: "20.x"

          - script: npm ci
            displayName: "Install dependencies"

          - task: DownloadPipelineArtifact@2
            inputs:
              patterns: "blob-report-*/**"
              path: all-blob-reports
            displayName: "Download blob reports"

          - script: npx playwright merge-reports --reporter=html ./all-blob-reports
            displayName: "Merge reports"

          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: pw-report
              artifact: pw-report
            displayName: "Upload merged report"
```

## JUnit Reporter Config

All platforms benefit from JUnit output for native test result display:

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  reporter: process.env.CI
    ? [
        ["dot"],
        ["html", { open: "never" }],
        ["junit", { outputFile: "results/junit.xml" }],
      ]
    : [["html", { open: "on-failure" }]],
});
```

## Platform Comparison

| Feature           | CircleCI                                        | Azure DevOps                     | Jenkins                |
| ----------------- | ----------------------------------------------- | -------------------------------- | ---------------------- |
| Docker support    | `docker:` executor                              | `vmImage` or container jobs      | Docker Pipeline plugin |
| Parallelism       | `parallelism: N` + `CIRCLE_NODE_INDEX`          | `strategy.matrix`                | `parallel` stages      |
| Artifact upload   | `store_artifacts`                               | `PublishPipelineArtifact@1`      | `archiveArtifacts`     |
| JUnit integration | `store_test_results`                            | `PublishTestResults@2`           | `junit` step           |
| Shard variable    | `$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL` | Define in matrix: `SHARD: '1/4'` | Hardcode per stage     |
| Cache key         | `checksum "package-lock.json"`                  | `Cache@2` with key template      | `stash`/`unstash`      |
| Secrets           | Context + env variables                         | Variable groups                  | Credentials plugin     |

## Troubleshooting

### Jenkins: "Browser closed unexpectedly"

Running as non-root in container causes sandbox issues.

```groovy
agent {
    docker {
        image 'mcr.microsoft.com/playwright:v1.48.0-noble'
        args '-u root'
    }
}
environment {
    HOME = '/root'
}
```

### CircleCI: "Executable doesn't exist"

Image version mismatch with `@playwright/test` version. Use `latest` tag or match versions:

```yaml
docker:
  - image: mcr.microsoft.com/playwright:v1.48.0-noble
```

### Azure DevOps: Test results not showing

Missing JUnit reporter or `PublishTestResults@2` task:

```typescript
reporter: [['junit', { outputFile: 'results/junit.xml' }]],
```

```yaml
- task: PublishTestResults@2
  condition: always()
  inputs:
    testResultsFormat: "JUnit"
    testResultsFiles: "results/junit.xml"
```

### Shard index off by one

CircleCI's `CIRCLE_NODE_INDEX` is 0-based, Playwright's `--shard` is 1-based:

```yaml
# CircleCI - add 1
command: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
```

## Anti-Patterns

| Anti-Pattern                        | Problem                                   | Solution                                             |
| ----------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
| Missing `--with-deps` on bare metal | OS libs missing, browser launch fails     | Use Playwright Docker image or `--with-deps`         |
| No JUnit reporter                   | CI can't display test results             | Add `['junit', { outputFile: 'results/junit.xml' }]` |
| No job timeout                      | Hung tests consume resources indefinitely | Set explicit timeout (20-30 min)                     |
| No artifact upload on success       | Can't verify passing results              | Always upload reports (`condition: always()`)        |
| Non-root in container without setup | Permission errors on browser binaries     | Run as root or configure permissions                 |
| Hardcoded shard count               | Must update multiple places               | Use CI-native variables                              |
infrastructure-ci-cd/parallel-sharding.md
# Sharding and Parallel Execution

## Table of Contents

1. [CLI Commands](#cli-commands)
2. [Patterns](#patterns)
3. [Decision Guide](#decision-guide)
4. [Anti-Patterns](#anti-patterns)
5. [Troubleshooting](#troubleshooting)

> **When to use**: Speeding up test suites by running tests concurrently on one machine (workers) or splitting across multiple CI jobs (sharding).

## CLI Commands

```bash
# Parallelism within one machine
npx playwright test --workers=4
npx playwright test --workers=50%

# Splitting across CI jobs
npx playwright test --shard=1/4
npx playwright test --shard=2/4

# Merging shard outputs
npx playwright merge-reports ./blob-report
npx playwright merge-reports --reporter=html,json ./blob-report

# Override config for single run
npx playwright test --fully-parallel
```

## Patterns

### Worker Configuration

**Use when**: Controlling concurrent test execution on a single machine.

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  // Tests WITHIN a file also run in parallel
  fullyParallel: true,

  // Worker count options:
  // - undefined: auto-detect (half CPU cores)
  // - number: fixed count
  // - string: percentage of cores
  workers: process.env.CI ? "50%" : undefined,
});
```

**`fullyParallel` behavior:**

| Setting                          | Files parallel | Tests in file parallel |
| -------------------------------- | -------------- | ---------------------- |
| `fullyParallel: false` (default) | Yes            | No (serial)            |
| `fullyParallel: true`            | Yes            | Yes                    |

**Serial execution for specific files:**

```ts
// tests/checkout-flow.spec.ts
import { test, expect } from "@playwright/test";

test.describe.configure({ mode: "serial" });

test("add items to cart", async ({ page }) => {
  // ...
});

test("complete payment", async ({ page }) => {
  // ...
});
```

### Sharding Across CI Machines

**Use when**: Suite exceeds 5 minutes even with maximum workers.

```bash
# Job 1            Job 2            Job 3            Job 4
--shard=1/4        --shard=2/4      --shard=3/4      --shard=4/4
```

**Config for sharded runs:**

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? "50%" : undefined,

  reporter: process.env.CI
    ? [["blob"], ["github"]]
    : [["html", { open: "on-failure" }]],
});
```

### Merging Shard Reports

**Use when**: Combining blob reports from multiple shards into a unified report.

```bash
# Merge all blobs into HTML
npx playwright merge-reports --reporter=html ./all-blob-reports

# Multiple formats
npx playwright merge-reports --reporter=html,json,junit ./all-blob-reports

# Custom output location
PLAYWRIGHT_HTML_REPORT=merged-report npx playwright merge-reports --reporter=html ./all-blob-reports
```

**GitHub Actions merge job:**

```yaml
merge-reports:
  if: ${{ !cancelled() }}
  needs: test
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: npm ci

    - uses: actions/download-artifact@v4
      with:
        path: all-blob-reports
        pattern: blob-report-*
        merge-multiple: true

    - run: npx playwright merge-reports --reporter=html ./all-blob-reports

    - uses: actions/upload-artifact@v4
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 14
```

### Worker-Scoped Fixtures

**Use when**: Expensive resources (DB connections, auth tokens) should be created once per worker, not per test.

```ts
// fixtures.ts
import { test as base } from "@playwright/test";

type WorkerFixtures = {
  dbClient: DatabaseClient;
  apiToken: string;
};

export const test = base.extend<{}, WorkerFixtures>({
  dbClient: [
    async ({}, use) => {
      const client = await DatabaseClient.connect(process.env.DB_URL!);
      await use(client);
      await client.disconnect();
    },
    { scope: "worker" },
  ],

  apiToken: [
    async ({}, use, workerInfo) => {
      const res = await fetch(`${process.env.API_URL}/auth`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          user: `test-user-${workerInfo.workerIndex}`,
          password: process.env.TEST_PASSWORD,
        }),
      });
      const { token } = await res.json();
      await use(token);
    },
    { scope: "worker" },
  ],
});

export { expect } from "@playwright/test";
```

### Test Isolation for Parallelism

**Use when**: Preparing tests to run without interference.

Each test must create its own state. No test should depend on or modify shared state.

```ts
// BAD: Shared user causes race conditions
test("edit settings", async ({ page }) => {
  await page.goto("/users/test-user/settings");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByRole("button", { name: "Save" }).click();
});

// GOOD: Unique user per test
test("edit settings", async ({ page, request }) => {
  const res = await request.post("/api/users", {
    data: { name: `user-${Date.now()}`, email: `${Date.now()}@test.com` },
  });
  const user = await res.json();

  await page.goto(`/users/${user.id}/settings`);
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByLabel("Email")).toHaveValue("[email protected]");

  await request.delete(`/api/users/${user.id}`);
});
```

**Using `testInfo` for unique identifiers:**

```ts
import { test, expect } from "@playwright/test";

test("submit order", async ({ page }, testInfo) => {
  const orderId = `order-${testInfo.workerIndex}-${Date.now()}`;
  await page.goto(`/orders/new?ref=${orderId}`);
  // ...
});
```

### Dynamic Shard Count

**Use when**: Automatically adjusting shards based on test count.

```yaml
# .github/workflows/playwright.yml
jobs:
  calculate-shards:
    runs-on: ubuntu-latest
    outputs:
      shard-count: ${{ steps.calc.outputs.count }}
      shard-matrix: ${{ steps.calc.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - id: calc
        run: |
          TEST_COUNT=$(npx playwright test --list --reporter=json 2>/dev/null | node -e "
            const data = require('fs').readFileSync('/dev/stdin', 'utf8');
            const parsed = JSON.parse(data);
            console.log(parsed.suites?.reduce((acc, s) => acc + (s.specs?.length || 0), 0) || 0);
          ")
          # 1 shard per 20 tests, min 1, max 8
          SHARDS=$(( (TEST_COUNT + 19) / 20 ))
          SHARDS=$(( SHARDS > 8 ? 8 : SHARDS ))
          SHARDS=$(( SHARDS < 1 ? 1 : SHARDS ))
          MATRIX="["
          for i in $(seq 1 $SHARDS); do
            [ $i -gt 1 ] && MATRIX+=","
            MATRIX+="\"$i/$SHARDS\""
          done
          MATRIX+="]"
          echo "count=$SHARDS" >> $GITHUB_OUTPUT
          echo "matrix=$MATRIX" >> $GITHUB_OUTPUT

  test:
    needs: calculate-shards
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: ${{ fromJson(needs.calculate-shards.outputs.shard-matrix) }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shard }}
```

## Decision Guide

| Scenario                         | Workers        | Shards | Reason                                  |
| -------------------------------- | -------------- | ------ | --------------------------------------- |
| < 50 tests, < 5 min              | Auto (default) | None   | No optimization needed                  |
| 50-200 tests, 5-15 min           | `'50%'` in CI  | 2-4    | Balance speed and cost                  |
| 200+ tests, > 15 min             | `'50%'` in CI  | 4-8    | Keep feedback under 10 min              |
| Flaky due to resource contention | Reduce to 2    | Keep   | Less CPU/memory pressure                |
| Tests modify shared database     | 1 or isolate   | Useful | Sharding splits files; workers run them |
| CI has limited resources         | 1 or `'25%'`   | More   | Compensate with more machines           |

| Aspect         | Workers (in-process)      | Shards (across machines)   |
| -------------- | ------------------------- | -------------------------- |
| What it splits | Tests across CPU cores    | Test files across CI jobs  |
| Controlled by  | Config or `--workers` CLI | `--shard=X/Y` CLI flag     |
| Shares memory  | Yes                       | No                         |
| Report merging | Not needed                | Required (`merge-reports`) |
| Cost           | Free (same machine)       | More CI minutes            |

## Anti-Patterns

| Anti-Pattern                            | Problem                                  | Solution                                             |
| --------------------------------------- | ---------------------------------------- | ---------------------------------------------------- |
| `fullyParallel: false` without reason   | Tests in files run serially              | Set `fullyParallel: true` unless tests need serial   |
| `workers: 1` in CI "for safety"         | Negates parallelism                      | Fix isolation issues; use `workers: '50%'`           |
| Hardcoded shared user account           | Race conditions in parallel runs         | Each test creates unique data                        |
| Sharding without blob reporter          | Each shard produces separate HTML report | Configure `reporter: [['blob']]` for CI              |
| Sharding with 3 tests                   | Setup overhead exceeds time saved        | Only shard when suite > 5 minutes                    |
| `test.describe.serial()` everywhere     | Kills parallelism, creates dependencies  | Use only when tests genuinely need prior state       |
| Workers > CPU cores                     | Context switching overhead               | Use `'50%'` or auto-detect                           |
| Missing `fail-fast: false` in CI matrix | One shard failure cancels others         | Always set `fail-fast: false` for sharded strategies |

## Troubleshooting

### Tests pass solo but fail together

- **Shared state**. Make test data unique:
  ```ts
  test("create item", async ({ request }, ti) => {
    await request.post("/api/items", {
      data: { name: `Item-${ti.workerIndex}-${Date.now()}` },
    });
  });
  ```

### "No tests found" in some shards

- **Too many shards**. Never exceed file count:
  ```bash
  npx playwright test --shard=1/10   # ok if 10 files
  npx playwright test --shard=1/20   # too many, some shards empty
  ```

### Merged report missing results

- **Blob reports collide**. Use unique names:
  ```yaml
  # Each shard
  - uses: actions/upload-artifact@v4
    with:
      name: blob-report-${{ strategy.job-index }}
      path: blob-report/
  # Merge step
  - uses: actions/download-artifact@v4
    with:
      pattern: blob-report-*
      merge-multiple: true
      path: all-blob-reports
  ```

### Worker-scoped fixture not working

- **Missing `{ scope: 'worker' }`**. Fix:
  ```ts
  export const test = base.extend({
    resource: [
      async ({}, use) => {
        const r = await Resource.create();
        await use(r);
        await r.destroy();
      },
      { scope: "worker" },
    ],
  });
  ```

### More workers = Slower

- **Too many workers thrash**. Limit in CI:
  ```ts
  export default defineConfig({
    workers: process.env.CI ? 2 : undefined,
  });
  ```
infrastructure-ci-cd/performance.md
# Performance & Parallelization

## Table of Contents

1. [Parallel Execution](#parallel-execution)
2. [Sharding](#sharding)
3. [Test Optimization](#test-optimization)
4. [Network Optimization](#network-optimization)
5. [Isolation and Parallel Execution](#isolation-and-parallel-execution)
6. [Resource Management](#resource-management)
7. [Benchmarking](#benchmarking)

## Parallel Execution

### Configuration

```typescript
// playwright.config.ts
export default defineConfig({
  // Run test files in parallel
  fullyParallel: true,

  // Number of worker processes
  workers: process.env.CI ? 1 : undefined, // undefined = half CPU cores

  // Or explicit count
  // workers: 4,
  // workers: '50%', // Percentage of CPU cores
});
```

### Serial Execution When Needed

```typescript
// Entire file serial
test.describe.configure({ mode: "serial" });

test.describe("Sequential Tests", () => {
  test("first", async ({ page }) => {
    // Runs first
  });

  test("second", async ({ page }) => {
    // Runs after first
  });
});
```

```typescript
// Single describe block serial
test.describe("Parallel Tests", () => {
  test("a", async () => {}); // Parallel
  test("b", async () => {}); // Parallel
});

test.describe.serial("Serial Tests", () => {
  test("c", async () => {}); // Serial
  test("d", async () => {}); // Serial
});
```

### Parallel Projects

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
});
```

```bash
# Run all projects in parallel
npx playwright test

# Run specific project
npx playwright test --project=chromium
```

## Sharding

### Basic Sharding

```bash
# Split tests across 4 machines
# Machine 1:
npx playwright test --shard=1/4

# Machine 2:
npx playwright test --shard=2/4

# Machine 3:
npx playwright test --shard=3/4

# Machine 4:
npx playwright test --shard=4/4
```

### Sharding Strategy

Tests are distributed evenly by file. For optimal sharding:

- Keep test files similar in size
- Use `fullyParallel: true` for even distribution
- Balance slow tests across files

### CI Sharding Pattern

```yaml
# GitHub Actions
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: npx playwright test --shard=${{ matrix.shard }}/4
```

> **For comprehensive CI sharding** (blob reports, merging sharded results, full workflows), see [ci-cd.md](ci-cd.md#sharding).

## Test Optimization

### Reuse Authentication

Avoid logging in for every test. Use setup projects with storage state to authenticate once and reuse the session.

> **For authentication patterns** (storage state, multiple auth states, setup projects), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).

### Reuse Page State (serial only — trade-off with isolation)

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

```typescript
// ⚠️ Serial only, no isolation: state from one test leaks into the next.
// Prefer test.describe.configure({ mode: 'serial' }) + fresh page per test, or beforeEach + page.goto().
test.describe.configure({ mode: "serial" });
test.describe("Dashboard", () => {
  let page: Page;

  test.beforeAll(async ({ browser }) => {
    const context = await browser.newContext({
      storageState: ".auth/user.json",
    });
    page = await context.newPage();
    await page.goto("/dashboard");
  });

  test.afterAll(async () => {
    await page?.close();
  });

  test("shows stats", async () => {
    await expect(page.getByTestId("stats")).toBeVisible();
  });

  test("shows chart", async () => {
    await expect(page.getByTestId("chart")).toBeVisible();
  });
});
```

### Lazy Navigation

```typescript
// Bad: Navigate in every test
test("check header", async ({ page }) => {
  await page.goto("/products");
  await expect(page.getByRole("heading")).toBeVisible();
});

test("check footer", async ({ page }) => {
  await page.goto("/products");
  await expect(page.getByRole("contentinfo")).toBeVisible();
});

// Good: Share navigation
test.describe("Products Page", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/products");
  });

  test("check header", async ({ page }) => {
    await expect(page.getByRole("heading")).toBeVisible();
  });

  test("check footer", async ({ page }) => {
    await expect(page.getByRole("contentinfo")).toBeVisible();
  });
});
```

### Skip Unnecessary Setup

```typescript
// Use test.skip for conditional execution
test("admin feature", async ({ page }) => {
  test.skip(!process.env.ADMIN_ENABLED, "Admin features disabled");
  // ...
});

// Use test.fixme for known broken tests
test.fixme("broken feature", async ({ page }) => {
  // Skipped but tracked
});
```

## Network Optimization

### Mock APIs

```typescript
test.beforeEach(async ({ page }) => {
  // Mock slow/heavy endpoints
  await page.route("**/api/analytics", (route) =>
    route.fulfill({ json: { views: 1000 } }),
  );

  await page.route("**/api/recommendations", (route) =>
    route.fulfill({ json: [] }),
  );
});
```

### Block Unnecessary Resources

```typescript
test.beforeEach(async ({ page }) => {
  // Block analytics, ads, tracking
  await page.route("**/*", (route) => {
    const url = route.request().url();
    if (
      url.includes("google-analytics") ||
      url.includes("facebook") ||
      url.includes("hotjar")
    ) {
      return route.abort();
    }
    return route.continue();
  });
});
```

### Block Resource Types

```typescript
// Block images and fonts for faster tests
await page.route("**/*", (route) => {
  const resourceType = route.request().resourceType();
  if (["image", "font", "stylesheet"].includes(resourceType)) {
    return route.abort();
  }
  return route.continue();
});
```

### Cache API Responses

```typescript
const apiCache = new Map<string, object>();

test.beforeEach(async ({ page }) => {
  await page.route("**/api/**", async (route) => {
    const url = route.request().url();

    if (apiCache.has(url)) {
      return route.fulfill({ json: apiCache.get(url) });
    }

    const response = await route.fetch();
    const json = await response.json();
    apiCache.set(url, json);
    return route.fulfill({ json });
  });
});
```

## Isolation and Parallel Execution

### Default: one context per test

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

### Avoiding state leak in parallel runs

- **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.
- Use **fixtures** for setup/teardown and **`beforeEach`** for per-test navigation so each test gets a fresh page or a clean slate.
- 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.

### Debugging flaky parallel runs

If a test is flaky only with multiple workers:

1. **Reproduce**: Run with default workers and `--repeat-each=10` (or `--repeat-each=100 --max-failures=1`).
2. **Confirm parallel-specific**: Run with `--workers=1`. If the failure disappears, the cause is likely shared state or non-isolated backend/DB data.
3. **Fix**: Remove shared page/context; use per-test fixtures and `beforeEach`; isolate test data per worker with `workerIndex` in a worker-scoped fixture.

Workers are restarted after a test failure so subsequent tests in that worker get a clean environment; fixing isolation still prevents the initial flakiness.

## Resource Management

### Browser Contexts

```typescript
// Recommended: One context per test (default) — full isolation
test("isolated test", async ({ page }) => {
  // Fresh context automatically
});

// Manual context for specific needs
test("multiple tabs", async ({ browser }) => {
  const context = await browser.newContext();
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  // Clean up
  await context.close();
});
```

### Memory Management

```typescript
// playwright.config.ts
export default defineConfig({
  // Limit concurrent workers
  workers: 2,

  // Limit parallel tests per worker
  use: {
    // Lower memory usage
    launchOptions: {
      args: ["--disable-dev-shm-usage"],
    },
  },
});
```

### Timeouts

```typescript
// playwright.config.ts
export default defineConfig({
  // Global test timeout
  timeout: 30000,

  // Assertion timeout
  expect: {
    timeout: 5000,
  },

  // Navigation timeout
  use: {
    navigationTimeout: 15000,
    actionTimeout: 10000,
  },
});
```

## Benchmarking

### Measure Test Duration

```typescript
test("performance test", async ({ page }, testInfo) => {
  const startTime = Date.now();

  await page.goto("/");

  const loadTime = Date.now() - startTime;
  console.log(`Page load: ${loadTime}ms`);

  // Add to test report
  testInfo.annotations.push({
    type: "performance",
    description: `Load time: ${loadTime}ms`,
  });
});
```

### Performance Metrics

```typescript
test("collect metrics", async ({ page }) => {
  await page.goto("/");

  const metrics = await page.evaluate(() => ({
    // Navigation timing
    loadTime:
      performance.timing.loadEventEnd - performance.timing.navigationStart,
    domContentLoaded:
      performance.timing.domContentLoadedEventEnd -
      performance.timing.navigationStart,

    // Performance entries
    resources: performance.getEntriesByType("resource").length,

    // Memory (Chrome only)
    // @ts-ignore
    memory: performance.memory?.usedJSHeapSize,
  }));

  console.log("Metrics:", metrics);
  expect(metrics.loadTime).toBeLessThan(3000);
});
```

### Lighthouse Integration

```typescript
import { playAudit } from "playwright-lighthouse";

test("lighthouse audit", async ({ page }) => {
  await page.goto("/");

  const audit = await playAudit({
    page,
    thresholds: {
      performance: 80,
      accessibility: 90,
      "best-practices": 80,
      seo: 80,
    },
    port: 9222,
  });

  expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(
    80,
  );
});
```

## Performance Checklist

| Optimization                   | Impact     |
| ------------------------------ | ---------- |
| Enable `fullyParallel`         | High       |
| Reuse authentication           | High       |
| Mock heavy APIs                | High       |
| Block tracking scripts         | Medium     |
| Use sharding in CI             | High       |
| Reduce workers if memory-bound | Medium     |
| Cache API responses            | Medium     |
| Skip unnecessary tests         | Low-Medium |

## Related References

- **CI/CD sharding**: See [ci-cd.md](ci-cd.md) for CI configuration
- **Test organization**: See [test-suite-structure.md](../core/test-suite-structure.md) for structuring tests
- **Fixtures for reuse**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for authentication patterns
infrastructure-ci-cd/reporting.md
# Test Reports & Artifacts

## Table of Contents

1. [CLI Commands](#cli-commands)
2. [Reporter Configuration](#reporter-configuration)
3. [Custom Reporter](#custom-reporter)
4. [Trace Configuration](#trace-configuration)
5. [Screenshot & Video Settings](#screenshot--video-settings)
6. [Artifact Directory Structure](#artifact-directory-structure)
7. [CI Artifact Upload](#ci-artifact-upload)
8. [Decision Guide](#decision-guide)
9. [Anti-Patterns](#anti-patterns)
10. [Troubleshooting](#troubleshooting)

> **When to use**: Configuring test output for debugging, CI dashboards, and team visibility.

## CLI Commands

```bash
# Display last HTML report
npx playwright show-report

# Specify reporter
npx playwright test --reporter=html
npx playwright test --reporter=dot           # minimal CI output
npx playwright test --reporter=line          # one line per test
npx playwright test --reporter=json          # machine-readable
npx playwright test --reporter=junit         # CI integration

# Combine reporters
npx playwright test --reporter=dot,html

# Merge sharded reports
npx playwright merge-reports --reporter=html ./blob-report
```

## Reporter Configuration

### Environment-Based Setup

```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: process.env.CI
    ? [
        ['dot'],
        ['html', { open: 'never' }],
        ['junit', { outputFile: 'results/junit.xml' }],
        ['github'],
      ]
    : [
        ['list'],
        ['html', { open: 'on-failure' }],
      ],
});
```

### Reporter Types

| Reporter | Output | Use Case |
|---|---|---|
| `list` | One line per test | Local development |
| `line` | Single updating line | Local, less verbose |
| `dot` | `.` pass, `F` fail | CI logs |
| `html` | Interactive HTML page | Post-run analysis |
| `json` | Machine-readable JSON | Custom tooling |
| `junit` | JUnit XML | CI platforms |
| `github` | PR annotations | GitHub Actions |
| `blob` | Binary archive | Shard merging |

### JSON Output to File

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    ['json', { outputFile: 'results/output.json' }],
  ],
});
```

### JUnit Customization

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    ['junit', {
      outputFile: 'results/junit.xml',
      stripANSIControlSequences: true,
      includeProjectInTestName: true,
    }],
  ],
});
```

## Custom Reporter

Build custom reporters for Slack notifications, database logging, or dashboards.

```typescript
// reporters/notification-reporter.ts
import type {
  FullResult,
  Reporter,
  TestCase,
  TestResult,
} from '@playwright/test/reporter';

class NotificationReporter implements Reporter {
  private passed = 0;
  private failed = 0;
  private skipped = 0;
  private failures: string[] = [];

  onTestEnd(test: TestCase, result: TestResult) {
    switch (result.status) {
      case 'passed':
        this.passed++;
        break;
      case 'failed':
      case 'timedOut':
        this.failed++;
        this.failures.push(`${test.title}: ${result.error?.message?.split('\n')[0]}`);
        break;
      case 'skipped':
        this.skipped++;
        break;
    }
  }

  async onEnd(result: FullResult) {
    const total = this.passed + this.failed + this.skipped;
    const status = this.failed > 0 ? 'FAILED' : 'PASSED';
    const message = [
      `Tests ${status}`,
      `Passed: ${this.passed} | Failed: ${this.failed} | Skipped: ${this.skipped}`,
      `Duration: ${(result.duration / 1000).toFixed(1)}s`,
    ];

    if (this.failures.length > 0) {
      message.push('', 'Failures:');
      this.failures.slice(0, 5).forEach((f) => message.push(`  - ${f}`));
      if (this.failures.length > 5) {
        message.push(`  ...and ${this.failures.length - 5} more`);
      }
    }

    const webhookUrl = process.env.NOTIFICATION_WEBHOOK;
    if (webhookUrl) {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 5000);
      try {
        await fetch(webhookUrl, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text: message.join('\n') }),
          signal: controller.signal,
        });
      } catch (error) {
        // Intentionally swallow notifier failures to avoid blocking test completion
        console.warn('Webhook notification failed:', error.message);
      } finally {
        clearTimeout(timeout);
      }
    }
  }
}

export default NotificationReporter;
```

**Register custom reporter:**

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    ['dot'],
    ['html', { open: 'never' }],
    ['./reporters/notification-reporter.ts'],
  ],
});
```

## Trace Configuration

Traces capture actions, network requests, DOM snapshots, and console logs.

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: 'on-first-retry',
  },
});
```

### Trace Options

| Value | Behavior | Overhead |
|---|---|---|
| `'off'` | Never records | None |
| `'on'` | Every test | High |
| `'on-first-retry'` | On first retry after failure | Minimal |
| `'retain-on-failure'` | Records all, keeps failures | Medium |
| `'retain-on-first-failure'` | Records all, keeps first failure | Medium |

### Viewing Traces

```bash
# Local trace viewer
npx playwright show-trace results/my-test/trace.zip

# From HTML report (click Traces tab)
npx playwright show-report

# Online viewer: https://trace.playwright.dev
```

## Screenshot & Video Settings

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});
```

### Video with Custom Size

```typescript
use: {
  video: {
    mode: 'retain-on-failure',
    size: { width: 1280, height: 720 },
  },
},
```

### Screenshot Options

| Value | Captures | Disk Cost |
|---|---|---|
| `'off'` | Never | None |
| `'on'` | Every test | High |
| `'only-on-failure'` | Failed tests | Low |

### Video Options

| Value | Records | Keeps | Disk Cost |
|---|---|---|---|
| `'off'` | Never | — | None |
| `'on'` | Every test | All | Very high |
| `'on-first-retry'` | On retry | Retried | Low |
| `'retain-on-failure'` | Every test | Failed | Medium |

## Artifact Directory Structure

```text
test-results/
├── checkout-test-chromium/
│   ├── trace.zip
│   ├── test-failed-1.png
│   └── video.webm
├── login-test-firefox/
│   ├── trace.zip
│   └── test-failed-1.png
└── junit.xml

playwright-report/
├── index.html
└── data/

blob-report/
└── report-1.zip
```

## CI Artifact Upload

### GitHub Actions

```yaml
- uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 14

- uses: actions/upload-artifact@v4
  if: failure()
  with:
    name: test-traces
    path: |
      test-results/**/trace.zip
      test-results/**/*.png
      test-results/**/*.webm
    retention-days: 7
```

## Decision Guide

| Scenario | Reporter Configuration |
|---|---|
| Local development | `[['list'], ['html', { open: 'on-failure' }]]` |
| GitHub Actions | `[['dot'], ['html'], ['github']]` |
| GitLab CI | `[['dot'], ['html'], ['junit']]` |
| Azure DevOps / Jenkins | `[['dot'], ['html'], ['junit']]` |
| Sharded CI | `[['blob'], ['github']]` |
| Custom dashboard | `[['json', { outputFile: '...' }]]` + custom reporter |

| Artifact | When to Collect | Retention | Upload Condition |
|---|---|---|---|
| HTML report | Always | 14 days | `if: ${{ !cancelled() }}` |
| Traces | On failure | 7 days | `if: failure()` |
| Screenshots | On failure | 7 days | `if: failure()` |
| Videos | On failure | 7 days | `if: failure()` |
| JUnit XML | Always | 14 days | `if: ${{ !cancelled() }}` |
| Blob report | Always (sharded) | 1 day | `if: ${{ !cancelled() }}` |

## Anti-Patterns

| Anti-Pattern | Problem | Solution |
|---|---|---|
| No reporter configured | Default `list` only; no persistent report | Configure `html` + CI reporter |
| `trace: 'on'` in CI | Massive artifacts, slow uploads | Use `trace: 'on-first-retry'` |
| `video: 'on'` in CI | Enormous storage, slower tests | Use `video: 'retain-on-failure'` |
| Upload artifacts only on failure | No report when tests pass | Upload with `if: ${{ !cancelled() }}` |
| No retention limits | CI storage fills quickly | Set `retention-days: 7-14` |
| Only `dot` reporter | Cannot drill into failures | Pair `dot` with `html` |
| JUnit to stdout | Interferes with console output | Write to file |
| Blocking `onEnd` in custom reporter | Slow HTTP calls delay pipeline | Use `Promise.race` with timeout |

## Troubleshooting

### Empty HTML Report

Check reporter config. HTML report defaults to `playwright-report/`:

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [['html', { outputFolder: 'playwright-report', open: 'never' }]],
});
```

### Traces Too Large

Switch from `trace: 'on'` to `'on-first-retry'` with retries enabled:

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: 'on-first-retry',
  },
});
```

### JUnit XML Not Recognized

Ensure path matches CI configuration:

```typescript
reporter: [['junit', { outputFile: 'results/junit.xml' }]],
```

```yaml
# GitHub Actions
- uses: dorny/test-reporter@latest
  with:
    path: results/junit.xml
    reporter: java-junit

# Azure DevOps
- task: PublishTestResults@latest
  inputs:
    testResultsFiles: 'results/junit.xml'

# Jenkins
junit 'results/junit.xml'
```

### Empty Merged Report

Use `blob` reporter for sharded runs (not `html`):

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: process.env.CI
    ? [['blob'], ['dot']]
    : [['html', { open: 'on-failure' }]],
});
```

### Missing Screenshots in Report

Enable screenshots and keep both directories:

```typescript
use: {
  screenshot: 'only-on-failure',
},
```

The HTML report embeds screenshots from `test-results/`. Deleting that directory removes screenshots from the report.
infrastructure-ci-cd/test-coverage.md
# Test Coverage

## Table of Contents

1. [Coverage Setup](#coverage-setup)
2. [Collecting Coverage](#collecting-coverage)
3. [Coverage Reports](#coverage-reports)
4. [Coverage Thresholds](#coverage-thresholds)
5. [Advanced Patterns](#advanced-patterns)
6. [CI Integration](#ci-integration)

## Coverage Setup

### Install Dependencies

```bash
# For V8 coverage (built into Playwright)
# No additional dependencies needed

# For Istanbul-based coverage (more features)
npm install -D nyc @istanbuljs/nyc-config-typescript
```

### Basic Configuration

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  use: {
    // Enable coverage collection
    contextOptions: {
      // V8 coverage is automatic with the API below
    },
  },
});
```

### V8 Coverage Fixture

```typescript
// fixtures/coverage.ts
import { test as base, expect } from "@playwright/test";
import fs from "fs";
import path from "path";
import { randomUUID } from "crypto";

export const test = base.extend<{}, { collectCoverage: void }>({
  collectCoverage: [
    async ({ browser }, use) => {
      // Start coverage for all pages
      const context = await browser.newContext();
      const page = await context.newPage();

      await page.coverage.startJSCoverage();
      await page.coverage.startCSSCoverage();

      await use();

      // Collect coverage
      const [jsCoverage, cssCoverage] = await Promise.all([
        page.coverage.stopJSCoverage(),
        page.coverage.stopCSSCoverage(),
      ]);

      // Save coverage data
      const coverageDir = "./coverage";
      if (!fs.existsSync(coverageDir)) {
        fs.mkdirSync(coverageDir, { recursive: true });
      }

      fs.writeFileSync(
        path.join(coverageDir, `coverage-${randomUUID()}.json`),
        JSON.stringify([...jsCoverage, ...cssCoverage])
      );

      await context.close();
    },
    { scope: "worker", auto: true },
  ],
});
```

## Collecting Coverage

### Per-Test Coverage

```typescript
test("collect coverage for single test", async ({ page }) => {
  // Start coverage collection
  await page.coverage.startJSCoverage({
    resetOnNavigation: false,
  });

  // Run test
  await page.goto("/app");
  await page.getByRole("button", { name: "Submit" }).click();
  await expect(page.getByText("Success")).toBeVisible();

  // Stop and get coverage
  const coverage = await page.coverage.stopJSCoverage();

  // Filter to only your source files
  const appCoverage = coverage.filter((entry) => entry.url.includes("/src/"));

  console.log(`Covered ${appCoverage.length} source files`);
});
```

### Coverage for Specific Files

```typescript
test("track specific module coverage", async ({ page }) => {
  await page.coverage.startJSCoverage();

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Pay" }).click();

  const coverage = await page.coverage.stopJSCoverage();

  // Find coverage for checkout module
  const checkoutCoverage = coverage.find((c) => c.url.includes("checkout.js"));

  if (checkoutCoverage) {
    const totalBytes = checkoutCoverage.text?.length || 0;
    const coveredBytes = checkoutCoverage.ranges.reduce(
      (sum, range) => sum + (range.end - range.start),
      0
    );
    const percentage = (coveredBytes / totalBytes) * 100;

    console.log(`Checkout module: ${percentage.toFixed(1)}% covered`);
    expect(percentage).toBeGreaterThan(80);
  }
});
```

### CSS Coverage

```typescript
test("collect CSS coverage", async ({ page }) => {
  await page.coverage.startCSSCoverage();

  await page.goto("/app");

  // Interact to trigger different CSS states
  await page.getByRole("button").hover();
  await page.getByRole("dialog").waitFor();

  const cssCoverage = await page.coverage.stopCSSCoverage();

  // Find unused CSS
  for (const entry of cssCoverage) {
    const totalBytes = entry.text?.length || 0;
    const usedBytes = entry.ranges.reduce(
      (sum, range) => sum + (range.end - range.start),
      0
    );
    const unusedPercentage = ((totalBytes - usedBytes) / totalBytes) * 100;

    if (unusedPercentage > 50) {
      console.warn(`${entry.url}: ${unusedPercentage.toFixed(1)}% unused CSS`);
    }
  }
});
```

## Coverage Reports

### Converting to Istanbul Format

```typescript
// scripts/convert-coverage.ts
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import v8ToIstanbul from "v8-to-istanbul";

async function convertCoverage() {
  const coverageDir = "./coverage";
  const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json"));

  const istanbulCoverage: any = {};

  for (const file of files) {
    const coverageData = JSON.parse(
      fs.readFileSync(path.join(coverageDir, file), "utf-8")
    );

    for (const entry of coverageData) {
      if (!entry.url.startsWith("file://")) continue;

      const filePath = entry.url.replace("file://", "");
      const converter = v8ToIstanbul(filePath);

      await converter.load();
      converter.applyCoverage(entry.functions || []);

      const istanbul = converter.toIstanbul();
      Object.assign(istanbulCoverage, istanbul);
    }
  }

  fs.writeFileSync(
    path.join(coverageDir, "coverage-final.json"),
    JSON.stringify(istanbulCoverage)
  );
}

convertCoverage();
```

### Generating HTML Report

```bash
# Using nyc to generate report
npx nyc report --reporter=html --reporter=text --temp-dir=./coverage
```

```typescript
// package.json scripts
{
  "scripts": {
    "test": "playwright test",
    "test:coverage": "playwright test && npm run coverage:report",
    "coverage:report": "npx nyc report --reporter=html --reporter=lcov --temp-dir=./coverage"
  }
}
```

### Custom Coverage Reporter

```typescript
// reporters/coverage-reporter.ts
import type { Reporter, FullResult } from "@playwright/test/reporter";
import fs from "fs";
import path from "path";

class CoverageReporter implements Reporter {
  private coverageData: any[] = [];

  onEnd(result: FullResult) {
    // Aggregate all coverage files
    const coverageDir = "./coverage";
    const files = fs
      .readdirSync(coverageDir)
      .filter((f) => f.endsWith(".json"));

    for (const file of files) {
      const data = JSON.parse(
        fs.readFileSync(path.join(coverageDir, file), "utf-8")
      );
      this.coverageData.push(...data);
    }

    // Generate summary
    const summary = this.generateSummary();
    console.log("\n📊 Coverage Summary:");
    console.log(`   Files: ${summary.totalFiles}`);
    console.log(`   Lines: ${summary.lineCoverage.toFixed(1)}%`);
    console.log(`   Bytes: ${summary.byteCoverage.toFixed(1)}%`);

    if (summary.lineCoverage < 80) {
      console.warn("⚠️  Coverage below 80% threshold!");
    }
  }

  private generateSummary() {
    let totalBytes = 0;
    let coveredBytes = 0;
    const files = new Set<string>();

    for (const entry of this.coverageData) {
      if (entry.url.includes("/src/")) {
        files.add(entry.url);
        totalBytes += entry.text?.length || 0;
        coveredBytes += entry.ranges.reduce(
          (sum: number, r: any) => sum + (r.end - r.start),
          0
        );
      }
    }

    return {
      totalFiles: files.size,
      byteCoverage: (coveredBytes / totalBytes) * 100,
      lineCoverage: (coveredBytes / totalBytes) * 100, // Simplified
    };
  }
}

export default CoverageReporter;
```

## Coverage Thresholds

### Enforcing Minimum Coverage

```typescript
// tests/coverage.spec.ts
import { test, expect } from "@playwright/test";
import fs from "fs";
import path from "path";

test.afterAll(async () => {
  const coverageDir = "./coverage";
  const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json"));

  let totalBytes = 0;
  let coveredBytes = 0;

  for (const file of files) {
    const coverage = JSON.parse(
      fs.readFileSync(path.join(coverageDir, file), "utf-8")
    );

    for (const entry of coverage) {
      if (!entry.url.includes("/src/")) continue;
      totalBytes += entry.text?.length || 0;
      coveredBytes += entry.ranges.reduce(
        (sum: number, r: any) => sum + (r.end - r.start),
        0
      );
    }
  }

  const coveragePercent = (coveredBytes / totalBytes) * 100;

  // Enforce threshold
  expect(coveragePercent).toBeGreaterThan(80);
});
```

### Per-Directory Thresholds

```typescript
// coverage-check.ts
interface CoverageThreshold {
  pattern: RegExp;
  minCoverage: number;
}

const thresholds: CoverageThreshold[] = [
  { pattern: /\/src\/core\//, minCoverage: 90 },
  { pattern: /\/src\/utils\//, minCoverage: 85 },
  { pattern: /\/src\/components\//, minCoverage: 70 },
  { pattern: /\/src\/pages\//, minCoverage: 60 },
];

function checkThresholds(coverage: any[]): string[] {
  const violations: string[] = [];

  for (const threshold of thresholds) {
    const matchingFiles = coverage.filter((c) => threshold.pattern.test(c.url));

    let total = 0;
    let covered = 0;

    for (const file of matchingFiles) {
      total += file.text?.length || 0;
      covered += file.ranges.reduce(
        (sum: number, r: any) => sum + (r.end - r.start),
        0
      );
    }

    const percent = total > 0 ? (covered / total) * 100 : 0;

    if (percent < threshold.minCoverage) {
      violations.push(
        `${threshold.pattern}: ${percent.toFixed(1)}% < ${
          threshold.minCoverage
        }%`
      );
    }
  }

  return violations;
}
```

## Advanced Patterns

### Merging Coverage Across Shards

```typescript
// scripts/merge-coverage.ts
import fs from "fs";
import { glob } from "glob";

async function mergeCoverage() {
  const files = await glob("shard-*/coverage/*.json");
  const merged = new Map<string, any>();

  for (const file of files) {
    const data = JSON.parse(fs.readFileSync(file, "utf-8"));
    for (const entry of data) {
      if (merged.has(entry.url)) {
        const existing = merged.get(entry.url);
        existing.ranges.push(...entry.ranges);
      } else {
        merged.set(entry.url, { ...entry });
      }
    }
  }

  fs.writeFileSync(
    "./coverage/merged.json",
    JSON.stringify([...merged.values()])
  );
}

mergeCoverage();
```

### Incremental Coverage

```typescript
// Check coverage only for changed files in CI
import { execSync } from "child_process";
import fs from "fs";

const changedFiles = execSync("git diff --name-only HEAD~1")
  .toString()
  .split("\n")
  .filter((f) => f.endsWith(".ts"));

const coverage = JSON.parse(fs.readFileSync("./coverage/merged.json", "utf-8"));

for (const file of changedFiles) {
  const entry = coverage.find((c: any) => c.url.includes(file));
  if (entry) {
    const percent =
      (entry.ranges.reduce((s: number, r: any) => s + r.end - r.start, 0) /
        (entry.text?.length || 1)) *
      100;
    console.log(`${file}: ${percent.toFixed(1)}%`);
  }
}
```

## CI Integration

### GitHub Actions

```yaml
# .github/workflows/test.yml
name: Tests with Coverage

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: Run tests with coverage
        run: npm run test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: true

      - name: Check coverage threshold
        run: |
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage $COVERAGE% is below 80% threshold"
            exit 1
          fi
```

## Anti-Patterns to Avoid

| Anti-Pattern                 | Problem                                | Solution                    |
| ---------------------------- | -------------------------------------- | --------------------------- |
| Coverage for coverage's sake | Gaming metrics                         | Focus on critical paths     |
| 100% coverage target         | Diminishing returns, tests for getters | Set realistic thresholds    |
| Ignoring coverage drops      | Technical debt                         | Enforce thresholds in CI    |
| No source map support        | Wrong line numbers                     | Enable source maps in build |
| Coverage only in CI          | Late feedback                          | Run locally too             |

## Related References

- **CI/CD**: See [ci-cd.md](ci-cd.md) for pipeline configuration
- **Performance**: See [performance.md](performance.md) for optimizing coverage collection
SKILL.md
---
name: playwright-best-practices
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.
license: MIT
metadata:
  author: currents.dev
  version: "1.2"
---

# 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                                                                                                                               |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Writing E2E tests**               | [test-suite-structure.md](core/test-suite-structure.md), [locators.md](core/locators.md), [assertions-waiting.md](core/assertions-waiting.md) |
| **Writing component tests**         | [component-testing.md](testing-patterns/component-testing.md), [test-suite-structure.md](core/test-suite-structure.md)                        |
| **Writing API tests**               | [api-testing.md](testing-patterns/api-testing.md), [test-suite-structure.md](core/test-suite-structure.md)                                    |
| **Writing GraphQL tests**           | [graphql-testing.md](testing-patterns/graphql-testing.md), [api-testing.md](testing-patterns/api-testing.md)                                  |
| **Writing visual regression tests** | [visual-regression.md](testing-patterns/visual-regression.md), [canvas-webgl.md](testing-patterns/canvas-webgl.md)                            |
| **Structuring test code with POM**  | [page-object-model.md](core/page-object-model.md), [test-suite-structure.md](core/test-suite-structure.md)                                    |
| **Setting up test data/fixtures**   | [fixtures-hooks.md](core/fixtures-hooks.md), [test-data.md](core/test-data.md)                                                                |
| **Handling authentication**         | [authentication.md](advanced/authentication.md), [authentication-flows.md](advanced/authentication-flows.md)                                  |
| **Testing date/time features**      | [clock-mocking.md](advanced/clock-mocking.md)                                                                                                 |
| **Testing file upload/download**    | [file-operations.md](testing-patterns/file-operations.md), [file-upload-download.md](testing-patterns/file-upload-download.md)                |
| **Testing forms/validation**        | [forms-validation.md](testing-patterns/forms-validation.md)                                                                                   |
| **Testing drag and drop**           | [drag-drop.md](testing-patterns/drag-drop.md)                                                                                                 |
| **Testing accessibility**           | [accessibility.md](testing-patterns/accessibility.md)                                                                                         |
| **Testing security (XSS, CSRF)**    | [security-testing.md](testing-patterns/security-testing.md)                                                                                   |
| **Using test annotations**          | [annotations.md](core/annotations.md)                                                                                                         |
| **Using test tags**                 | [test-tags.md](core/test-tags.md)                                                                                                             |
| **Testing iframes**                 | [iframes.md](browser-apis/iframes.md)                                                                                                         |
| **Testing canvas/WebGL**            | [canvas-webgl.md](testing-patterns/canvas-webgl.md)                                                                                           |
| **Internationalization (i18n)**     | [i18n.md](testing-patterns/i18n.md)                                                                                                           |
| **Testing Electron apps**           | [electron.md](testing-patterns/electron.md)                                                                                                   |
| **Testing browser extensions**      | [browser-extensions.md](testing-patterns/browser-extensions.md)                                                                               |

### Mobile & Responsive Testing

**When to use**: Testing mobile devices, touch interactions, responsive layouts

| Activity                        | Reference Files                                                                  |
| ------------------------------- | -------------------------------------------------------------------------------- |
| **Device emulation**            | [mobile-testing.md](advanced/mobile-testing.md)                                  |
| **Touch gestures (swipe, tap)** | [mobile-testing.md](advanced/mobile-testing.md)                                  |
| **Viewport/breakpoint testing** | [mobile-testing.md](advanced/mobile-testing.md)                                  |
| **Mobile-specific UI**          | [mobile-testing.md](advanced/mobile-testing.md), [locators.md](core/locators.md) |

### Real-Time & Browser APIs

**When to use**: Testing WebSockets, geolocation, permissions, multi-tab flows

| Activity                        | Reference Files                                                                          |
| ------------------------------- | ---------------------------------------------------------------------------------------- |
| **WebSocket/real-time testing** | [websockets.md](browser-apis/websockets.md)                                              |
| **Geolocation mocking**         | [browser-apis.md](browser-apis/browser-apis.md)                                          |
| **Permission handling**         | [browser-apis.md](browser-apis/browser-apis.md)                                          |
| **Clipboard testing**           | [browser-apis.md](browser-apis/browser-apis.md)                                          |
| **Camera/microphone mocking**   | [browser-apis.md](browser-apis/browser-apis.md)                                          |
| **Multi-tab/popup flows**       | [multi-context.md](advanced/multi-context.md)                                            |
| **OAuth popup handling**        | [third-party.md](advanced/third-party.md), [multi-context.md](advanced/multi-context.md) |

### Debugging & Troubleshooting

**When to use**: Test failures, element not found, timeouts, unexpected behavior

| Activity                                          | Reference Files                                                                                                                                |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Debugging test failures**                       | [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md)                                                    |
| **Fixing flaky tests**                            | [flaky-tests.md](debugging/flaky-tests.md), [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md)        |
| **Debugging flaky parallel runs**                 | [flaky-tests.md](debugging/flaky-tests.md), [performance.md](infrastructure-ci-cd/performance.md), [fixtures-hooks.md](core/fixtures-hooks.md) |
| **Ensuring test isolation / avoiding state leak** | [flaky-tests.md](debugging/flaky-tests.md), [fixtures-hooks.md](core/fixtures-hooks.md), [performance.md](infrastructure-ci-cd/performance.md) |
| **Fixing selector issues**                        | [locators.md](core/locators.md), [debugging.md](debugging/debugging.md)                                                                        |
| **Investigating timeout issues**                  | [assertions-waiting.md](core/assertions-waiting.md), [debugging.md](debugging/debugging.md)                                                    |
| **Using trace viewer**                            | [debugging.md](debugging/debugging.md)                                                                                                         |
| **Debugging race conditions**                     | [flaky-tests.md](debugging/flaky-tests.md), [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md)        |
| **Debugging console/JS errors**                   | [console-errors.md](debugging/console-errors.md), [debugging.md](debugging/debugging.md)                                                       |

### Error & Edge Case Testing

**When to use**: Testing error states, offline mode, network failures, validation

| Activity                       | Reference Files                                                                                       |
| ------------------------------ | ----------------------------------------------------------------------------------------------------- |
| **Error boundary testing**     | [error-testing.md](debugging/error-testing.md)                                                        |
| **Network failure simulation** | [error-testing.md](debugging/error-testing.md), [network-advanced.md](advanced/network-advanced.md)   |
| **Offline mode testing**       | [error-testing.md](debugging/error-testing.md), [service-workers.md](browser-apis/service-workers.md) |
| **Service worker testing**     | [service-workers.md](browser-apis/service-workers.md)                                                 |
| **Loading state testing**      | [error-testing.md](debugging/error-testing.md)                                                        |
| **Form validation testing**    | [error-testing.md](debugging/error-testing.md)                                                        |

### Multi-User & Collaboration Testing

**When to use**: Testing features involving multiple users, roles, or real-time collaboration

| Activity                       | Reference Files                                                                      |
| ------------------------------ | ------------------------------------------------------------------------------------ |
| **Multiple users in one test** | [multi-user.md](advanced/multi-user.md)                                              |
| **Real-time collaboration**    | [multi-user.md](advanced/multi-user.md), [websockets.md](browser-apis/websockets.md) |
| **Role-based access testing**  | [multi-user.md](advanced/multi-user.md)                                              |
| **Concurrent action testing**  | [multi-user.md](advanced/multi-user.md)                                              |

### Architecture Decisions

**When to use**: Choosing test patterns, deciding between approaches, planning test architecture

| Activity                     | Reference Files                                           |
| ---------------------------- | --------------------------------------------------------- |
| **POM vs fixtures decision** | [pom-vs-fixtures.md](architecture/pom-vs-fixtures.md)     |
| **Test type selection**      | [test-architecture.md](architecture/test-architecture.md) |
| **Mock vs real services**    | [when-to-mock.md](architecture/when-to-mock.md)           |
| **Test suite structure**     | [test-suite-structure.md](core/test-suite-structure.md)   |

### Framework-Specific Testing

**When to use**: Testing React, Angular, Vue, or Next.js applications

| Activity                  | Reference Files                     |
| ------------------------- | ----------------------------------- |
| **Testing React apps**    | [react.md](frameworks/react.md)     |
| **Testing Angular apps**  | [angular.md](frameworks/angular.md) |
| **Testing Vue/Nuxt apps** | [vue.md](frameworks/vue.md)         |
| **Testing Next.js apps**  | [nextjs.md](frameworks/nextjs.md)   |

### Refactoring & Maintenance

**When to use**: Improving existing tests, code review, reducing duplication

| Activity                             | Reference Files                                                                                            |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| **Refactoring to Page Object Model** | [page-object-model.md](core/page-object-model.md), [test-suite-structure.md](core/test-suite-structure.md) |
| **Improving test organization**      | [test-suite-structure.md](core/test-suite-structure.md), [page-object-model.md](core/page-object-model.md) |
| **Extracting common setup/teardown** | [fixtures-hooks.md](core/fixtures-hooks.md)                                                                |
| **Replacing brittle selectors**      | [locators.md](core/locators.md)                                                                            |
| **Removing explicit waits**          | [assertions-waiting.md](core/assertions-waiting.md)                                                        |
| **Creating test data factories**     | [test-data.md](core/test-data.md)                                                                          |
| **Configuration setup**              | [configuration.md](core/configuration.md)                                                                  |

### Infrastructure & Configuration

**When to use**: Setting up projects, configuring CI/CD, optimizing performance

| Activity                                | Reference Files                                                                                                          |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Configuring Playwright project**      | [configuration.md](core/configuration.md), [projects-dependencies.md](core/projects-dependencies.md)                     |
| **Setting up CI/CD pipelines**          | [ci-cd.md](infrastructure-ci-cd/ci-cd.md), [github-actions.md](infrastructure-ci-cd/github-actions.md)                   |
| **GitHub Actions setup**                | [github-actions.md](infrastructure-ci-cd/github-actions.md)                                                              |
| **GitLab CI setup**                     | [gitlab.md](infrastructure-ci-cd/gitlab.md)                                                                              |
| **Other CI providers**                  | [other-providers.md](infrastructure-ci-cd/other-providers.md)                                                            |
| **Docker/container setup**              | [docker.md](infrastructure-ci-cd/docker.md)                                                                              |
| **Global setup & teardown**             | [global-setup.md](core/global-setup.md)                                                                                  |
| **Project dependencies**                | [projects-dependencies.md](core/projects-dependencies.md)                                                                |
| **Optimizing test performance**         | [performance.md](infrastructure-ci-cd/performance.md), [test-suite-structure.md](core/test-suite-structure.md)           |
| **Configuring parallel execution**      | [parallel-sharding.md](infrastructure-ci-cd/parallel-sharding.md), [performance.md](infrastructure-ci-cd/performance.md) |
| **Isolating test data between workers** | [fixtures-hooks.md](core/fixtures-hooks.md), [performance.md](infrastructure-ci-cd/performance.md)                       |
| **Test coverage**                       | [test-coverage.md](infrastructure-ci-cd/test-coverage.md)                                                                |
| **Test reporting/artifacts**            | [reporting.md](infrastructure-ci-cd/reporting.md)                                                                        |

### Advanced Patterns

**When to use**: Complex scenarios, API mocking, network interception

| Activity                             | Reference Files                                                                                              |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| **Mocking API responses**            | [test-suite-structure.md](core/test-suite-structure.md), [network-advanced.md](advanced/network-advanced.md) |
| **Network interception**             | [network-advanced.md](advanced/network-advanced.md), [assertions-waiting.md](core/assertions-waiting.md)     |
| **GraphQL mocking**                  | [network-advanced.md](advanced/network-advanced.md)                                                          |
| **HAR recording/playback**           | [network-advanced.md](advanced/network-advanced.md)                                                          |
| **Custom fixtures**                  | [fixtures-hooks.md](core/fixtures-hooks.md)                                                                  |
| **Advanced waiting strategies**      | [assertions-waiting.md](core/assertions-waiting.md)                                                          |
| **OAuth/SSO mocking**                | [third-party.md](advanced/third-party.md), [multi-context.md](advanced/multi-context.md)                     |
| **Payment gateway mocking**          | [third-party.md](advanced/third-party.md)                                                                    |
| **Email/SMS verification mocking**   | [third-party.md](advanced/third-party.md)                                                                    |
| **Failing on console errors**        | [console-errors.md](debugging/console-errors.md)                                                             |
| **Security testing (XSS, CSRF)**     | [security-testing.md](testing-patterns/security-testing.md)                                                  |
| **Performance budgets & Web Vitals** | [performance-testing.md](testing-patterns/performance-testing.md)                                            |
| **Lighthouse integration**           | [performance-testing.md](testing-patterns/performance-testing.md)                                            |
| **Test annotations (skip, fixme)**   | [annotations.md](core/annotations.md)                                                                        |
| **Test tags (@smoke, @fast)**        | [test-tags.md](core/test-tags.md)                                                                            |
| **Test steps for reporting**         | [annotations.md](core/annotations.md)                                                                        |

## Quick Decision Tree

```
What are you doing?
│
├─ Writing a new test?
│  ├─ E2E test → core/test-suite-structure.md, core/locators.md, core/assertions-waiting.md
│  ├─ Component test → testing-patterns/component-testing.md
│  ├─ API test → testing-patterns/api-testing.md, core/test-suite-structure.md
│  ├─ GraphQL test → testing-patterns/graphql-testing.md
│  ├─ Visual regression → testing-patterns/visual-regression.md
│  ├─ Visual/canvas test → testing-patterns/canvas-webgl.md, core/test-suite-structure.md
│  ├─ Accessibility test → testing-patterns/accessibility.md
│  ├─ Mobile/responsive test → advanced/mobile-testing.md
│  ├─ i18n/locale test → testing-patterns/i18n.md
│  ├─ Electron app test → testing-patterns/electron.md
│  ├─ Browser extension test → testing-patterns/browser-extensions.md
│  ├─ Multi-user test → advanced/multi-user.md
│  ├─ Form validation test → testing-patterns/forms-validation.md
│  └─ Drag and drop test → testing-patterns/drag-drop.md
│
├─ Testing specific features?
│  ├─ File upload/download → testing-patterns/file-operations.md, testing-patterns/file-upload-download.md
│  ├─ Date/time dependent → advanced/clock-mocking.md
│  ├─ WebSocket/real-time → browser-apis/websockets.md
│  ├─ Geolocation/permissions → browser-apis/browser-apis.md
│  ├─ OAuth/SSO mocking → advanced/third-party.md, advanced/multi-context.md
│  ├─ Payments/email/SMS → advanced/third-party.md
│  ├─ iFrames → browser-apis/iframes.md
│  ├─ Canvas/WebGL/charts → testing-patterns/canvas-webgl.md
│  ├─ Service workers/PWA → browser-apis/service-workers.md
│  ├─ i18n/localization → testing-patterns/i18n.md
│  ├─ Security (XSS, CSRF) → testing-patterns/security-testing.md
│  └─ Performance/Web Vitals → testing-patterns/performance-testing.md
│
├─ Architecture decisions?
│  ├─ POM vs fixtures → architecture/pom-vs-fixtures.md
│  ├─ Test type selection → architecture/test-architecture.md
│  ├─ Mock vs real services → architecture/when-to-mock.md
│  └─ Test suite structure → core/test-suite-structure.md
│
├─ Framework-specific testing?
│  ├─ React app → frameworks/react.md
│  ├─ Angular app → frameworks/angular.md
│  ├─ Vue/Nuxt app → frameworks/vue.md
│  └─ Next.js app → frameworks/nextjs.md
│
├─ Authentication testing?
│  ├─ Basic auth patterns → advanced/authentication.md
│  └─ Complex flows (MFA, reset) → advanced/authentication-flows.md
│
├─ Test is failing/flaky?
│  ├─ Flaky test investigation → debugging/flaky-tests.md
│  ├─ Element not found → core/locators.md, debugging/debugging.md
│  ├─ Timeout issues → core/assertions-waiting.md, debugging/debugging.md
│  ├─ Race conditions → debugging/flaky-tests.md, debugging/debugging.md
│  ├─ Flaky only with multiple workers → debugging/flaky-tests.md, infrastructure-ci-cd/performance.md
│  ├─ State leak / isolation → debugging/flaky-tests.md, core/fixtures-hooks.md
│  ├─ Console/JS errors → debugging/console-errors.md, debugging/debugging.md
│  └─ General debugging → debugging/debugging.md
│
├─ Testing error scenarios?
│  ├─ Network failures → debugging/error-testing.md, advanced/network-advanced.md
│  ├─ Offline (unexpected) → debugging/error-testing.md
│  ├─ Offline-first/PWA → browser-apis/service-workers.md
│  ├─ Error boundaries → debugging/error-testing.md
│  └─ Form validation → testing-patterns/forms-validation.md, debugging/error-testing.md
│
├─ Refactoring existing code?
│  ├─ Implementing POM → core/page-object-model.md
│  ├─ Improving selectors → core/locators.md
│  ├─ Extracting fixtures → core/fixtures-hooks.md
│  ├─ Creating data factories → core/test-data.md
│  └─ Configuration setup → core/configuration.md
│
├─ Setting up infrastructure?
│  ├─ CI/CD → infrastructure-ci-cd/ci-cd.md
│  ├─ GitHub Actions → infrastructure-ci-cd/github-actions.md
│  ├─ GitLab CI → infrastructure-ci-cd/gitlab.md
│  ├─ Other CI providers → infrastructure-ci-cd/other-providers.md
│  ├─ Docker/containers → infrastructure-ci-cd/docker.md
│  ├─ Sharding/parallel → infrastructure-ci-cd/parallel-sharding.md
│  ├─ Reporting/artifacts → infrastructure-ci-cd/reporting.md
│  ├─ Global setup/teardown → core/global-setup.md
│  ├─ Project dependencies → core/projects-dependencies.md
│  ├─ Test performance → infrastructure-ci-cd/performance.md
│  ├─ Test coverage → infrastructure-ci-cd/test-coverage.md
│  └─ Project config → core/configuration.md, core/projects-dependencies.md
│
├─ Organizing tests?
│  ├─ Skip/fixme/slow tests → core/annotations.md
│  ├─ Test tags (@smoke, @fast) → core/test-tags.md
│  ├─ Filtering tests (--grep) → core/test-tags.md
│  ├─ Test steps → core/annotations.md
│  └─ Conditional execution → core/annotations.md
│
└─ Running subset of tests?
   ├─ By tag (@smoke, @critical) → core/test-tags.md
   ├─ Exclude slow/flaky tests → core/test-tags.md
   ├─ PR vs nightly tests → core/test-tags.md, infrastructure-ci-cd/ci-cd.md
   └─ Project-specific filtering → core/test-tags.md, core/configuration.md
```

## Test Validation Loop

After writing or modifying tests:

1. **Run tests**: `npx playwright test --reporter=list`
2. **If tests fail**:
   - Review error output and trace (`npx playwright show-trace`)
   - Fix locators, waits, or assertions
   - Re-run tests
3. **Only proceed when all tests pass**
4. **Run multiple times** for critical tests: `npx playwright test --repeat-each=5`
testing-patterns/accessibility.md
# Accessibility Testing

## Table of Contents

1. [Axe-Core Integration](#axe-core-integration)
2. [Keyboard Navigation](#keyboard-navigation)
3. [ARIA Validation](#aria-validation)
4. [Focus Management](#focus-management)
5. [Color & Contrast](#color--contrast)

## Axe-Core Integration

### Setup

```bash
npm install -D @axe-core/playwright
```

### Basic A11y Test

```typescript
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("homepage should have no a11y violations", async ({ page }) => {
  await page.goto("/");

  const results = await new AxeBuilder({ page }).analyze();

  expect(results.violations).toEqual([]);
});
```

### Scoped Analysis

```typescript
test("form accessibility", async ({ page }) => {
  await page.goto("/contact");

  // Analyze only the form
  const results = await new AxeBuilder({ page })
    .include("#contact-form")
    .analyze();

  expect(results.violations).toEqual([]);
});

test("ignore known issues", async ({ page }) => {
  await page.goto("/legacy-page");

  const results = await new AxeBuilder({ page })
    .exclude(".legacy-widget") // Skip legacy component
    .disableRules(["color-contrast"]) // Disable specific rule
    .analyze();

  expect(results.violations).toEqual([]);
});
```

### A11y Fixture

```typescript
// fixtures/a11y.fixture.ts
import { test as base } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

type A11yFixtures = {
  makeAxeBuilder: () => AxeBuilder;
};

export const test = base.extend<A11yFixtures>({
  makeAxeBuilder: async ({ page }, use) => {
    await use(() =>
      new AxeBuilder({ page }).withTags([
        "wcag2a",
        "wcag2aa",
        "wcag21a",
        "wcag21aa",
      ]),
    );
  },
});

// Usage
test("dashboard a11y", async ({ page, makeAxeBuilder }) => {
  await page.goto("/dashboard");
  const results = await makeAxeBuilder().analyze();
  expect(results.violations).toEqual([]);
});
```

### Detailed Violation Reporting

```typescript
test("report a11y issues", async ({ page }) => {
  await page.goto("/");

  const results = await new AxeBuilder({ page }).analyze();

  // Custom failure message with details
  const violations = results.violations.map((v) => ({
    id: v.id,
    impact: v.impact,
    description: v.description,
    nodes: v.nodes.map((n) => n.html),
  }));

  expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);
});
```

## Keyboard Navigation

### Tab Order Testing

```typescript
test("correct tab order in form", async ({ page }) => {
  await page.goto("/signup");

  // Start from the beginning
  await page.keyboard.press("Tab");
  await expect(page.getByLabel("Email")).toBeFocused();

  await page.keyboard.press("Tab");
  await expect(page.getByLabel("Password")).toBeFocused();

  await page.keyboard.press("Tab");
  await expect(page.getByRole("button", { name: "Sign up" })).toBeFocused();
});
```

### Keyboard-Only Interaction

```typescript
test("complete flow with keyboard only", async ({ page }) => {
  await page.goto("/products");

  // Navigate to product with keyboard
  await page.keyboard.press("Tab"); // Skip to main content
  await page.keyboard.press("Tab"); // First product
  await page.keyboard.press("Enter"); // Open product

  await expect(page).toHaveURL(/\/products\/\d+/);

  // Add to cart with keyboard
  await page.keyboard.press("Tab");
  await page.keyboard.press("Tab"); // Navigate to "Add to Cart"
  await page.keyboard.press("Enter");

  await expect(page.getByRole("alert")).toContainText("Added to cart");
});
```

### Skip Links

```typescript
test("skip link works", async ({ page }) => {
  await page.goto("/");

  await page.keyboard.press("Tab");
  const skipLink = page.getByRole("link", { name: /skip to main/i });
  await expect(skipLink).toBeFocused();

  await page.keyboard.press("Enter");

  // Focus should move to main content
  await expect(page.getByRole("main")).toBeFocused();
});
```

### Escape Key Handling

```typescript
test("escape closes modal", async ({ page }) => {
  await page.goto("/dashboard");
  await page.getByRole("button", { name: "Settings" }).click();

  const modal = page.getByRole("dialog");
  await expect(modal).toBeVisible();

  await page.keyboard.press("Escape");

  await expect(modal).toBeHidden();
  // Focus should return to trigger
  await expect(page.getByRole("button", { name: "Settings" })).toBeFocused();
});
```

## ARIA Validation

### Role Verification

```typescript
test("correct ARIA roles", async ({ page }) => {
  await page.goto("/dashboard");

  // Verify landmark roles
  await expect(page.getByRole("navigation")).toBeVisible();
  await expect(page.getByRole("main")).toBeVisible();
  await expect(page.getByRole("contentinfo")).toBeVisible(); // footer

  // Verify interactive roles
  await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
  await expect(page.getByRole("search")).toBeVisible();
});
```

### ARIA States

```typescript
test("aria-expanded updates correctly", async ({ page }) => {
  await page.goto("/faq");

  const accordion = page.getByRole("button", { name: "Shipping" });

  // Initially collapsed
  await expect(accordion).toHaveAttribute("aria-expanded", "false");

  await accordion.click();

  // Now expanded
  await expect(accordion).toHaveAttribute("aria-expanded", "true");

  // Content is visible
  const panel = page.getByRole("region", { name: "Shipping" });
  await expect(panel).toBeVisible();
});
```

### Live Regions

```typescript
test("live region announces updates", async ({ page }) => {
  await page.goto("/checkout");

  // Find live region
  const liveRegion = page.locator('[aria-live="polite"]');

  await page.getByLabel("Quantity").fill("3");

  // Live region should update with new total
  await expect(liveRegion).toContainText("Total: $29.97");
});
```

## Focus Management

### Focus Trap in Modal

```typescript
test("focus trapped in modal", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button", { name: "Open Modal" }).click();

  const modal = page.getByRole("dialog");
  await expect(modal).toBeVisible();

  // Get all focusable elements in modal
  const focusableElements = modal.locator(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
  );
  const count = await focusableElements.count();

  // Tab through all elements, should stay in modal
  for (let i = 0; i < count + 1; i++) {
    await page.keyboard.press("Tab");
    const focused = page.locator(":focus");
    await expect(modal).toContainText((await focused.textContent()) || "");
  }
});
```

### Focus Restoration

```typescript
test("focus returns after modal close", async ({ page }) => {
  await page.goto("/");

  const trigger = page.getByRole("button", { name: "Delete Item" });
  await trigger.click();

  await page.getByRole("button", { name: "Cancel" }).click();

  // Focus should return to the trigger
  await expect(trigger).toBeFocused();
});
```

## Color & Contrast

### High Contrast Mode

```typescript
test("works in high contrast mode", async ({ page }) => {
  await page.emulateMedia({ forcedColors: "active" });
  await page.goto("/");

  // Verify key elements are visible
  await expect(page.getByRole("navigation")).toBeVisible();
  await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();

  // Take screenshot for visual verification
  await expect(page).toHaveScreenshot("high-contrast.png");
});
```

### Reduced Motion

```typescript
test("respects reduced motion preference", async ({ page }) => {
  await page.emulateMedia({ reducedMotion: "reduce" });
  await page.goto("/");

  // Animations should be disabled
  const hero = page.getByTestId("hero-animation");
  const animation = await hero.evaluate(
    (el) => getComputedStyle(el).animationDuration,
  );

  expect(animation).toBe("0s");
});
```

## CI Integration

### A11y as CI Gate

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: "a11y",
      testMatch: /.*\.a11y\.spec\.ts/,
      use: { ...devices["Desktop Chrome"] },
    },
  ],
});
```

```yaml
# .github/workflows/a11y.yml
- name: Run accessibility tests
  run: npx playwright test --project=a11y
```

## Anti-Patterns to Avoid

| Anti-Pattern                  | Problem                      | Solution                                   |
| ----------------------------- | ---------------------------- | ------------------------------------------ |
| Testing a11y only on homepage | Misses issues on other pages | Test all critical user flows               |
| Ignoring all violations       | No value from tests          | Address or explicitly exclude known issues |
| Only automated testing        | Misses many a11y issues      | Combine with manual testing                |
| Testing without screen reader | Misses interaction issues    | Test with VoiceOver/NVDA periodically      |

## Related References

- **Locators**: See [locators.md](../core/locators.md) for role-based selectors
- **Visual testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot comparison
testing-patterns/api-testing.md
# API Testing

## Table of Contents

1. [Patterns](#patterns)
2. [Decision Guide](#decision-guide)
3. [Anti-Patterns](#anti-patterns)
4. [Troubleshooting](#troubleshooting)

> **When to use**: Testing REST APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead.
> **See also**: [graphql-testing.md](graphql-testing.md) for GraphQL-specific patterns.

## Patterns

### Request Fixtures for Authenticated Clients

**Use when**: Multiple tests need an authenticated API client with shared configuration.
**Avoid when**: A single test makes one-off API calls — use the built-in `request` fixture directly.

```typescript
// fixtures/api-fixtures.ts
import { test as base, expect, APIRequestContext } from "@playwright/test";

type ApiFixtures = {
  authApi: APIRequestContext;
  adminApi: APIRequestContext;
};

export const test = base.extend<ApiFixtures>({
  authApi: async ({ playwright }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL: "https://api.myapp.io",
      extraHTTPHeaders: {
        Authorization: `Bearer ${process.env.API_TOKEN}`,
        Accept: "application/json",
      },
    });
    await use(ctx);
    await ctx.dispose();
  },

  adminApi: async ({ playwright }, use) => {
    const loginCtx = await playwright.request.newContext({
      baseURL: "https://api.myapp.io",
    });
    const loginResp = await loginCtx.post("/auth/login", {
      data: {
        email: process.env.ADMIN_EMAIL,
        password: process.env.ADMIN_PASSWORD,
      },
    });
    expect(loginResp.ok()).toBeTruthy();
    const { token } = await loginResp.json();
    await loginCtx.dispose();

    const ctx = await playwright.request.newContext({
      baseURL: "https://api.myapp.io",
      extraHTTPHeaders: {
        Authorization: `Bearer ${token}`,
        Accept: "application/json",
      },
    });
    await use(ctx);
    await ctx.dispose();
  },
});

export { expect };
```

```typescript
// tests/api/admin.spec.ts
import { test, expect } from "../../fixtures/api-fixtures";

test("admin retrieves all accounts", async ({ adminApi }) => {
  const resp = await adminApi.get("/admin/accounts");
  expect(resp.status()).toBe(200);
  const body = await resp.json();
  expect(body.accounts.length).toBeGreaterThan(0);
});
```

### CRUD Operations

**Use when**: Making HTTP requests — GET, POST, PUT, PATCH, DELETE with headers, query params, and bodies.
**Avoid when**: You need to test browser-rendered responses (redirects, cookies with `HttpOnly`).

```typescript
import { test, expect } from "@playwright/test";

test("full CRUD cycle", async ({ request }) => {
  // GET with query params
  const listResp = await request.get("/api/items", {
    params: { page: 1, limit: 10, category: "tools" },
  });
  expect(listResp.ok()).toBeTruthy();

  // POST with JSON body
  const createResp = await request.post("/api/items", {
    data: {
      title: "Hammer",
      price: 19.99,
      category: "tools",
    },
  });
  expect(createResp.status()).toBe(201);
  const created = await createResp.json();

  // PUT — full replacement
  const putResp = await request.put(`/api/items/${created.id}`, {
    data: {
      title: "Claw Hammer",
      price: 24.99,
      category: "tools",
    },
  });
  expect(putResp.ok()).toBeTruthy();

  // PATCH — partial update
  const patchResp = await request.patch(`/api/items/${created.id}`, {
    data: { price: 22.5 },
  });
  expect(patchResp.ok()).toBeTruthy();
  const patched = await patchResp.json();
  expect(patched.price).toBe(22.5);

  // DELETE
  const delResp = await request.delete(`/api/items/${created.id}`);
  expect(delResp.status()).toBe(204);

  // Verify deletion
  const getDeleted = await request.get(`/api/items/${created.id}`);
  expect(getDeleted.status()).toBe(404);
});

test("form-urlencoded body", async ({ request }) => {
  const resp = await request.post("/oauth/token", {
    form: {
      grant_type: "client_credentials",
      client_id: "my-client",
      client_secret: "secret-value",
    },
  });
  expect(resp.ok()).toBeTruthy();
  const token = await resp.json();
  expect(token).toHaveProperty("access_token");
});
```

### Dedicated API Project Configuration

**Use when**: Writing dedicated API test suites that do not need a browser.

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  projects: [
    {
      name: "api",
      testDir: "./tests/api",
      use: {
        baseURL: "https://api.myapp.io",
        extraHTTPHeaders: { Accept: "application/json" },
      },
    },
    {
      name: "e2e",
      testDir: "./tests/e2e",
      use: {
        baseURL: "https://myapp.io",
        browserName: "chromium",
      },
    },
  ],
});
```

### Response Assertions

**Use when**: Validating response status, headers, and body structure.
**Avoid when**: Never skip these — every API test should assert on status and body.

```typescript
import { test, expect } from "@playwright/test";

test("comprehensive response validation", async ({ request }) => {
  const resp = await request.get("/api/items/101");

  // Status code — always check first
  expect(resp.status()).toBe(200);
  expect(resp.ok()).toBeTruthy();

  // Headers
  expect(resp.headers()["content-type"]).toContain("application/json");
  expect(resp.headers()["cache-control"]).toMatch(/max-age=\d+/);

  const item = await resp.json();

  // Exact match on known fields
  expect(item.id).toBe(101);
  expect(item.title).toBe("Widget");

  // Partial match — ignore fields you don't care about
  expect(item).toMatchObject({
    id: 101,
    title: "Widget",
    status: expect.stringMatching(/^(active|inactive|archived)$/),
  });

  // Type checks
  expect(item).toMatchObject({
    id: expect.any(Number),
    title: expect.any(String),
    createdAt: expect.any(String),
    tags: expect.any(Array),
  });

  // Array content
  expect(item.tags).toEqual(expect.arrayContaining(["featured"]));
  expect(item.tags).not.toContain("deprecated");

  // Nested object
  expect(item.metadata).toMatchObject({
    views: expect.any(Number),
    rating: expect.any(Number),
  });

  // Date format
  expect(new Date(item.createdAt).toISOString()).toBe(item.createdAt);
});

test("list response structure", async ({ request }) => {
  const resp = await request.get("/api/items");
  const body = await resp.json();

  expect(body.items).toHaveLength(10);

  for (const item of body.items) {
    expect(item).toMatchObject({
      id: expect.any(Number),
      title: expect.any(String),
      price: expect.any(Number),
    });
  }

  expect(body.pagination).toEqual({
    page: 1,
    limit: 10,
    total: expect.any(Number),
    totalPages: expect.any(Number),
  });
});
```

### API Data Seeding

**Use when**: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup.
**Avoid when**: The test specifically validates the creation flow through the UI.

```typescript
import { test as base, expect } from "@playwright/test";

type SeedFixtures = {
  seedAccount: { id: number; email: string; password: string };
  seedWorkspace: { id: number; name: string };
};

export const test = base.extend<SeedFixtures>({
  seedAccount: async ({ request }, use) => {
    const email = `account-${Date.now()}@test.io`;
    const password = "SecurePass123!";

    const resp = await request.post("/api/accounts", {
      data: { name: "Test Account", email, password },
    });
    expect(resp.ok()).toBeTruthy();
    const account = await resp.json();

    await use({ id: account.id, email, password });

    // Cleanup
    await request.delete(`/api/accounts/${account.id}`);
  },

  seedWorkspace: async ({ request, seedAccount }, use) => {
    const resp = await request.post("/api/workspaces", {
      data: { name: `Workspace ${Date.now()}`, ownerId: seedAccount.id },
    });
    expect(resp.ok()).toBeTruthy();
    const workspace = await resp.json();

    await use({ id: workspace.id, name: workspace.name });

    await request.delete(`/api/workspaces/${workspace.id}`);
  },
});

export { expect };
```

```typescript
// tests/e2e/workspace-dashboard.spec.ts
import { test, expect } from "../../fixtures/seed-fixtures";

test("user sees workspace on dashboard", async ({
  page,
  seedAccount,
  seedWorkspace,
}) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(seedAccount.email);
  await page.getByLabel("Password").fill(seedAccount.password);
  await page.getByRole("button", { name: "Sign in" }).click();

  await page.waitForURL("/dashboard");
  await expect(
    page.getByRole("heading", { name: seedWorkspace.name })
  ).toBeVisible();
});
```

### Error Response Testing

**Use when**: Every API has error paths — test them. A missing 401 test today is a security hole tomorrow.

```typescript
import { test, expect } from "@playwright/test";

test.describe("Error responses", () => {
  test("400 — validation error with details", async ({ request }) => {
    const resp = await request.post("/api/items", {
      data: { title: "", price: -5 },
    });
    expect(resp.status()).toBe(400);

    const body = await resp.json();
    expect(body).toMatchObject({
      error: "Validation Error",
      details: expect.any(Array),
    });
    expect(body.details).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          field: "title",
          message: expect.any(String),
        }),
        expect.objectContaining({
          field: "price",
          message: expect.any(String),
        }),
      ])
    );
  });

  test("401 — missing authentication", async ({ request }) => {
    const resp = await request.get("/api/protected/resource", {
      headers: { Authorization: "" },
    });
    expect(resp.status()).toBe(401);
    const body = await resp.json();
    expect(body.error).toMatch(/unauthorized|unauthenticated/i);
  });

  test("403 — insufficient permissions", async ({ request }) => {
    const resp = await request.delete("/api/admin/items/1");
    expect(resp.status()).toBe(403);
    const body = await resp.json();
    expect(body.error).toMatch(/forbidden|insufficient permissions/i);
  });

  test("404 — resource not found", async ({ request }) => {
    const resp = await request.get("/api/items/999999");
    expect(resp.status()).toBe(404);
    const body = await resp.json();
    expect(body).toMatchObject({ error: expect.stringMatching(/not found/i) });
  });

  test("409 — conflict on duplicate", async ({ request }) => {
    const sku = `SKU-${Date.now()}`;
    await request.post("/api/items", { data: { title: "First", sku } });

    const resp = await request.post("/api/items", {
      data: { title: "Duplicate", sku },
    });
    expect(resp.status()).toBe(409);
  });

  test("422 — unprocessable entity", async ({ request }) => {
    const resp = await request.post("/api/orders", {
      data: { items: [] },
    });
    expect(resp.status()).toBe(422);
    const body = await resp.json();
    expect(body.error).toContain("at least one item");
  });

  test("429 — rate limiting", async ({ request }) => {
    const responses = await Promise.all(
      Array.from({ length: 50 }, () =>
        request.get("/api/search", { params: { q: "test" } })
      )
    );
    const rateLimited = responses.filter((r) => r.status() === 429);
    expect(rateLimited.length).toBeGreaterThan(0);
    expect(rateLimited[0].headers()["retry-after"]).toBeDefined();
  });
});
```

### File Upload via API

**Use when**: Testing file upload endpoints with multipart form data.
**Avoid when**: You need to test the browser file picker dialog — use `page.setInputFiles()` instead.

```typescript
import { test, expect } from "@playwright/test";
import path from "path";
import fs from "fs";

test("upload file via multipart", async ({ request }) => {
  const filePath = path.resolve("tests/fixtures/report.pdf");

  const resp = await request.post("/api/documents/upload", {
    multipart: {
      file: {
        name: "report.pdf",
        mimeType: "application/pdf",
        buffer: fs.readFileSync(filePath),
      },
      description: "Monthly report",
      category: "reports",
    },
  });

  expect(resp.status()).toBe(201);
  const body = await resp.json();
  expect(body).toMatchObject({
    id: expect.any(String),
    filename: "report.pdf",
    mimeType: "application/pdf",
    size: expect.any(Number),
    url: expect.stringMatching(/^https:\/\//),
  });
});

test("rejects oversized files", async ({ request }) => {
  const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB

  const resp = await request.post("/api/documents/upload", {
    multipart: {
      file: {
        name: "large-file.bin",
        mimeType: "application/octet-stream",
        buffer: largeBuffer,
      },
    },
  });

  expect(resp.status()).toBe(413);
});
```

### Chained API Calls

**Use when**: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions.
**Avoid when**: You can test each endpoint in isolation and the interactions are trivial.

```typescript
import { test, expect } from "@playwright/test";

test("complete order workflow", async ({ request }) => {
  // Step 1: Create a product
  const productResp = await request.post("/api/products", {
    data: { name: "Gadget", price: 49.99, stock: 50 },
  });
  expect(productResp.status()).toBe(201);
  const product = await productResp.json();

  // Step 2: Create a cart
  const cartResp = await request.post("/api/carts", {
    data: { items: [{ productId: product.id, quantity: 3 }] },
  });
  expect(cartResp.status()).toBe(201);
  const cart = await cartResp.json();
  expect(cart.total).toBe(149.97);

  // Step 3: Checkout
  const orderResp = await request.post("/api/orders", {
    data: {
      cartId: cart.id,
      shippingAddress: {
        street: "456 Main Ave",
        city: "Metropolis",
        zip: "54321",
      },
    },
  });
  expect(orderResp.status()).toBe(201);
  const order = await orderResp.json();
  expect(order.status).toBe("pending");
  expect(order.items).toHaveLength(1);

  // Step 4: Verify order in list
  const ordersResp = await request.get("/api/orders");
  const orders = await ordersResp.json();
  expect(orders.items.map((o: any) => o.id)).toContain(order.id);

  // Step 5: Verify stock decreased
  const updatedProduct = await (
    await request.get(`/api/products/${product.id}`)
  ).json();
  expect(updatedProduct.stock).toBe(47);

  // Cleanup
  await request.delete(`/api/orders/${order.id}`);
  await request.delete(`/api/products/${product.id}`);
});

test("state machine transitions — publish workflow", async ({ request }) => {
  const createResp = await request.post("/api/articles", {
    data: { title: "Draft Article", body: "Content here." },
  });
  const article = await createResp.json();
  expect(article.status).toBe("draft");

  // Submit for review
  const reviewResp = await request.patch(`/api/articles/${article.id}/status`, {
    data: { status: "in_review" },
  });
  expect(reviewResp.ok()).toBeTruthy();
  expect((await reviewResp.json()).status).toBe("in_review");

  // Approve
  const approveResp = await request.patch(
    `/api/articles/${article.id}/status`,
    {
      data: { status: "published" },
    }
  );
  expect(approveResp.ok()).toBeTruthy();
  expect((await approveResp.json()).status).toBe("published");

  // Cannot revert to draft from published
  const revertResp = await request.patch(`/api/articles/${article.id}/status`, {
    data: { status: "draft" },
  });
  expect(revertResp.status()).toBe(422);

  await request.delete(`/api/articles/${article.id}`);
});

test("API + E2E hybrid — seed via API, verify in browser", async ({
  request,
  page,
}) => {
  const resp = await request.post("/api/products", {
    data: {
      name: `Hybrid Product ${Date.now()}`,
      price: 35.0,
      published: true,
    },
  });
  const product = await resp.json();

  await page.goto("/products");
  await expect(page.getByRole("heading", { name: product.name })).toBeVisible();
  await expect(page.getByText("$35.00")).toBeVisible();

  await request.delete(`/api/products/${product.id}`);
});
```

### Schema Validation with Zod

**Use when**: Verifying API responses match a contract — field types, required fields, value constraints.
**Avoid when**: You only need to check one or two specific fields — use `toMatchObject` instead.

```typescript
import { test, expect } from "@playwright/test";
import { z } from "zod";

const ItemSchema = z.object({
  id: z.number().positive(),
  title: z.string().min(1),
  price: z.number().nonnegative(),
  status: z.enum(["active", "inactive", "archived"]),
  createdAt: z.string().datetime(),
  metadata: z.object({
    views: z.number().int().nonnegative(),
    rating: z.number().min(0).max(5).nullable(),
  }),
});

const PaginatedItemsSchema = z.object({
  items: z.array(ItemSchema),
  pagination: z.object({
    page: z.number().int().positive(),
    limit: z.number().int().positive(),
    total: z.number().int().nonnegative(),
  }),
});

test("GET /api/items matches schema", async ({ request }) => {
  const resp = await request.get("/api/items");
  expect(resp.ok()).toBeTruthy();

  const body = await resp.json();
  const result = PaginatedItemsSchema.safeParse(body);

  if (!result.success) {
    throw new Error(
      `Schema validation failed:\n${result.error.issues
        .map((i) => `  ${i.path.join(".")}: ${i.message}`)
        .join("\n")}`
    );
  }
});
```

## Decision Guide

| Scenario                                         | Use API Tests               | Use E2E Tests                  | Why                                                                |
| ------------------------------------------------ | --------------------------- | ------------------------------ | ------------------------------------------------------------------ |
| Validate response status/body/headers            | Yes                         | No                             | No browser needed; 10-100x faster                                  |
| Test business logic (calculations, rules)        | Yes                         | No                             | API tests isolate backend logic from UI                            |
| Verify form submission creates correct data      | Seed via API, submit via UI | Yes                            | UI test validates the form; API check confirms persistence         |
| Test error messages shown to user                | No                          | Yes                            | Error rendering is a UI concern                                    |
| Validate pagination, filtering, sorting          | Yes                         | Maybe both                     | API test for correctness; E2E test only if the UI logic is complex |
| Seed test data for E2E tests                     | Yes (fixture)               | No                             | API seeding is fast and reliable                                   |
| Test auth flows (login/logout/RBAC)              | Yes for token/session logic | Yes for UI flow                | Both matter: API protects resources, UI guides users               |
| Verify file upload processing                    | Yes                         | Only if testing file picker UI | API test validates backend processing                              |
| Contract/schema regression testing               | Yes                         | No                             | Schema tests run in milliseconds                                   |
| Test third-party webhook handling                | Yes                         | No                             | Webhooks are API-to-API; no UI involved                            |
| Verify redirect behavior after action            | No                          | Yes                            | Redirects are browser/navigation concerns                          |
| Test real-time updates (WebSocket + API trigger) | API triggers                | E2E verifies                   | Seed via API, observe in browser                                   |

## Anti-Patterns

| Don't Do This                                        | Problem                                                                                | Do This Instead                                                   |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Use E2E tests to validate pure API responses         | Slow, flaky, launches a browser for no reason                                          | Use `request` fixture — no browser, direct HTTP                   |
| Ignore `response.status()`                           | A 500 with a fallback body can pass all body assertions                                | Always assert status first: `expect(response.status()).toBe(200)` |
| Skip response header checks                          | Missing `Content-Type`, `Cache-Control`, CORS headers cause production bugs            | Assert critical headers                                           |
| 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                    |
| Hardcode IDs in API tests                            | Tests break when database is reset or IDs are reassigned                               | Create resources in the test, use returned IDs                    |
| 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                      |
| Parse `response.text()` then `JSON.parse()` manually | Playwright's `response.json()` handles this and throws clear errors on non-JSON        | Use `await response.json()`                                       |
| 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             |
| 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           |

## Troubleshooting

### "Request failed: connect ECONNREFUSED 127.0.0.1:3000"

**Cause**: The API server is not running, or `baseURL` points to the wrong host/port.

**Fix**: Verify the server is running before tests. Use `webServer` in config to start it automatically.

```typescript
// playwright.config.ts
export default defineConfig({
  webServer: {
    command: "npm run start:api",
    url: "http://localhost:3000/api/health",
    reuseExistingServer: !process.env.CI,
  },
  use: { baseURL: "http://localhost:3000" },
});
```

### "response.json() failed — body is not valid JSON"

**Cause**: The endpoint returned HTML (error page), plain text, or an empty body instead of JSON.

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

```typescript
const resp = await request.get("/api/endpoint");
if (!resp.ok()) {
  console.error(`Status: ${resp.status()}, Body: ${await resp.text()}`);
}
const body = await resp.json();
```

### "401 Unauthorized" when using `request` fixture

**Cause**: The built-in `request` fixture does not carry browser cookies or auth tokens automatically.

**Fix**: Set `extraHTTPHeaders` in config or create a custom authenticated fixture. If you need cookies from a browser login, use `page.request` instead.

```typescript
// Option A: config-level headers
export default defineConfig({
  use: {
    extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
  },
});

// Option B: per-request headers
const resp = await request.get("/api/resource", {
  headers: { Authorization: `Bearer ${token}` },
});

// Option C: use page.request to inherit browser cookies
test("API call with browser auth", async ({ page }) => {
  await page.goto("/login");
  // ... login via UI ...
  const resp = await page.request.get("/api/profile");
  expect(resp.ok()).toBeTruthy();
});
```

### Tests pass locally but fail in CI

**Cause**: Different environments, database state, or missing environment variables.

**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.
testing-patterns/browser-extensions.md
# Browser Extension Testing

## Table of Contents

1. [Setup & Configuration](#setup--configuration)
2. [Loading Extensions](#loading-extensions)
3. [Popup Testing](#popup-testing)
4. [Background Script Testing](#background-script-testing)
5. [Content Script Testing](#content-script-testing)
6. [Extension APIs](#extension-apis)
7. [Cross-Browser Testing](#cross-browser-testing)

## Setup & Configuration

### Prerequisites

```bash
npm install -D @playwright/test
npx playwright install chromium  # Extensions only work in Chromium
```

### Basic Configuration

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import path from "path";

export default defineConfig({
  testDir: "./tests",
  use: {
    // Extensions require non-headless Chromium
    headless: false,
  },
  projects: [
    {
      name: "chromium-extension",
      use: {
        browserName: "chromium",
      },
    },
  ],
});
```

### Extension Fixture

```typescript
// fixtures/extension.ts
import { test as base, chromium, BrowserContext, Page } from "@playwright/test";
import path from "path";

type ExtensionFixtures = {
  context: BrowserContext;
  extensionId: string;
  backgroundPage: Page;
};

export const test = base.extend<ExtensionFixtures>({
  context: async ({}, use) => {
    const pathToExtension = path.join(__dirname, "../extension");

    const context = await chromium.launchPersistentContext("", {
      headless: false,
      args: [
        `--disable-extensions-except=${pathToExtension}`,
        `--load-extension=${pathToExtension}`,
      ],
    });

    await use(context);
    await context.close();
  },

  extensionId: async ({ context }, use) => {
    // Get extension ID from service worker URL
    let extensionId = "";

    // Wait for service worker to be registered
    const serviceWorker =
      context.serviceWorkers()[0] ||
      (await context.waitForEvent("serviceworker"));

    extensionId = serviceWorker.url().split("/")[2];

    await use(extensionId);
  },

  backgroundPage: async ({ context }, use) => {
    // For Manifest V2 extensions
    const backgroundPage =
      context.backgroundPages()[0] ||
      (await context.waitForEvent("backgroundpage"));

    await use(backgroundPage);
  },
});

export { expect } from "@playwright/test";
```

## Loading Extensions

### Manifest V3 (Service Worker)

```typescript
test("load MV3 extension", async () => {
  const pathToExtension = path.join(__dirname, "../my-extension");

  const context = await chromium.launchPersistentContext("", {
    headless: false,
    args: [
      `--disable-extensions-except=${pathToExtension}`,
      `--load-extension=${pathToExtension}`,
    ],
  });

  // Wait for service worker
  const serviceWorker = await context.waitForEvent("serviceworker");
  expect(serviceWorker.url()).toContain("chrome-extension://");

  await context.close();
});
```

### Manifest V2 (Background Page)

```typescript
test("load MV2 extension", async () => {
  const pathToExtension = path.join(__dirname, "../my-extension-v2");

  const context = await chromium.launchPersistentContext("", {
    headless: false,
    args: [
      `--disable-extensions-except=${pathToExtension}`,
      `--load-extension=${pathToExtension}`,
    ],
  });

  // Wait for background page
  const backgroundPage = await context.waitForEvent("backgroundpage");
  expect(backgroundPage.url()).toContain("chrome-extension://");

  await context.close();
});
```

### Multiple Extensions

```typescript
test("load multiple extensions", async () => {
  const extension1 = path.join(__dirname, "../extension1");
  const extension2 = path.join(__dirname, "../extension2");

  const context = await chromium.launchPersistentContext("", {
    headless: false,
    args: [
      `--disable-extensions-except=${extension1},${extension2}`,
      `--load-extension=${extension1},${extension2}`,
    ],
  });

  // Both service workers should be available
  await context.waitForEvent("serviceworker");
  await context.waitForEvent("serviceworker");

  expect(context.serviceWorkers().length).toBe(2);

  await context.close();
});
```

## Popup Testing

### Opening Extension Popup

```typescript
test("test popup UI", async ({ context, extensionId }) => {
  // Open popup directly by URL
  const popupPage = await context.newPage();
  await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

  // Test popup interactions
  await expect(popupPage.getByRole("heading")).toHaveText("My Extension");
  await popupPage.getByRole("button", { name: "Enable" }).click();
  await expect(popupPage.getByText("Enabled")).toBeVisible();
});
```

### Popup State Persistence

```typescript
test("popup remembers state", async ({ context, extensionId }) => {
  // First interaction
  const popup1 = await context.newPage();
  await popup1.goto(`chrome-extension://${extensionId}/popup.html`);
  await popup1.getByRole("checkbox", { name: "Dark Mode" }).check();
  await popup1.close();

  // Reopen popup
  const popup2 = await context.newPage();
  await popup2.goto(`chrome-extension://${extensionId}/popup.html`);

  // State should persist
  await expect(
    popup2.getByRole("checkbox", { name: "Dark Mode" }),
  ).toBeChecked();
});
```

### Popup Communication with Background

```typescript
test("popup sends message to background", async ({ context, extensionId }) => {
  const popup = await context.newPage();
  await popup.goto(`chrome-extension://${extensionId}/popup.html`);

  // Set up listener for response
  const responsePromise = popup.evaluate(() => {
    return new Promise((resolve) => {
      chrome.runtime.onMessage.addListener((message) => {
        if (message.type === "RESPONSE") resolve(message.data);
      });
    });
  });

  // Click button that sends message
  await popup.getByRole("button", { name: "Fetch Data" }).click();

  // Verify response
  const response = await responsePromise;
  expect(response).toBeDefined();
});
```

## Background Script Testing

### Manifest V3 Service Worker

```typescript
test("service worker handles messages", async ({ context, extensionId }) => {
  const page = await context.newPage();
  await page.goto("https://example.com");

  // Send message to service worker from page
  const response = await page.evaluate(async (extId) => {
    return new Promise((resolve) => {
      chrome.runtime.sendMessage(extId, { type: "GET_STATUS" }, resolve);
    });
  }, extensionId);

  expect(response).toEqual({ status: "active" });
});
```

### Testing Background Logic

```typescript
test("background script logic", async ({ context }) => {
  const serviceWorker =
    context.serviceWorkers()[0] ||
    (await context.waitForEvent("serviceworker"));

  // Evaluate in service worker context
  const result = await serviceWorker.evaluate(async () => {
    // Access extension APIs
    const storage = await chrome.storage.local.get("settings");
    return storage;
  });

  expect(result.settings).toBeDefined();
});
```

### Alarms and Timers

```typescript
test("alarm triggers correctly", async ({ context }) => {
  const serviceWorker = await context.waitForEvent("serviceworker");

  // Create alarm
  await serviceWorker.evaluate(async () => {
    await chrome.alarms.create("test-alarm", { delayInMinutes: 0.01 });
  });

  // Wait for alarm handler
  await serviceWorker.evaluate(() => {
    return new Promise<void>((resolve) => {
      chrome.alarms.onAlarm.addListener((alarm) => {
        if (alarm.name === "test-alarm") resolve();
      });
    });
  });

  // Verify alarm was handled (check side effects)
  const wasHandled = await serviceWorker.evaluate(async () => {
    const { alarmTriggered } = await chrome.storage.local.get("alarmTriggered");
    return alarmTriggered;
  });

  expect(wasHandled).toBe(true);
});
```

## Content Script Testing

### Injected Content Script

```typescript
test("content script injects UI", async ({ context }) => {
  const page = await context.newPage();
  await page.goto("https://example.com");

  // Wait for content script to inject elements
  await expect(page.locator("#my-extension-widget")).toBeVisible();

  // Interact with injected UI
  await page.locator("#my-extension-widget button").click();
  await expect(page.locator("#my-extension-widget .result")).toHaveText(
    "Success",
  );
});
```

### Content Script Communication

```typescript
test("content script communicates with background", async ({
  context,
  extensionId,
}) => {
  const page = await context.newPage();
  await page.goto("https://example.com");

  // Trigger content script action
  await page.locator("#my-extension-button").click();

  // Wait for background response reflected in UI
  await expect(page.locator("#my-extension-status")).toHaveText("Connected");
});
```

### Page Modification Testing

```typescript
test("content script modifies page", async ({ context }) => {
  const page = await context.newPage();
  await page.goto("https://example.com");

  // Verify content script modifications
  const hasModification = await page.evaluate(() => {
    // Check for injected styles
    const styles = document.querySelectorAll('style[data-extension="my-ext"]');
    return styles.length > 0;
  });

  expect(hasModification).toBe(true);

  // Check DOM modifications
  const modifiedElements = await page
    .locator("[data-modified-by-extension]")
    .count();
  expect(modifiedElements).toBeGreaterThan(0);
});
```

## Extension APIs

### Storage API

```typescript
test("chrome.storage operations", async ({ context }) => {
  const serviceWorker = await context.waitForEvent("serviceworker");

  // Set storage
  await serviceWorker.evaluate(async () => {
    await chrome.storage.local.set({ key: "value", count: 42 });
  });

  // Get storage
  const data = await serviceWorker.evaluate(async () => {
    return await chrome.storage.local.get(["key", "count"]);
  });

  expect(data).toEqual({ key: "value", count: 42 });

  // Test storage.sync
  await serviceWorker.evaluate(async () => {
    await chrome.storage.sync.set({ synced: true });
  });

  const syncData = await serviceWorker.evaluate(async () => {
    return await chrome.storage.sync.get("synced");
  });

  expect(syncData.synced).toBe(true);
});
```

### Tabs API

```typescript
test("chrome.tabs operations", async ({ context }) => {
  const serviceWorker = await context.waitForEvent("serviceworker");

  // Create a tab
  const page = await context.newPage();
  await page.goto("https://example.com");

  // Query tabs from service worker
  const tabs = await serviceWorker.evaluate(async () => {
    return await chrome.tabs.query({ url: "*://example.com/*" });
  });

  expect(tabs.length).toBeGreaterThan(0);
  expect(tabs[0].url).toContain("example.com");

  // Send message to tab
  await serviceWorker.evaluate(async (tabId) => {
    await chrome.tabs.sendMessage(tabId, { type: "PING" });
  }, tabs[0].id);
});
```

### Context Menus

```typescript
test("context menu actions", async ({ context, extensionId }) => {
  const serviceWorker = await context.waitForEvent("serviceworker");

  // Create context menu
  await serviceWorker.evaluate(async () => {
    await chrome.contextMenus.create({
      id: "test-menu",
      title: "Test Action",
      contexts: ["selection"],
    });
  });

  // Simulate context menu click
  const page = await context.newPage();
  await page.goto("https://example.com");

  // Select text
  await page.evaluate(() => {
    const range = document.createRange();
    range.selectNodeContents(document.body.firstChild!);
    window.getSelection()?.addRange(range);
  });

  // Trigger context menu action programmatically
  await serviceWorker.evaluate(async () => {
    // Simulate the click handler
    chrome.contextMenus.onClicked.dispatch(
      { menuItemId: "test-menu", selectionText: "selected text" },
      { id: 1, url: "https://example.com" },
    );
  });
});
```

### Permissions API

```typescript
test("request permissions", async ({ context, extensionId }) => {
  const popup = await context.newPage();
  await popup.goto(`chrome-extension://${extensionId}/popup.html`);

  // Check current permissions
  const hasPermission = await popup.evaluate(async () => {
    return await chrome.permissions.contains({
      origins: ["https://*.github.com/*"],
    });
  });

  // Request new permission (will show prompt in real scenario)
  // For testing, we check the request is made correctly
  const permissionRequest = popup.evaluate(async () => {
    try {
      return await chrome.permissions.request({
        origins: ["https://*.github.com/*"],
      });
    } catch (e) {
      return false;
    }
  });

  // In automated tests, permission prompts are typically auto-granted or mocked
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                   | Problem               | Solution                                 |
| ------------------------------ | --------------------- | ---------------------------------------- |
| Testing in headless mode       | Extensions don't load | Use `headless: false`                    |
| Not waiting for service worker | Race conditions       | Wait for `serviceworker` event           |
| Hardcoding extension ID        | ID changes on reload  | Extract ID from service worker URL       |
| Testing packed extensions only | Slow iteration        | Test unpacked during development         |
| Ignoring MV3 differences       | Breaking changes      | Test both MV2 and MV3 if supporting both |

## Related References

- **Service Workers**: See [service-workers.md](../browser-apis/service-workers.md) for SW testing patterns
- **Multi-Context**: See [multi-context.md](../advanced/multi-context.md) for popup handling
- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions testing
testing-patterns/canvas-webgl.md
# Canvas & WebGL Testing

## Table of Contents

1. [Canvas Basics](#canvas-basics)
2. [Visual Comparison](#visual-comparison)
3. [Interaction Testing](#interaction-testing)
4. [WebGL Testing](#webgl-testing)
5. [Chart Libraries](#chart-libraries)
6. [Game & Animation Testing](#game--animation-testing)

## Canvas Basics

### Locating Canvas Elements

```typescript
test("find canvas", async ({ page }) => {
  await page.goto("/canvas-app");

  // By tag
  const canvas = page.locator("canvas");

  // By ID or class
  const gameCanvas = page.locator("canvas#game");
  const chartCanvas = page.locator("canvas.chart-canvas");

  // Verify canvas is present and visible
  await expect(canvas).toBeVisible();

  // Get canvas dimensions
  const box = await canvas.boundingBox();
  console.log(`Canvas size: ${box?.width}x${box?.height}`);
});
```

### Canvas Screenshot Testing

```typescript
test("canvas renders correctly", async ({ page }) => {
  await page.goto("/chart");

  // Wait for canvas to be ready (check for specific content)
  await page.waitForFunction(() => {
    const canvas = document.querySelector("canvas");
    const ctx = canvas?.getContext("2d");
    // Check if canvas has been drawn to
    return ctx && !isCanvasBlank(canvas);

    function isCanvasBlank(canvas) {
      const ctx = canvas.getContext("2d");
      const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
      return !data.some((channel) => channel !== 0);
    }
  });

  // Screenshot just the canvas
  const canvas = page.locator("canvas");
  await expect(canvas).toHaveScreenshot("chart.png");
});
```

### Extracting Canvas Data

```typescript
test("verify canvas content", async ({ page }) => {
  await page.goto("/drawing-app");

  // Get canvas image data
  const imageData = await page.evaluate(() => {
    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
    return canvas.toDataURL("image/png");
  });

  // Verify it's not empty
  expect(imageData).toMatch(/^data:image\/png;base64,.+/);

  // Get pixel data at specific location
  const pixelColor = await page.evaluate(() => {
    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
    const ctx = canvas.getContext("2d")!;
    const pixel = ctx.getImageData(100, 100, 1, 1).data;
    return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] };
  });

  // Verify specific pixel color
  expect(pixelColor.r).toBeGreaterThan(200); // Expecting red-ish
});
```

## Visual Comparison

### Screenshot Assertions

```typescript
test("chart matches baseline", async ({ page }) => {
  await page.goto("/dashboard");

  // Wait for chart animation to complete
  await page.waitForTimeout(1000); // Or better: wait for specific state

  // Full page screenshot
  await expect(page).toHaveScreenshot("dashboard.png", {
    maxDiffPixels: 100, // Allow small differences
  });

  // Just the canvas
  const chart = page.locator("canvas#sales-chart");
  await expect(chart).toHaveScreenshot("sales-chart.png", {
    maxDiffPixelRatio: 0.01, // 1% difference allowed
  });
});
```

### Handling Animation

```typescript
test("animated canvas", async ({ page }) => {
  await page.goto("/animated-chart");

  // Pause animation before screenshot
  await page.evaluate(() => {
    // Common pattern: chart libraries expose pause method
    window.chartInstance?.stop?.();

    // Or override requestAnimationFrame
    window.requestAnimationFrame = () => 0;
  });

  await expect(page.locator("canvas")).toHaveScreenshot();
});

test("wait for animation complete", async ({ page }) => {
  await page.goto("/chart-with-animation");

  // Wait for animation complete event
  await page.evaluate(() => {
    return new Promise<void>((resolve) => {
      if (window.chart?.isAnimating === false) {
        resolve();
      } else {
        window.chart?.on("animationComplete", resolve);
      }
    });
  });

  await expect(page.locator("canvas")).toHaveScreenshot();
});
```

### Threshold Configuration

```typescript
// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      // Increased threshold for canvas (anti-aliasing differences)
      maxDiffPixelRatio: 0.02,
      threshold: 0.3, // Per-pixel color threshold
      animations: "disabled",
    },
  },
});
```

## Interaction Testing

### Click on Canvas

```typescript
test("click on canvas element", async ({ page }) => {
  await page.goto("/interactive-map");

  const canvas = page.locator("canvas");

  // Click at specific coordinates
  await canvas.click({ position: { x: 150, y: 200 } });

  // Verify click was registered
  await expect(page.locator("#info-panel")).toContainText("Location: Paris");
});
```

### Drawing on Canvas

```typescript
test("draw on canvas", async ({ page }) => {
  await page.goto("/whiteboard");

  const canvas = page.locator("canvas");
  const box = await canvas.boundingBox();

  // Draw a line using mouse
  await page.mouse.move(box!.x + 50, box!.y + 50);
  await page.mouse.down();
  await page.mouse.move(box!.x + 200, box!.y + 200, { steps: 10 });
  await page.mouse.up();

  // Verify something was drawn
  const hasDrawing = await page.evaluate(() => {
    const canvas = document.querySelector("canvas") as HTMLCanvasElement;
    const ctx = canvas.getContext("2d")!;
    const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
    return data.some((v, i) => i % 4 !== 3 && v !== 255); // Non-white pixels
  });

  expect(hasDrawing).toBe(true);
});
```

### Drag and Drop

```typescript
test("drag canvas element", async ({ page }) => {
  await page.goto("/diagram-editor");

  const canvas = page.locator("canvas");
  const box = await canvas.boundingBox();

  // Drag shape from position A to B
  await page.mouse.move(box!.x + 100, box!.y + 100);
  await page.mouse.down();
  await page.mouse.move(box!.x + 300, box!.y + 200, { steps: 20 });
  await page.mouse.up();

  // Verify via screenshot or state check
  await expect(canvas).toHaveScreenshot("shape-moved.png");
});
```

### Touch Gestures on Canvas

```typescript
test("pinch zoom on canvas", async ({ page }) => {
  await page.goto("/map");

  const canvas = page.locator("canvas");
  const box = await canvas.boundingBox();
  const centerX = box!.x + box!.width / 2;
  const centerY = box!.y + box!.height / 2;

  // Simulate pinch zoom using two touch points
  await page.touchscreen.tap(centerX, centerY);

  // Use evaluate for complex gestures
  await page.evaluate(
    async ({ x, y }) => {
      const target = document.querySelector("canvas")!;

      // Simulate pinch start
      const touch1 = new Touch({
        identifier: 1,
        target,
        clientX: x - 50,
        clientY: y,
      });
      const touch2 = new Touch({
        identifier: 2,
        target,
        clientX: x + 50,
        clientY: y,
      });

      target.dispatchEvent(
        new TouchEvent("touchstart", {
          touches: [touch1, touch2],
          targetTouches: [touch1, touch2],
          bubbles: true,
        }),
      );

      // Simulate pinch out
      const touch1End = new Touch({
        identifier: 1,
        target,
        clientX: x - 100,
        clientY: y,
      });
      const touch2End = new Touch({
        identifier: 2,
        target,
        clientX: x + 100,
        clientY: y,
      });

      target.dispatchEvent(
        new TouchEvent("touchmove", {
          touches: [touch1End, touch2End],
          targetTouches: [touch1End, touch2End],
          bubbles: true,
        }),
      );

      target.dispatchEvent(new TouchEvent("touchend", { bubbles: true }));
    },
    { x: centerX, y: centerY },
  );

  // Verify zoom level changed
  const zoomLevel = await page.locator("#zoom-indicator").textContent();
  expect(parseFloat(zoomLevel!)).toBeGreaterThan(1);
});
```

## WebGL Testing

### Checking WebGL Support

```typescript
test("WebGL is supported", async ({ page }) => {
  await page.goto("/3d-viewer");

  const hasWebGL = await page.evaluate(() => {
    const canvas = document.createElement("canvas");
    const gl =
      canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
    return !!gl;
  });

  expect(hasWebGL).toBe(true);
});
```

### WebGL Screenshot Testing

```typescript
test("3D scene renders", async ({ page }) => {
  await page.goto("/3d-model-viewer");

  // Wait for WebGL scene to render
  await page.waitForFunction(() => {
    const canvas = document.querySelector("canvas");
    if (!canvas) return false;

    const gl = canvas.getContext("webgl") || canvas.getContext("webgl2");
    if (!gl) return false;

    // Check if something has been drawn
    const pixels = new Uint8Array(4);
    gl.readPixels(
      canvas.width / 2,
      canvas.height / 2,
      1,
      1,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      pixels,
    );
    return pixels.some((p) => p > 0);
  });

  // Screenshot comparison (higher threshold for WebGL)
  await expect(page.locator("canvas")).toHaveScreenshot("3d-scene.png", {
    maxDiffPixelRatio: 0.05, // WebGL can have more variation
  });
});
```

### Testing Three.js Applications

```typescript
test("Three.js scene interaction", async ({ page }) => {
  await page.goto("/three-demo");

  // Wait for scene to be ready
  await page.waitForFunction(() => window.scene?.children?.length > 0);

  // Interact with scene (orbit controls)
  const canvas = page.locator("canvas");
  const box = await canvas.boundingBox();

  // Rotate camera by dragging
  await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
  await page.mouse.down();
  await page.mouse.move(
    box!.x + box!.width / 2 + 100,
    box!.y + box!.height / 2,
    {
      steps: 10,
    },
  );
  await page.mouse.up();

  // Verify camera position changed
  const cameraRotation = await page.evaluate(() => {
    return window.camera?.rotation?.y;
  });

  expect(cameraRotation).not.toBe(0);
});
```

## Chart Libraries

### Chart.js Testing

```typescript
test("Chart.js renders data", async ({ page }) => {
  await page.goto("/chartjs-demo");

  // Wait for Chart.js to initialize
  await page.waitForFunction(() => {
    return window.Chart && document.querySelector("canvas")?.__chart__;
  });

  // Get chart data via Chart.js API
  const chartData = await page.evaluate(() => {
    const canvas = document.querySelector("canvas") as any;
    const chart = canvas.__chart__;
    return chart.data.datasets[0].data;
  });

  expect(chartData).toEqual([12, 19, 3, 5, 2, 3]);

  // Screenshot test
  await expect(page.locator("canvas")).toHaveScreenshot();
});
```

### D3.js / ECharts Testing

```typescript
test("chart library interaction", async ({ page }) => {
  await page.goto("/chart-demo");

  // Wait for chart to render
  await page.waitForFunction(() => document.querySelector("canvas, svg.chart"));

  // For SVG charts (D3)
  const bars = page.locator("svg.chart rect.bar");
  if ((await bars.count()) > 0) {
    await bars.first().hover();
    await expect(page.locator(".tooltip")).toBeVisible();
  }

  // For canvas charts (ECharts, Chart.js)
  const canvas = page.locator("canvas");
  await canvas.click({ position: { x: 200, y: 150 } });
});
```

## Game & Animation Testing

### Frame-by-Frame Testing

```typescript
test("game frame control", async ({ page }) => {
  await page.goto("/game");

  // Pause and step through frames
  await page.evaluate(() => window.gameLoop?.pause());
  await page.evaluate(() => window.gameLoop?.tick());
  await expect(page.locator("canvas")).toHaveScreenshot("frame-1.png");

  for (let i = 0; i < 10; i++) {
    await page.evaluate(() => window.gameLoop?.tick());
  }
  await expect(page.locator("canvas")).toHaveScreenshot("frame-11.png");
});
```

### Testing Game State

```typescript
test("game state changes", async ({ page }) => {
  await page.goto("/game");

  const initialScore = await page.evaluate(() => window.game?.score);
  expect(initialScore).toBe(0);

  await page.keyboard.press("Space"); // Action
  await page.waitForTimeout(500);

  const newScore = await page.evaluate(() => window.game?.score);
  expect(newScore).toBeGreaterThan(0);
});
```

## Anti-Patterns to Avoid

| Anti-Pattern             | Problem                  | Solution                            |
| ------------------------ | ------------------------ | ----------------------------------- |
| Pixel-perfect assertions | Fails across browsers/OS | Use maxDiffPixelRatio threshold     |
| Not waiting for render   | Blank canvas screenshots | Wait for draw completion            |
| Testing raw pixel data   | Brittle and slow         | Use visual comparison               |
| Ignoring animation       | Flaky screenshots        | Pause/disable animations            |
| Hardcoded coordinates    | Breaks on resize         | Calculate relative to canvas bounds |

## Related References

- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for visual regression setup
- **Mobile Gestures**: See [mobile-testing.md](../advanced/mobile-testing.md) for touch interactions
- **Performance**: See [performance-testing.md](performance-testing.md) for FPS monitoring
testing-patterns/component-testing.md
# Component Testing

## Table of Contents

1. [Setup & Configuration](#setup--configuration)
2. [Mounting Components](#mounting-components)
3. [Props & State Testing](#props--state-testing)
4. [Events & Interactions](#events--interactions)
5. [Slots & Children](#slots--children)
6. [Mocking Dependencies](#mocking-dependencies)
7. [Framework-Specific Patterns](#framework-specific-patterns)

## Setup & Configuration

### Installation

```bash
# React
npm init playwright@latest -- --ct

# Vue
npm init playwright@latest -- --ct

# Svelte
npm init playwright@latest -- --ct

# Solid
npm init playwright@latest -- --ct
```

### Configuration

```typescript
// playwright-ct.config.ts
import { defineConfig, devices } from "@playwright/experimental-ct-react";

export default defineConfig({
  testDir: "./tests/components",
  snapshotDir: "./tests/components/__snapshots__",

  use: {
    ctPort: 3100,
    ctViteConfig: {
      resolve: {
        alias: {
          "@": "/src",
        },
      },
    },
  },

  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
});
```

### Project Structure

```
src/
  components/
    Button.tsx
    Modal.tsx
tests/
  components/
    Button.spec.tsx
    Modal.spec.tsx
playwright/
  index.html    # CT entry point
  index.tsx     # CT setup (providers, styles)
```

## Mounting Components

### Basic Mount

```tsx
// Button.spec.tsx
import { test, expect } from "@playwright/experimental-ct-react";
import { Button } from "@/components/Button";

test("renders button with text", async ({ mount }) => {
  const component = await mount(<Button>Click me</Button>);

  await expect(component).toContainText("Click me");
  await expect(component).toBeVisible();
});
```

### Mount with Props

```tsx
test("renders with all props", async ({ mount }) => {
  const component = await mount(
    <Button variant="primary" size="large" disabled={false} icon="check">
      Submit
    </Button>,
  );

  await expect(component).toHaveClass(/primary/);
  await expect(component).toHaveClass(/large/);
  await expect(component.locator("svg")).toBeVisible(); // icon
});
```

### Mount with Wrapper/Provider

```tsx
// playwright/index.tsx - Global providers
import { ThemeProvider } from "@/providers/theme";
import { QueryClientProvider } from "@tanstack/react-query";
import "@/styles/globals.css";

export default function PlaywrightWrapper({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>{children}</ThemeProvider>
    </QueryClientProvider>
  );
}
```

```tsx
// Or per-test wrapper
test("with custom provider", async ({ mount }) => {
  const component = await mount(
    <AuthProvider initialUser={{ name: "Test" }}>
      <UserProfile />
    </AuthProvider>,
  );

  await expect(component.getByText("Test")).toBeVisible();
});
```

## Props & State Testing

### Testing Prop Variations

```tsx
test.describe("Button variants", () => {
  const variants = ["primary", "secondary", "danger", "ghost"] as const;

  for (const variant of variants) {
    test(`renders ${variant} variant`, async ({ mount }) => {
      const component = await mount(<Button variant={variant}>Button</Button>);
      await expect(component).toHaveClass(new RegExp(variant));
    });
  }
});
```

### Updating Props

```tsx
test("responds to prop changes", async ({ mount }) => {
  const component = await mount(<Counter initialCount={0} />);

  await expect(component.getByTestId("count")).toHaveText("0");

  // Update props
  await component.update(<Counter initialCount={10} />);
  await expect(component.getByTestId("count")).toHaveText("10");
});
```

### Testing Controlled Components

```tsx
test("controlled input", async ({ mount }) => {
  let externalValue = "";

  const component = await mount(
    <Input
      value={externalValue}
      onChange={(e) => {
        externalValue = e.target.value;
      }}
    />,
  );

  await component.locator("input").fill("hello");

  // For controlled components, update with new value
  await component.update(
    <Input value="hello" onChange={(e) => (externalValue = e.target.value)} />,
  );

  await expect(component.locator("input")).toHaveValue("hello");
});
```

### Testing Internal State

```tsx
test("internal state updates", async ({ mount }) => {
  const component = await mount(<Toggle defaultChecked={false} />);

  // Initial state
  await expect(component.locator('[role="switch"]')).toHaveAttribute(
    "aria-checked",
    "false",
  );

  // Trigger state change
  await component.click();

  // Verify state updated
  await expect(component.locator('[role="switch"]')).toHaveAttribute(
    "aria-checked",
    "true",
  );
});
```

## Events & Interactions

### Testing Click Events

```tsx
test("click event fires", async ({ mount }) => {
  let clicked = false;

  const component = await mount(
    <Button onClick={() => (clicked = true)}>Click</Button>,
  );

  await component.click();

  expect(clicked).toBe(true);
});
```

### Testing Event Payloads

```tsx
test("onChange provides correct value", async ({ mount }) => {
  const values: string[] = [];

  const component = await mount(
    <Select
      options={["a", "b", "c"]}
      onChange={(value) => values.push(value)}
    />,
  );

  await component.getByRole("combobox").click();
  await component.getByRole("option", { name: "b" }).click();

  expect(values).toEqual(["b"]);
});
```

### Testing Form Submission

```tsx
test("form submission", async ({ mount }) => {
  let submittedData: FormData | null = null;

  const component = await mount(
    <LoginForm
      onSubmit={(data) => {
        submittedData = data;
      }}
    />,
  );

  await component.getByLabel("Email").fill("[email protected]");
  await component.getByLabel("Password").fill("secret123");
  await component.getByRole("button", { name: "Sign in" }).click();

  expect(submittedData).toEqual({
    email: "[email protected]",
    password: "secret123",
  });
});
```

### Testing Keyboard Interactions

```tsx
test("keyboard navigation", async ({ mount }) => {
  const component = await mount(
    <Dropdown options={["Apple", "Banana", "Cherry"]} />,
  );

  // Open dropdown
  await component.getByRole("button").click();

  // Navigate with keyboard
  await component.press("ArrowDown");
  await component.press("ArrowDown");
  await component.press("Enter");

  await expect(component.getByRole("button")).toHaveText("Banana");
});
```

## Slots & Children

### Testing Children Content

```tsx
test("renders children", async ({ mount }) => {
  const component = await mount(
    <Card>
      <h2>Title</h2>
      <p>Description</p>
    </Card>,
  );

  await expect(component.getByRole("heading")).toHaveText("Title");
  await expect(component.getByText("Description")).toBeVisible();
});
```

### Testing Named Slots (Vue)

```tsx
// Vue component with slots
test("renders named slots", async ({ mount }) => {
  const component = await mount(Modal, {
    slots: {
      header: "<h2>Modal Title</h2>",
      default: "<p>Modal content</p>",
      footer: "<button>Close</button>",
    },
  });

  await expect(component.getByRole("heading")).toHaveText("Modal Title");
  await expect(component.getByRole("button")).toHaveText("Close");
});
```

### Testing Render Props

```tsx
test("render prop pattern", async ({ mount }) => {
  const component = await mount(
    <DataFetcher url="/api/users">
      {({ data, loading }) =>
        loading ? <span>Loading...</span> : <span>{data.name}</span>
      }
    </DataFetcher>,
  );

  // Initially loading
  await expect(component.getByText("Loading...")).toBeVisible();

  // After data loads
  await expect(component.getByText(/User/)).toBeVisible();
});
```

## Mocking Dependencies

### Mocking Imports

```tsx
// playwright/index.tsx - Mock at setup level
import { beforeMount } from "@playwright/experimental-ct-react/hooks";

beforeMount(async ({ hooksConfig }) => {
  // Mock analytics
  window.analytics = {
    track: () => {},
    identify: () => {},
  };

  // Mock feature flags
  if (hooksConfig?.featureFlags) {
    window.__FEATURE_FLAGS__ = hooksConfig.featureFlags;
  }
});
```

```tsx
// Test with mocked config
test("with feature flag", async ({ mount }) => {
  const component = await mount(<FeatureComponent />, {
    hooksConfig: {
      featureFlags: { newFeature: true },
    },
  });

  await expect(component.getByText("New Feature")).toBeVisible();
});
```

### Mocking API Calls

```tsx
test("component with API", async ({ mount, page }) => {
  // Mock API before mounting
  await page.route("**/api/user", (route) => {
    route.fulfill({
      json: { id: 1, name: "Test User" },
    });
  });

  const component = await mount(<UserProfile userId={1} />);

  await expect(component.getByText("Test User")).toBeVisible();
});
```

### Mocking Hooks

```tsx
// Mock custom hook via module mock
test("with mocked hook", async ({ mount }) => {
  const component = await mount(<Dashboard />, {
    hooksConfig: {
      mockAuth: { user: { name: "Admin" }, isAdmin: true },
    },
  });

  await expect(component.getByText("Admin Panel")).toBeVisible();
});
```

## Framework-Specific Patterns

### React Testing

```tsx
// React with refs
test("exposes ref methods", async ({ mount }) => {
  let inputRef: HTMLInputElement | null = null;

  const component = await mount(<Input ref={(el) => (inputRef = el)} />);

  await component.locator("input").fill("test");
  expect(inputRef?.value).toBe("test");
});

// React with context
test("uses context", async ({ mount }) => {
  const component = await mount(
    <UserContext.Provider value={{ name: "Test" }}>
      <UserGreeting />
    </UserContext.Provider>,
  );

  await expect(component).toContainText("Hello, Test");
});
```

### Vue Testing

```tsx
import { test, expect } from "@playwright/experimental-ct-vue";
import MyInput from "@/components/MyInput.vue";

// With v-model
test("v-model binding", async ({ mount }) => {
  let modelValue = "";
  const component = await mount(MyInput, {
    props: {
      modelValue,
      "onUpdate:modelValue": (v: string) => (modelValue = v),
    },
  });

  await component.locator("input").fill("test");
  expect(modelValue).toBe("test");
});
```

### Svelte Testing

```tsx
import { test, expect } from "@playwright/experimental-ct-svelte";
import Counter from "./Counter.svelte";

test("Svelte component", async ({ mount }) => {
  const component = await mount(Counter, { props: { initialCount: 5 } });
  await expect(component.getByTestId("count")).toHaveText("5");
  await component.getByRole("button", { name: "+" }).click();
  await expect(component.getByTestId("count")).toHaveText("6");
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                   | Problem             | Solution                          |
| ------------------------------ | ------------------- | --------------------------------- |
| Testing implementation details | Brittle tests       | Test behavior, not internal state |
| Snapshot testing everything    | Maintenance burden  | Use for visual regression only    |
| Not isolating components       | Hidden dependencies | Mock all external dependencies    |
| Testing framework behavior     | Redundant           | Focus on your component logic     |
| Skipping accessibility         | Misses real issues  | Include a11y checks in CT         |

## Related References

- **Accessibility**: See [accessibility.md](accessibility.md) for a11y testing in components
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for shared test setup
testing-patterns/drag-drop.md
# Drag and Drop Testing

## Table of Contents

1. [Kanban Board (Cross-Column Movement)](#kanban-board-cross-column-movement)
2. [Sortable Lists (Reordering)](#sortable-lists-reordering)
3. [Native HTML5 Drag and Drop](#native-html5-drag-and-drop)
4. [File Drop Zone](#file-drop-zone)
5. [Canvas Coordinate-Based Dragging](#canvas-coordinate-based-dragging)
6. [Custom Drag Preview](#custom-drag-preview)
7. [Variations](#variations)
8. [Tips](#tips)

> **When to use**: Testing drag-and-drop interactions — sortable lists, kanban boards, file drop zones, or repositionable elements.

---

## Kanban Board (Cross-Column Movement)

```typescript
import { test, expect } from '@playwright/test';

test('moves card between columns', async ({ page }) => {
  await page.goto('/board');

  const backlog = page.locator('[data-column="backlog"]');
  const active = page.locator('[data-column="active"]');

  const ticket = backlog.getByText('Update API docs');
  await expect(ticket).toBeVisible();

  const backlogCountBefore = await backlog.getByRole('article').count();
  const activeCountBefore = await active.getByRole('article').count();

  await ticket.dragTo(active);

  await expect(active.getByText('Update API docs')).toBeVisible();
  await expect(backlog.getByText('Update API docs')).not.toBeVisible();

  await expect(backlog.getByRole('article')).toHaveCount(backlogCountBefore - 1);
  await expect(active.getByRole('article')).toHaveCount(activeCountBefore + 1);
});

test('progresses card through workflow stages', async ({ page }) => {
  await page.goto('/board');

  const cols = {
    backlog: page.locator('[data-column="backlog"]'),
    active: page.locator('[data-column="active"]'),
    review: page.locator('[data-column="review"]'),
    complete: page.locator('[data-column="complete"]'),
  };

  await cols.backlog.getByText('Update API docs').dragTo(cols.active);
  await expect(cols.active.getByText('Update API docs')).toBeVisible();

  await cols.active.getByText('Update API docs').dragTo(cols.review);
  await expect(cols.review.getByText('Update API docs')).toBeVisible();

  await cols.review.getByText('Update API docs').dragTo(cols.complete);
  await expect(cols.complete.getByText('Update API docs')).toBeVisible();

  await expect(cols.backlog.getByText('Update API docs')).not.toBeVisible();
  await expect(cols.active.getByText('Update API docs')).not.toBeVisible();
  await expect(cols.review.getByText('Update API docs')).not.toBeVisible();
});

test('reorders cards within same column', async ({ page }) => {
  await page.goto('/board');

  const backlog = page.locator('[data-column="backlog"]');

  const itemX = backlog.getByRole('article').filter({ hasText: 'Item X' });
  const itemZ = backlog.getByRole('article').filter({ hasText: 'Item Z' });

  await itemZ.dragTo(itemX);

  const cards = await backlog.getByRole('article').allTextContents();
  expect(cards.indexOf('Item Z')).toBeLessThan(cards.indexOf('Item X'));
});

test('verifies drag persists via API', async ({ page }) => {
  await page.goto('/board');

  const backlog = page.locator('[data-column="backlog"]');
  const active = page.locator('[data-column="active"]');

  const responsePromise = page.waitForResponse(
    (r) => r.url().includes('/api/tickets') && r.request().method() === 'PATCH'
  );

  await backlog.getByText('Update API docs').dragTo(active);

  const response = await responsePromise;
  expect(response.status()).toBe(200);

  const body = await response.json();
  expect(body.column).toBe('active');

  await page.reload();
  await expect(active.getByText('Update API docs')).toBeVisible();
});
```

---

## Sortable Lists (Reordering)

```typescript
import { test, expect } from '@playwright/test';

test('reorders list items', async ({ page }) => {
  await page.goto('/priorities');

  const list = page.getByRole('list', { name: 'Priority list' });

  const initial = await list.getByRole('listitem').allTextContents();
  expect(initial[0]).toContain('Priority A');
  expect(initial[1]).toContain('Priority B');
  expect(initial[2]).toContain('Priority C');

  const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
  const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });

  await priorityC.dragTo(priorityA);

  const reordered = await list.getByRole('listitem').allTextContents();
  expect(reordered[0]).toContain('Priority C');
  expect(reordered[1]).toContain('Priority A');
  expect(reordered[2]).toContain('Priority B');
});

test('reorders via drag handle', async ({ page }) => {
  await page.goto('/priorities');

  const list = page.getByRole('list', { name: 'Priority list' });

  const handle = list
    .getByRole('listitem')
    .filter({ hasText: 'Priority C' })
    .getByRole('button', { name: /drag|reorder|grip/i });

  const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });

  await handle.dragTo(target);

  const items = await list.getByRole('listitem').allTextContents();
  expect(items[0]).toContain('Priority C');
});

test('reorder persists after reload', async ({ page }) => {
  await page.goto('/priorities');

  const list = page.getByRole('list', { name: 'Priority list' });

  const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
  const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });

  await priorityC.dragTo(priorityA);

  await page.waitForResponse((response) =>
    response.url().includes('/api/priorities/reorder') && response.status() === 200
  );

  await page.reload();

  const items = await list.getByRole('listitem').allTextContents();
  expect(items[0]).toContain('Priority C');
  expect(items[1]).toContain('Priority A');
  expect(items[2]).toContain('Priority B');
});
```

### Incremental Mouse Movement for Custom Libraries

Some drag libraries (react-beautiful-dnd, dnd-kit) require incremental mouse movements:

```typescript
test('reorders with incremental mouse movements', async ({ page }) => {
  await page.goto('/priorities');

  const list = page.getByRole('list', { name: 'Priority list' });
  const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
  const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });

  const sourceBox = await source.boundingBox();
  const targetBox = await target.boundingBox();

  await source.hover();
  await page.mouse.down();

  const steps = 10;
  for (let i = 1; i <= steps; i++) {
    await page.mouse.move(
      sourceBox!.x + sourceBox!.width / 2,
      sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),
      { steps: 1 }
    );
  }

  await page.mouse.up();

  const items = await list.getByRole('listitem').allTextContents();
  expect(items[0]).toContain('Priority C');
});
```

---

## Native HTML5 Drag and Drop

```typescript
import { test, expect } from '@playwright/test';

test('drags item to drop zone', async ({ page }) => {
  await page.goto('/drag-example');

  const source = page.getByText('Movable Element');
  const dropArea = page.locator('#target-zone');

  await expect(source).toBeVisible();
  await expect(dropArea).not.toContainText('Movable Element');

  await source.dragTo(dropArea);

  await expect(dropArea).toContainText('Movable Element');
});

test('drags between zones', async ({ page }) => {
  await page.goto('/drag-example');

  const item = page.locator('[data-testid="element-1"]');
  const areaA = page.locator('[data-testid="area-a"]');
  const areaB = page.locator('[data-testid="area-b"]');

  await expect(areaA).toContainText('Element 1');

  await item.dragTo(areaB);

  await expect(areaB).toContainText('Element 1');
  await expect(areaA).not.toContainText('Element 1');

  await areaB.getByText('Element 1').dragTo(areaA);

  await expect(areaA).toContainText('Element 1');
  await expect(areaB).not.toContainText('Element 1');
});

test('verifies drag visual feedback', async ({ page }) => {
  await page.goto('/drag-example');

  const source = page.getByText('Movable Element');
  const dropArea = page.locator('#target-zone');

  await source.hover();
  await page.mouse.down();

  const dropBox = await dropArea.boundingBox();
  await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);

  await expect(dropArea).toHaveClass(/drag-over|highlight/);

  await page.mouse.up();

  await expect(dropArea).not.toHaveClass(/drag-over|highlight/);
  await expect(dropArea).toContainText('Movable Element');
});
```

---

## File Drop Zone

```typescript
import { test, expect } from '@playwright/test';
import path from 'path';

test('uploads file via drop zone', async ({ page }) => {
  await page.goto('/upload');

  const dropZone = page.locator('[data-testid="file-drop-zone"]');

  await expect(dropZone).toContainText('Drag files here');

  const fileInput = page.locator('input[type="file"]');

  await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));

  await expect(page.getByText('report.pdf')).toBeVisible();
  await expect(page.getByText(/\d+ KB/)).toBeVisible();
});

test('simulates drag-over visual feedback', async ({ page }) => {
  await page.goto('/upload');

  const dropZone = page.locator('[data-testid="file-drop-zone"]');

  await dropZone.dispatchEvent('dragenter', {
    dataTransfer: { types: ['Files'] },
  });

  await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);
  await expect(dropZone).toContainText(/drop.*here|release.*upload/i);

  await dropZone.dispatchEvent('dragleave');

  await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);
});

test('rejects invalid file types', async ({ page }) => {
  await page.goto('/upload');

  const fileInput = page.locator('input[type="file"]');

  await fileInput.setInputFiles({
    name: 'script.exe',
    mimeType: 'application/x-msdownload',
    buffer: Buffer.from('fake-content'),
  });

  await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
  await expect(page.getByText('script.exe')).not.toBeVisible();
});
```

---

## Canvas Coordinate-Based Dragging

```typescript
import { test, expect } from '@playwright/test';

test('drags element to specific coordinates', async ({ page }) => {
  await page.goto('/design-tool');

  const canvas = page.locator('#editor-canvas');
  const shape = page.locator('[data-testid="shape-1"]');

  const canvasBox = await canvas.boundingBox();
  const targetX = canvasBox!.x + 300;
  const targetY = canvasBox!.y + 200;

  await shape.hover();
  await page.mouse.down();
  await page.mouse.move(targetX, targetY, { steps: 10 });
  await page.mouse.up();

  const newBox = await shape.boundingBox();
  expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);
  expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);
});

test('snaps element to grid', async ({ page }) => {
  await page.goto('/design-tool');

  const shape = page.locator('[data-testid="shape-1"]');
  const canvas = page.locator('#editor-canvas');

  const canvasBox = await canvas.boundingBox();

  await shape.hover();
  await page.mouse.down();
  await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });
  await page.mouse.up();

  const snappedBox = await shape.boundingBox();
  expect(snappedBox!.x % 20).toBeCloseTo(0, 0);
  expect(snappedBox!.y % 20).toBeCloseTo(0, 0);
});

test('constrains drag within boundaries', async ({ page }) => {
  await page.goto('/design-tool');

  const shape = page.locator('[data-testid="bounded-shape"]');
  const container = page.locator('#bounds-container');

  const containerBox = await container.boundingBox();

  await shape.hover();
  await page.mouse.down();
  await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
    steps: 10,
  });
  await page.mouse.up();

  const shapeBox = await shape.boundingBox();

  expect(shapeBox!.x).toBeGreaterThanOrEqual(containerBox!.x);
  expect(shapeBox!.y).toBeGreaterThanOrEqual(containerBox!.y);
  expect(shapeBox!.x + shapeBox!.width).toBeLessThanOrEqual(
    containerBox!.x + containerBox!.width
  );
  expect(shapeBox!.y + shapeBox!.height).toBeLessThanOrEqual(
    containerBox!.y + containerBox!.height
  );
});

test('resizes element via handle', async ({ page }) => {
  await page.goto('/design-tool');

  const shape = page.locator('[data-testid="shape-1"]');
  await shape.click();

  const resizeHandle = shape.locator('.resize-handle-se');
  const handleBox = await resizeHandle.boundingBox();

  const initialBox = await shape.boundingBox();

  await resizeHandle.hover();
  await page.mouse.down();
  await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });
  await page.mouse.up();

  const newBox = await shape.boundingBox();
  expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);
  expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);
});
```

---

## Custom Drag Preview

```typescript
import { test, expect } from '@playwright/test';

test('shows custom drag preview', async ({ page }) => {
  await page.goto('/board');

  const card = page.locator('[data-testid="ticket-1"]');
  const targetCol = page.locator('[data-column="active"]');

  const cardBox = await card.boundingBox();
  const targetBox = await targetCol.boundingBox();

  await card.hover();
  await page.mouse.down();

  const midX = (cardBox!.x + targetBox!.x) / 2;
  const midY = (cardBox!.y + targetBox!.y) / 2;
  await page.mouse.move(midX, midY, { steps: 5 });

  await expect(page.locator('.drag-preview')).toBeVisible();
  await expect(card).toHaveClass(/dragging|placeholder/);

  await page.mouse.move(
    targetBox!.x + targetBox!.width / 2,
    targetBox!.y + targetBox!.height / 2,
    { steps: 5 }
  );
  await page.mouse.up();

  await expect(page.locator('.drag-preview')).not.toBeVisible();
});

test('multi-select drag shows item count', async ({ page }) => {
  await page.goto('/board');

  await page.locator('[data-testid="ticket-1"]').click();
  await page.locator('[data-testid="ticket-2"]').click({ modifiers: ['Shift'] });
  await page.locator('[data-testid="ticket-3"]').click({ modifiers: ['Shift'] });

  const card = page.locator('[data-testid="ticket-1"]');
  const targetCol = page.locator('[data-column="complete"]');

  await card.hover();
  await page.mouse.down();

  const targetBox = await targetCol.boundingBox();
  await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });

  await expect(page.locator('.drag-preview')).toContainText('3 items');

  await page.mouse.up();

  await expect(targetCol.locator('[data-testid="ticket-1"]')).toBeVisible();
  await expect(targetCol.locator('[data-testid="ticket-2"]')).toBeVisible();
  await expect(targetCol.locator('[data-testid="ticket-3"]')).toBeVisible();
});
```

---

## Variations

### Keyboard-Based Reordering

```typescript
test('reorders using keyboard', async ({ page }) => {
  await page.goto('/priorities');

  const list = page.getByRole('list', { name: 'Priority list' });
  const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });

  await priorityC.focus();
  await page.keyboard.press('Space');

  await page.keyboard.press('ArrowUp');
  await page.keyboard.press('ArrowUp');

  await page.keyboard.press('Space');

  const items = await list.getByRole('listitem').allTextContents();
  expect(items[0]).toContain('Priority C');
});
```

### Cross-Frame Dragging

```typescript
test('drags between main page and iframe', async ({ page }) => {
  await page.goto('/composer');

  const sourceWidget = page.getByText('Component A');
  const iframe = page.frameLocator('#preview-frame');
  const iframeElement = page.locator('#preview-frame');

  const sourceBox = await sourceWidget.boundingBox();
  const iframeBox = await iframeElement.boundingBox();

  const targetX = iframeBox!.x + 100;
  const targetY = iframeBox!.y + 100;

  await sourceWidget.hover();
  await page.mouse.down();
  await page.mouse.move(targetX, targetY, { steps: 20 });
  await page.mouse.up();

  await expect(iframe.getByText('Component A')).toBeVisible();
});
```

### Touch-Based Drag on Mobile

```typescript
test('drags via touch events', async ({ page }) => {
  await page.goto('/priorities');

  const list = page.getByRole('list', { name: 'Priority list' });
  const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
  const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });

  const sourceBox = await source.boundingBox();
  const targetBox = await target.boundingBox();

  await source.dispatchEvent('touchstart', {
    touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],
  });

  for (let i = 1; i <= 5; i++) {
    const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);
    await source.dispatchEvent('touchmove', {
      touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
    });
  }

  await source.dispatchEvent('touchend');

  const items = await list.getByRole('listitem').allTextContents();
  expect(items[0]).toContain('Priority C');
});
```

---

## Tips

1. **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.

2. **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.

3. **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.

4. **Use `boundingBox()` for coordinate assertions**. For canvas editors or position-sensitive drops, capture bounding box after the operation and compare with `toBeCloseTo()` for tolerance.

5. **Test undo after drag operations**. If your app supports Ctrl+Z, verify the drag is reversible — this catches state management bugs.
testing-patterns/electron.md
# Electron Testing

## Table of Contents

1. [Setup & Configuration](#setup--configuration)
2. [Launching Electron Apps](#launching-electron-apps)
3. [Main Process Testing](#main-process-testing)
4. [Renderer Process Testing](#renderer-process-testing)
5. [IPC Communication](#ipc-communication)
6. [Native Features](#native-features)
7. [Packaging & Distribution](#packaging--distribution)

## Setup & Configuration

### Installation

```bash
npm install -D @playwright/test electron
```

### Basic Configuration

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  timeout: 30000,
  use: {
    trace: "on-first-retry",
  },
});
```

### Electron Test Fixture

```typescript
// fixtures/electron.ts
import {
  test as base,
  _electron as electron,
  ElectronApplication,
  Page,
} from "@playwright/test";

type ElectronFixtures = {
  electronApp: ElectronApplication;
  window: Page;
};

export const test = base.extend<ElectronFixtures>({
  electronApp: async ({}, use) => {
    // Launch Electron app
    const electronApp = await electron.launch({
      args: [".", "--no-sandbox"],
      env: {
        ...process.env,
        NODE_ENV: "test",
      },
    });

    await use(electronApp);

    // Cleanup
    await electronApp.close();
  },

  window: async ({ electronApp }, use) => {
    // Wait for first window
    const window = await electronApp.firstWindow();

    // Wait for app to be ready
    await window.waitForLoadState("domcontentloaded");

    await use(window);
  },
});

export { expect } from "@playwright/test";
```

### Launch Options

```typescript
// Advanced launch configuration
const electronApp = await electron.launch({
  args: ["main.js", "--custom-flag"],
  cwd: "/path/to/app",
  env: {
    ...process.env,
    ELECTRON_ENABLE_LOGGING: "1",
    NODE_ENV: "test",
  },
  timeout: 30000,
  // For packaged apps
  executablePath: "/path/to/MyApp.app/Contents/MacOS/MyApp",
});
```

## Launching Electron Apps

### Development Mode

```typescript
test("launch in dev mode", async () => {
  const electronApp = await electron.launch({
    args: ["."], // Points to package.json main
  });

  const window = await electronApp.firstWindow();
  await expect(window.locator("h1")).toContainText("My App");

  await electronApp.close();
});
```

### Packaged Application

```typescript
test("launch packaged app", async () => {
  const appPath =
    process.platform === "darwin"
      ? "/Applications/MyApp.app/Contents/MacOS/MyApp"
      : process.platform === "win32"
        ? "C:\\Program Files\\MyApp\\MyApp.exe"
        : "/usr/bin/myapp";

  const electronApp = await electron.launch({
    executablePath: appPath,
  });

  const window = await electronApp.firstWindow();
  await expect(window).toHaveTitle(/MyApp/);

  await electronApp.close();
});
```

### Multiple Windows

```typescript
test("handle multiple windows", async ({ electronApp }) => {
  const mainWindow = await electronApp.firstWindow();

  // Trigger new window
  await mainWindow.getByRole("button", { name: "Open Settings" }).click();

  // Wait for new window
  const settingsWindow = await electronApp.waitForEvent("window");

  // Both windows are now accessible
  await expect(settingsWindow.locator("h1")).toHaveText("Settings");
  await expect(mainWindow.locator("h1")).toHaveText("Main");

  // Get all windows
  const windows = electronApp.windows();
  expect(windows.length).toBe(2);
});
```

## Main Process Testing

### Evaluate in Main Process

```typescript
test("access main process", async ({ electronApp }) => {
  // Evaluate in main process context
  const appPath = await electronApp.evaluate(async ({ app }) => {
    return app.getAppPath();
  });

  expect(appPath).toContain("my-electron-app");
});
```

### Access Electron APIs

```typescript
test("electron API access", async ({ electronApp }) => {
  // Get app version
  const version = await electronApp.evaluate(async ({ app }) => {
    return app.getVersion();
  });
  expect(version).toMatch(/^\d+\.\d+\.\d+$/);

  // Get platform info
  const platform = await electronApp.evaluate(async ({ app }) => {
    return process.platform;
  });
  expect(["darwin", "win32", "linux"]).toContain(platform);

  // Check if app is ready
  const isReady = await electronApp.evaluate(async ({ app }) => {
    return app.isReady();
  });
  expect(isReady).toBe(true);
});
```

### BrowserWindow Properties

```typescript
test("check window properties", async ({ electronApp, window }) => {
  // Get BrowserWindow from main process
  const windowBounds = await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    return win.getBounds();
  });

  expect(windowBounds.width).toBeGreaterThan(0);
  expect(windowBounds.height).toBeGreaterThan(0);

  // Check window state
  const isMaximized = await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    return win.isMaximized();
  });

  // Check window title
  const title = await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    return win.getTitle();
  });
  expect(title).toBeTruthy();
});
```

## Renderer Process Testing

### Standard Page Testing

```typescript
test("renderer interactions", async ({ window }) => {
  // Standard Playwright page interactions
  await window.getByRole("button", { name: "Click Me" }).click();
  await expect(window.getByText("Clicked!")).toBeVisible();

  // Fill forms
  await window.getByLabel("Username").fill("testuser");
  await window.getByLabel("Password").fill("password123");
  await window.getByRole("button", { name: "Login" }).click();

  // Verify navigation
  await expect(window).toHaveURL(/dashboard/);
});
```

### Access Node.js in Renderer

```typescript
test("node integration", async ({ window }) => {
  // If nodeIntegration is enabled
  const nodeVersion = await window.evaluate(() => {
    return (window as any).process?.version;
  });

  // Check if Node APIs are available
  const hasFs = await window.evaluate(() => {
    return typeof (window as any).require === "function";
  });
});
```

### Context Isolation Testing

```typescript
test("context isolation", async ({ window }) => {
  // Test preload script exposed APIs
  const apiAvailable = await window.evaluate(() => {
    return typeof (window as any).electronAPI !== "undefined";
  });
  expect(apiAvailable).toBe(true);

  // Call exposed API
  const result = await window.evaluate(async () => {
    return await (window as any).electronAPI.getAppVersion();
  });
  expect(result).toMatch(/^\d+\.\d+\.\d+$/);
});
```

## IPC Communication

### Testing IPC from Renderer

```typescript
test("IPC invoke", async ({ window }) => {
  // Test preload-exposed IPC call
  const result = await window.evaluate(async () => {
    return await (window as any).electronAPI.getData("user-settings");
  });

  expect(result).toHaveProperty("theme");
});
```

### Testing IPC from Main Process

```typescript
test("main to renderer IPC", async ({ electronApp, window }) => {
  // Set up listener in renderer
  await window.evaluate(() => {
    (window as any).receivedMessage = null;
    (window as any).electronAPI.onMessage((msg: string) => {
      (window as any).receivedMessage = msg;
    });
  });

  // Send from main process
  await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    win.webContents.send("message", "Hello from main!");
  });

  // Verify receipt
  await window.waitForFunction(() => (window as any).receivedMessage !== null);
  const message = await window.evaluate(() => (window as any).receivedMessage);
  expect(message).toBe("Hello from main!");
});
```

### Mock IPC Handlers

```typescript
// In test setup or fixture
test("mock IPC handler", async ({ electronApp, window }) => {
  // Override IPC handler in main process
  await electronApp.evaluate(async ({ ipcMain }) => {
    // Remove existing handler
    ipcMain.removeHandler("fetch-data");

    // Add mock handler
    ipcMain.handle("fetch-data", async () => {
      return { mocked: true, data: "test-data" };
    });
  });

  // Test with mocked handler
  const result = await window.evaluate(async () => {
    return await (window as any).electronAPI.fetchData();
  });

  expect(result.mocked).toBe(true);
});
```

## Native Features

### File System Dialogs

```typescript
test("file dialog", async ({ electronApp, window }) => {
  // Mock dialog response
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showOpenDialog = async () => ({
      canceled: false,
      filePaths: ["/mock/path/file.txt"],
    });
  });

  // Trigger file open
  await window.getByRole("button", { name: "Open File" }).click();

  // Verify file was "opened"
  await expect(window.getByText("file.txt")).toBeVisible();
});

test("save dialog", async ({ electronApp, window }) => {
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showSaveDialog = async () => ({
      canceled: false,
      filePath: "/mock/path/saved-file.txt",
    });
  });

  await window.getByRole("button", { name: "Save" }).click();
  await expect(window.getByText("Saved successfully")).toBeVisible();
});
```

### Menu Testing

```typescript
test("application menu", async ({ electronApp }) => {
  // Get menu structure
  const menuLabels = await electronApp.evaluate(async ({ Menu }) => {
    const menu = Menu.getApplicationMenu();
    return menu?.items.map((item) => item.label) || [];
  });

  expect(menuLabels).toContain("File");
  expect(menuLabels).toContain("Edit");

  // Trigger menu action
  await electronApp.evaluate(async ({ Menu }) => {
    const menu = Menu.getApplicationMenu();
    const fileMenu = menu?.items.find((item) => item.label === "File");
    const newItem = fileMenu?.submenu?.items.find(
      (item) => item.label === "New",
    );
    newItem?.click();
  });
});
```

### Native Notifications

```typescript
test("notifications", async ({ electronApp, window }) => {
  // Mock Notification
  let notificationShown = false;
  await electronApp.evaluate(async ({ Notification }) => {
    const OriginalNotification = Notification;
    (global as any).Notification = class extends OriginalNotification {
      constructor(options: any) {
        super(options);
        (global as any).lastNotification = options;
      }
    };
  });

  // Trigger notification
  await window.getByRole("button", { name: "Notify" }).click();

  // Verify notification was created
  const notification = await electronApp.evaluate(async () => {
    return (global as any).lastNotification;
  });

  expect(notification.title).toBe("New Message");
});
```

### Clipboard

```typescript
test("clipboard operations", async ({ electronApp, window }) => {
  // Write to clipboard
  await electronApp.evaluate(async ({ clipboard }) => {
    clipboard.writeText("Test clipboard content");
  });

  // Paste in app
  await window.getByRole("textbox").focus();
  await window.keyboard.press("ControlOrMeta+v");

  // Read clipboard
  const clipboardContent = await electronApp.evaluate(async ({ clipboard }) => {
    return clipboard.readText();
  });

  expect(clipboardContent).toBe("Test clipboard content");
});
```

## Packaging & Distribution

### Testing Packaged Apps

```typescript
// fixtures/packaged-electron.ts
import { test as base, _electron as electron } from "@playwright/test";
import path from "path";
import { execSync } from "child_process";

export const test = base.extend({
  electronApp: async ({}, use) => {
    // Build the app first (or use pre-built)
    const distPath = path.join(__dirname, "../dist");

    let executablePath: string;
    if (process.platform === "darwin") {
      executablePath = path.join(
        distPath,
        "mac",
        "MyApp.app",
        "Contents",
        "MacOS",
        "MyApp",
      );
    } else if (process.platform === "win32") {
      executablePath = path.join(distPath, "win-unpacked", "MyApp.exe");
    } else {
      executablePath = path.join(distPath, "linux-unpacked", "myapp");
    }

    const electronApp = await electron.launch({ executablePath });
    await use(electronApp);
    await electronApp.close();
  },
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                          | Problem                      | Solution                                     |
| ------------------------------------- | ---------------------------- | -------------------------------------------- |
| Not closing ElectronApplication       | Resource leaks               | Always call `electronApp.close()` in cleanup |
| Hardcoded executable paths            | Breaks cross-platform        | Use platform detection                       |
| Testing packaged app without building | Outdated code                | Build before testing or test dev mode        |
| Ignoring IPC in tests                 | Missing coverage             | Test IPC communication explicitly            |
| Not mocking native dialogs            | Tests hang waiting for input | Mock dialog responses                        |

## Related References

- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for custom fixture patterns
- **Component Testing**: See [component-testing.md](component-testing.md) for renderer testing patterns
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting
testing-patterns/file-operations.md
# File Upload & Download Testing

> For advanced patterns (progress tracking, cancellation, retry logic), see [file-upload-download.md](./file-upload-download.md)

## Table of Contents

1. [File Downloads](#file-downloads)
2. [File Uploads](#file-uploads)
3. [Drag and Drop](#drag-and-drop)
4. [File Content Verification](#file-content-verification)

## File Downloads

### Basic Download

```typescript
test("download PDF report", async ({ page }) => {
  await page.goto("/reports");

  // Start waiting for download before clicking
  const downloadPromise = page.waitForEvent("download");
  await page.getByRole("button", { name: "Download PDF" }).click();
  const download = await downloadPromise;

  // Verify filename
  expect(download.suggestedFilename()).toBe("report.pdf");

  // Save to specific path
  await download.saveAs("./downloads/report.pdf");
});
```

### Download with Custom Path

```typescript
test("download to temp directory", async ({ page }, testInfo) => {
  await page.goto("/exports");

  const downloadPromise = page.waitForEvent("download");
  await page.getByRole("link", { name: "Export CSV" }).click();
  const download = await downloadPromise;

  // Save to test output directory
  const path = testInfo.outputPath(download.suggestedFilename());
  await download.saveAs(path);

  // Attach to test report
  await testInfo.attach("downloaded-file", { path });
});
```

### Verify Download Content

```typescript
import fs from "fs";
import path from "path";

test("verify CSV content", async ({ page }, testInfo) => {
  await page.goto("/data");

  const downloadPromise = page.waitForEvent("download");
  await page.getByRole("button", { name: "Export" }).click();
  const download = await downloadPromise;

  const filePath = testInfo.outputPath("export.csv");
  await download.saveAs(filePath);

  // Read and verify content
  const content = fs.readFileSync(filePath, "utf-8");
  expect(content).toContain("Name,Email,Status");
  expect(content).toContain("John Doe");

  // Verify row count
  const rows = content.trim().split("\n");
  expect(rows.length).toBeGreaterThan(1);
});
```

### Multiple Downloads

```typescript
test("download multiple files", async ({ page }) => {
  await page.goto("/batch-export");

  await page.getByRole("checkbox", { name: "Select All" }).check();

  // Collect all downloads
  const downloads: Download[] = [];
  page.on("download", (download) => downloads.push(download));

  await page.getByRole("button", { name: "Download Selected" }).click();

  // Wait for all downloads
  await expect.poll(() => downloads.length, { timeout: 30000 }).toBe(5);

  // Verify each download
  for (const download of downloads) {
    expect(download.suggestedFilename()).toMatch(/\.pdf$/);
  }
});
```

### Download Fixture

```typescript
// fixtures/download.fixture.ts
import { test as base, Download } from "@playwright/test";
import fs from "fs";
import path from "path";

type DownloadFixtures = {
  downloadDir: string;
  downloadAndVerify: (
    trigger: () => Promise<void>,
    expectedFilename: string,
  ) => Promise<string>;
};

export const test = base.extend<DownloadFixtures>({
  downloadDir: async ({}, use, testInfo) => {
    const dir = testInfo.outputPath("downloads");
    fs.mkdirSync(dir, { recursive: true });
    await use(dir);
  },

  downloadAndVerify: async ({ page, downloadDir }, use) => {
    await use(async (trigger, expectedFilename) => {
      const downloadPromise = page.waitForEvent("download");
      await trigger();
      const download = await downloadPromise;

      expect(download.suggestedFilename()).toBe(expectedFilename);

      const filePath = path.join(downloadDir, expectedFilename);
      await download.saveAs(filePath);
      return filePath;
    });
  },
});
```

## File Uploads

### Basic Upload

```typescript
test("upload profile picture", async ({ page }) => {
  await page.goto("/settings/profile");

  // Upload file
  await page
    .getByLabel("Profile Picture")
    .setInputFiles("./fixtures/avatar.png");

  // Verify preview
  await expect(page.getByAltText("Profile preview")).toBeVisible();

  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByText("Profile updated")).toBeVisible();
});
```

### Multiple File Upload

```typescript
test("upload multiple documents", async ({ page }) => {
  await page.goto("/documents/upload");

  await page
    .getByLabel("Documents")
    .setInputFiles([
      "./fixtures/doc1.pdf",
      "./fixtures/doc2.pdf",
      "./fixtures/doc3.pdf",
    ]);

  // Verify all files listed
  await expect(page.getByText("doc1.pdf")).toBeVisible();
  await expect(page.getByText("doc2.pdf")).toBeVisible();
  await expect(page.getByText("doc3.pdf")).toBeVisible();

  await page.getByRole("button", { name: "Upload All" }).click();
  await expect(page.getByText("3 files uploaded")).toBeVisible();
});
```

### Upload with File Chooser

```typescript
test("upload via file chooser dialog", async ({ page }) => {
  await page.goto("/upload");

  // Handle file chooser
  const fileChooserPromise = page.waitForEvent("filechooser");
  await page.getByRole("button", { name: "Choose File" }).click();
  const fileChooser = await fileChooserPromise;

  await fileChooser.setFiles("./fixtures/document.pdf");

  await expect(page.getByText("document.pdf")).toBeVisible();
});
```

### Clear and Re-upload

```typescript
test("replace uploaded file", async ({ page }) => {
  await page.goto("/upload");

  const input = page.getByLabel("Document");

  // Upload first file
  await input.setInputFiles("./fixtures/old.pdf");
  await expect(page.getByText("old.pdf")).toBeVisible();

  // Clear selection
  await input.setInputFiles([]);

  // Upload new file
  await input.setInputFiles("./fixtures/new.pdf");
  await expect(page.getByText("new.pdf")).toBeVisible();
  await expect(page.getByText("old.pdf")).toBeHidden();
});
```

### Upload from Buffer

```typescript
test("upload generated file", async ({ page }) => {
  await page.goto("/upload");

  // Create file content dynamically
  const content = "Name,Email\nJohn,[email protected]";

  await page.getByLabel("CSV File").setInputFiles({
    name: "users.csv",
    mimeType: "text/csv",
    buffer: Buffer.from(content),
  });

  await expect(page.getByText("users.csv")).toBeVisible();
});
```

## Drag and Drop

### Drag and Drop Upload

```typescript
test("drag and drop file upload", async ({ page }) => {
  await page.goto("/upload");

  const dropzone = page.getByTestId("dropzone");

  // Create a DataTransfer with the file
  const dataTransfer = await page.evaluateHandle(() => new DataTransfer());

  // Read file and add to DataTransfer
  const buffer = fs.readFileSync("./fixtures/image.png");
  await page.evaluate(
    async ([dataTransfer, data]) => {
      const file = new File([new Uint8Array(data)], "image.png", {
        type: "image/png",
      });
      dataTransfer.items.add(file);
    },
    [dataTransfer, [...buffer]] as const,
  );

  // Dispatch drop event
  await dropzone.dispatchEvent("drop", { dataTransfer });

  await expect(page.getByText("image.png uploaded")).toBeVisible();
});
```

### Simpler Drag and Drop

```typescript
test("drag and drop with setInputFiles", async ({ page }) => {
  await page.goto("/upload");

  // Most dropzones have a hidden file input
  const input = page.locator('input[type="file"]');

  // This works even if the input is hidden
  await input.setInputFiles("./fixtures/document.pdf");

  await expect(page.getByText("document.pdf")).toBeVisible();
});
```

## File Content Verification

### Verify PDF Content

```typescript
import pdf from "pdf-parse";

test("verify PDF content", async ({ page }, testInfo) => {
  await page.goto("/invoice/123");

  const downloadPromise = page.waitForEvent("download");
  await page.getByRole("button", { name: "Download Invoice" }).click();
  const download = await downloadPromise;

  const path = testInfo.outputPath("invoice.pdf");
  await download.saveAs(path);

  // Parse PDF
  const dataBuffer = fs.readFileSync(path);
  const data = await pdf(dataBuffer);

  expect(data.text).toContain("Invoice #123");
  expect(data.text).toContain("Total: $99.99");
});
```

### Verify Excel Content

```typescript
import XLSX from "xlsx";

test("verify Excel export", async ({ page }, testInfo) => {
  await page.goto("/reports");

  const downloadPromise = page.waitForEvent("download");
  await page.getByRole("button", { name: "Export Excel" }).click();
  const download = await downloadPromise;

  const path = testInfo.outputPath("report.xlsx");
  await download.saveAs(path);

  // Parse Excel
  const workbook = XLSX.readFile(path);
  const sheet = workbook.Sheets[workbook.SheetNames[0]];
  const data = XLSX.utils.sheet_to_json(sheet);

  expect(data).toHaveLength(10);
  expect(data[0]).toHaveProperty("Name");
  expect(data[0]).toHaveProperty("Email");
});
```

### Verify JSON Download

```typescript
test("verify JSON export", async ({ page }, testInfo) => {
  await page.goto("/api-data");

  const downloadPromise = page.waitForEvent("download");
  await page.getByRole("button", { name: "Export JSON" }).click();
  const download = await downloadPromise;

  const path = testInfo.outputPath("data.json");
  await download.saveAs(path);

  const content = JSON.parse(fs.readFileSync(path, "utf-8"));

  expect(content.users).toHaveLength(5);
  expect(content.exportDate).toBeDefined();
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                          | Problem                         | Solution                                      |
| ------------------------------------- | ------------------------------- | --------------------------------------------- |
| Not waiting for download              | Race condition, test fails      | Always use `waitForEvent("download")`         |
| Hardcoded download paths              | Conflicts in parallel runs      | Use `testInfo.outputPath()`                   |
| Skipping content verification         | Download might be empty/corrupt | Verify file content when possible             |
| Using `force: true` for hidden inputs | May not trigger proper events   | Use `setInputFiles` on hidden inputs directly |

## Related References

- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for download fixture patterns
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting download issues
testing-patterns/file-upload-download.md
# File Upload and Download Testing

> **When to use**: Testing file uploads (single, multiple, drag-and-drop), downloads (content verification, filename, type), upload progress indicators, or file type/size restrictions.

## Table of Contents

1. [Downloading Files](#downloading-files)
2. [Single File Upload](#single-file-upload)
3. [Multiple File Upload](#multiple-file-upload)
4. [Drag-and-Drop Zones](#drag-and-drop-zones)
5. [File Chooser Dialog](#file-chooser-dialog)
6. [Upload Progress and Cancellation](#upload-progress-and-cancellation)
7. [Retry After Failure](#retry-after-failure)
8. [File Type and Size Restrictions](#file-type-and-size-restrictions)
9. [Image Preview](#image-preview)
10. [Authenticated Downloads](#authenticated-downloads)
11. [Tips](#tips)

---

## Downloading Files

### Capturing Downloads and Verifying Content

```typescript
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';

test('verifies downloaded CSV content', async ({ page }) => {
  await page.goto('/exports');

  // Set up download listener BEFORE triggering the download
  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('link', { name: 'transactions.csv' }).click();

  const download = await downloadPromise;
  const savePath = path.join(__dirname, '../tmp', download.suggestedFilename());
  await download.saveAs(savePath);

  const content = fs.readFileSync(savePath, 'utf-8');
  expect(content).toContain('id,amount,date');
  expect(content).toContain('1001,250.00,2025-01-15');

  const rows = content.trim().split('\n');
  expect(rows.length).toBeGreaterThan(1);

  fs.unlinkSync(savePath);
});

test('reads download via stream without disk I/O', async ({ page }) => {
  await page.goto('/exports');

  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('link', { name: 'transactions.csv' }).click();

  const download = await downloadPromise;
  const readable = await download.createReadStream();
  const chunks: Buffer[] = [];

  for await (const chunk of readable!) {
    chunks.push(Buffer.from(chunk));
  }

  const content = Buffer.concat(chunks).toString('utf-8');
  expect(content).toContain('id,amount,date');
});
```

### Verifying Filename and Format

```typescript
test('export filename matches selected format', async ({ page }) => {
  await page.goto('/analytics');

  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Export PDF' }).click();

  const download = await downloadPromise;
  expect(download.suggestedFilename()).toMatch(/^analytics-\d{4}-\d{2}-\d{2}\.pdf$/);
});

test('format selector changes output extension', async ({ page }) => {
  await page.goto('/analytics');

  await page.getByLabel('Format').selectOption('csv');
  const csvDownload = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download' }).click();
  expect((await csvDownload).suggestedFilename()).toMatch(/\.csv$/);

  await page.getByLabel('Format').selectOption('xlsx');
  const xlsxDownload = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download' }).click();
  expect((await xlsxDownload).suggestedFilename()).toMatch(/\.xlsx$/);
});
```

### Checking Response Headers

```typescript
test('download response has correct MIME type', async ({ page }) => {
  await page.goto('/analytics');

  const responsePromise = page.waitForResponse('**/api/analytics/export**');
  const downloadPromise = page.waitForEvent('download');

  await page.getByRole('button', { name: 'Export PDF' }).click();

  const response = await responsePromise;
  expect(response.headers()['content-type']).toContain('application/pdf');
  expect(response.headers()['content-disposition']).toContain('attachment');

  await downloadPromise;
});
```

### Handling Download Failures

```typescript
test('shows error when download fails', async ({ page }) => {
  await page.route('**/api/analytics/export**', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Generation failed' }),
    });
  });

  await page.goto('/analytics');
  await page.getByRole('button', { name: 'Export PDF' }).click();

  await expect(page.getByRole('alert')).toContainText(/failed|error/i);
});
```

---

## Single File Upload

### From Fixture File

```typescript
import path from 'path';

test('uploads document from fixture', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/invoice.pdf'));

  await expect(page.getByText('invoice.pdf')).toBeVisible();

  await page.getByRole('button', { name: 'Upload' }).click();
  await expect(page.getByRole('alert')).toContainText('uploaded successfully');
  await expect(page.getByRole('link', { name: 'invoice.pdf' })).toBeVisible();
});
```

### From In-Memory Buffer

```typescript
test('uploads in-memory CSV', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles({
    name: 'contacts.csv',
    mimeType: 'text/csv',
    buffer: Buffer.from('name,email\nAlice,[email protected]\nBob,[email protected]'),
  });

  await expect(page.getByText('contacts.csv')).toBeVisible();
  await page.getByRole('button', { name: 'Upload' }).click();
  await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});
```

### Clearing Selection

```typescript
test('clears selected file', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles({
    name: 'draft.txt',
    mimeType: 'text/plain',
    buffer: Buffer.from('draft content'),
  });

  await expect(page.getByText('draft.txt')).toBeVisible();

  // Clear via API
  await fileInput.setInputFiles([]);
  await expect(page.getByText('draft.txt')).not.toBeVisible();
});
```

---

## Multiple File Upload

```typescript
test('uploads multiple files at once', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles([
    { name: 'doc1.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf1') },
    { name: 'doc2.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf2') },
    { name: 'doc3.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf3') },
  ]);

  await expect(page.getByText('doc1.pdf')).toBeVisible();
  await expect(page.getByText('doc2.pdf')).toBeVisible();
  await expect(page.getByText('doc3.pdf')).toBeVisible();
  await expect(page.getByText('3 files selected')).toBeVisible();

  await page.getByRole('button', { name: 'Upload all' }).click();
  await expect(page.getByRole('alert')).toContainText('3 files uploaded');
});

test('removes one file from selection', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles([
    { name: 'keep.txt', mimeType: 'text/plain', buffer: Buffer.from('keep') },
    { name: 'discard.txt', mimeType: 'text/plain', buffer: Buffer.from('discard') },
  ]);

  const discardRow = page.getByText('discard.txt').locator('..');
  await discardRow.getByRole('button', { name: /remove|delete|×/i }).click();

  await expect(page.getByText('discard.txt')).not.toBeVisible();
  await expect(page.getByText('keep.txt')).toBeVisible();
});
```

---

## Drag-and-Drop Zones

Drop zones always have an underlying `input[type="file"]`—target it directly instead of simulating OS-level drag events.

```typescript
test('uploads via drop zone', async ({ page }) => {
  await page.goto('/attachments');

  const dropZone = page.locator('[data-testid="drop-zone"]');
  await expect(dropZone).toContainText(/drag.*here|drop.*files/i);

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles({
    name: 'dropped.pdf',
    mimeType: 'application/pdf',
    buffer: Buffer.from('pdf-content'),
  });

  await expect(dropZone.getByText('dropped.pdf')).toBeVisible();
  await page.getByRole('button', { name: 'Upload' }).click();
  await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});

test('shows visual feedback on drag-over', async ({ page }) => {
  await page.goto('/attachments');

  const dropZone = page.locator('[data-testid="drop-zone"]');

  await dropZone.dispatchEvent('dragenter', {
    dataTransfer: { types: ['Files'], files: [] },
  });

  await expect(dropZone).toHaveClass(/active|highlight|drag-over/);
  await expect(dropZone).toContainText(/release|drop now/i);

  await dropZone.dispatchEvent('dragleave');
  await expect(dropZone).not.toHaveClass(/active|highlight|drag-over/);
});
```

---

## File Chooser Dialog

```typescript
test('uploads via native file chooser', async ({ page }) => {
  await page.goto('/attachments');

  const fileChooserPromise = page.waitForEvent('filechooser');
  await page.getByRole('button', { name: 'Choose file' }).click();

  const fileChooser = await fileChooserPromise;
  expect(fileChooser.isMultiple()).toBe(false);

  await fileChooser.setFiles({
    name: 'selected.pdf',
    mimeType: 'application/pdf',
    buffer: Buffer.from('pdf-content'),
  });

  await expect(page.getByText('selected.pdf')).toBeVisible();
});
```

---

## Upload Progress and Cancellation

```typescript
test('displays upload progress for large file', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');

  await fileInput.setInputFiles({
    name: 'dataset.bin',
    mimeType: 'application/octet-stream',
    buffer: largeBuffer,
  });

  await page.getByRole('button', { name: 'Upload' }).click();

  const progressBar = page.getByRole('progressbar');
  await expect(progressBar).toBeVisible();

  await expect(async () => {
    const value = await progressBar.getAttribute('aria-valuenow');
    expect(Number(value)).toBeGreaterThan(0);
  }).toPass({ timeout: 10000 });

  await expect(progressBar).not.toBeVisible({ timeout: 60000 });
  await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});

test('cancels in-progress upload', async ({ page }) => {
  await page.route('**/api/attachments/upload', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 10000));
    await route.continue();
  });

  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles({
    name: 'large.bin',
    mimeType: 'application/octet-stream',
    buffer: Buffer.alloc(5 * 1024 * 1024, 'x'),
  });

  await page.getByRole('button', { name: 'Upload' }).click();
  await expect(page.getByRole('progressbar')).toBeVisible();

  await page.getByRole('button', { name: 'Cancel upload' }).click();

  await expect(page.getByRole('progressbar')).not.toBeVisible();
  await expect(page.getByText(/cancelled|aborted/i)).toBeVisible();
  await expect(page.getByRole('link', { name: 'large.bin' })).not.toBeVisible();
});
```

---

## Retry After Failure

```typescript
test('retries failed upload', async ({ page }) => {
  let attempt = 0;

  await page.route('**/api/attachments/upload', async (route) => {
    attempt++;
    if (attempt === 1) {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Server error' }),
      });
    } else {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ id: 'abc', name: 'data.csv' }),
      });
    }
  });

  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles({
    name: 'data.csv',
    mimeType: 'text/csv',
    buffer: Buffer.from('col1,col2\nval1,val2'),
  });

  await page.getByRole('button', { name: 'Upload' }).click();
  await expect(page.getByText(/upload failed|error/i)).toBeVisible();

  await page.getByRole('button', { name: /retry/i }).click();
  await expect(page.getByRole('alert')).toContainText('uploaded successfully');
  expect(attempt).toBe(2);
});
```

---

## File Type and Size Restrictions

### Validating Allowed Types

```typescript
test('accepts allowed file types', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  await expect(fileInput).toHaveAttribute('accept', /\.pdf|\.doc|\.docx|\.txt/);

  await fileInput.setInputFiles({
    name: 'report.pdf',
    mimeType: 'application/pdf',
    buffer: Buffer.from('pdf-content'),
  });

  await expect(page.getByText('report.pdf')).toBeVisible();
  await expect(page.getByText(/not allowed|invalid/i)).not.toBeVisible();
});

test('rejects disallowed file types', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  // setInputFiles bypasses the accept attribute—tests JavaScript validation
  await fileInput.setInputFiles({
    name: 'malware.exe',
    mimeType: 'application/x-msdownload',
    buffer: Buffer.from('exe-content'),
  });

  await expect(page.getByRole('alert')).toContainText(
    /not allowed|unsupported file type|only .pdf, .doc/i
  );
  await expect(page.getByText('malware.exe')).not.toBeVisible();
});
```

### Enforcing Size Limits

```typescript
test('rejects oversized file', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  const oversizedBuffer = Buffer.alloc(11 * 1024 * 1024, 'x');

  await fileInput.setInputFiles({
    name: 'huge.pdf',
    mimeType: 'application/pdf',
    buffer: oversizedBuffer,
  });

  await expect(page.getByRole('alert')).toContainText(/file.*too large|exceeds.*10 ?MB/i);
  await expect(page.getByText('huge.pdf')).not.toBeVisible();
});
```

### Enforcing File Count Limits

```typescript
test('rejects too many files', async ({ page }) => {
  await page.goto('/attachments');

  const fileInput = page.locator('input[type="file"]');
  const files = Array.from({ length: 6 }, (_, i) => ({
    name: `file-${i + 1}.txt`,
    mimeType: 'text/plain' as const,
    buffer: Buffer.from(`content ${i + 1}`),
  }));

  await fileInput.setInputFiles(files);

  await expect(page.getByRole('alert')).toContainText(/maximum.*5 files|too many files/i);
});
```

### Validating Image Dimensions

```typescript
test('rejects image below minimum dimensions', async ({ page }) => {
  await page.goto('/profile/avatar');

  const fileInput = page.locator('input[type="file"]');
  // Minimal 1x1 PNG
  const tinyPng = Buffer.from(
    'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
    'base64'
  );

  await fileInput.setInputFiles({
    name: 'tiny.png',
    mimeType: 'image/png',
    buffer: tinyPng,
  });

  await expect(page.getByRole('alert')).toContainText(/minimum.*dimensions|too small/i);
});
```

---

## Image Preview

```typescript
test('shows image preview after selection', async ({ page }) => {
  await page.goto('/profile/avatar');

  const fileInput = page.locator('input[type="file"]');
  await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/photo.jpg'));

  const preview = page.getByRole('img', { name: /preview|avatar/i });
  await expect(preview).toBeVisible();

  const src = await preview.getAttribute('src');
  expect(src).toMatch(/^(blob:|data:image)/);
});
```

---

## Authenticated Downloads

```typescript
test('downloads file requiring authentication', async ({ page, request }) => {
  await page.goto('/attachments');

  // Browser download works because cookies are sent
  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('link', { name: 'confidential.pdf' }).click();

  const download = await downloadPromise;
  expect(download.suggestedFilename()).toBe('confidential.pdf');

  // Verify via API request (carries auth context)
  const response = await request.get('/api/attachments/456/download');
  expect(response.ok()).toBeTruthy();
  expect(response.headers()['content-type']).toContain('application/pdf');
});
```

---

## Tips

1. **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.

2. **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).

3. **Set up download listener before clicking**. Call `page.waitForEvent('download')` before the click that triggers the download—otherwise you may miss the event.

4. **Use `createReadStream()` for content verification**. Reading directly from the stream avoids disk I/O and cleanup of temporary files.

5. **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.
testing-patterns/forms-validation.md
# Form Testing Patterns

## Table of Contents

1. [Quick Reference](#quick-reference)
2. [Patterns](#patterns)
3. [Decision Guide](#decision-guide)
4. [Anti-Patterns](#anti-patterns)
5. [Troubleshooting](#troubleshooting)

> **When to use**: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.

## Quick Reference

```typescript
// Text input
await page.getByLabel("Username").fill("john_doe");

// Select dropdown
await page.getByLabel("Region").selectOption("EU");
await page.getByLabel("Region").selectOption({ label: "Europe" });

// Checkbox and radio
await page.getByLabel("Subscribe").check();
await page.getByLabel("Priority shipping").click();

// Date input
await page.getByLabel("Departure").fill("2025-08-20");

// Clear a field
await page.getByLabel("Username").clear();

// Submit
await page.getByRole("button", { name: "Register" }).click();

// Verify validation error
await expect(page.getByText("Username is required")).toBeVisible();
```

## Patterns

### Auto-Complete and Typeahead Fields

**Use when**: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.

```typescript
test("select from typeahead suggestions", async ({ page }) => {
  await page.goto("/products");

  const searchBox = page.getByRole("combobox", { name: "Find product" });
  await searchBox.pressSequentially("lapt", { delay: 100 });

  const suggestionList = page.getByRole("listbox");
  await expect(suggestionList).toBeVisible();

  await suggestionList.getByRole("option", { name: "Laptop Pro" }).click();
  await expect(searchBox).toHaveValue("Laptop Pro");
});

test("typeahead with API-driven suggestions", async ({ page }) => {
  await page.goto("/shipping");

  const streetField = page.getByLabel("Street");
  const responsePromise = page.waitForResponse("**/api/address-lookup*");
  await streetField.pressSequentially("456 Elm", { delay: 50 });

  await responsePromise;

  await page.getByRole("option", { name: /456 Elm St/ }).click();

  await expect(page.getByLabel("Town")).toHaveValue("Austin");
  await expect(page.getByLabel("State")).toHaveValue("TX");
  await expect(page.getByLabel("Postal code")).toHaveValue("78701");
});

test("dismiss suggestions and enter custom value", async ({ page }) => {
  await page.goto("/labels");

  const labelInput = page.getByLabel("New label");
  await labelInput.pressSequentially("my-label");

  await labelInput.press("Escape");
  await expect(page.getByRole("listbox")).not.toBeVisible();

  await labelInput.press("Enter");
  await expect(page.getByText("my-label")).toBeVisible();
});
```

### Dynamic Forms — Conditional Fields

**Use when**: Form fields appear, disappear, or change based on the value of other fields.

```typescript
test("conditional fields appear based on selection", async ({ page }) => {
  await page.goto("/loan/apply");

  await page.getByLabel("Applicant type").selectOption("corporate");

  await expect(page.getByLabel("Business name")).toBeVisible();
  await expect(page.getByLabel("EIN")).toBeVisible();

  await page.getByLabel("Business name").fill("TechCorp Inc");
  await page.getByLabel("EIN").fill("98-7654321");

  await page.getByLabel("Applicant type").selectOption("individual");
  await expect(page.getByLabel("Business name")).not.toBeVisible();
  await expect(page.getByLabel("EIN")).not.toBeVisible();
});

test("checkbox toggles additional section", async ({ page }) => {
  await page.goto("/delivery");

  await page.getByLabel("Separate invoice address").check();

  const invoiceSection = page.getByRole("group", { name: "Invoice address" });
  await expect(invoiceSection).toBeVisible();

  await invoiceSection.getByLabel("Address").fill("789 Pine Rd");
  await invoiceSection.getByLabel("City").fill("Denver");

  await page.getByLabel("Separate invoice address").uncheck();
  await expect(invoiceSection).not.toBeVisible();
});

test("dependent dropdown chains", async ({ page }) => {
  await page.goto("/region-selector");

  await page.getByLabel("Country").selectOption("CA");

  const provinceDropdown = page.getByLabel("Province");
  await expect(provinceDropdown.getByRole("option")).not.toHaveCount(0);

  await provinceDropdown.selectOption("ON");

  const cityDropdown = page.getByLabel("City");
  await expect(cityDropdown.getByRole("option")).not.toHaveCount(0);

  await cityDropdown.selectOption({ label: "Toronto" });
});
```

### Multi-Step Forms and Wizards

**Use when**: The form spans multiple pages or steps, with next/previous navigation and per-step validation.

```typescript
test("complete a multi-step booking wizard", async ({ page }) => {
  await page.goto("/booking");

  await test.step("enter guest information", async () => {
    await expect(
      page.getByRole("heading", { name: "Guest Info" }),
    ).toBeVisible();

    await page.getByLabel("Full name").fill("Alice Smith");
    await page.getByLabel("Email").fill("[email protected]");
    await page.getByLabel("Phone").fill("555-1234");

    await page.getByRole("button", { name: "Next" }).click();
  });

  await test.step("select room options", async () => {
    await expect(
      page.getByRole("heading", { name: "Room Selection" }),
    ).toBeVisible();

    await page.getByLabel("Room type").selectOption("suite");
    await page.getByLabel("Check-in").fill("2025-09-01");
    await page.getByLabel("Check-out").fill("2025-09-05");

    await page.getByRole("button", { name: "Next" }).click();
  });

  await test.step("confirm booking", async () => {
    await expect(
      page.getByRole("heading", { name: "Confirmation" }),
    ).toBeVisible();

    await expect(page.getByText("Alice Smith")).toBeVisible();
    await expect(page.getByText("suite")).toBeVisible();

    await page.getByRole("button", { name: "Confirm booking" }).click();
  });

  await expect(
    page.getByRole("heading", { name: "Booking complete" }),
  ).toBeVisible();
});

test("wizard validates each step before proceeding", async ({ page }) => {
  await page.goto("/booking");

  await page.getByRole("button", { name: "Next" }).click();

  await expect(page.getByRole("heading", { name: "Guest Info" })).toBeVisible();
  await expect(page.getByText("Full name is required")).toBeVisible();
});

test("wizard supports going back without losing data", async ({ page }) => {
  await page.goto("/booking");

  await page.getByLabel("Full name").fill("Alice Smith");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Phone").fill("555-1234");
  await page.getByRole("button", { name: "Next" }).click();

  await page.getByRole("button", { name: "Previous" }).click();

  await expect(page.getByLabel("Full name")).toHaveValue("Alice Smith");
  await expect(page.getByLabel("Email")).toHaveValue("[email protected]");
});
```

### Form Submission and Response Handling

**Use when**: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission.

```typescript
test("successful form submission shows confirmation", async ({ page }) => {
  await page.goto("/feedback");

  await page.getByLabel("Subject").fill("Feature request");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Details").fill("Please add dark mode");

  const responsePromise = page.waitForResponse("**/api/feedback");
  await page.getByRole("button", { name: "Submit feedback" }).click();
  const response = await responsePromise;

  expect(response.status()).toBe(200);
  await expect(page.getByText("Feedback received")).toBeVisible();
});

test("form submission shows server-side validation errors", async ({
  page,
}) => {
  await page.goto("/signup");

  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Password", { exact: true }).fill("Secure1@pass");
  await page.getByRole("button", { name: "Sign up" }).click();

  await expect(
    page.getByText("Email address already registered"),
  ).toBeVisible();
});

test("form shows loading state during submission", async ({ page }) => {
  await page.goto("/feedback");

  await page.getByLabel("Subject").fill("Bug report");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Details").fill("Found an issue");

  const submit = page.getByRole("button", {
    name: /Submit feedback|Submitting/,
  });
  await submit.click();

  await expect(submit).toHaveText(/Submitting/);
  await expect(submit).toBeDisabled();

  await expect(submit).toHaveText("Submit feedback");
  await expect(submit).toBeEnabled();
});

test("form redirects after successful submission", async ({ page }) => {
  await page.goto("/auth/login");

  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Password").fill("admin123");
  await page.getByRole("button", { name: "Log in" }).click();

  await page.waitForURL("/home");
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});
```

### Filling Basic Form Fields

**Use when**: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.

```typescript
test("fill and submit a signup form", async ({ page }) => {
  await page.goto("/signup");

  await page.getByLabel("First name").fill("Bob");
  await page.getByLabel("Last name").fill("Wilson");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Password", { exact: true }).fill("P@ssw0rd!");
  await page.getByLabel("Confirm password").fill("P@ssw0rd!");

  await page.getByLabel("About you").fill("Developer with 5 years experience.");
  await page.getByLabel("Years of experience").fill("5");

  await page.getByLabel("Country").selectOption("UK");
  await page.getByLabel("City").selectOption({ label: "London" });
  await page
    .getByLabel("Skills")
    .selectOption(["typescript", "playwright", "nodejs"]);

  await page.getByLabel("Accept terms").check();
  await expect(page.getByLabel("Accept terms")).toBeChecked();

  await page.getByLabel("Annual billing").check();
  await expect(page.getByLabel("Annual billing")).toBeChecked();

  await page.getByRole("button", { name: "Create account" }).click();
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});
```

### Date and Time Inputs

**Use when**: Testing native `<input type="date">`, `<input type="time">`, `<input type="datetime-local">`, or third-party date pickers.

```typescript
test("fill native date and time inputs", async ({ page }) => {
  await page.goto("/reservation");

  await page.getByLabel("Reservation date").fill("2025-07-10");
  await expect(page.getByLabel("Reservation date")).toHaveValue("2025-07-10");

  await page.getByLabel("Time slot").fill("18:00");
  await page.getByLabel("Reminder").fill("2025-07-10T17:30");
});

test("interact with a third-party date picker", async ({ page }) => {
  await page.goto("/reservation");

  await page.getByLabel("Event date").click();
  await page.getByRole("button", { name: "Next month" }).click();
  await page.getByRole("gridcell", { name: "25" }).click();

  await expect(page.getByLabel("Event date")).toHaveValue(/2025/);
});
```

### Required Field Validation

**Use when**: Testing that the form shows appropriate error messages when required fields are empty.

```typescript
test("shows validation errors for empty required fields", async ({ page }) => {
  await page.goto("/inquiry");

  await page.getByRole("button", { name: "Send inquiry" }).click();

  await expect(page.getByText("Name is required")).toBeVisible();
  await expect(page.getByText("Email is required")).toBeVisible();
  await expect(page.getByText("Question is required")).toBeVisible();

  await expect(page).toHaveURL(/\/inquiry/);
});

test("clears validation errors when fields are filled", async ({ page }) => {
  await page.goto("/inquiry");

  await page.getByRole("button", { name: "Send inquiry" }).click();
  await expect(page.getByText("Name is required")).toBeVisible();

  await page.getByLabel("Name").fill("Carol Brown");
  await page.getByLabel("Email").focus();

  await expect(page.getByText("Name is required")).not.toBeVisible();
});

test("native HTML5 validation with required attribute", async ({ page }) => {
  await page.goto("/basic-form");

  await page.getByRole("button", { name: "Submit" }).click();

  const emailInput = page.getByLabel("Email");
  const validationMessage = await emailInput.evaluate(
    (el: HTMLInputElement) => el.validationMessage,
  );
  expect(validationMessage).toBeTruthy();
});
```

### Format Validation and Custom Rules

**Use when**: Testing email format, phone number format, password strength, and business-specific validation rules.

```typescript
test("validates email format", async ({ page }) => {
  await page.goto("/signup");

  const emailField = page.getByLabel("Email");

  const invalidEmails = [
    "invalid",
    "missing@",
    "@nodomain.com",
    "has [email protected]",
  ];

  for (const email of invalidEmails) {
    await emailField.fill(email);
    await emailField.blur();
    await expect(page.getByText("Enter a valid email address")).toBeVisible();
  }

  await emailField.fill("[email protected]");
  await emailField.blur();
  await expect(page.getByText("Enter a valid email address")).not.toBeVisible();
});

test("validates password strength rules", async ({ page }) => {
  await page.goto("/signup");

  const passwordField = page.getByLabel("Password", { exact: true });

  await passwordField.fill("Xy1!");
  await passwordField.blur();
  await expect(page.getByText("Minimum 8 characters")).toBeVisible();

  await passwordField.fill("lowercase1!");
  await passwordField.blur();
  await expect(page.getByText("Include an uppercase letter")).toBeVisible();

  await passwordField.fill("SecureP@ss1");
  await passwordField.blur();
  await expect(page.getByText(/Minimum|Include/)).not.toBeVisible();
});

test("validates custom business rule — minimum amount", async ({ page }) => {
  await page.goto("/transfer");

  await page.getByLabel("Amount").fill("5");
  await page.getByLabel("Amount").blur();
  await expect(page.getByText("Minimum transfer is $10")).toBeVisible();

  await page.getByLabel("Amount").fill("1000000");
  await page.getByLabel("Amount").blur();
  await expect(page.getByText("Maximum transfer is $100,000")).toBeVisible();

  await page.getByLabel("Amount").fill("500");
  await page.getByLabel("Amount").blur();
  await expect(page.getByText(/Minimum|Maximum/)).not.toBeVisible();
});
```

### Form Reset Testing

**Use when**: Testing "clear form" or "reset" functionality, verifying that fields return to their default values.

```typescript
test("reset button clears all fields to defaults", async ({ page }) => {
  await page.goto("/preferences");

  await page.getByLabel("Nickname").fill("CustomNick");
  await page.getByLabel("Language").selectOption("es");
  await page.getByLabel("Email alerts").uncheck();

  await page.getByRole("button", { name: "Reset" }).click();

  await expect(page.getByLabel("Nickname")).toHaveValue("");
  await expect(page.getByLabel("Language")).toHaveValue("en");
  await expect(page.getByLabel("Email alerts")).toBeChecked();
});

test("confirmation dialog before resetting a dirty form", async ({ page }) => {
  await page.goto("/document");

  await page.getByLabel("Document title").fill("Draft document");

  page.on("dialog", (dialog) => dialog.accept());
  await page.getByRole("button", { name: "Clear changes" }).click();

  await expect(page.getByLabel("Document title")).toHaveValue("");
});
```

## Decision Guide

| Scenario                             | Approach                                               | Key API                                                      |
| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |
| Standard text input                  | `fill()` (clears, then types)                          | `page.getByLabel('Field').fill('value')`                     |
| Need keystroke events (autocomplete) | `pressSequentially()` with delay                       | `locator.pressSequentially('text', { delay: 100 })`          |
| Native `<select>` dropdown           | `selectOption()` by value or label                     | `locator.selectOption('US')` or `{ label: 'United States' }` |
| Custom dropdown (ARIA listbox)       | Click trigger, then select option role                 | `getByRole('option', { name: '...' }).click()`               |
| Checkbox                             | `check()` / `uncheck()` (idempotent)                   | `locator.check()` — safe to call even if already checked     |
| Radio button                         | `check()` on the target radio                          | `page.getByLabel('Option').check()`                          |
| Date input (native)                  | `fill()` with ISO format                               | `locator.fill('2025-03-15')`                                 |
| Date picker (third-party)            | Click to open, navigate, select day                    | `getByRole('gridcell', { name: '15' }).click()`              |
| Validation errors                    | Submit, then assert error text                         | `expect(page.getByText('Required')).toBeVisible()`           |
| Multi-step wizard                    | `test.step()` per step, assert heading                 | `await test.step('Step 1', async () => { ... })`             |
| Conditional/dynamic fields           | Change trigger field, assert new field visibility      | `expect(locator).toBeVisible()` / `.not.toBeVisible()`       |
| Form submission                      | `waitForResponse` + click submit                       | Register response listener before click                      |
| Auto-complete                        | `pressSequentially()`, wait for listbox, select option | `getByRole('option', { name }).click()`                      |
| Form reset                           | Click reset, assert default values                     | `expect(locator).toHaveValue('')`                            |

## Anti-Patterns

| Don't Do This                                           | Problem                                                    | Do This Instead                                        |
| ------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
| `await page.getByLabel('Field').type('value')`          | `type()` appends to existing content; does not clear first | `await page.getByLabel('Field').fill('value')`         |
| `await page.getByLabel('Option').click()`               | `click()` toggles — if already checked, it unchecks        | `await page.getByLabel('Option').check()`              |
| `await page.fill('#email', '[email protected]')`            | CSS selector is fragile                                    | `await page.getByLabel('Email').fill('[email protected]')` |
| `await page.selectOption('select', 'US')` without label | Targets first `<select>` on page; ambiguous                | `await page.getByLabel('Country').selectOption('US')`  |
| Testing every invalid input in one test                 | Test becomes huge, slow, and hard to debug                 | One test per validation rule or group related rules    |
| `expect(await input.inputValue()).toBe('value')`        | Resolves once — no retry. Race condition.                  | `await expect(input).toHaveValue('value')`             |
| Filling fields with `page.evaluate()`                   | Bypasses event handlers (no `input`, `change` events fire) | Use `fill()` or `pressSequentially()`                  |
| Not waiting for conditional fields before filling       | `fill()` fails on hidden/detached elements                 | `await expect(field).toBeVisible()` first              |
| Hardcoding wait after selecting a dropdown              | `waitForTimeout(500)` is flaky and slow                    | Wait for the dependent element to appear               |
| Skipping server-side validation tests                   | Client-side validation can be bypassed                     | Test both client-side UX and server response           |

## Troubleshooting

### `fill()` does nothing or clears but doesn't type

**Cause**: The input field uses a contenteditable div (rich text editors), not a real `<input>` or `<textarea>`.

```typescript
const isContentEditable = await page
  .getByTestId("editor")
  .evaluate((el) => el.getAttribute("contenteditable"));

if (isContentEditable) {
  await page.getByTestId("editor").click();
  await page.getByTestId("editor").pressSequentially("Hello world");
}
```

### Date picker does not accept `fill()` value

**Cause**: Third-party date pickers often render custom UI over a hidden input. `fill()` sets the hidden input but the UI does not update.

```typescript
await page.getByLabel("Date").click();
await page.getByRole("button", { name: "Next month" }).click();
await page.getByRole("gridcell", { name: "15" }).click();

// Alternatively, if the library reads from the input on change:
await page.getByLabel("Date").fill("2025-06-15");
await page.getByLabel("Date").dispatchEvent("change");
```

### `selectOption()` throws "not a select element"

**Cause**: The dropdown is a custom component (ARIA listbox), not a native `<select>`.

```typescript
await page.getByRole("combobox", { name: "Country" }).click();
await page.getByRole("option", { name: "United States" }).click();
```

### Validation errors do not appear after `fill()` and submit

**Cause**: The validation triggers on `blur` (focus leaving the field), but `fill()` does not trigger blur automatically.

```typescript
await page.getByLabel("Email").fill("invalid");
await page.getByLabel("Email").blur();
await expect(page.getByText("Enter a valid email")).toBeVisible();

// Or move focus to the next field
await page.getByLabel("Password").focus();
```
testing-patterns/graphql-testing.md
# GraphQL Testing

## Table of Contents

1. [Patterns](#patterns)
2. [Anti-Patterns](#anti-patterns)
3. [Troubleshooting](#troubleshooting)

> **When to use**: Testing GraphQL APIs — queries, mutations, variables, and error handling.

## Patterns

### Basic Query with Variables

All GraphQL requests go through `POST` to a single endpoint. Send `query`, `variables`, and optionally `operationName` in the JSON body.

```typescript
import { test, expect } from "@playwright/test";

const GQL_ENDPOINT = "/graphql";

test("query with variables", async ({ request }) => {
  const resp = await request.post(GQL_ENDPOINT, {
    data: {
      query: `
        query FetchItem($id: ID!) {
          item(id: $id) {
            id
            title
            price
            reviews { id rating }
          }
        }
      `,
      variables: { id: "101" },
    },
  });

  expect(resp.ok()).toBeTruthy();
  const { data, errors } = await resp.json();

  // GraphQL returns 200 even on errors — always check both
  expect(errors).toBeUndefined();
  expect(data.item).toMatchObject({
    id: "101",
    title: expect.any(String),
    price: expect.any(Number),
  });
  expect(data.item.reviews).toEqual(
    expect.arrayContaining([
      expect.objectContaining({
        id: expect.any(String),
        rating: expect.any(Number),
      }),
    ])
  );
});
```

### Mutations

```typescript
import { test, expect } from "@playwright/test";

const GQL_ENDPOINT = "/graphql";

test("mutation creates resource", async ({ request }) => {
  const resp = await request.post(GQL_ENDPOINT, {
    data: {
      query: `
        mutation AddItem($input: ItemInput!) {
          addItem(input: $input) {
            id
            title
            status
          }
        }
      `,
      variables: {
        input: {
          title: "New Widget",
          price: 15.0,
          status: "DRAFT",
        },
      },
    },
  });

  const { data, errors } = await resp.json();
  expect(errors).toBeUndefined();
  expect(data.addItem).toMatchObject({
    id: expect.any(String),
    title: "New Widget",
    status: "DRAFT",
  });
});
```

### Validation Errors

```typescript
import { test, expect } from "@playwright/test";

const GQL_ENDPOINT = "/graphql";

test("handles validation errors", async ({ request }) => {
  const resp = await request.post(GQL_ENDPOINT, {
    data: {
      query: `
        mutation AddItem($input: ItemInput!) {
          addItem(input: $input) { id }
        }
      `,
      variables: { input: { title: "" } },
    },
  });

  const { data, errors } = await resp.json();
  expect(errors).toBeDefined();
  expect(errors.length).toBeGreaterThan(0);
  expect(errors[0].message).toContain("title");
  expect(errors[0].extensions?.code).toBe("BAD_USER_INPUT");
});
```

### Authorization Errors

```typescript
import { test, expect } from "@playwright/test";

const GQL_ENDPOINT = "/graphql";

test("handles authorization errors", async ({ request }) => {
  const resp = await request.post(GQL_ENDPOINT, {
    data: {
      query: `
        query AdminDashboard {
          adminMetrics { revenue activeUsers }
        }
      `,
    },
  });

  const { data, errors } = await resp.json();
  expect(errors).toBeDefined();
  expect(errors[0].extensions?.code).toBe("UNAUTHORIZED");
  expect(data?.adminMetrics).toBeNull();
});
```

### Authenticated GraphQL Fixture

```typescript
// fixtures/graphql-fixtures.ts
import { test as base, expect, APIRequestContext } from "@playwright/test";

type GraphQLFixtures = {
  gqlClient: APIRequestContext;
  adminGqlClient: APIRequestContext;
};

export const test = base.extend<GraphQLFixtures>({
  gqlClient: async ({ playwright }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL: "https://api.myapp.io",
      extraHTTPHeaders: {
        Authorization: `Bearer ${process.env.API_TOKEN}`,
        "Content-Type": "application/json",
      },
    });
    await use(ctx);
    await ctx.dispose();
  },

  adminGqlClient: async ({ playwright }, use) => {
    const loginCtx = await playwright.request.newContext({
      baseURL: "https://api.myapp.io",
    });
    const loginResp = await loginCtx.post("/graphql", {
      data: {
        query: `
          mutation Login($email: String!, $password: String!) {
            login(email: $email, password: $password) { token }
          }
        `,
        variables: {
          email: process.env.ADMIN_EMAIL,
          password: process.env.ADMIN_PASSWORD,
        },
      },
    });
    const { data } = await loginResp.json();
    
    if (!data?.login?.token) {
      throw new Error(`Admin login failed: status ${loginResp.status()}, response: ${JSON.stringify(data)}`);
    }
    
    await loginCtx.dispose();

    const ctx = await playwright.request.newContext({
      baseURL: "https://api.myapp.io",
      extraHTTPHeaders: {
        Authorization: `Bearer ${data.login.token}`,
        "Content-Type": "application/json",
      },
    });
    await use(ctx);
    await ctx.dispose();
  },
});

export { expect };
```

### GraphQL Helper Function

```typescript
// utils/graphql.ts
import { APIRequestContext, expect } from "@playwright/test";

export async function gqlQuery<T = any>(
  request: APIRequestContext,
  query: string,
  variables?: Record<string, any>
): Promise<{ data: T; errors?: any[] }> {
  const resp = await request.post("/graphql", {
    data: { query, variables },
  });
  expect(resp.ok()).toBeTruthy();
  return resp.json();
}

export async function gqlMutation<T = any>(
  request: APIRequestContext,
  mutation: string,
  variables?: Record<string, any>
): Promise<{ data: T; errors?: any[] }> {
  return gqlQuery<T>(request, mutation, variables);
}
```

```typescript
// tests/api/items.spec.ts
import { test, expect } from "@playwright/test";
import { gqlQuery, gqlMutation } from "../../utils/graphql";

test("fetch and update item", async ({ request }) => {
  const { data: fetchData } = await gqlQuery(
    request,
    `query GetItem($id: ID!) { item(id: $id) { id title } }`,
    { id: "101" }
  );
  expect(fetchData.item.title).toBeDefined();

  const { data: updateData, errors } = await gqlMutation(
    request,
    `mutation UpdateItem($id: ID!, $title: String!) {
      updateItem(id: $id, title: $title) { id title }
    }`,
    { id: "101", title: "Updated Title" }
  );
  expect(errors).toBeUndefined();
  expect(updateData.updateItem.title).toBe("Updated Title");
});
```

## Anti-Patterns

| Don't Do This | Problem | Do This Instead |
| --- | --- | --- |
| 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 |
| Ignore `errors` array | Validation and auth errors appear in `errors`, not HTTP status | Destructure and assert: `expect(errors).toBeUndefined()` |
| Hardcode query strings inline everywhere | Duplicated queries are hard to maintain | Extract queries to constants or use a helper function |
| Skip variable validation | Invalid variables cause cryptic server errors | Validate input shape before sending |

## Troubleshooting

### GraphQL returns 200 but data is null

**Cause**: GraphQL servers return HTTP 200 even when the query has errors. The actual error is in the `errors` array.

**Fix**: Always destructure and check both `data` and `errors`.

```typescript
const { data, errors } = await resp.json();
if (errors) {
  console.error("GraphQL errors:", JSON.stringify(errors, null, 2));
}
expect(errors).toBeUndefined();
expect(data.item).toBeDefined();
```

### "Cannot query field X on type Y"

**Cause**: The field doesn't exist in the schema, or you're querying the wrong type.

**Fix**: Verify the schema. Use introspection or check your GraphQL IDE for available fields.

```typescript
// Introspection query to debug schema
const { data } = await request.post("/graphql", {
  data: {
    query: `{ __type(name: "Item") { fields { name type { name } } } }`,
  },
});
console.log(data.__type.fields);
```

### Variables not being applied

**Cause**: Variable names in the query don't match the `variables` object keys, or types don't match.

**Fix**: Ensure variable names match exactly (case-sensitive) and types align with the schema.

```typescript
// Wrong: variable name mismatch
const resp = await request.post("/graphql", {
  data: {
    query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,
    variables: { id: "101" }, // Should be { itemId: "101" }
  },
});

// Correct
const resp = await request.post("/graphql", {
  data: {
    query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,
    variables: { itemId: "101" },
  },
});
```
testing-patterns/i18n.md
# Internationalization (i18n) Testing

## Table of Contents

1. [Locale Configuration](#locale-configuration)
2. [Testing Multiple Locales](#testing-multiple-locales)
3. [RTL Layout Testing](#rtl-layout-testing)
4. [Date, Time & Number Formats](#date-time--number-formats)
5. [Translation Verification](#translation-verification)
6. [Visual Regression for i18n](#visual-regression-for-i18n)

## Locale Configuration

### Setting Browser Locale

```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  projects: [
    {
      name: "english",
      use: {
        ...devices["Desktop Chrome"],
        locale: "en-US",
        timezoneId: "America/New_York",
      },
    },
    {
      name: "german",
      use: {
        ...devices["Desktop Chrome"],
        locale: "de-DE",
        timezoneId: "Europe/Berlin",
      },
    },
    {
      name: "japanese",
      use: {
        ...devices["Desktop Chrome"],
        locale: "ja-JP",
        timezoneId: "Asia/Tokyo",
      },
    },
    {
      name: "arabic",
      use: {
        ...devices["Desktop Chrome"],
        locale: "ar-SA",
        timezoneId: "Asia/Riyadh",
      },
    },
  ],
});
```

### Per-Test Locale Override

```typescript
test("test in French locale", async ({ browser }) => {
  const context = await browser.newContext({
    locale: "fr-FR",
    timezoneId: "Europe/Paris",
  });

  const page = await context.newPage();
  await page.goto("/");

  // Verify French content
  await expect(page.getByRole("button", { name: "Connexion" })).toBeVisible();

  await context.close();
});
```

### Accept-Language Header

```typescript
test("server-side locale detection", async ({ browser }) => {
  const context = await browser.newContext({
    locale: "es-ES",
    extraHTTPHeaders: {
      "Accept-Language": "es-ES,es;q=0.9,en;q=0.8",
    },
  });

  const page = await context.newPage();
  await page.goto("/");

  // Server should respond with Spanish content
  await expect(page.locator("html")).toHaveAttribute("lang", "es");
});
```

## Testing Multiple Locales

### Parameterized Locale Tests

```typescript
const locales = [
  { locale: "en-US", greeting: "Hello", button: "Sign In" },
  { locale: "de-DE", greeting: "Hallo", button: "Anmelden" },
  { locale: "fr-FR", greeting: "Bonjour", button: "Se connecter" },
  { locale: "ja-JP", greeting: "こんにちは", button: "ログイン" },
];

for (const { locale, greeting, button } of locales) {
  test(`login page in ${locale}`, async ({ browser }) => {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();

    await page.goto("/login");

    await expect(page.getByText(greeting)).toBeVisible();
    await expect(page.getByRole("button", { name: button })).toBeVisible();

    await context.close();
  });
}
```

### Locale Fixture

```typescript
// fixtures/i18n.ts
import { test as base } from "@playwright/test";

type LocaleFixtures = {
  localePage: (locale: string) => Promise<Page>;
};

export const test = base.extend<LocaleFixtures>({
  localePage: async ({ browser }, use) => {
    const pages: Page[] = [];

    const createLocalePage = async (locale: string) => {
      const context = await browser.newContext({ locale });
      const page = await context.newPage();
      pages.push(page);
      return page;
    };

    await use(createLocalePage);

    // Cleanup
    for (const page of pages) {
      await page.context().close();
    }
  },
});

// Usage
test("compare locales", async ({ localePage }) => {
  const enPage = await localePage("en-US");
  const dePage = await localePage("de-DE");

  await enPage.goto("/pricing");
  await dePage.goto("/pricing");

  const enPrice = await enPage.getByTestId("price").textContent();
  const dePrice = await dePage.getByTestId("price").textContent();

  expect(enPrice).toContain("$");
  expect(dePrice).toContain("€");
});
```

### Testing Locale Switching

```typescript
test("user can switch locale", async ({ page }) => {
  await page.goto("/");

  // Initial locale (from browser)
  await expect(page.locator("html")).toHaveAttribute("lang", "en");

  // Switch to German
  await page.getByRole("button", { name: "Language" }).click();
  await page.getByRole("menuitem", { name: "Deutsch" }).click();

  // Verify switch
  await expect(page.locator("html")).toHaveAttribute("lang", "de");
  await expect(page.getByRole("heading", { level: 1 })).toContainText(
    /Willkommen/,
  );

  // Verify persistence (reload)
  await page.reload();
  await expect(page.locator("html")).toHaveAttribute("lang", "de");
});
```

## RTL Layout Testing

### Setting Up RTL Tests

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: "rtl-arabic",
      use: {
        locale: "ar-SA",
        // RTL is usually set by the app based on locale
      },
    },
    {
      name: "rtl-hebrew",
      use: {
        locale: "he-IL",
      },
    },
  ],
});
```

### Verifying RTL Direction

```typescript
test("RTL layout is applied", async ({ page }) => {
  await page.goto("/");

  // Check document direction
  await expect(page.locator("html")).toHaveAttribute("dir", "rtl");

  // Or check computed style
  const direction = await page.evaluate(() => {
    return window.getComputedStyle(document.body).direction;
  });
  expect(direction).toBe("rtl");
});
```

### RTL-Specific Element Positioning

```typescript
test("sidebar is on the right in RTL", async ({ page }) => {
  await page.goto("/dashboard");

  const sidebar = page.getByTestId("sidebar");
  const main = page.getByTestId("main-content");

  const sidebarBox = await sidebar.boundingBox();
  const mainBox = await main.boundingBox();

  // In RTL, sidebar should be to the right of main content
  expect(sidebarBox!.x).toBeGreaterThan(mainBox!.x);
});
```

### RTL Visual Regression

```typescript
test("RTL layout matches snapshot", async ({ page }) => {
  await page.goto("/");

  // Screenshot for RTL comparison
  await expect(page).toHaveScreenshot("homepage-rtl.png", {
    // Separate snapshots per locale/direction
    fullPage: true,
  });
});

// LTR comparison
test("LTR layout matches snapshot", async ({ browser }) => {
  const context = await browser.newContext({ locale: "en-US" });
  const page = await context.newPage();

  await page.goto("/");
  await expect(page).toHaveScreenshot("homepage-ltr.png", { fullPage: true });
});
```

### Testing Bidirectional Text

```typescript
test("bidirectional text renders correctly", async ({ page }) => {
  await page.goto("/profile");

  // Mixed LTR/RTL content
  const nameField = page.getByTestId("full-name");

  // Arabic name with English email
  await expect(nameField).toContainText("محمد ([email protected])");

  // Verify text doesn't overlap or break
  const box = await nameField.boundingBox();
  expect(box!.width).toBeGreaterThan(100); // Content not collapsed
});
```

## Date, Time & Number Formats

### Testing Date Formats

```typescript
test("dates are formatted per locale", async ({ browser }) => {
  const testDate = new Date("2024-03-15");

  const formats = [
    { locale: "en-US", expected: "March 15, 2024" },
    { locale: "en-GB", expected: "15 March 2024" },
    { locale: "de-DE", expected: "15. März 2024" },
    { locale: "ja-JP", expected: "2024年3月15日" },
  ];

  for (const { locale, expected } of formats) {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();

    await page.goto(`/event?date=${testDate.toISOString()}`);

    const dateDisplay = page.getByTestId("event-date");
    await expect(dateDisplay).toContainText(expected);

    await context.close();
  }
});
```

### Testing Number Formats

```typescript
test("numbers are formatted per locale", async ({ browser }) => {
  const testNumber = 1234567.89;

  const formats = [
    { locale: "en-US", expected: "1,234,567.89" },
    { locale: "de-DE", expected: "1.234.567,89" },
    { locale: "fr-FR", expected: "1 234 567,89" },
  ];

  for (const { locale, expected } of formats) {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();

    await page.goto(`/stats?value=${testNumber}`);

    await expect(page.getByTestId("formatted-number")).toHaveText(expected);

    await context.close();
  }
});
```

### Testing Currency Formats

```typescript
test("currency displays correctly", async ({ browser }) => {
  const price = 99.99;

  const currencies = [
    { locale: "en-US", currency: "USD", expected: "$99.99" },
    { locale: "de-DE", currency: "EUR", expected: "99,99 €" },
    { locale: "ja-JP", currency: "JPY", expected: "¥100" }, // JPY has no decimals
    { locale: "en-GB", currency: "GBP", expected: "£99.99" },
  ];

  for (const { locale, currency, expected } of currencies) {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();

    await page.goto(`/product?price=${price}&currency=${currency}`);

    await expect(page.getByTestId("price")).toContainText(expected);

    await context.close();
  }
});
```

## Translation Verification

### Checking for Missing Translations

```typescript
test("no missing translations", async ({ page }) => {
  await page.goto("/");

  // Common patterns for missing translations
  const missingPatterns = [
    /\{\{.*\}\}/, // Handlebars-style
    /\$\{.*\}/, // Template literal style
    /t\(["'][\w.]+["']\)/, // i18n key exposed
    /MISSING_TRANSLATION/, // Common placeholder
    /\[UNTRANSLATED\]/, // Another placeholder
  ];

  const bodyText = await page.locator("body").textContent();

  for (const pattern of missingPatterns) {
    expect(bodyText).not.toMatch(pattern);
  }
});
```

### Detecting Text Overflow

```typescript
test("translations fit UI containers", async ({ browser }) => {
  const locales = ["en-US", "de-DE", "fr-FR", "es-ES"];
  const issues: string[] = [];

  for (const locale of locales) {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();
    await page.goto("/");

    const overflowing = await page.evaluate(() => {
      const elements = document.querySelectorAll("button, .label, h1, h2, h3");
      return Array.from(elements)
        .filter(
          (el) =>
            (el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth,
        )
        .map((el) => `${el.tagName}: "${el.textContent?.substring(0, 20)}..."`);
    });

    if (overflowing.length > 0)
      issues.push(`${locale}: ${overflowing.join(", ")}`);
    await context.close();
  }

  expect(issues).toEqual([]);
});
```

## Visual Regression for i18n

### Locale-Specific Snapshots

```typescript
// playwright.config.ts
export default defineConfig({
  snapshotPathTemplate:
    "{testDir}/__snapshots__/{projectName}/{testFilePath}/{arg}{ext}",

  projects: [
    { name: "en-US", use: { locale: "en-US" } },
    { name: "de-DE", use: { locale: "de-DE" } },
    { name: "ja-JP", use: { locale: "ja-JP" } },
    { name: "ar-SA", use: { locale: "ar-SA" } },
  ],
});
```

```typescript
// test file
test("homepage visual", async ({ page }) => {
  await page.goto("/");

  // Snapshot auto-saved to {projectName}/homepage.png
  await expect(page).toHaveScreenshot("homepage.png");
});
```

### Critical Element Screenshots

```typescript
test("navigation in all locales", async ({ page }) => {
  await page.goto("/");

  // Just the nav - catches overflow, truncation
  const nav = page.getByRole("navigation");
  await expect(nav).toHaveScreenshot("navigation.png");
});

test("buttons dont truncate", async ({ page }) => {
  await page.goto("/checkout");

  const ctaButton = page.getByRole("button", {
    name: /checkout|kaufen|acheter/i,
  });
  await expect(ctaButton).toHaveScreenshot("checkout-button.png");
});
```

### Font Loading for i18n

```typescript
test("wait for fonts before screenshot", async ({ page }) => {
  await page.goto("/");

  // Wait for fonts (important for CJK, Arabic)
  await page.evaluate(() => document.fonts.ready);
  await page.waitForFunction(() =>
    document.fonts.check("16px 'Noto Sans Arabic'"),
  );

  await expect(page).toHaveScreenshot("with-fonts.png");
});
```

## Anti-Patterns to Avoid

| Anti-Pattern              | Problem                         | Solution                        |
| ------------------------- | ------------------------------- | ------------------------------- |
| Hardcoded text assertions | Breaks in other locales         | Use test IDs or parameterize    |
| Single locale testing     | Misses i18n bugs                | Test multiple locales           |
| Ignoring RTL              | Layout broken for RTL users     | Dedicated RTL project           |
| No font wait              | Screenshots with fallback fonts | Wait for `document.fonts.ready` |

## Related References

- **Clock Mocking**: See [clock-mocking.md](../advanced/clock-mocking.md) for timezone testing
- **Mobile Testing**: See [mobile-testing.md](../advanced/mobile-testing.md) for device-specific locales
testing-patterns/performance-testing.md
# Performance Testing & Web Vitals

## Table of Contents

1. [Core Web Vitals](#core-web-vitals)
2. [Performance Metrics](#performance-metrics)
3. [Performance Budgets](#performance-budgets)
4. [Lighthouse Integration](#lighthouse-integration)
5. [Performance Fixtures](#performance-fixtures)
6. [CI Performance Monitoring](#ci-performance-monitoring)

## Core Web Vitals

### Measure LCP, FID, CLS

```typescript
test("core web vitals within thresholds", async ({ page }) => {
  // Inject web-vitals library
  await page.addInitScript(() => {
    (window as any).__webVitals = {};

    // Simplified web vitals collection
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === "largest-contentful-paint") {
          (window as any).__webVitals.lcp = entry.startTime;
        }
      }
    }).observe({ type: "largest-contentful-paint", buffered: true });

    new PerformanceObserver((list) => {
      let cls = 0;
      for (const entry of list.getEntries() as any[]) {
        if (!entry.hadRecentInput) {
          cls += entry.value;
        }
      }
      (window as any).__webVitals.cls = cls;
    }).observe({ type: "layout-shift", buffered: true });
  });

  await page.goto("/");

  // Wait for page to stabilize
  await page.waitForLoadState("networkidle");

  // Get metrics
  const vitals = await page.evaluate(() => (window as any).__webVitals);

  // Assert thresholds (Google's "good" thresholds)
  expect(vitals.lcp).toBeLessThan(2500); // LCP < 2.5s
  expect(vitals.cls).toBeLessThan(0.1); // CLS < 0.1
});
```

### Using web-vitals Library

```typescript
test("web vitals with library", async ({ page }) => {
  await page.addInitScript(() => {
    (window as any).__vitals = {};
  });

  // Inject web-vitals after navigation
  await page.goto("/");

  await page.addScriptTag({
    url: "https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js",
  });

  await page.evaluate(() => {
    const { onLCP, onFID, onCLS, onFCP, onTTFB } = (window as any).webVitals;

    onLCP((metric: any) => ((window as any).__vitals.lcp = metric.value));
    onFID((metric: any) => ((window as any).__vitals.fid = metric.value));
    onCLS((metric: any) => ((window as any).__vitals.cls = metric.value));
    onFCP((metric: any) => ((window as any).__vitals.fcp = metric.value));
    onTTFB((metric: any) => ((window as any).__vitals.ttfb = metric.value));
  });

  // Trigger FID by clicking
  await page.getByRole("button").first().click();

  // Wait and collect
  await page.waitForTimeout(1000);

  const vitals = await page.evaluate(() => (window as any).__vitals);

  console.log("Web Vitals:", vitals);

  // Assertions
  if (vitals.lcp) expect(vitals.lcp).toBeLessThan(2500);
  if (vitals.fid) expect(vitals.fid).toBeLessThan(100);
  if (vitals.cls) expect(vitals.cls).toBeLessThan(0.1);
});
```

## Performance Metrics

### Navigation Timing

```typescript
test("page load performance", async ({ page }) => {
  await page.goto("/");

  const timing = await page.evaluate(() => {
    const nav = performance.getEntriesByType(
      "navigation",
    )[0] as PerformanceNavigationTiming;

    return {
      // Time to First Byte
      ttfb: nav.responseStart - nav.requestStart,
      // DOM Content Loaded
      domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
      // Full page load
      loadComplete: nav.loadEventEnd - nav.startTime,
      // DNS lookup
      dns: nav.domainLookupEnd - nav.domainLookupStart,
      // Connection time
      connection: nav.connectEnd - nav.connectStart,
      // Download time
      download: nav.responseEnd - nav.responseStart,
      // DOM processing
      domProcessing: nav.domComplete - nav.domInteractive,
    };
  });

  console.log("Performance timing:", timing);

  // Assertions
  expect(timing.ttfb).toBeLessThan(600); // TTFB < 600ms
  expect(timing.domContentLoaded).toBeLessThan(2000); // DCL < 2s
  expect(timing.loadComplete).toBeLessThan(4000); // Load < 4s
});
```

### Resource Timing

```typescript
test("resource loading performance", async ({ page }) => {
  await page.goto("/");

  const resources = await page.evaluate(() => {
    return performance.getEntriesByType("resource").map((entry) => ({
      name: entry.name.split("/").pop(),
      type: (entry as PerformanceResourceTiming).initiatorType,
      duration: entry.duration,
      size: (entry as PerformanceResourceTiming).transferSize,
    }));
  });

  // Find slow resources
  const slowResources = resources.filter((r) => r.duration > 1000);

  if (slowResources.length > 0) {
    console.warn("Slow resources:", slowResources);
  }

  // Find large resources
  const largeResources = resources.filter((r) => r.size > 500000); // > 500KB

  expect(largeResources.length).toBe(0);
});
```

### Memory Usage

```typescript
test("memory usage is reasonable", async ({ page }) => {
  await page.goto("/dashboard");

  // Check memory (Chrome only)
  const memory = await page.evaluate(() => {
    if ((performance as any).memory) {
      return {
        usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
        totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
      };
    }
    return null;
  });

  if (memory) {
    const usedMB = memory.usedJSHeapSize / 1024 / 1024;
    console.log(`Memory usage: ${usedMB.toFixed(2)} MB`);

    // Assert reasonable memory usage
    expect(usedMB).toBeLessThan(100); // < 100MB
  }
});
```

## Performance Budgets

### Define Budgets

```typescript
// performance-budgets.ts
export const budgets = {
  homepage: {
    lcp: 2500,
    cls: 0.1,
    fcp: 1800,
    ttfb: 600,
    totalSize: 1500000, // 1.5MB
    jsSize: 500000, // 500KB
    imageCount: 20,
  },
  dashboard: {
    lcp: 3000,
    cls: 0.1,
    fcp: 2000,
    ttfb: 800,
    totalSize: 2000000,
    jsSize: 800000,
  },
};
```

### Test Against Budgets

```typescript
import { budgets } from "./performance-budgets";

test("homepage meets performance budget", async ({ page }) => {
  const budget = budgets.homepage;

  await page.goto("/");
  await page.waitForLoadState("networkidle");

  // Measure LCP
  const lcp = await page.evaluate(() => {
    return new Promise<number>((resolve) => {
      new PerformanceObserver((list) => {
        const entries = list.getEntries();
        resolve(entries[entries.length - 1].startTime);
      }).observe({ type: "largest-contentful-paint", buffered: true });
    });
  });

  // Measure resources
  const resources = await page.evaluate(() => {
    const entries = performance.getEntriesByType(
      "resource",
    ) as PerformanceResourceTiming[];
    return {
      totalSize: entries.reduce((sum, e) => sum + (e.transferSize || 0), 0),
      jsSize: entries
        .filter((e) => e.initiatorType === "script")
        .reduce((sum, e) => sum + (e.transferSize || 0), 0),
      imageCount: entries.filter((e) => e.initiatorType === "img").length,
    };
  });

  // Assert budgets
  expect(lcp, "LCP exceeds budget").toBeLessThan(budget.lcp);
  expect(resources.totalSize, "Total size exceeds budget").toBeLessThan(
    budget.totalSize,
  );
  expect(resources.jsSize, "JS size exceeds budget").toBeLessThan(
    budget.jsSize,
  );
  expect(resources.imageCount, "Too many images").toBeLessThanOrEqual(
    budget.imageCount,
  );
});
```

### Budget Fixture

```typescript
// fixtures/performance.fixture.ts
type PerformanceBudget = {
  lcp?: number;
  cls?: number;
  ttfb?: number;
  totalSize?: number;
};

type PerformanceFixtures = {
  assertBudget: (budget: PerformanceBudget) => Promise<void>;
};

export const test = base.extend<PerformanceFixtures>({
  assertBudget: async ({ page }, use) => {
    await use(async (budget) => {
      const metrics = await page.evaluate(() => {
        const nav = performance.getEntriesByType(
          "navigation",
        )[0] as PerformanceNavigationTiming;
        const resources = performance.getEntriesByType(
          "resource",
        ) as PerformanceResourceTiming[];

        return {
          ttfb: nav.responseStart - nav.requestStart,
          totalSize: resources.reduce(
            (sum, r) => sum + (r.transferSize || 0),
            0,
          ),
        };
      });

      if (budget.ttfb) {
        expect(
          metrics.ttfb,
          `TTFB ${metrics.ttfb}ms exceeds budget ${budget.ttfb}ms`,
        ).toBeLessThan(budget.ttfb);
      }

      if (budget.totalSize) {
        expect(metrics.totalSize, `Total size exceeds budget`).toBeLessThan(
          budget.totalSize,
        );
      }
    });
  },
});
```

## Lighthouse Integration

### Using playwright-lighthouse

```bash
npm install -D playwright-lighthouse lighthouse
```

```typescript
import { playAudit } from "playwright-lighthouse";

test("lighthouse audit", async ({ page }) => {
  await page.goto("/");

  // Run Lighthouse
  const audit = await playAudit({
    page,
    port: 9222, // Chrome debugging port
    thresholds: {
      performance: 80,
      accessibility: 90,
      "best-practices": 80,
      seo: 80,
    },
  });

  // Assertions
  expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(
    80,
  );
  expect(audit.lhr.categories.accessibility.score * 100).toBeGreaterThanOrEqual(
    90,
  );
});
```

### Lighthouse with Config

```typescript
test("lighthouse with custom config", async ({ page }, testInfo) => {
  await page.goto("/");

  const audit = await playAudit({
    page,
    port: 9222,
    thresholds: {
      performance: 70,
    },
    config: {
      extends: "lighthouse:default",
      settings: {
        onlyCategories: ["performance"],
        throttling: {
          rttMs: 40,
          throughputKbps: 10240,
          cpuSlowdownMultiplier: 1,
        },
      },
    },
  });

  // Save report
  const reportPath = testInfo.outputPath("lighthouse-report.html");
  // Save audit.report to file

  // Attach to test report
  await testInfo.attach("lighthouse", {
    body: JSON.stringify(audit.lhr),
    contentType: "application/json",
  });
});
```

## CI Performance Monitoring

### Track Performance Over Time

```typescript
// reporters/perf-reporter.ts
import { Reporter, TestResult } from "@playwright/test/reporter";

class PerfReporter implements Reporter {
  private metrics: any[] = [];

  onTestEnd(test: any, result: TestResult) {
    const perfAnnotation = test.annotations.find(
      (a: any) => a.type === "performance",
    );

    if (perfAnnotation) {
      this.metrics.push({
        test: test.title,
        ...JSON.parse(perfAnnotation.description),
        timestamp: new Date().toISOString(),
      });
    }
  }

  async onEnd() {
    // Send to metrics service
    if (process.env.METRICS_ENDPOINT) {
      await fetch(process.env.METRICS_ENDPOINT, {
        method: "POST",
        body: JSON.stringify({
          commit: process.env.GITHUB_SHA,
          branch: process.env.GITHUB_REF,
          metrics: this.metrics,
        }),
      });
    }
  }
}

export default PerfReporter;
```

### Performance Regression Detection

```typescript
test("no performance regression", async ({ page }) => {
  await page.goto("/");

  const metrics = await page.evaluate(() => {
    const nav = performance.getEntriesByType(
      "navigation",
    )[0] as PerformanceNavigationTiming;
    return {
      loadTime: nav.loadEventEnd - nav.startTime,
    };
  });

  // Compare against baseline (could be from file or API)
  const baseline = 2000; // ms
  const threshold = 1.1; // 10% regression allowed

  expect(
    metrics.loadTime,
    `Load time ${metrics.loadTime}ms is ${((metrics.loadTime / baseline - 1) * 100).toFixed(1)}% slower than baseline`,
  ).toBeLessThan(baseline * threshold);
});
```

## Anti-Patterns to Avoid

| Anti-Pattern                | Problem                   | Solution                         |
| --------------------------- | ------------------------- | -------------------------------- |
| Testing only once           | Results vary              | Run multiple times, use averages |
| Ignoring network conditions | Unrealistic results       | Test with throttling             |
| No baseline comparison      | Can't detect regressions  | Track metrics over time          |
| Testing in dev mode         | Slow, not production-like | Test production builds           |

## Related References

- **Performance Optimization**: See [performance.md](../infrastructure-ci-cd/performance.md) for test execution performance
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI integration
testing-patterns/security-testing.md
# Security Testing Basics

## Table of Contents

1. [XSS Prevention](#xss-prevention)
2. [CSRF Protection](#csrf-protection)
3. [Authentication Security](#authentication-security)
4. [Authorization Testing](#authorization-testing)
5. [Input Validation](#input-validation)
6. [Security Headers](#security-headers)

## XSS Prevention

### Test Reflected XSS

```typescript
test("input is properly escaped", async ({ page }) => {
  const xssPayloads = [
    '<script>alert("xss")</script>',
    '<img src="x" onerror="alert(1)">',
    '"><script>alert(1)</script>',
    "javascript:alert(1)",
    '<svg onload="alert(1)">',
  ];

  for (const payload of xssPayloads) {
    await page.goto(`/search?q=${encodeURIComponent(payload)}`);

    // Verify script didn't execute
    const alertTriggered = await page.evaluate(() => {
      return (window as any).__xssTriggered === true;
    });
    expect(alertTriggered).toBe(false);

    // Verify payload is escaped in HTML
    const content = await page.content();
    expect(content).not.toContain("<script>alert");
    expect(content).not.toContain("onerror=");
  }
});
```

### Test Stored XSS

```typescript
test("user content is sanitized", async ({ page }) => {
  await page.goto("/create-post");

  // Try to inject script via form
  await page.getByLabel("Content").fill('<script>alert("xss")</script>Hello');
  await page.getByRole("button", { name: "Submit" }).click();

  // View the post
  await page.goto("/posts/latest");

  // Script should not be in page
  const scripts = await page.locator("script").count();
  const pageContent = await page.content();

  // The script tag should be escaped or removed
  expect(pageContent).not.toContain("<script>alert");

  // Text should still be visible (just sanitized)
  await expect(page.getByText("Hello")).toBeVisible();
});
```

### Monitor for XSS Execution

```typescript
test("no XSS execution", async ({ page }) => {
  // Set up XSS detection
  await page.addInitScript(() => {
    (window as any).__xssDetected = false;

    // Override alert/confirm/prompt
    window.alert = () => {
      (window as any).__xssDetected = true;
    };
    window.confirm = () => {
      (window as any).__xssDetected = true;
      return false;
    };
    window.prompt = () => {
      (window as any).__xssDetected = true;
      return null;
    };
  });

  // Perform test actions
  await page.goto("/vulnerable-page");
  await page.getByLabel("Search").fill('"><img src=x onerror=alert(1)>');
  await page.getByLabel("Search").press("Enter");

  // Check if XSS triggered
  const xssDetected = await page.evaluate(() => (window as any).__xssDetected);
  expect(xssDetected).toBe(false);
});
```

## CSRF Protection

### Verify CSRF Token Present

```typescript
test("forms include CSRF token", async ({ page }) => {
  await page.goto("/settings");

  // Check form has CSRF token
  const csrfInput = page.locator(
    'input[name="_csrf"], input[name="csrf_token"]',
  );
  await expect(csrfInput).toBeAttached();

  const csrfValue = await csrfInput.getAttribute("value");
  expect(csrfValue).toBeTruthy();
  expect(csrfValue!.length).toBeGreaterThan(20);
});
```

### Test CSRF Token Validation

```typescript
test("rejects requests without CSRF token", async ({ page, request }) => {
  await page.goto("/settings");

  // Try to submit without CSRF token
  const response = await request.post("/api/settings", {
    data: { theme: "dark" },
    headers: {
      "Content-Type": "application/json",
    },
  });

  // Should be rejected
  expect(response.status()).toBe(403);
});

test("rejects requests with invalid CSRF token", async ({ page, request }) => {
  await page.goto("/settings");

  const response = await request.post("/api/settings", {
    data: { theme: "dark" },
    headers: {
      "X-CSRF-Token": "invalid-token",
    },
  });

  expect(response.status()).toBe(403);
});
```

### Test CSRF with Valid Token

```typescript
test("accepts requests with valid CSRF token", async ({ page }) => {
  await page.goto("/settings");

  // Get CSRF token from page
  const csrfToken = await page
    .locator('meta[name="csrf-token"]')
    .getAttribute("content");

  // Submit form normally
  await page.getByLabel("Theme").selectOption("dark");
  await page.getByRole("button", { name: "Save" }).click();

  // Should succeed
  await expect(page.getByText("Settings saved")).toBeVisible();
});
```

## Authentication Security

### Test Session Expiry

```typescript
test("session expires after timeout", async ({ page, context }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Password").fill("password");
  await page.getByRole("button", { name: "Sign in" }).click();

  await expect(page).toHaveURL("/dashboard");

  // Simulate time passing (if using clock mocking)
  await page.clock.fastForward("02:00:00"); // 2 hours

  // Try to access protected page
  await page.goto("/profile");

  // Should redirect to login
  await expect(page).toHaveURL(/\/login/);
  await expect(page.getByText("Session expired")).toBeVisible();
});
```

### Test Concurrent Sessions

```typescript
test("handles concurrent session limit", async ({ browser }) => {
  // Login from first browser
  const context1 = await browser.newContext();
  const page1 = await context1.newPage();

  await page1.goto("/login");
  await page1.getByLabel("Email").fill("[email protected]");
  await page1.getByLabel("Password").fill("password");
  await page1.getByRole("button", { name: "Sign in" }).click();
  await expect(page1).toHaveURL("/dashboard");

  // Login from second browser (same user)
  const context2 = await browser.newContext();
  const page2 = await context2.newPage();

  await page2.goto("/login");
  await page2.getByLabel("Email").fill("[email protected]");
  await page2.getByLabel("Password").fill("password");
  await page2.getByRole("button", { name: "Sign in" }).click();

  // First session should be invalidated (or warning shown)
  await page1.reload();
  await expect(
    page1.getByText(/session.*another device|logged out/i),
  ).toBeVisible();

  await context1.close();
  await context2.close();
});
```

### Test Password Reset Security

```typescript
test("password reset token is single-use", async ({ page, request }) => {
  // Request password reset
  await page.goto("/forgot-password");
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByRole("button", { name: "Reset" }).click();

  // Get token (in test env, might be exposed or use email mock)
  const resetToken = "mock-reset-token";

  // Use token first time
  await page.goto(`/reset-password?token=${resetToken}`);
  await page.getByLabel("New Password").fill("NewPassword123");
  await page.getByRole("button", { name: "Reset" }).click();

  await expect(page.getByText("Password updated")).toBeVisible();

  // Try to use same token again
  await page.goto(`/reset-password?token=${resetToken}`);

  await expect(page.getByText("Invalid or expired token")).toBeVisible();
});
```

## Authorization Testing

### Test Unauthorized Access

```typescript
test.describe("authorization", () => {
  test("cannot access admin routes as user", async ({ browser }) => {
    const context = await browser.newContext({
      storageState: ".auth/user.json", // Regular user
    });
    const page = await context.newPage();

    // Try to access admin page
    await page.goto("/admin/users");

    // Should be denied
    await expect(page).not.toHaveURL("/admin/users");
    expect(
      (await page.getByText("Access denied").isVisible()) ||
        (await page.url()).includes("/login") ||
        (await page.url()).includes("/403"),
    ).toBe(true);

    await context.close();
  });

  test("cannot access other user's data", async ({ page }) => {
    // Logged in as user 1, try to access user 2's profile
    await page.goto("/users/other-user-id/settings");

    await expect(page.getByText("Access denied")).toBeVisible();
  });
});
```

### Test IDOR (Insecure Direct Object Reference)

```typescript
test("cannot access other user resources by changing ID", async ({
  page,
  request,
}) => {
  // Get current user's order
  await page.goto("/orders/my-order-123");
  await expect(page.getByText("Order #my-order-123")).toBeVisible();

  // Try to access another user's order
  const response = await request.get("/api/orders/other-user-order-456");

  // Should be forbidden
  expect(response.status()).toBe(403);
});
```

## Input Validation

### Test SQL Injection Prevention

```typescript
test("SQL injection is prevented", async ({ page }) => {
  const sqlPayloads = [
    "'; DROP TABLE users; --",
    "1' OR '1'='1",
    "1; DELETE FROM orders",
    "' UNION SELECT * FROM users --",
  ];

  for (const payload of sqlPayloads) {
    await page.goto("/search");
    await page.getByLabel("Search").fill(payload);
    await page.getByRole("button", { name: "Search" }).click();

    // Should not error (injection blocked/escaped)
    await expect(page.getByText("Error")).not.toBeVisible();

    // Should show no results or escaped text
    const hasError = await page
      .getByText(/database error|sql|syntax/i)
      .isVisible();
    expect(hasError).toBe(false);
  }
});
```

### Test Input Length Limits

```typescript
test("enforces input length limits", async ({ page }) => {
  await page.goto("/profile");

  // Try to submit very long input
  const longString = "a".repeat(10000);

  await page.getByLabel("Bio").fill(longString);
  await page.getByRole("button", { name: "Save" }).click();

  // Should show validation error or truncate
  const bioValue = await page.getByLabel("Bio").inputValue();
  expect(bioValue.length).toBeLessThanOrEqual(500); // Expected max
});
```

## Security Headers

### Verify Security Headers

```typescript
test("response includes security headers", async ({ page }) => {
  const response = await page.goto("/");

  const headers = response!.headers();

  // Content Security Policy
  expect(headers["content-security-policy"]).toBeTruthy();

  // Prevent clickjacking
  expect(headers["x-frame-options"]).toMatch(/DENY|SAMEORIGIN/);

  // Prevent MIME type sniffing
  expect(headers["x-content-type-options"]).toBe("nosniff");

  // XSS Protection (legacy but good to have)
  expect(headers["x-xss-protection"]).toBeTruthy();

  // HTTPS enforcement
  if (!page.url().includes("localhost")) {
    expect(headers["strict-transport-security"]).toBeTruthy();
  }
});
```

### Test CSP Violations

```typescript
test("CSP blocks inline scripts", async ({ page }) => {
  const cspViolations: string[] = [];

  // Listen for CSP violations via console
  page.on("console", (msg) => {
    if (msg.text().includes("Content Security Policy")) {
      cspViolations.push(msg.text());
    }
  });

  await page.goto("/");

  // Try to inject inline script - CSP should block it
  await page.evaluate(() => {
    const script = document.createElement("script");
    script.textContent = 'console.log("injected")';
    document.body.appendChild(script);
  });

  expect(cspViolations.length).toBeGreaterThan(0);
});
```

> **For comprehensive console monitoring** (fixtures, allowed patterns, fail on errors), see [console-errors.md](../debugging/console-errors.md).

## Anti-Patterns to Avoid

| Anti-Pattern               | Problem               | Solution                      |
| -------------------------- | --------------------- | ----------------------------- |
| Testing only happy path    | Misses security holes | Test malicious inputs         |
| Hardcoded test credentials | Security risk         | Use environment variables     |
| Skipping auth tests in dev | Bugs reach production | Test auth in all environments |
| Not testing authorization  | Access control bugs   | Test all role combinations    |

## Related References

- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth fixtures
- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for role-based testing
- **Error Testing**: See [error-testing.md](../debugging/error-testing.md) for validation testing
testing-patterns/visual-regression.md
# Visual Regression Testing

## Table of Contents

1. [Quick Reference](#quick-reference)
2. [Patterns](#patterns)
3. [Decision Guide](#decision-guide)
4. [Anti-Patterns](#anti-patterns)
5. [Troubleshooting](#troubleshooting)

> **When to use**: Detecting unintended visual changes—layout shifts, style regressions, broken responsive designs—that functional assertions miss.

## Quick Reference

```typescript
// Element screenshot
await expect(page.getByTestId('product-card')).toHaveScreenshot();

// Full page screenshot
await expect(page).toHaveScreenshot('landing-hero.png');

// Threshold for minor pixel variance
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01 });

// Mask volatile content
await expect(page).toHaveScreenshot({
  mask: [page.getByTestId('clock'), page.getByRole('img', { name: 'User photo' })],
});

// Disable CSS animations
await expect(page).toHaveScreenshot({ animations: 'disabled' });

// Update baselines
npx playwright test --update-snapshots
```

## Patterns

### Masking Volatile Content

**Use when**: Page contains timestamps, avatars, ad slots, relative dates, random images, or A/B variants.

The `mask` option overlays a solid box over specified locators before capturing.

```typescript
test('analytics panel with masked dynamic elements', async ({ page }) => {
  await page.goto('/analytics');

  await expect(page).toHaveScreenshot('analytics.png', {
    mask: [
      page.getByTestId('last-updated'),
      page.getByTestId('profile-avatar'),
      page.getByTestId('active-users'),
      page.locator('.promo-banner'),
    ],
    maskColor: '#FF00FF',
  });
});

test('activity stream with relative times', async ({ page }) => {
  await page.goto('/activity');

  await expect(page).toHaveScreenshot('activity.png', {
    mask: [page.locator('time[datetime]')],
  });
});
```

**Alternative: freeze content with JavaScript** when masking affects layout:

```typescript
test('freeze timestamps before capture', async ({ page }) => {
  await page.goto('/analytics');

  await page.evaluate(() => {
    document.querySelectorAll('[data-testid="time-display"]').forEach((el) => {
      el.textContent = 'Jan 1, 2025 12:00 PM';
    });
  });

  await expect(page).toHaveScreenshot('analytics-frozen.png');
});
```

### Disabling Animations

**Use when**: Always. CSS animations and transitions are the primary cause of flaky visual diffs.

```typescript
test('renders without animation interference', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveScreenshot('home.png', {
    animations: 'disabled',
  });
});
```

**Set globally** in config:

```typescript
// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      animations: 'disabled',
    },
  },
});
```

When `animations: 'disabled'` is set, Playwright injects CSS forcing animation/transition duration to 0s, waits for running animations to finish, then captures.

For JavaScript-driven animations (GSAP, Framer Motion), wait for stability:

```typescript
test('page with JS animations', async ({ page }) => {
  await page.goto('/animated-hero');

  const heroBanner = page.getByTestId('hero-banner');
  await heroBanner.waitFor({ state: 'visible' });
  
  // Wait for animation to complete by checking for stable state
  await expect(heroBanner).not.toHaveClass(/animating/);

  await expect(page).toHaveScreenshot('hero.png', {
    animations: 'disabled',
  });
});
```

### Configuring Thresholds

**Use when**: Minor rendering differences from anti-aliasing, font hinting, or sub-pixel rendering cause false failures.

| Option | Controls | Typical Value |
|---|---|---|
| `maxDiffPixels` | Absolute pixel count that can differ | `100` for pages, `10` for components |
| `maxDiffPixelRatio` | Fraction of total pixels (0-1) | `0.01` (1%) for pages |
| `threshold` | Per-pixel color tolerance (0-1) | `0.2` for most UIs, `0.1` for design systems |

```typescript
test('control panel allows minor variance', async ({ page }) => {
  await page.goto('/control-panel');

  await expect(page).toHaveScreenshot('control-panel.png', {
    maxDiffPixelRatio: 0.01,
  });
});

test('brand logo renders pixel-perfect', async ({ page }) => {
  await page.goto('/brand');

  await expect(page.getByTestId('brand-logo')).toHaveScreenshot('brand-logo.png', {
    maxDiffPixels: 0,
    threshold: 0,
  });
});

test('graph allows anti-aliasing differences', async ({ page }) => {
  await page.goto('/reports');

  await expect(page.getByTestId('sales-graph')).toHaveScreenshot('sales-graph.png', {
    threshold: 0.3,
    maxDiffPixels: 200,
  });
});
```

**Global thresholds** in config:

```typescript
// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01,
      threshold: 0.2,
      animations: 'disabled',
    },
  },
});
```

### CI Configuration

**Use when**: Running visual tests in CI. Consistent rendering is critical—the same test must produce identical screenshots every time.

**The problem**: Font rendering and anti-aliasing differ across operating systems. macOS snapshots won't match Linux.

**The solution**: Run visual tests in Docker using the official Playwright container. Generate and update snapshots from the same container.

**GitHub Actions with Docker**

```yaml
# .github/workflows/visual-tests.yml
name: Visual Regression Tests
on: [push, pull_request]

jobs:
  visual-tests:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.48.0-noble
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
          cache: npm

      - run: npm ci

      - name: Run visual tests
        run: npx playwright test --project=visual
        env:
          HOME: /root

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-test-report
          path: playwright-report/
          retention-days: 14
```

**Updating snapshots locally using Docker**:

```bash
docker run --rm -v $(pwd):/work -w /work \
  mcr.microsoft.com/playwright:v1.48.0-noble \
  npx playwright test --update-snapshots --project=visual
```

**Add script to `package.json`**:

```json
{
  "scripts": {
    "test:visual": "npx playwright test --project=visual",
    "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"
  }
}
```

**Platform-agnostic snapshots** (requires Docker for generation):

```typescript
// playwright.config.ts
export default defineConfig({
  snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',
  projects: [
    {
      name: 'visual',
      testMatch: '**/*.visual.spec.ts',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});
```

### Full Page vs Element Screenshots

**Use when**: Deciding scope. Full page catches layout shifts. Element screenshots isolate components and are more stable.

```typescript
test('full page captures layout shifts', async ({ page }) => {
  await page.goto('/');

  // Visible viewport
  await expect(page).toHaveScreenshot('home-viewport.png');

  // Entire scrollable page
  await expect(page).toHaveScreenshot('home-full.png', {
    fullPage: true,
  });
});

test('element screenshot isolates component', async ({ page }) => {
  await page.goto('/catalog');

  await expect(page.getByRole('table')).toHaveScreenshot('catalog-table.png');
  await expect(page.getByTestId('featured-item')).toHaveScreenshot('featured-item.png');
});
```

**Rule of thumb**: Element screenshots for independently changing components. Full page screenshots for key layouts where spacing matters.

### Responsive Visual Testing

**Use when**: Application has responsive breakpoints requiring verification at different viewport sizes.

```typescript
const breakpoints = [
  { name: 'phone', width: 375, height: 812 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1440, height: 900 },
];

for (const bp of breakpoints) {
  test(`landing at ${bp.name} (${bp.width}x${bp.height})`, async ({ page }) => {
    await page.setViewportSize({ width: bp.width, height: bp.height });
    await page.goto('/');

    await expect(page).toHaveScreenshot(`landing-${bp.name}.png`, {
      animations: 'disabled',
      fullPage: true,
    });
  });
}
```

**Alternative: use projects for responsive testing**:

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'desktop',
      testMatch: '**/*.visual.spec.ts',
      use: {
        ...devices['Desktop Chrome'],
        viewport: { width: 1440, height: 900 },
      },
    },
    {
      name: 'tablet',
      testMatch: '**/*.visual.spec.ts',
      use: { ...devices['iPad (gen 7)'] },
    },
    {
      name: 'mobile',
      testMatch: '**/*.visual.spec.ts',
      use: { ...devices['iPhone 14'] },
    },
  ],
});
```

### Component Visual Testing

**Use when**: Testing individual UI components in isolation—buttons, cards, forms, modals. Faster and more stable than full-page screenshots.

```typescript
test.describe('Button visual states', () => {
  test('primary button', async ({ page }) => {
    await page.goto('/storybook/iframe.html?id=button--primary');
    const btn = page.getByRole('button');
    await expect(btn).toHaveScreenshot('btn-primary.png', {
      animations: 'disabled',
    });
  });

  test('primary button hover', async ({ page }) => {
    await page.goto('/storybook/iframe.html?id=button--primary');
    const btn = page.getByRole('button');
    await btn.hover();
    await expect(btn).toHaveScreenshot('btn-primary-hover.png', {
      animations: 'disabled',
    });
  });

  test('button sizes', async ({ page }) => {
    for (const size of ['small', 'medium', 'large']) {
      await page.goto(`/storybook/iframe.html?id=button--${size}`);
      const btn = page.getByRole('button');
      await expect(btn).toHaveScreenshot(`btn-${size}.png`, {
        animations: 'disabled',
      });
    }
  });
});
```

**Using a dedicated test harness** instead of Storybook:

```typescript
test.describe('Card component', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/test-harness/card');
  });

  test('default state', async ({ page }) => {
    await expect(page.getByTestId('card')).toHaveScreenshot('card-default.png', {
      animations: 'disabled',
    });
  });

  test('truncates long content', async ({ page }) => {
    await page.goto('/test-harness/card?content=long');
    await expect(page.getByTestId('card')).toHaveScreenshot('card-long.png', {
      animations: 'disabled',
    });
  });
});
```

### Updating Snapshots

**Use when**: Intentionally changed UI—design refresh, rebrand, new feature. Never update when diff is unexpected.

```bash
# Update all snapshots
npx playwright test --update-snapshots

# Update for specific file
npx playwright test tests/landing.spec.ts --update-snapshots

# Update for specific project
npx playwright test --project=chromium --update-snapshots
```

**Workflow for reviewing changes:**

1. Run tests and view failures in HTML report:
   ```bash
   npx playwright test
   npx playwright show-report
   ```
   The report shows expected, actual, and diff images side-by-side.

2. If changes are intentional, update:
   ```bash
   npx playwright test --update-snapshots
   ```

3. Review updated snapshots before committing:
   ```bash
   git diff --name-only
   ```

**Tag visual tests for selective updates:**

```typescript
test('landing visual @visual', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('landing.png', {
    animations: 'disabled',
  });
});
```

```bash
npx playwright test --grep @visual --update-snapshots
```

### Cross-Browser Visual Testing

**Use when**: Users span Chrome, Firefox, Safari and you need per-browser rendering verification.

Playwright separates snapshots by project name automatically. Each browser gets its own baseline—browsers render fonts and shadows differently.

```typescript
// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      animations: 'disabled',
      maxDiffPixelRatio: 0.01,
    },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});
```

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

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'visual',
      testMatch: '**/*.visual.spec.ts',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'chromium',
      testIgnore: '**/*.visual.spec.ts',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      testIgnore: '**/*.visual.spec.ts',
      use: { ...devices['Desktop Firefox'] },
    },
  ],
});
```

## Decision Guide

| Scenario | Approach | Rationale |
|---|---|---|
| Key landing/marketing pages | Full page, `fullPage: true` | Catches layout shifts, spacing, overall harmony |
| Individual components | Element screenshot | Isolated, fast, immune to unrelated changes |
| Page with dynamic content | Full page + `mask` | Covers layout while ignoring volatile content |
| Design system library | Element per variant, zero threshold | Pixel-perfect enforcement |
| Responsive verification | Screenshot per viewport | Catches breakpoint bugs |
| Cross-browser consistency | Separate snapshots per browser | Browsers render differently |
| CI pipeline | Docker container, Linux-only snapshots | Consistent rendering |
| Threshold: design system | `threshold: 0`, `maxDiffPixels: 0` | Zero tolerance |
| Threshold: content pages | `maxDiffPixelRatio: 0.01`, `threshold: 0.2` | Minor anti-aliasing variance |
| Threshold: charts/graphs | `maxDiffPixels: 200`, `threshold: 0.3` | Anti-aliasing on curves varies |

## Anti-Patterns

| Don't | Problem | Do Instead |
|---|---|---|
| Visual test every page | Massive maintenance, constant false failures | Pick 5-10 key pages and critical components |
| Skip masking dynamic content | Screenshots differ every run, permanently flaky | Use `mask` for all volatile elements |
| Run across macOS, Linux, Windows | Font rendering differs, snapshots never match | Standardize on Linux via Docker |
| Skip Docker in CI | OS updates shift rendering silently | Pin specific Playwright Docker image |
| Blindly run `--update-snapshots` | Accepts unintentional regressions | Always review diff in HTML report first |
| Skip `animations: 'disabled'` | CSS transitions create random diffs | Set globally in config |
| Replace functional assertions with visual tests | Diffs don't tell you *what* broke | Visual tests complement, never replace |
| Commit snapshots from different platforms | Tests fail for everyone | All team members use same Docker container |
| Set threshold too high (`0.1`) | 10% pixel change passes, defeats purpose | Start with `0.01`, adjust per-test |
| Full page on infinite scroll pages | Page height nondeterministic | Element screenshots on above-the-fold content |

## Troubleshooting

### "Screenshot comparison failed" on first CI run after local development

**Cause**: Snapshots generated on macOS locally. CI runs on Linux. Font rendering differs.

**Fix**: Generate snapshots using Docker:

```bash
docker run --rm -v $(pwd):/work -w /work \
  mcr.microsoft.com/playwright:v1.48.0-noble \
  npx playwright test --update-snapshots --project=visual
```

Commit Linux-generated snapshots.

### "Expected screenshot to match but X pixels differ"

**Cause**: Anti-aliasing, font hinting, sub-pixel rendering differences.

**Fix**: Add tolerance:

```typescript
await expect(page).toHaveScreenshot('page.png', {
  maxDiffPixelRatio: 0.01,
  threshold: 0.2,
});
```

Check HTML report diff image to determine if it's regression or noise.

### Visual tests pass locally but fail in CI (even with Docker)

**Cause**: Different Playwright versions locally vs CI.

**Fix**: Ensure `package.json` version matches Docker image tag:

```json
{
  "devDependencies": {
    "@playwright/test": "latest"
  }
}
```

```yaml
container:
  image: mcr.microsoft.com/playwright:v1.48.0-noble
```

### Animations cause random diff failures

**Cause**: CSS animations captured mid-frame.

**Fix**: Set `animations: 'disabled'` globally:

```typescript
// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      animations: 'disabled',
    },
  },
});
```

For JS animations, wait for stable state before capture.

### Snapshot file names conflict between tests

**Cause**: Two tests use same screenshot name without unique paths.

**Fix**: Use explicit unique names:

```typescript
await expect(page).toHaveScreenshot('auth-home.png');
await expect(page).toHaveScreenshot('public-home.png');
```

Or customize snapshot path template:

```typescript
export default defineConfig({
  snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',
});
```

### Too many snapshot files to maintain

**Cause**: Visual tests for every page, browser, viewport.

**Fix**: Be selective. Visual test only high-risk pages:
- Landing and marketing pages
- Design system components
- Complex layouts (dashboards, data tables)
- Pages after major refactor

Skip pages where functional assertions cover key elements.