Browser Automation (Playwright MCP)

SkillWeb & browsing

Playwright browser automation via MCP. Covers E2E testing, UI review, web scraping, screenshot capture, and general browser interaction. MCP-first — CLI is fallback only.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Browser Automation (Playwright MCP) skill

What this skill tells your AI

The instructions your AI receives, as published by irahardianto/awesome-agv in .agents/skills/browser-automation/SKILL.md and read by ahel’s review.

Tool Hierarchy

PRIMARY — MCP tools (always prefer):

  • mcp_playwright_browser_snapshot — read page structure, get element refs
  • mcp_playwright_browser_navigate — go to URL
  • mcp_playwright_browser_click — click element
  • mcp_playwright_browser_type — type into element
  • mcp_playwright_browser_fill_form — fill multiple form fields
  • mcp_playwright_browser_select_option — dropdown selection
  • mcp_playwright_browser_hover — hover element
  • mcp_playwright_browser_drag — drag and drop
  • mcp_playwright_browser_drop — drop files/data onto element
  • mcp_playwright_browser_press_key — keyboard input
  • mcp_playwright_browser_evaluate — execute JS on page
  • mcp_playwright_browser_wait_for — wait for text/disappearance/time
  • mcp_playwright_browser_console_messages — read console output
  • mcp_playwright_browser_network_requests — list network activity
  • mcp_playwright_browser_network_request — inspect single request
  • mcp_playwright_browser_file_upload — upload files
  • mcp_playwright_browser_handle_dialog — accept/dismiss dialogs
  • mcp_playwright_browser_tabs — manage tabs
  • mcp_playwright_browser_resize — change viewport
  • mcp_playwright_browser_run_code_unsafe — arbitrary Playwright code (escape hatch)

FALLBACK — CLI via run_command (only when MCP lacks capability):

  • Tracing start/stop
  • Video recording
  • Persistent browser profiles
  • Browser installation (npx playwright install)

NEVER mix MCP and CLI for the same session. One approach per workflow.


Snapshot-First Workflow

Every browser interaction follows this loop:

  1. Snapshotmcp_playwright_browser_snapshot to understand current state
  2. Act — click/type/navigate using refs from snapshot
  3. Verify — snapshot again to confirm mutation took effect

Rules:

  • ALWAYS snapshot before first interaction on a page
  • Use snapshot element refs (ref="e5") for targeting — not CSS selectors
  • After navigation or state mutation, snapshot again to read new state
  • Use depth param to limit snapshot size for large pages
  • Use target param to scope snapshot to a subtree

Selector Strategy

When writing test code or run_code_unsafe scripts, prefer selectors in this order:

  1. Role-based (most resilient, mirrors user experience)

    page.getByRole('button', { name: 'Submit' })
    page.getByRole('textbox', { name: 'Email' })
    page.getByLabel('Password')
    page.getByText('Sign in')
    page.getByPlaceholder('Enter your email')
    page.getByTitle('Close dialog')
    

    If you can't locate an element by role/label, it signals an accessibility gap in the component.

  2. Test ID attributes (stable, decoupled from UI)

    page.getByTestId('submit-button')          // uses data-testid by default
    page.locator('[data-testid="submit-button"]') // explicit attribute form
    
  3. Semantic HTML (acceptable)

    page.locator('button[type="submit"]')
    page.locator('input[name="email"]')
    
  4. CSS class/ID (avoid — fragile, changes with styling)

    page.locator('.btn-primary')  // AVOID
    page.locator('#submit')       // AVOID
    
  5. Complex CSS/XPath (last resort — brittle)

    page.locator('div.container > form > button')  // FRAGILE
    

Waiting Strategy

Do

  • mcp_playwright_browser_wait_for with text or textGone
  • Playwright's auto-waiting (built into click/fill/etc.)
  • waitForURL, waitForSelector, waitForLoadState in test code
  • page.locator('.el').waitFor({ state: 'visible' }) for explicit waits

Never

  • page.waitForTimeout(N) — arbitrary delays cause flakiness
  • waitForLoadState('networkidle') — unreliable, race-prone, slow
  • page.waitForTimeout as a "fix" for timing issues
  • Skipping hooks or adding sleeps as a fix for failures

Assertion Patterns (E2E Tests)

Locator-Assertion Pairing Rules

  • When locator is text-based (getByText, text filter) → assert toBeVisible() (don't re-assert the text)
  • When locator is non-text (getByTestId, getByLabel) → assert toHaveText(), toContainText()
  • Input assertions → toHaveValue(), toBeEmpty()
  • Checkbox/radio → toBeChecked() / not.toBeChecked()
  • Structural assertions → toMatchAriaSnapshot() for partial page structure

Common Assertions

await expect(page).toHaveTitle('My App');
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('.message')).toBeVisible();
await expect(page.locator('.spinner')).toBeHidden();
await expect(page.locator('button')).toBeEnabled();
await expect(page.locator('input')).toHaveValue('test@example.com');
await expect(page.locator('.item')).toHaveCount(5);

Network Mocking (Test Isolation)

Use mcp_playwright_browser_run_code_unsafe to set up route handlers:

Mock API response

async (page) => {
  await page.route('**/api/users', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([{ id: 1, name: 'Test User' }])
    });
  });
}

Simulate network failure

async (page) => {
  await page.route('**/api/data', route => route.abort('internetdisconnected'));
}
// Abort reasons: connectionrefused, timedout, connectionreset, internetdisconnected

Modify real response

async (page) => {
  await page.route('**/api/user', async route => {
    const response = await route.fetch();
    const json = await response.json();
    json.isPremium = true;
    await route.fulfill({ response, json });
  });
}

Block resources (speed up tests)

async (page) => {
  await page.route('**/*.{png,jpg,jpeg,gif,svg}', route => route.abort());
}

Error Diagnosis Protocol

When a browser interaction fails or produces unexpected results:

  1. Snapshot — capture DOM state at failure point
    mcp_playwright_browser_snapshot
    
  2. Console — check for app-side errors
    mcp_playwright_browser_console_messages level="error"
    
  3. Network — check for failed requests
    mcp_playwright_browser_network_requests
    
  4. Evaluate — inspect specific state
    mcp_playwright_browser_evaluate function="() => document.title"
    

Common failure causes:

  • Selector drift (element renamed/moved/wrapped)
  • Timing (async load not waited for)
  • Network failure (API returned error)
  • Dialog blocking (unhandled alert/confirm)
  • iframe boundary (element in child frame)
  • OS/environment rendering difference (visual regression)

CI artifact retention — always configure Playwright to save traces, screenshots, and videos on failure:

// playwright.config.ts
use: {
  trace: 'on-first-retry',     // trace on first failure, not every run
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
}

E2E Test Structure

One test per file (AI-optimized)

Each test file contains exactly one test() call. This ensures:

  • Single-responsibility reasoning for generation and healing
  • No shared state leakage between sibling tests
  • File = natural unit of parallelism
  • Test name maps 1:1 to file name (zero ambiguity)

File naming

tests/e2e/<group>/<kebab-case-scenario>.spec.ts

Template

import { test, expect } from '@playwright/test';

test.describe('<Group Name>', () => {
  test('<scenario description>', async ({ page }) => {
    // 1. Navigate
    await page.goto('/path');

    // 2. Act
    await page.getByRole('textbox', { name: 'Email' }).fill('user@test.com');
    await page.getByRole('button', { name: 'Submit' }).click();

    // 3. Assert
    await expect(page).toHaveURL(/.*success/);
    await expect(page.getByRole('heading')).toContainText('Welcome');
  });
});

Scenario independence

  • Each test starts from a clean state (no chaining between tests)
  • Use fixtures for shared setup (navigation, auth)
  • Clean up test data after run
  • No ordering dependencies

Auth State Management (storageState)

Authenticate once per suite run via API — reuse across all tests. Never log in via UI per test.

Setup project pattern

// tests/auth.setup.ts
import { test as setup } from '@playwright/test';

setup('authenticate', async ({ request, page }) => {
  // Prefer API auth — faster, no UI dependency
  const response = await request.post('/api/auth/login', {
    data: { email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }
  });
  // OR via UI login if API not available
  // await page.goto('/login'); ...
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});

Rules:

  • Add playwright/.auth/ to .gitignore — contains live session tokens
  • Use process.env for credentials — never hardcode
  • Keep dedicated login/logout E2E tests — auth setup skips UI, tests verify it
  • Parallel-safe: if tests mutate server-side state, use testInfo.parallelIndex to map each worker to a separate test account

Test Data Management

Seeding strategy (priority order)

  1. API calls — use request fixture to create/destroy data via backend endpoints (fast, no UI dependency)
  2. Unique dynamic data — generate per-test with crypto.randomUUID() or faker-js to prevent collisions in parallel runs
  3. External JSON fixtures — for complex static structures (product catalogs, locale strings)
  4. UI-driven setup — last resort only; never for data that could be created via API

Fixture pattern for data lifecycle

// fixtures.ts
export const test = base.extend<{ createdUser: User }>({
  createdUser: async ({ request }, use) => {
    // Setup
    const user = await request.post('/api/users', {
      data: { email: `test-${crypto.randomUUID()}@example.com` }
    }).then(r => r.json());
    // Inject into test
    await use(user);
    // Teardown — runs even if test fails
    await request.delete(`/api/users/${user.id}`);
  },
});

Anti-patterns

  • ❌ UI-driven test data creation (login flow to create a record)
  • ❌ Static shared test accounts (race conditions in parallel runs)
  • ❌ Relying on pre-seeded database records by fixed ID/name
  • ❌ Database transaction rollback hacks (breaks async workflows)
  • ❌ Excessive beforeEach for setup not used by every test

playwright.config.ts — Recommended CI Configuration

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,               // tests within a file run concurrently
  forbidOnly: !!process.env.CI,       // fail CI if test.only left in source
  retries: process.env.CI ? 2 : 0,   // retry flaky tests on CI only
  workers: process.env.CI ? 4 : undefined,
  reporter: process.env.CI ? 'blob' : 'html',  // blob enables shard merging

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',          // trace on first failure — not every run
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Sharding for large suites (horizontal CI scaling):

# Run shard 1 of 4 — spread across 4 CI nodes
npx playwright test --shard=1/4
# Merge shard reports after all complete
npx playwright merge-reports ./blob-reports

Visual Regression Testing

Use toHaveScreenshot() for pixel-level regression detection. Baselines are committed to VCS.

test('homepage layout', async ({ page }) => {
  await page.goto('/');
  // First run creates baseline; subsequent runs diff against it
  await expect(page).toHaveScreenshot('homepage.png', {
    maxDiffPixels: 100,      // tolerate minor rendering differences
    threshold: 0.2,
    mask: [page.locator('.timestamp'), page.locator('.ad-banner')],  // hide dynamic content
  });
});

// Element-scoped screenshot (preferred over full-page for components)
await expect(page.locator('.chart')).toHaveScreenshot('chart.png');

Rules:

  • Run in consistent environment (Docker in CI) — OS affects font rendering
  • Commit baseline files to VCS — they ARE the expected truth
  • Update baselines intentionally: npx playwright test --update-snapshots
  • Mask dynamic elements (timestamps, avatars, ads) to avoid false positives
  • Disable CSS animations in test config: reducedMotion: 'reduce'
  • Full-page screenshots: unreliable for long pages — scope to component

Accessibility Testing

Layer 1: Structural regression (toMatchAriaSnapshot)

Built into Playwright. Validates accessibility tree — catches semantic changes axe-core misses.

await expect(page.locator('nav')).toMatchAriaSnapshot(`
  - navigation:
    - link "Home"
    - link "Products"
    - link "Sign in"
`);

Layer 2: Rule-based scanning (@axe-core/playwright)

Catches WCAG violations: missing labels, contrast failures, duplicate IDs.

import { AxeBuilder } from '@axe-core/playwright';

test('no accessibility violations', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toHaveLength(0);
});

Layer 3: Keyboard navigation

Axe-core does not verify keyboard usability — must be explicit:

// Verify tab order and focus trap in modals
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'first-focusable');

// Escape dismisses modal
await page.keyboard.press('Escape');
await expect(page.locator('[role="dialog"]')).toBeHidden();

Rule: if role-based locators can't find an element during regular test writing, flag it — it means the component has an accessibility gap.


API Testing Patterns

browserContext.request — shares cookies with browser

Use when API action must be reflected immediately in the UI:

test('create post then see it', async ({ page, context }) => {
  // API call shares session with the browser context
  await context.request.post('/api/posts', { data: { title: 'Test' } });
  await page.reload();
  await expect(page.getByText('Test')).toBeVisible();
});

request.newContext() — fully isolated API context

Use for pure API/contract testing with no browser:

test('POST /api/users returns 201', async ({ playwright }) => {
  const apiContext = await playwright.request.newContext({
    baseURL: process.env.API_URL,
    extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` }
  });
  const response = await apiContext.post('/api/users', {
    data: { email: 'test@example.com' }
  });
  expect(response.status()).toBe(201);
  const body = await response.json();
  expect(body).toMatchObject({ email: 'test@example.com' });
  await apiContext.dispose();
});

Hybrid: API for setup, UI for assertion

test('newly created item appears in list', async ({ request, page }) => {
  // Fast API seed
  await request.post('/api/items', { data: { name: 'Widget' } });
  // Verify UI
  await page.goto('/items');
  await expect(page.getByText('Widget')).toBeVisible();
});

Rules:

  • Centralize baseURL and auth headers in playwright.config.ts
  • Check response.ok() or assert status explicitly — Playwright returns response regardless of status code
  • Use TypeScript interfaces for request/response bodies
  • browserContext.request for UI-reflected API calls; request.newContext() for isolated API smoke tests

Test Coverage Strategy (Test Pyramid)

LayerTargetToolWhen to Write
Unit~70% of logicVitest/JestIndividual functions, pure logic, edge cases
Integration/API~20%Playwright request, API clientsService contracts, DB interactions, API responses
E2E~10% critical pathsPlaywright UIMoney paths only — login, checkout, key user journeys

E2E is expensive: slow to run, costly to maintain, prone to flakiness. Write E2E tests only when:

  • The flow crosses multiple systems/services
  • A regression in this flow causes direct business loss
  • The flow cannot be adequately covered by integration tests

When a bug is found in E2E, ask: could this have been caught earlier (unit/integration)? If yes, add that lower-level test too.


Page Object Model (Complex Flows)

For repeated interaction patterns across multiple tests:

class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = page.getByRole('textbox', { name: 'Email' });
    this.passwordInput = page.getByRole('textbox', { name: 'Password' });
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
  }

  async login(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Use POM when:

  • Same page interactions appear in 3+ test files
  • Form has 4+ fields
  • Complex multi-step flows (checkout, onboarding)

Non-Testing Browser Automation

UI Review / Visual Inspection

  1. Navigate → mcp_playwright_browser_navigate
  2. Resize for viewport → mcp_playwright_browser_resize
  3. Snapshot for structure → mcp_playwright_browser_snapshot
  4. Evaluate for computed styles → mcp_playwright_browser_evaluate

Screenshot Capture

Use browser_subagent tool for screenshot workflows — it automatically records as WebP video.

Web Scraping / Data Extraction

  1. Navigate to target
  2. Snapshot to identify structure
  3. Evaluate to extract data:
    async (page) => {
      return await page.$$eval('.item', els =>
        els.map(el => ({
          title: el.querySelector('h2')?.textContent?.trim(),
          link: el.querySelector('a')?.href,
        }))
      );
    }
    

Form Automation

Use mcp_playwright_browser_fill_form for multi-field forms — single call, atomic:

fields: [
  { target: "ref", name: "Email", type: "textbox", value: "user@test.com" },
  { target: "ref", name: "Remember", type: "checkbox", value: "true" }
]

Anti-Patterns

❌ Don't✅ Do Instead
page.waitForTimeout(2000)page.locator('.el').waitFor({ state: 'visible' })
waitForLoadState('networkidle')waitForURL() or waitForSelector()
CSS class/ID selectors in testsRole-based or getByTestId() selectors
Skip hooks as a "fix"Find and fix the root cause
Silent test.skip()test.fixme('reason or issue link')
Chain test scenariosIndependent tests from clean state
Assert text from text-based locatorUse toBeVisible() for text locators
Multiple tests per fileOne test per file for AI predictability
Mix MCP and CLI in same sessionChoose one approach per workflow
UI login in every teststorageState fixture via setup project
UI-driven test data setupAPI request fixture for seeding
Static shared test accountsUnique dynamic data per test / per-worker accounts
E2E tests for all business logicE2E for critical paths only — unit/integration for the rest
Hardcoded credentialsprocess.env.* always
Committing .auth/ filesAdd playwright/.auth/ to .gitignore

E2E Testing with Playwright MCP (Interactive Development)

When running E2E tests interactively (during development or verification), use Playwright MCP directly:

# Navigate to the page
mcp_playwright_browser_navigate(url="http://localhost:5173/login")

# Capture accessible state (better than screenshot for assertions)
mcp_playwright_browser_snapshot()

# Interact with elements by ref from snapshot
mcp_playwright_browser_type(ref="<ref>", text="test@example.com")
mcp_playwright_browser_click(ref="<ref>")

# Wait for results
mcp_playwright_browser_wait_for(text="Welcome")

# Capture snapshot for walkthrough documentation
mcp_playwright_browser_snapshot(filename="login-success.md")

MCP E2E Requirements

  • Capture a snapshot (mcp_playwright_browser_snapshot) at each major step
  • Save snapshots to walkthrough as proof of functionality
  • If visual proof is needed, use browser_subagent with RecordingName to produce a recorded video artifact
  • Test happy path AND at least one error path
  • Clean up test data after test (or use unique identifiers per test run)

For E2E test file organization and directory structure, see the testing-strategy rule.

Signals

GitHub stars
156
Forks
53
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
browser-automation-irahardianto
Source
github.com/irahardianto/awesome-agv