Skills

Install

$ npx ai-agents-skills add --skill playwright
Tooling v1.1

Also Installs

Playwright

Cross-browser E2E testing with auto-waiting, fixtures, and assertions.

When to Use

  • E2E browser testing, cross-browser automation
  • CI/CD integration, visual regression
  • Don’t use for: unit tests (vitest/jest), API-only (supertest), static analysis

Critical Patterns

Locators Over Raw Selectors

Semantic locators mirror user actions; resist refactors.

// CORRECT: role-based locators, resilient to markup changes
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('user@example.com');
// WRONG: brittle CSS selectors that break on class renames
await page.click('.btn-primary');

Auto-Waiting Instead of Manual Waits

Locators auto-wait for actionable elements; never add sleeps.

// CORRECT: auto-waits then asserts
await page.getByRole('link', { name: 'Dashboard' }).click();
await expect(page.getByText('Welcome back')).toBeVisible();
// WRONG: arbitrary sleep that slows tests and still flakes
await page.waitForTimeout(3000);

Test Fixtures for Setup

Fixtures share setup logic and isolate test state.

const test = base.extend<{ loggedInPage: Page }>({
  loggedInPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('test@co.com');
    await page.getByLabel('Password').fill('pass123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await use(page);
  },
});

Web-First Assertions

Use expect(locator) assertions that auto-retry until the condition is met. Cover both presence (positive) and absence (negative) — incomplete tests miss half the contract.

// ✅ POSITIVE: element must exist and have expected state
await expect(page.getByRole('alert')).toHaveText('Saved');
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page).toHaveURL('/dashboard');

// ✅ NEGATIVE: element must be absent or in disabled state
await expect(page.getByText('Error')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Place order' })).toBeDisabled();
await expect(page.getByRole('dialog')).toBeHidden(); // modal dismissed

// ❌ WRONG: snapshot check with no retry -- races against async UI
const text = await page.locator('.alert').textContent();
expect(text).toBe('Saved');

Network Mocking for Stable Tests

Mock API calls to prevent flakes and control responses.

// CORRECT: Mock network requests for stable tests
test('displays user data from API', async ({ page }) => {
  await page.route('**/api/user', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ name: 'John Doe', email: 'john@example.com' }),
    });
  });

  await page.goto('/profile');
  await expect(page.getByText('John Doe')).toBeVisible();
});

// WRONG: Depends on external API (flaky, slow, requires network)
test('displays user data', async ({ page }) => {
  await page.goto('/profile'); // Makes real API call
  await expect(page.getByText(/\w+/)).toBeVisible(); // Can't verify specific data
});

Parallel Execution with Test Isolation

Parallel tests safe when independent.

// CORRECT: Isolated test with unique data
test('creates new user', async ({ page }) => {
  const uniqueEmail = `user-${Date.now()}@example.com`;
  await page.goto('/signup');
  await page.getByLabel('Email').fill(uniqueEmail);
  await page.getByLabel('Password').fill('pass123');
  await page.getByRole('button', { name: 'Sign up' }).click();
  await expect(page.getByText('Welcome')).toBeVisible();
});

// WRONG: Uses shared data - tests interfere with each other
test('creates user', async ({ page }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill('test@example.com'); // Fails if already exists
  // ...
});

Decision Tree

UI flow or API only?
  → UI: page fixture; API: request fixture

Need authentication?
  → Create a fixture with stored storageState

Visual testing?
  → page.screenshot() with toMatchSnapshot()

Cross-browser?
  → Configure projects in playwright.config.ts

Flaky network?
  → Mock with page.route() to intercept requests

CI integration?
  → npx playwright test --reporter=html with artifact upload

Example

import { test, expect } from '@playwright/test';
test.describe('Login flow', () => {
  test('logs in with valid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('securePass1');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading')).toHaveText('Welcome back');
  });
  test('shows error on invalid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('wrong@example.com');
    await page.getByLabel('Password').fill('bad');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByRole('alert')).toHaveText('Invalid credentials');
  });
});

Edge Cases

  • Flaky network: Mock APIs with page.route() in CI. Record with page.route('**/*', route => route.continue()), convert to mocks.

  • Browser compat: Test Chromium/Firefox/WebKit via projects. CSS/JS differ; test all for prod.

  • Headless vs headed: CI headless; --headed local. Visual bugs (scroll, animations) show in headed.

  • Iframes: page.frameLocator('#id').getByRole(). Separate contexts; can’t use page locators.

  • File uploads: setInputFiles() on inputs. Drag-drop: page.setInputFiles() with eventInit.

  • Auth persistence: storageState saves login. await context.storageState({ path: 'auth.json' }), then test.use({ storageState: 'auth.json' }).

  • Dynamic content: waitForLoadState('networkidle') for SPAs. Infinite scroll: page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).

  • Shadow DOM: locator.locator() pierces shadow: page.locator('my-component').locator('#shadow-button').click().

  • Popups/tabs: page.waitForEvent('popup'): const [popup] = await Promise.all([page.waitForEvent('popup'), page.click('a[target="_blank"]')]).

  • Slow CI: Parallel --workers=4, fullyParallel: true. Shard: --shard=1/4, --shard=2/4.


Checklist

  • All locators use getByRole, getByLabel, getByTestId, or getByText
  • No waitForTimeout or manual sleeps in test code
  • Tests are independent and can run in any order
  • Assertions use expect(locator) web-first form
  • CI uploads trace files on failure (--trace on-first-retry)
  • Auth state reused via storageState to avoid repeated logins

Resources