{"content":"# Browser Tests with Playwright\n\nEnd-to-end browser testing with Playwright. Test user interactions, form validation, navigation, and visual feedback with full browser automation.\n\n## Install Playwright\n\nInstall Playwright as a dev dependency:\n\n```bash\nbun add -D @playwright/test\n```\n\nInstall browsers (run once):\n\n```bash\nbunx playwright install chromium\n```\n\n---\n\n## Update .gitignore\n\nAdd Playwright output directories to `.gitignore`:\n\n```gitignore\n# Playwright\ntest-results/\nplaywright-report/\n```\n\nThese directories contain screenshots, traces, and HTML reports generated during test runs.\n\n---\n\n## Folder Structure\n\nPlaywright tests live in `tests/playwright/` and are organized by feature:\n\n```\ntests/\n├── playwright/\n│   ├── auth.spec.ts            # Authentication flow tests\n│   ├── chat.spec.ts            # Chat feature tests\n│   ├── home.spec.ts            # Homepage tests\n│   ├── profile.spec.ts         # Profile page tests\n│   ├── errors.spec.ts          # Error page tests\n│   └── lib/\n│       └── test-user.ts        # Playwright-specific test helpers\n```\n\nPlaywright tests use the `.spec.ts` suffix to distinguish them from Bun tests.\n\n---\n\n## When to Write Playwright Tests\n\nPlaywright tests are the **preferred testing method** for features that involve:\n\n- User interactions (clicking, typing, form submission)\n- Visual feedback (toasts, loading states, modals)\n- Navigation and URL changes\n- Complex multi-step UI flows\n- Accessibility testing (via Playwright's accessibility tree)\n\n**Use integration tests instead** when:\n\n- Testing API responses only (no UI)\n- Verifying database state after operations\n- Testing internal server logic\n\n---\n\n## Playwright Configuration\n\nCreate `playwright.config.ts`:\n\n```typescript\nimport { defineConfig, devices } from \"@playwright/test\";\n\n/**\n * Playwright configuration for browser tests.\n *\n * Tests run against the server at TEST_BASE_URL (default: http://localhost:3000).\n * When running via `bun run test`, the test-with-branch.ts script starts\n * a test server with a fresh database branch.\n */\n\nconst baseURL = process.env.TEST_BASE_URL || \"http://localhost:3000\";\n\nexport default defineConfig({\n  testDir: \"./tests/playwright\",\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: \"list\",\n  use: {\n    baseURL,\n    trace: \"on-first-retry\",\n    screenshot: \"only-on-failure\",\n  },\n  projects: [\n    {\n      name: \"chromium\",\n      use: { ...devices[\"Desktop Chrome\"] },\n    },\n  ],\n  // Don't start dev server - test-with-branch.ts handles this\n  webServer: undefined,\n});\n```\n\n---\n\n## Test Helpers\n\nCreate `tests/playwright/lib/test-user.ts` for browser-based authentication:\n\n```typescript\nimport type { Page } from \"@playwright/test\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nconst BASE_URL = process.env.TEST_BASE_URL || \"http://localhost:3000\";\n\nexport type TestUser = {\n  id: string;\n  email: string;\n  name: string;\n  password: string;\n};\n\n/**\n * Create a test user via the Better Auth API.\n * Uses fetch for speed (faster than browser UI).\n */\nexport async function createTestUser(\n  overrides: Partial<Omit<TestUser, \"id\">> = {},\n): Promise<TestUser> {\n  const uniqueId = uuidv4().slice(0, 8);\n  const email = overrides.email || `test-${uniqueId}@example.com`;\n  const name = overrides.name || \"Test User\";\n  const password = overrides.password || \"testpassword123\";\n\n  const signUpRes = await fetch(`${BASE_URL}/api/auth/sign-up/email`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ email, password, name }),\n  });\n\n  if (!signUpRes.ok) {\n    const text = await signUpRes.text();\n    throw new Error(`Failed to create test user: ${signUpRes.status} ${text}`);\n  }\n\n  const signUpData = await signUpRes.json();\n  const userId = signUpData.user.id;\n\n  return { id: userId, email, name, password };\n}\n\n/**\n * Sign in a test user via the browser UI\n */\nexport async function signInWithBrowser(\n  page: Page,\n  email: string,\n  password: string,\n): Promise<void> {\n  await page.goto(\"/sign-in\");\n\n  await page.getByLabel(/email/i).fill(email);\n  await page.getByLabel(/password/i).fill(password);\n  await page.getByRole(\"button\", { name: /sign in/i }).click();\n\n  await page.waitForURL(/\\/chats/, { timeout: 10000 });\n}\n\n/**\n * Sign out via the browser UI\n */\nexport async function signOutWithBrowser(page: Page): Promise<void> {\n  const userMenu = page\n    .locator('[data-user-menu], [aria-label*=\"user\"]')\n    .first();\n  if ((await userMenu.count()) > 0) {\n    await userMenu.click();\n  } else {\n    const avatar = page.locator(\"header button\").last();\n    await avatar.click();\n  }\n\n  await page.getByRole(\"menuitem\", { name: /sign out/i }).click();\n  await page.waitForURL(\"/\");\n}\n\n/**\n * Check if currently signed in\n */\nexport async function isSignedIn(page: Page): Promise<boolean> {\n  const signInButton = page.getByRole(\"link\", { name: /sign in/i });\n  return (await signInButton.count()) === 0;\n}\n```\n\n---\n\n## Writing Playwright Tests\n\n### Example: Homepage Tests\n\nCreate `tests/playwright/home.spec.ts`:\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\ntest.describe(\"Homepage\", () => {\n  test.describe(\"Hero Section\", () => {\n    test(\"should display hero title and description\", async ({ page }) => {\n      await page.goto(\"/\");\n\n      await expect(\n        page.getByRole(\"heading\", { name: /My App/i }).first(),\n      ).toBeVisible();\n    });\n\n    test(\"should have working CTA button\", async ({ page }) => {\n      await page.goto(\"/\");\n\n      const ctaButton = page.getByRole(\"link\", { name: /Get Started/i });\n      await expect(ctaButton).toBeVisible();\n      await ctaButton.click();\n\n      await expect(page).toHaveURL(/sign-up/);\n    });\n  });\n\n  test.describe(\"Theme Toggle\", () => {\n    test(\"should toggle between light and dark theme\", async ({ page }) => {\n      await page.goto(\"/\");\n\n      const themeToggle = page.getByRole(\"button\", { name: /toggle theme/i });\n      await expect(themeToggle).toBeVisible();\n\n      await themeToggle.click();\n\n      const darkOption = page.getByRole(\"menuitem\", { name: /dark/i });\n      if ((await darkOption.count()) > 0) {\n        await darkOption.click();\n        await page.waitForTimeout(100);\n\n        const html = page.locator(\"html\");\n        const newClass = await html.getAttribute(\"class\");\n        expect(newClass).toContain(\"dark\");\n      }\n    });\n  });\n});\n```\n\n### Example: Authentication Tests\n\nCreate `tests/playwright/auth.spec.ts`:\n\n```typescript\nimport { test, expect } from \"@playwright/test\";\n\ntest.describe(\"Sign In Page\", () => {\n  test(\"should display sign in form\", async ({ page }) => {\n    await page.goto(\"/sign-in\");\n\n    await expect(page.getByRole(\"heading\", { name: /sign in/i })).toBeVisible();\n    await expect(page.getByLabel(/email/i)).toBeVisible();\n    await expect(page.getByLabel(/password/i)).toBeVisible();\n    await expect(page.getByRole(\"button\", { name: /sign in/i })).toBeVisible();\n  });\n\n  test(\"should validate required fields\", async ({ page }) => {\n    await page.goto(\"/sign-in\");\n\n    await page.getByRole(\"button\", { name: /sign in/i }).click();\n\n    const emailInput = page.getByLabel(/email/i);\n    const isInvalid = await emailInput.evaluate(\n      (el: HTMLInputElement) => !el.validity.valid,\n    );\n    expect(isInvalid).toBe(true);\n  });\n\n  test(\"should show error for invalid credentials\", async ({ page }) => {\n    await page.goto(\"/sign-in\");\n\n    await page.getByLabel(/email/i).fill(\"nonexistent@example.com\");\n    await page.getByLabel(/password/i).fill(\"wrongpassword\");\n    await page.getByRole(\"button\", { name: /sign in/i }).click();\n\n    await expect(\n      page\n        .getByText(/invalid|incorrect|error/i)\n        .or(page.locator('[role=\"alert\"]')),\n    ).toBeVisible({ timeout: 5000 });\n  });\n\n  test(\"should link to forgot password page\", async ({ page }) => {\n    await page.goto(\"/sign-in\");\n\n    const forgotLink = page.getByRole(\"link\", { name: /forgot/i });\n    await expect(forgotLink).toBeVisible();\n    await forgotLink.click();\n\n    await expect(page).toHaveURL(/forgot-password/);\n  });\n\n  test(\"should link to sign up page\", async ({ page }) => {\n    await page.goto(\"/sign-in\");\n\n    const signUpLink = page.getByRole(\"link\", { name: /sign up|create/i });\n    await expect(signUpLink).toBeVisible();\n    await signUpLink.click();\n\n    await expect(page).toHaveURL(/sign-up/);\n  });\n});\n\ntest.describe(\"Protected Routes\", () => {\n  test(\"should redirect unauthenticated user from /chats\", async ({ page }) => {\n    await page.goto(\"/chats\");\n    await expect(page).toHaveURL(/sign-in/);\n  });\n\n  test(\"should redirect unauthenticated user from /profile\", async ({\n    page,\n  }) => {\n    await page.goto(\"/profile\");\n    await expect(page).toHaveURL(/sign-in/);\n  });\n});\n```\n\n---\n\n## Testing Patterns\n\n**1. Wait for navigation:**\n\n```typescript\nawait page.goto(\"/some-page\");\nawait expect(page).toHaveURL(/expected-url/);\n```\n\n**2. Click and verify dialog:**\n\n```typescript\nawait page.getByRole(\"button\", { name: /delete/i }).click();\nawait expect(page.getByRole(\"dialog\")).toBeVisible();\nawait expect(page.getByText(/confirm/i)).toBeVisible();\n```\n\n**3. Form validation:**\n\n```typescript\nconst emailInput = page.getByLabel(/email/i);\nconst isInvalid = await emailInput.evaluate(\n  (el: HTMLInputElement) => !el.validity.valid,\n);\nexpect(isInvalid).toBe(true);\n```\n\n**4. Conditional element checks:**\n\n```typescript\nconst element = page.getByRole(\"button\", { name: /optional/i });\nif ((await element.count()) > 0) {\n  await expect(element).toBeVisible();\n}\n```\n\n**5. Wait for async content:**\n\n```typescript\nawait expect(page.getByText(/loading complete/i)).toBeVisible({\n  timeout: 5000,\n});\n```\n\n---\n\n## Running Playwright Tests\n\n```bash\nbun run test:playwright                 # Run with isolated Neon branch\nbunx playwright test                    # Run all Playwright tests directly\nbunx playwright test auth.spec.ts       # Run a specific test file\nbunx playwright test --headed           # Run with browser visible\nbunx playwright test --ui               # Open interactive UI\nbunx playwright show-report             # View last test report\n```\n\n---\n\n## CI with GitHub Actions\n\n```yaml\n# .github/workflows/test.yml\nname: Test\n\non:\n  push:\n    branches: [\"**\"]\n  pull_request:\n    branches: [\"**\"]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    env:\n      NEON_API_KEY: ${{ secrets.NEON_API_KEY }}\n      NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: oven-sh/setup-bun@v2\n        with:\n          bun-version: latest\n\n      - run: bun install --frozen-lockfile\n\n      - name: Install Playwright browsers\n        run: bunx playwright install chromium --with-deps\n\n      - name: Run Playwright tests\n        run: bun run test:playwright\n\n      - name: Upload test artifacts\n        if: failure()\n        uses: actions/upload-artifact@v4\n        with:\n          name: playwright-report\n          path: |\n            playwright-report/\n            test-results/\n          retention-days: 7\n```\n\nThe `--with-deps` flag installs system dependencies needed by Chromium on Ubuntu."}