SurfSense/.cursor/skills/playwright-testing/configuration.md
2026-05-04 13:54:13 +05:30

22 KiB
Executable file

Configuration

When to use: Setting up a new Playwright project, adjusting timeouts, adding browser targets, configuring CI behavior, or connecting environment-specific settings.

Quick Reference

npx playwright init                    # scaffold config + first test
npx playwright test --config=custom.config.ts  # use non-default config
npx playwright test --project=chromium # run single project
npx playwright test --reporter=html    # override reporter
npx playwright show-report             # open last HTML report
DEBUG=pw:api npx playwright test       # verbose Playwright logging

Production-Ready Config (Copy-Paste Starter)

TypeScript

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';

// Load environment variables from .env file
dotenv.config({ path: path.resolve(__dirname, '.env') });

export default defineConfig({
  // ── Test discovery ──────────────────────────────────────────────
  testDir: './tests',
  testMatch: '**/*.spec.ts',

  // ── Execution ───────────────────────────────────────────────────
  fullyParallel: true,
  forbidOnly: !!process.env.CI,       // fail CI if test.only left in
  retries: process.env.CI ? 2 : 0,    // retry flakes in CI only
  workers: process.env.CI ? '50%' : undefined, // half CPU in CI, auto locally

  // ── Reporting ───────────────────────────────────────────────────
  reporter: process.env.CI
    ? [['html', { open: 'never' }], ['github']]
    : [['html', { open: 'on-failure' }]],

  // ── Timeouts ────────────────────────────────────────────────────
  timeout: 30_000,                     // per-test timeout
  expect: {
    timeout: 5_000,                    // per-assertion retry timeout
  },

  // ── Shared browser context options ──────────────────────────────
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    actionTimeout: 10_000,             // click, fill, etc.
    navigationTimeout: 15_000,         // goto, waitForURL, etc.

    // Artifact collection
    trace: 'on-first-retry',          // full trace on first retry only
    screenshot: 'only-on-failure',     // screenshot on failure
    video: 'retain-on-failure',        // video only kept for failures

    // Sensible defaults
    locale: 'en-US',
    timezoneId: 'America/New_York',
    extraHTTPHeaders: {
      'x-test-automation': 'playwright',
    },
  },

  // ── Projects (browser targets) ─────────────────────────────────
  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'] },
    },
  ],

  // ── Dev server ──────────────────────────────────────────────────
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,                  // 2 min for cold builds
    stdout: 'pipe',
    stderr: 'pipe',
  },
});

JavaScript

// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');
const dotenv = require('dotenv');
const path = require('path');

dotenv.config({ path: path.resolve(__dirname, '.env') });

module.exports = defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.js',

  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:3000',
    actionTimeout: 10_000,
    navigationTimeout: 15_000,

    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',

    locale: 'en-US',
    timezoneId: 'America/New_York',
    extraHTTPHeaders: {
      'x-test-automation': 'playwright',
    },
  },

  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 dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    stdout: 'pipe',
    stderr: 'pipe',
  },
});

Patterns

Pattern 1: Environment-Specific Configuration

Use when: Tests run against dev, staging, and production environments. Avoid when: Single-environment local-only projects.

TypeScript

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';

// Load environment-specific .env file: .env.staging, .env.production, etc.
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:3000',       retries: 0 },
  staging:    { baseURL: 'https://staging.example.com', retries: 2 },
  production: { baseURL: 'https://www.example.com',     retries: 2 },
};

const env = envConfig[ENV];

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',
  retries: env.retries,
  use: {
    baseURL: env.baseURL,
  },
});
# Run against staging
TEST_ENV=staging npx playwright test

# Run against production (subset of smoke tests)
TEST_ENV=production npx playwright test --grep @smoke

JavaScript

// playwright.config.js
const { defineConfig } = require('@playwright/test');
const dotenv = require('dotenv');
const path = require('path');

const ENV = process.env.TEST_ENV || 'local';
dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) });

const envConfig = {
  local:      { baseURL: 'http://localhost:3000',       retries: 0 },
  staging:    { baseURL: 'https://staging.example.com', retries: 2 },
  production: { baseURL: 'https://www.example.com',     retries: 2 },
};

const env = envConfig[ENV];

module.exports = defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.js',
  retries: env.retries,
  use: {
    baseURL: env.baseURL,
  },
});

Pattern 2: Multi-Project with Setup Dependencies

Use when: Tests need shared authentication state or database seeding before running. Avoid when: Tests are fully independent with no shared setup phase.

TypeScript

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',

  projects: [
    // Setup project runs first, saves auth state
    {
      name: 'setup',
      testMatch: /global\.setup\.ts/,
    },

    // Browser projects depend on setup
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});
// tests/global.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  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 });
});

JavaScript

// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.js',

  projects: [
    {
      name: 'setup',
      testMatch: /global\.setup\.js/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});
// tests/global.setup.js
const { test: setup, expect } = require('@playwright/test');

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  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 });
});

Pattern 3: webServer with Build Step

Use when: Tests need a running application server. Let Playwright manage the server lifecycle. Avoid when: Testing against an already-deployed environment (staging/prod).

TypeScript

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: process.env.CI
      ? 'npm run build && npm run start'  // production build in CI
      : 'npm run dev',                    // dev server locally
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    stdout: 'pipe',
    stderr: 'pipe',
    env: {
      NODE_ENV: 'test',
      DATABASE_URL: process.env.DATABASE_URL || 'postgresql://localhost:5432/test',
    },
  },
});

JavaScript

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.js',
  use: {
    baseURL: 'http://localhost:3000',
  },
  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,
    stdout: 'pipe',
    stderr: 'pipe',
    env: {
      NODE_ENV: 'test',
      DATABASE_URL: process.env.DATABASE_URL || 'postgresql://localhost:5432/test',
    },
  },
});

Pattern 4: globalSetup / globalTeardown

Use when: One-time non-browser work: seeding a database, starting a service, setting env vars. Runs once per npx playwright test invocation. Avoid when: You need browser context (use a setup project instead) or per-test isolation (use fixtures).

TypeScript

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',
  globalSetup: './tests/global-setup.ts',
  globalTeardown: './tests/global-teardown.ts',
});
// tests/global-setup.ts
import { FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig) {
  // Seed test database
  const { execSync } = await import('child_process');
  execSync('npx prisma db seed', { stdio: 'inherit' });

  // Store data for tests to use via environment variables
  process.env.TEST_RUN_ID = `run-${Date.now()}`;
}

export default globalSetup;
// tests/global-teardown.ts
import { FullConfig } from '@playwright/test';

async function globalTeardown(config: FullConfig) {
  const { execSync } = await import('child_process');
  execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
}

export default globalTeardown;

JavaScript

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.js',
  globalSetup: './tests/global-setup.js',
  globalTeardown: './tests/global-teardown.js',
});
// tests/global-setup.js
const { execSync } = require('child_process');

async function globalSetup(config) {
  execSync('npx prisma db seed', { stdio: 'inherit' });
  process.env.TEST_RUN_ID = `run-${Date.now()}`;
}

module.exports = globalSetup;
// tests/global-teardown.js
const { execSync } = require('child_process');

async function globalTeardown(config) {
  execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
}

module.exports = globalTeardown;

Pattern 5: .env File Setup

Use when: Managing secrets, URLs, or feature flags without hardcoding. Avoid when: Never commit .env files with real secrets. Provide .env.example instead.

# .env.example (commit this)
BASE_URL=http://localhost:3000
TEST_PASSWORD=
API_KEY=

# .env.local (gitignored)
BASE_URL=http://localhost:3000
TEST_PASSWORD=s3cret
API_KEY=test-key-abc123

# .env.staging (gitignored)
BASE_URL=https://staging.example.com
TEST_PASSWORD=staging-password
API_KEY=staging-key-xyz789
# .gitignore
.env
.env.local
.env.staging
.env.production
playwright/.auth/

Install dotenv:

npm install -D dotenv

Pattern 6: Trace, Screenshot, and Video Settings

Use when: Deciding artifact collection strategy for local development vs CI.

Setting Local CI Why
trace 'off' or 'on-first-retry' 'on-first-retry' Traces are large; only collect on failure
screenshot 'off' 'only-on-failure' Useful for CI debugging only
video 'off' 'retain-on-failure' Video is slow to record; keep only failures

TypeScript

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',
  use: {
    // CI: capture everything on failure; Local: minimal overhead
    trace: process.env.CI ? 'on-first-retry' : 'off',
    screenshot: process.env.CI ? 'only-on-failure' : 'off',
    video: process.env.CI ? 'retain-on-failure' : 'off',
  },
});

JavaScript

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.js',
  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',
  },
});

Decision Guide

Which Timeout to Adjust

Symptom Timeout to Change Default Recommended Range
Test takes too long overall timeout 30s 30-60s (never above 120s)
Assertion expect() keeps retrying too long or not long enough expect.timeout 5s 5-10s
page.goto() or waitForURL() times out navigationTimeout 30s 10-30s
click(), fill(), check() time out actionTimeout 0 (no limit) 10-15s
Dev server slow to start webServer.timeout 60s 60-180s

Server Management

Scenario Approach Why
Local dev + CI, app in same repo webServer with reuseExistingServer: !process.env.CI Playwright manages server; reuses yours locally
Separate frontend/backend repos Manual start or Docker Compose webServer can only run one command
Testing deployed staging/production No webServer; set baseURL via env var Server already running remotely
Multiple services needed (API + frontend) Array of webServer entries Each gets its own command and URL health check

Single vs Multi-Project Config

Scenario Approach Why
Starting out, early development Single project (chromium only) Faster feedback, simpler config
Pre-release cross-browser validation Multi-project: chromium + firefox + webkit Catch rendering/API differences
Mobile-responsive app Add mobile projects alongside desktop Viewport + touch differences matter
Authenticated + unauthenticated tests Setup project + dependent projects Share auth state without re-login per test
CI pipeline with tight time budget Chromium in PR checks; all browsers on merge to main Balance speed vs coverage

globalSetup vs Setup Projects vs Fixtures

Need Use Why
One-time DB seed or external service prep globalSetup Runs once, no browser needed
Shared browser auth (login once, reuse cookies) Setup project with dependencies Needs browser context; globalSetup has none
Per-test isolated state (unique user, fresh data) Custom fixture via test.extend() Each test gets its own instance with teardown
Cleanup after all tests globalTeardown Runs once at the end regardless of pass/fail

Anti-Patterns

Don't Do This Problem Do This Instead
timeout: 300_000 globally Masks flaky tests; CI runs take forever Fix the root cause; keep timeout at 30s; raise navigationTimeout only if warranted
Hardcoded URLs in tests: page.goto('http://localhost:3000/login') Breaks in every non-local environment Use baseURL in config, then page.goto('/login')
Running chromium + firefox + webkit on every PR 3x CI time for marginal benefit on most PRs Chromium on PRs; all browsers on main branch merges
trace: 'on' always in CI Huge artifacts, slow uploads, disk full trace: 'on-first-retry' -- only captures when a test fails and retries
video: 'on' always in CI Massive CI storage; recording slows tests video: 'retain-on-failure' -- records all but only keeps failures
Config values inline in test files: test.use({ viewport: { width: 1280, height: 720 } }) in every file Scattered, hard to maintain, inconsistent Define once in project config; override per-file only when genuinely needed
retries: 3 locally Hides flakiness during development retries: 0 locally, retries: 2 in CI
No forbidOnly in CI Accidentally committed test.only runs a single test, everything else silently skipped forbidOnly: !!process.env.CI
globalSetup for browser auth No browser context available; complex workarounds needed Use a setup project with dependencies
Committing .env files with real credentials Security risk Commit .env.example only; gitignore real .env files

Troubleshooting

"baseURL" not working -- tests navigate to full URL

Cause: Using page.goto('http://localhost:3000/path') instead of page.goto('/path'). When goto receives an absolute URL, it ignores baseURL.

Fix: Always pass relative paths to page.goto():

// Wrong -- ignores baseURL
await page.goto('http://localhost:3000/dashboard');

// Correct -- uses baseURL from config
await page.goto('/dashboard');

webServer starts but tests still fail with connection refused

Cause: The url in webServer does not match what the server actually serves, or the health check endpoint returns non-200.

Fix: Ensure webServer.url matches the actual server address. Add a health check route if needed:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000/api/health',  // use a real endpoint
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Tests pass locally but timeout in CI

Cause: CI machines are slower. Default timeouts too tight for CI hardware.

Fix: Increase navigationTimeout for CI, reduce workers to avoid resource contention:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',
  workers: process.env.CI ? '50%' : undefined,
  use: {
    navigationTimeout: process.env.CI ? 30_000 : 15_000,
    actionTimeout: process.env.CI ? 15_000 : 10_000,
  },
});

"Error: page.goto: Target page, context or browser has been closed"

Cause: Test exceeded its timeout and Playwright tore down the browser while an action was still running.

Fix: Do not increase the global timeout. Instead, find the slow step using --trace on and fix it. Common causes: waiting for a slow API, unresolved network request, or missing await.

# Record a trace for debugging
npx playwright test --trace on
npx playwright show-report