> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getsabo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing with Playwright

> Learn how to run, write, and debug end-to-end tests in Sabo using Playwright for comprehensive application testing.

<Info>
  Sabo includes a complete Playwright E2E testing suite covering authentication, dashboard, marketing pages, blog, changelog, and legal pages. Tests run across multiple browsers and viewports to ensure cross-platform compatibility.
</Info>

## Overview

Playwright is a powerful end-to-end testing framework that allows you to test your application in real browsers. Sabo's testing setup provides:

* **Cross-browser testing** (Chromium, Firefox, WebKit)
* **Mobile viewport testing** (Pixel 5, iPhone 12)
* **Comprehensive coverage across 16 spec files** spanning authentication, dashboard, marketing, blog, changelog, and legal flows
* **Authentication helpers** for testing protected routes
* **Parallel test execution** for fast feedback
* **Visual debugging** with Playwright UI mode
* **HTML test reports** with screenshots and traces

<Note>
  Playwright tests run against a production build (`pnpm build && pnpm start`) to ensure tests match real-world behavior.
</Note>

<Tip>
  Need deeper coverage (API testing, fixtures, component testing)? Keep the official <a href="https://playwright.dev/docs/intro" target="_blank" rel="noreferrer">Playwright documentation</a> open while following this guide.
</Tip>

***

## Quick Start

<Steps>
  <Step title="Install Playwright">
    Playwright is already included in `package.json`. If you need to reinstall or update:

    ```bash theme={null}
    # Install dependencies (includes Playwright)
    pnpm install

    # Install Playwright browsers (first time only)
    pnpm exec playwright install
    ```

    <Check>
      Playwright will download Chromium, Firefox, and WebKit browsers (\~300MB total).
    </Check>
  </Step>

  <Step title="Run your first test">
    Start the test suite with a single command:

    ```bash theme={null}
    # Run all tests
    pnpm test:e2e
    ```

    This command will:

    1. Build your application (`pnpm build`)
    2. Start a production server (`pnpm start` on port 3000)
    3. Run all tests across all configured browsers
    4. Generate an HTML report

    <Tip>
      Tests run in parallel by default for speed. On CI, tests run sequentially to avoid flakiness.
    </Tip>
  </Step>

  <Step title="View test results">
    After tests complete, view the detailed HTML report:

    ```bash theme={null}
    # Open test report in browser
    pnpm test:e2e:report
    ```

    The report includes:

    * Pass/fail status for each test
    * Screenshots of failures
    * Execution traces for debugging
    * Performance metrics

    <Check>
      Test reports are automatically generated in `playwright-report/` directory.
    </Check>
  </Step>
</Steps>

***

## Test Commands

Sabo provides several npm scripts for different testing workflows:

```bash package.json theme={null}
{
  "scripts": {
    "test:e2e": "playwright test",                 // Run all tests
    "test:e2e:ui": "playwright test --ui",         // Interactive UI mode
    "test:e2e:headed": "playwright test --headed", // See browser while testing
    "test:e2e:debug": "playwright test --debug",   // Step-by-step debugging
    "test:e2e:report": "playwright show-report"    // View HTML report
  }
}
```

### Command Breakdown

<AccordionGroup>
  <Accordion title="pnpm test:e2e - Standard test run">
    **When to use:** CI/CD pipelines, pre-commit checks, final verification before deployment.

    ```bash theme={null}
    pnpm test:e2e
    ```

    **Behavior:**

    * Runs all tests in headless mode (no browser UI)
    * Tests run in parallel for speed
    * Automatically builds and starts your app
    * Generates HTML report at the end
    * Exit code 0 (success) or 1 (failure) for CI integration

    **Output:**

    ```
    Running 85 tests using 5 workers
      ✓ homepage.spec.ts:8:5 › Homepage › should load homepage successfully (1.2s)
      ✓ sign-in.spec.ts:8:5 › Sign In Page › should load sign in page successfully (890ms)
      ...
      85 passed (1.5m)
    ```
  </Accordion>

  <Accordion title="pnpm test:e2e:ui - Interactive UI mode">
    **When to use:** Developing new tests, debugging flaky tests, exploring test coverage.

    ```bash theme={null}
    pnpm test:e2e:ui
    ```

    **Behavior:**

    * Opens Playwright's interactive test runner UI
    * Watch mode: tests re-run when files change
    * Time-travel debugging with DOM snapshots
    * Click through test steps one at a time
    * Filter tests by name, file, or status

    **Features:**

    * See test execution in real-time
    * Inspect DOM at each step
    * View network requests and console logs
    * Record new tests by clicking in your app
    * Compare screenshots side-by-side

    <Tip>
      UI mode is the fastest way to write new tests. Use the "Record" feature to generate test code automatically.
    </Tip>
  </Accordion>

  <Accordion title="pnpm test:e2e:headed - Visual browser mode">
    **When to use:** Debugging visual issues, understanding test flow, demonstrating tests to team.

    ```bash theme={null}
    pnpm test:e2e:headed
    ```

    **Behavior:**

    * Tests run with visible browser windows
    * See exactly what Playwright sees
    * Slower than headless mode
    * Useful for debugging timing issues

    **Example workflow:**

    1. Run `pnpm test:e2e:headed`
    2. Watch browser open and navigate your app
    3. Identify visual bugs or timing issues
    4. Fix and re-run specific tests
  </Accordion>

  <Accordion title="pnpm test:e2e:debug - Step-by-step debugging">
    **When to use:** Investigating test failures, understanding complex interactions, learning Playwright.

    ```bash theme={null}
    pnpm test:e2e:debug
    ```

    **Behavior:**

    * Opens Playwright Inspector
    * Pauses at each test step
    * Step forward/backward through test
    * Explore locators and selectors
    * Modify test code on the fly

    **Debugging features:**

    * **Pick locator:** Click any element to generate a selector
    * **Step over/into:** Control test execution
    * **Console:** Run Playwright commands interactively
    * **Source:** View test code with current line highlighted

    ```bash theme={null}
    # Debug a specific test file
    pnpm exec playwright test tests/e2e/auth/sign-in.spec.ts --debug

    # Debug a specific test by name
    pnpm exec playwright test --grep "should load homepage" --debug
    ```
  </Accordion>

  <Accordion title="pnpm test:e2e:report - View test results">
    **When to use:** After test run completes, reviewing failures, sharing results with team.

    ```bash theme={null}
    pnpm test:e2e:report
    ```

    **Behavior:**

    * Opens HTML report in your default browser
    * Shows pass/fail status for all tests
    * Includes screenshots of failures
    * Execution traces for debugging
    * Test duration and retry information

    **Report features:**

    * Filter by status (passed/failed/flaky/skipped)
    * Search tests by name
    * View screenshots and videos
    * Download trace files for offline debugging
    * Compare test results across runs
  </Accordion>
</AccordionGroup>

***

## Test Configuration

Playwright configuration is defined in `playwright.config.ts` at the project root:

```ts playwright.config.ts theme={null}
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",                       // Test files location
  fullyParallel: true,                      // Run tests in parallel
  forbidOnly: !!process.env.CI,            // Fail CI if test.only is left
  retries: process.env.CI ? 2 : 0,         // Retry flaky tests on CI
  workers: process.env.CI ? 1 : undefined, // Sequential on CI, parallel locally
  reporter: "html",                         // HTML report generator
  
  use: {
    baseURL: "http://localhost:3000",      // Base URL for page.goto('/')
    trace: "on-first-retry",               // Capture traces on retry
  },

  // Test across multiple browsers
  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 5"] } },
    { name: "Mobile Safari", use: { ...devices["iPhone 12"] } },
  ],

  // Auto-start dev server before tests
  webServer: {
    command: "pnpm build && pnpm start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000, // 2 minutes for build + start
  },
});
```

### Configuration Options Explained

<AccordionGroup>
  <Accordion title="testDir - Test files location">
    ```ts theme={null}
    testDir: "./tests"
    ```

    All test files must be inside the `tests/` directory. Playwright looks for files ending in `.spec.ts` or `.test.ts`.

    **Sabo's test structure:**

    ```
    tests/
    └── e2e/
        ├── auth/           # Authentication tests
        ├── dashboard/      # Dashboard tests
        ├── marketing/      # Marketing page tests
        ├── blog/           # Blog tests
        ├── changelog/      # Changelog tests
        ├── legal/          # Legal page tests
        └── helpers/        # Test utilities
    ```
  </Accordion>

  <Accordion title="fullyParallel - Run tests simultaneously">
    ```ts theme={null}
    fullyParallel: true
    ```

    Enables parallel test execution across multiple worker processes. Dramatically speeds up test runs.

    **Impact:**

    * **Without parallel:** 85 tests in \~8 minutes
    * **With parallel (5 workers):** 85 tests in \~1.5 minutes

    <Warning>
      Tests must be independent (no shared state) for parallel execution to work correctly.
    </Warning>
  </Accordion>

  <Accordion title="retries - Handle flaky tests">
    ```ts theme={null}
    retries: process.env.CI ? 2 : 0
    ```

    Automatically retry failed tests on CI environments. Local failures don't retry (faster feedback).

    **Why retry on CI:**

    * Network latency
    * Resource constraints
    * Timing issues in CI environments

    **Best practice:** Fix flaky tests instead of relying on retries. Use retries as a temporary safety net.
  </Accordion>

  <Accordion title="workers - Control parallelism">
    ```ts theme={null}
    workers: process.env.CI ? 1 : undefined
    ```

    * **Local:** Uses all CPU cores (`undefined` = auto)
    * **CI:** Sequential execution (`1` worker) for stability

    **Override workers:**

    ```bash theme={null}
    # Run with specific number of workers
    pnpm exec playwright test --workers=3

    # Run tests sequentially (helpful for debugging)
    pnpm exec playwright test --workers=1
    ```
  </Accordion>

  <Accordion title="baseURL - Simplified navigation">
    ```ts theme={null}
    use: {
      baseURL: "http://localhost:3000"
    }
    ```

    Allows relative URLs in tests:

    ```ts theme={null}
    // With baseURL
    await page.goto("/");           // Goes to http://localhost:3000/
    await page.goto("/dashboard");  // Goes to http://localhost:3000/dashboard

    // Without baseURL (not recommended)
    await page.goto("http://localhost:3000/dashboard");
    ```
  </Accordion>

  <Accordion title="webServer - Auto-start application">
    ```ts theme={null}
    webServer: {
      command: "pnpm build && pnpm start",
      url: "http://localhost:3000",
      reuseExistingServer: !process.env.CI,
      timeout: 120 * 1000,
    }
    ```

    Playwright automatically:

    1. Builds your app (`pnpm build`)
    2. Starts production server (`pnpm start`)
    3. Waits for server to be ready (checks `url`)
    4. Runs tests
    5. Shuts down server after tests complete

    **`reuseExistingServer`:**

    * **Local:** Reuses running server (faster during development)
    * **CI:** Always starts fresh server (ensures clean state)

    <Tip>
      Keep your dev server running (`pnpm dev`) during test development. Set `reuseExistingServer: true` to avoid rebuilding.
    </Tip>
  </Accordion>
</AccordionGroup>

***

## Test Structure

Sabo's tests are organized by feature area in `tests/e2e/`:

```
tests/e2e/
├── auth/                           # Authentication flows
│   ├── sign-in.spec.ts            # Sign in page tests
│   ├── sign-up.spec.ts            # Registration tests
│   └── password-reset.spec.ts     # Password reset flow
│
├── dashboard/                      # Protected dashboard area
│   ├── dashboard.spec.ts          # Main dashboard page
│   ├── settings-general.spec.ts   # General settings
│   ├── settings-account.spec.ts   # Account settings
│   ├── settings-billing.spec.ts   # Billing settings
│   └── settings-notifications.spec.ts
│
├── marketing/                      # Public marketing pages
│   ├── homepage.spec.ts           # Homepage tests
│   ├── pricing.spec.ts            # Pricing page tests
│   └── contact.spec.ts            # Contact form tests
│
├── blog/                           # Blog system
│   ├── blog-list.spec.ts          # Blog post listing
│   └── blog-post.spec.ts          # Individual post page
│
├── changelog/                      # Changelog system
│   ├── changelog-list.spec.ts     # Changelog listing
│   └── changelog-detail.spec.ts   # Changelog entry detail
│
├── legal/                          # Legal pages
│   └── legal-pages.spec.ts        # Privacy, ToS, Cookie Policy
│
└── helpers/                        # Test utilities
    └── auth.ts                     # Authentication helpers
```

### Test File Anatomy

Here's a typical test file structure:

```ts tests/e2e/marketing/contact.spec.ts theme={null}
import { test, expect } from "@playwright/test";

test.describe("Contact Page", () => {
  // Runs before each test in this describe block
  test.beforeEach(async ({ page }) => {
    await page.goto("/contact");
  });

  test("should load contact page successfully", async ({ page }) => {
    await expect(page).toHaveTitle(/Contact/);
  });

  test("should display contact form", async ({ page }) => {
    const form = page.locator("form");
    await expect(form).toBeVisible();
  });

  test("should validate email format", async ({ page }) => {
    const emailInput = page.locator('input[type="email"]');
    await emailInput.fill("invalid-email");
    
    const submitButton = page.locator('button[type="submit"]');
    await submitButton.click();
    
    const emailError = page.locator("text=/valid email/i");
    await expect(emailError).toBeVisible();
  });

  test("should accept valid form submission", async ({ page }) => {
    await page.locator('input[name="name"]').fill("Test User");
    await page.locator('input[type="email"]').fill("test@example.com");
    await page.locator('textarea').fill("This is a test message.");
    await page.locator('button[type="submit"]').click();

    const successMessage = page.locator("text=/success|thank you/i");
    await expect(successMessage).toBeVisible();
  });
});
```

### Test Patterns

<AccordionGroup>
  <Accordion title="test.describe() - Group related tests">
    ```ts theme={null}
    test.describe("Feature Name", () => {
      // All tests for this feature
    });
    ```

    **Benefits:**

    * Logical organization
    * Shared setup/teardown with `beforeEach`/`afterEach`
    * Better test reports (grouped by feature)
    * Can be nested for sub-features
  </Accordion>

  <Accordion title="test.beforeEach() - Setup before each test">
    ```ts theme={null}
    test.beforeEach(async ({ page }) => {
      // Navigate to page
      await page.goto("/dashboard");
      
      // Set up authentication (if needed)
      await setupAuthenticatedUser(page);
      
      // Reset state
      await page.evaluate(() => localStorage.clear());
    });
    ```

    **Use cases:**

    * Navigate to a common page
    * Set up authentication
    * Clear browser state
    * Inject test data
  </Accordion>

  <Accordion title="Locators - Find elements on the page">
    Playwright provides multiple ways to find elements:

    ```ts theme={null}
    // By role (most recommended - accessible)
    page.getByRole("button", { name: "Submit" })
    page.getByRole("link", { name: "Sign In" })

    // By text content
    page.locator("text=Hello World")
    page.locator("text=/success|thank you/i") // Regex, case-insensitive

    // By CSS selector
    page.locator("form")
    page.locator('input[type="email"]')
    page.locator(".btn-primary")

    // By test ID (best for dynamic content)
    page.locator('[data-testid="user-menu"]')

    // By placeholder
    page.getByPlaceholder("Enter your email")

    // By label text (for form inputs)
    page.getByLabel("Email address")
    ```

    **Best practices:**

    1. Prefer `getByRole` for accessibility
    2. Use `data-testid` for dynamic content
    3. Avoid brittle selectors (CSS classes that may change)
    4. Use regex for flexible text matching
  </Accordion>

  <Accordion title="Assertions - Verify expected behavior">
    ```ts theme={null}
    // Page assertions
    await expect(page).toHaveURL("/dashboard");
    await expect(page).toHaveTitle(/Dashboard/);

    // Element visibility
    await expect(page.locator("form")).toBeVisible();
    await expect(page.locator(".loading")).toBeHidden();

    // Element state
    await expect(page.locator("button")).toBeEnabled();
    await expect(page.locator("input")).toBeDisabled();
    await expect(page.locator("checkbox")).toBeChecked();

    // Text content
    await expect(page.locator("h1")).toHaveText("Welcome");
    await expect(page.locator("p")).toContainText("Hello");

    // Count
    await expect(page.locator("li")).toHaveCount(5);

    // Attribute
    await expect(page.locator("a")).toHaveAttribute("href", "/about");
    ```

    <Tip>
      Playwright's assertions are **auto-waiting**: they retry until the condition is met or timeout occurs (default 30s).
    </Tip>
  </Accordion>
</AccordionGroup>

***

## Writing Your First Test

Let's write a test for a new feature page step by step.

<Steps>
  <Step title="Create test file">
    Create a new file in the appropriate directory:

    ```bash theme={null}
    tests/e2e/features/new-feature.spec.ts
    ```

    <Tip>
      Name your test file after the feature or page you're testing, ending with `.spec.ts`.
    </Tip>
  </Step>

  <Step title="Import Playwright">
    ```ts tests/e2e/features/new-feature.spec.ts theme={null}
    import { test, expect } from "@playwright/test";
    ```
  </Step>

  <Step title="Write your first test">
    ```ts tests/e2e/features/new-feature.spec.ts theme={null}
    import { test, expect } from "@playwright/test";

    test.describe("New Feature Page", () => {
      test.beforeEach(async ({ page }) => {
        await page.goto("/new-feature");
      });

      test("should load the page successfully", async ({ page }) => {
        // Check page loaded
        await expect(page).toHaveURL("/new-feature");
        
        // Check title is correct
        await expect(page).toHaveTitle(/New Feature/);
        
        // Check main heading is visible
        const heading = page.locator("h1");
        await expect(heading).toBeVisible();
        await expect(heading).toHaveText("New Feature");
      });
    });
    ```
  </Step>

  <Step title="Run your test">
    ```bash theme={null}
    # Run only your new test file
    pnpm exec playwright test new-feature.spec.ts

    # Run with UI for easier debugging
    pnpm exec playwright test new-feature.spec.ts --ui
    ```

    <Check>
      If your test passes, you'll see a green checkmark. If it fails, Playwright will show exactly which assertion failed and why.
    </Check>
  </Step>

  <Step title="Add more test cases">
    ```ts theme={null}
    test("should display feature description", async ({ page }) => {
      const description = page.locator('p[class*="description"]');
      await expect(description).toBeVisible();
      await expect(description).toContainText("This feature allows you to");
    });

    test("should have a call-to-action button", async ({ page }) => {
      const ctaButton = page.getByRole("button", { name: /get started/i });
      await expect(ctaButton).toBeVisible();
      await expect(ctaButton).toBeEnabled();
    });

    test("should navigate when CTA is clicked", async ({ page }) => {
      const ctaButton = page.getByRole("button", { name: /get started/i });
      await ctaButton.click();
      
      // Should redirect to sign-up
      await expect(page).toHaveURL("/sign-up");
    });
    ```
  </Step>
</Steps>

***

## Testing Protected Routes

Many pages in your app require authentication. Sabo provides an auth helper to set up authenticated sessions in tests.

### Authentication Helper

The auth helper is located at `tests/e2e/helpers/auth.ts`:

```ts tests/e2e/helpers/auth.ts theme={null}
import { Page } from "@playwright/test";

export async function setupAuthenticatedUser(page: Page): Promise<void> {
  const { createClient } = await import("@supabase/supabase-js");
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  const { data, error } = await supabase.auth.signInWithPassword({
    email: process.env.TEST_USER_EMAIL!,
    password: process.env.TEST_USER_PASSWORD!,
  });

  if (error) {
    throw new Error(`Failed to authenticate test user: ${error.message}`);
  }

  await page.context().addCookies([
    {
      name: "sb-access-token",
      value: data.session.access_token,
      domain: "localhost",
      path: "/",
      httpOnly: true,
      secure: false,
      sameSite: "Lax",
    },
    {
      name: "sb-refresh-token",
      value: data.session.refresh_token,
      domain: "localhost",
      path: "/",
      httpOnly: true,
      secure: false,
      sameSite: "Lax",
    },
  ]);

  console.warn(
    "setupAuthenticatedUser is not implemented yet. Add test credentials to .env.test and uncomment the implementation."
  );
}
```

<Tip>
  The helper logs a warning by default as a reminder to configure test credentials. Once `.env.test` is set up and you have confirmed the helper works, delete or comment out the `console.warn` line to keep test output clean.
</Tip>

### Using the Auth Helper

```ts tests/e2e/dashboard/dashboard.spec.ts theme={null}
import { test, expect } from "@playwright/test";
import { setupAuthenticatedUser } from "../helpers/auth";

test.describe("Dashboard", () => {
  test.beforeEach(async ({ page }) => {
    await setupAuthenticatedUser(page);
    await page.goto("/dashboard");
  });

  test("should show user menu", async ({ page }) => {
    const userMenu = page
      .locator("button")
      .filter({ has: page.locator('img[alt*="avatar" i], svg') })
      .first();

    await userMenu.click();
    await expect(
      page.locator("text=/account|billing|log out/i").first()
    ).toBeVisible();
  });
});
```

<Info>
  In the repository, tests that require authentication are marked with `test.skip` until `setupAuthenticatedUser()` is configured. Once your test credentials are in place, remove the skips to exercise the protected flows.
</Info>

<Tip>
  Want to reuse Playwright's built-in authentication storage? See the official <a href="https://playwright.dev/docs/auth" target="_blank" rel="noreferrer">Playwright authentication guide</a>. If you need to understand how Supabase sessions are issued in Sabo before writing tests, review <a href="/core-features/auth-with-supabase">Auth with Supabase</a>.
</Tip>

### Setting Up Test Credentials

Create a test user in your Supabase dashboard, then add credentials to `.env.test`:

```bash .env.test theme={null}
# Test user credentials
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=your_secure_test_password

# Supabase credentials (same as .env.local)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```

<Warning>
  **Never commit** `.env.test` to version control. Add it to `.gitignore`. Use a dedicated test user, not a real user account.
</Warning>

<Note>
  Playwright doesn’t automatically load `.env.test`. Either export these variables in your shell before running tests or run commands through the `dotenv` CLI: `pnpm exec dotenv -e .env.test -- pnpm test:e2e`. This ensures helpers like `setupAuthenticatedUser()` can read the credentials.
</Note>

***

## Common Test Patterns

### Testing Forms

```ts theme={null}
test("should submit contact form successfully", async ({ page }) => {
  // Fill form fields
  await page.locator('input[name="firstName"]').fill("John");
  await page.locator('input[name="lastName"]').fill("Doe");
  await page.locator('input[type="email"]').fill("john@example.com");
  await page.locator('textarea[name="message"]').fill("Hello, this is a test message.");
  
  // Submit form
  await page.locator('button[type="submit"]').click();
  
  // Verify success message
  await expect(page.locator("text=/success|thank you/i")).toBeVisible();
  
  // Verify form is cleared
  await expect(page.locator('input[name="firstName"]')).toHaveValue("");
});
```

### Testing Navigation

```ts theme={null}
test("should navigate to pricing page", async ({ page }) => {
  await page.goto("/");
  
  // Click navigation link
  const pricingLink = page.getByRole("link", { name: /pricing/i });
  await pricingLink.click();
  
  // Verify navigation
  await expect(page).toHaveURL("/pricing");
  await expect(page.locator("h1")).toHaveText("Pricing");
});
```

### Testing Responsive Design

```ts theme={null}
test("should be responsive on mobile", async ({ page }) => {
  // Set mobile viewport
  await page.setViewportSize({ width: 375, height: 667 });
  
  await page.goto("/");
  
  // Mobile menu should be visible
  const mobileMenuButton = page.locator('button[aria-label="Menu"]');
  await expect(mobileMenuButton).toBeVisible();
  
  // Open mobile menu
  await mobileMenuButton.click();
  
  // Mobile nav should appear
  const mobileNav = page.locator('nav[data-mobile]');
  await expect(mobileNav).toBeVisible();
});
```

### Testing Conditional Rendering

```ts theme={null}
test("should show billing section for paid users", async ({ page }) => {
  await setupAuthenticatedUser(page);
  await page.goto("/dashboard/settings/billing");
  
  // Check if user has paid plan
  const planBadge = page.locator('[data-plan]');
  const planType = await planBadge.getAttribute("data-plan");
  
  if (planType === "pro" || planType === "enterprise") {
    // Paid users should see billing history
    await expect(page.locator("text=/billing history/i")).toBeVisible();
  } else {
    // Free users should see upgrade prompt
    await expect(page.locator("text=/upgrade/i")).toBeVisible();
  }
});
```

### Testing Async Operations

```ts theme={null}
test("should load and display blog posts", async ({ page }) => {
  await page.goto("/blog");
  
  // Wait for posts to load
  await page.waitForSelector('[data-testid="blog-post"]', { timeout: 5000 });
  
  // Verify posts are displayed
  const posts = page.locator('[data-testid="blog-post"]');
  const postCount = await posts.count();
  
  expect(postCount).toBeGreaterThan(0);
  
  // Check first post has required elements
  const firstPost = posts.first();
  await expect(firstPost.locator("h2")).toBeVisible();
  await expect(firstPost.locator("img")).toBeVisible();
  await expect(firstPost.locator("time")).toBeVisible();
});
```

***

## Debugging Tests

<AccordionGroup>
  <Accordion title="Using Playwright Inspector">
    The Playwright Inspector provides step-by-step debugging:

    ```bash theme={null}
    # Debug specific test
    pnpm exec playwright test auth/sign-in.spec.ts --debug
    ```

    **Features:**

    * **Step through:** Execute one action at a time
    * **Pick locator:** Click elements to generate selectors
    * **Console:** Run Playwright commands interactively
    * **Screenshots:** Capture state at each step

    **Common debugging commands:**

    ```js theme={null}
    // In Playwright Inspector console
    page.locator('button') // Test selector
    page.screenshot() // Capture current state
    page.pause() // Add breakpoint in test code
    ```
  </Accordion>

  <Accordion title="Using console.log() in tests">
    Add console output to understand test flow:

    ```ts theme={null}
    test("debugging test", async ({ page }) => {
      await page.goto("/dashboard");
      
      console.log("Current URL:", page.url());
      
      const button = page.locator("button");
      const isVisible = await button.isVisible();
      console.log("Button visible:", isVisible);
      
      const count = await page.locator("li").count();
      console.log("List item count:", count);
    });
    ```

    **View output:**

    ```bash theme={null}
    pnpm test:e2e -- --headed
    ```

    Console logs appear in terminal output.
  </Accordion>

  <Accordion title="Taking screenshots">
    Capture screenshots at specific points:

    ```ts theme={null}
    test("screenshot debugging", async ({ page }) => {
      await page.goto("/dashboard");
      
      // Take screenshot
      await page.screenshot({ path: "screenshots/dashboard.png" });
      
      // Screenshot specific element
      const sidebar = page.locator("aside");
      await sidebar.screenshot({ path: "screenshots/sidebar.png" });
    });
    ```

    <Tip>
      Screenshots are automatically captured on test failures. Find them in `test-results/` directory.
    </Tip>
  </Accordion>

  <Accordion title="Using test.skip() and test.only()">
    Control which tests run during debugging:

    ```ts theme={null}
    // Run only this test (ignore all others)
    test.only("focused test", async ({ page }) => {
      // ...
    });

    // Skip this test temporarily
    test.skip("broken test to fix later", async ({ page }) => {
      // ...
    });

    // Conditional skip
    test("auth test", async ({ page }) => {
      test.skip(!process.env.TEST_USER_EMAIL, "Test credentials not configured");
      // ...
    });
    ```

    <Warning>
      Remove `test.only()` before committing! CI will fail if `test.only()` is detected (`forbidOnly: true` in config).
    </Warning>
  </Accordion>

  <Accordion title="Analyzing trace files">
    Trace files provide complete test execution history:

    ```bash theme={null}
    # Run test with trace (automatically enabled on failures)
    pnpm test:e2e

    # View trace from failed test
    pnpm exec playwright show-trace test-results/path/to/trace.zip
    ```

    **Trace viewer features:**

    * Timeline of all actions
    * Screenshots at each step
    * Network requests and responses
    * Console logs and errors
    * DOM snapshots (time-travel debugging)

    **Manual trace capture:**

    ```ts theme={null}
    test("with trace", async ({ page }) => {
      await page.context().tracing.start({ screenshots: true, snapshots: true });
      
      // Your test actions
      await page.goto("/dashboard");
      
      await page.context().tracing.stop({ path: "trace.zip" });
    });
    ```
  </Accordion>
</AccordionGroup>

***

## Best Practices

<Tip>
  For more examples straight from the Playwright team, refer to the official <a href="https://playwright.dev/docs/best-practices" target="_blank" rel="noreferrer">Best Practices guide</a>. It complements the patterns outlined below.
</Tip>

<AccordionGroup>
  <Accordion title="1. Write independent tests">
    Each test should be completely isolated and not depend on other tests.

    **Bad:**

    ```ts theme={null}
    let userId: string;

    test("create user", async ({ page }) => {
      userId = await createUser(); // Sets global state
    });

    test("update user", async ({ page }) => {
      await updateUser(userId); // Depends on previous test
    });
    ```

    **Good:**

    ```ts theme={null}
    test("create user", async ({ page }) => {
      const userId = await createUser();
      // Test only user creation
    });

    test("update user", async ({ page }) => {
      const userId = await createUser(); // Create fresh user
      await updateUser(userId);
      // Test only user update
    });
    ```
  </Accordion>

  <Accordion title="2. Use descriptive test names">
    Test names should clearly describe what is being tested.

    **Bad:**

    ```ts theme={null}
    test("test 1", async ({ page }) => { });
    test("it works", async ({ page }) => { });
    ```

    **Good:**

    ```ts theme={null}
    test("should display validation error for empty email field", async ({ page }) => { });
    test("should redirect to dashboard after successful sign in", async ({ page }) => { });
    ```

    **Pattern:** "should \[expected behavior] when \[condition]"
  </Accordion>

  <Accordion title="3. Prefer user-facing selectors">
    Use selectors that reflect how users interact with your app.

    **Priority order:**

    1. **Role-based** (best for accessibility)
       ```ts theme={null}
       page.getByRole("button", { name: "Submit" })
       ```

    2. **Label-based** (for form inputs)
       ```ts theme={null}
       page.getByLabel("Email address")
       ```

    3. **Test IDs** (for dynamic content)
       ```ts theme={null}
       page.locator('[data-testid="user-menu"]')
       ```

    4. **Text content** (for static text)
       ```ts theme={null}
       page.locator("text=Welcome")
       ```

    **Avoid:**

    * CSS classes that may change: `.btn-primary-v2-new`
    * Complex CSS selectors: `div > ul > li:nth-child(3)`
    * XPath selectors (hard to maintain)
  </Accordion>

  <Accordion title="4. Keep tests fast">
    Fast tests provide quick feedback and encourage frequent testing.

    **Tips:**

    * Use `baseURL` to avoid full URLs
    * Reuse authenticated sessions when possible
    * Mock external API calls (if needed)
    * Run tests in parallel (`fullyParallel: true`)
    * Use `page.waitForLoadState("domcontentloaded")` instead of arbitrary timeouts

    **Bad:**

    ```ts theme={null}
    await page.waitForTimeout(3000); // Arbitrary delay
    ```

    **Good:**

    ```ts theme={null}
    await page.waitForSelector('[data-testid="loaded"]'); // Wait for specific element
    ```
  </Accordion>

  <Accordion title="5. Group related tests">
    Use `test.describe()` to organize tests logically.

    ```ts theme={null}
    test.describe("Authentication", () => {
      test.describe("Sign In", () => {
        test("should sign in with valid credentials", async ({ page }) => { });
        test("should show error for invalid credentials", async ({ page }) => { });
      });

      test.describe("Sign Up", () => {
        test("should register new user", async ({ page }) => { });
        test("should validate email format", async ({ page }) => { });
      });
    });
    ```

    **Benefits:**

    * Better organized test reports
    * Shared setup with `beforeEach`
    * Easier to run specific test groups
  </Accordion>

  <Accordion title="6. Clean up test data">
    Remove test data after tests to ensure isolation.

    ```ts theme={null}
    test.afterEach(async ({ page }) => {
      // Clean up: delete test user, clear database, etc.
      await cleanupTestUser();
    });

    test.afterAll(async () => {
      // Clean up: close connections, delete temp files, etc.
    });
    ```

    <Note>
      Playwright automatically clears browser cookies, local storage, and session storage between tests. You only need to clean up server-side data.
    </Note>
  </Accordion>
</AccordionGroup>

***

## CI/CD Integration

Playwright tests can run automatically in your CI/CD pipeline.

<Info>
  For provider-specific examples (GitHub Actions, GitLab, Jenkins, Azure), refer to Playwright's <a href="https://playwright.dev/docs/ci" target="_blank" rel="noreferrer">CI guide</a>; the workflow below shows how we configure GitHub Actions for Sabo.
</Info>

### GitHub Actions Example

```yaml .github/workflows/playwright.yml theme={null}
name: Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          
      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
          
      - name: Install dependencies
        run: pnpm install
        
      - name: Install Playwright browsers
        run: pnpm exec playwright install --with-deps
        
      - name: Run Playwright tests
        run: pnpm test:e2e
        env:
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
          
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
```

### Vercel Integration

Run tests before deployment:

```json vercel.json theme={null}
{
  "buildCommand": "pnpm build && pnpm test:e2e",
  "installCommand": "pnpm install && pnpm exec playwright install"
}
```

<Warning>
  Running E2E tests on every deployment can slow down your pipeline. Consider running them only on main branch or scheduled runs.
</Warning>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Tests fail with 'Target closed' error">
    **Cause:** Browser crashed or was closed unexpectedly.

    **Fix:**

    1. Check for memory issues (tests using too much RAM)
    2. Reduce parallel workers: `pnpm exec playwright test --workers=1`
    3. Update Playwright: `pnpm update @playwright/test`
    4. Check for infinite loops or long-running operations
  </Accordion>

  <Accordion title="Tests are flaky (pass/fail randomly)">
    **Cause:** Race conditions, timing issues, or network dependencies.

    **Fix:**

    1. Avoid `page.waitForTimeout()` - use specific waits instead
    2. Use auto-waiting assertions (`await expect(...)`)
    3. Wait for network to be idle: `await page.waitForLoadState("networkidle")`
    4. Increase timeout for slow operations: `{ timeout: 60000 }`
    5. Mock external API calls to remove network dependency
  </Accordion>

  <Accordion title="'locator.click(): Target element is not visible' error">
    **Cause:** Element is hidden, covered, or not yet rendered.

    **Fix:**

    1. Wait for element to be visible first:
       ```ts theme={null}
       await page.locator("button").waitFor({ state: "visible" });
       await page.locator("button").click();
       ```

    2. Scroll element into view:
       ```ts theme={null}
       await page.locator("button").scrollIntoViewIfNeeded();
       await page.locator("button").click();
       ```

    3. Check for overlays or modals covering the element

    4. Use `force: true` (last resort):
       ```ts theme={null}
       await page.locator("button").click({ force: true });
       ```
  </Accordion>

  <Accordion title="Authentication helper not working">
    **Cause:** Missing test credentials or incorrect Supabase configuration.

    **Fix:**

    1. Verify `.env.test` exists with correct credentials
    2. Check test user exists in Supabase dashboard
    3. Ensure `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` are set
    4. Verify cookie domain matches (`localhost` for local tests)
    5. Check Supabase session is not expired
  </Accordion>

  <Accordion title="Tests pass locally but fail on CI">
    **Cause:** Different environment, timing, or dependencies.

    **Fix:**

    1. Run tests sequentially on CI: `workers: 1` in config
    2. Enable retries on CI: `retries: 2` in config
    3. Check environment variables are set in CI
    4. Increase timeouts for slower CI environments
    5. Use `webServer.reuseExistingServer: !process.env.CI` to ensure fresh server
  </Accordion>

  <Accordion title="Playwright browsers not installing">
    **Cause:** Network issues, permissions, or disk space.

    **Fix:**

    ```bash theme={null}
    # Install with dependencies
    pnpm exec playwright install --with-deps

    # Install specific browser
    pnpm exec playwright install chromium

    # Clear cache and reinstall
    rm -rf ~/.cache/ms-playwright
    pnpm exec playwright install
    ```

    **Check disk space:**

    ```bash theme={null}
    df -h
    ```

    Playwright browsers require \~1GB of disk space.
  </Accordion>
</AccordionGroup>

***
