| name | browser-automation |
| description | Playwright browser automation via MCP. Covers E2E testing, UI review, web scraping, screenshot capture, and general browser interaction. MCP-first — CLI is fallback only. |
Browser Automation (Playwright MCP)
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:
- Snapshot —
mcp_playwright_browser_snapshot to understand current state
- Act — click/type/navigate using refs from snapshot
- 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:
-
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.
-
Test ID attributes (stable, decoupled from UI)
page.getByTestId('submit-button')
page.locator('[data-testid="submit-button"]')
-
Semantic HTML (acceptable)
page.locator('button[type="submit"]')
page.locator('input[name="email"]')
-
CSS class/ID (avoid — fragile, changes with styling)
page.locator('.btn-primary')
page.locator('#submit')
-
Complex CSS/XPath (last resort — brittle)
page.locator('div.container > form > button')
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'));
}
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:
- Snapshot — capture DOM state at failure point
mcp_playwright_browser_snapshot
- Console — check for app-side errors
mcp_playwright_browser_console_messages level="error"
- Network — check for failed requests
mcp_playwright_browser_network_requests
- 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:
use: {
trace: 'on-first-retry',
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 }) => {
await page.goto('/path');
await page.getByRole('textbox', { name: 'Email' }).fill('user@test.com');
await page.getByRole('button', { name: 'Submit' }).click();
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
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ request, page }) => {
const response = await request.post('/api/auth/login', {
data: { email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }
});
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
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)
- API calls — use
request fixture to create/destroy data via backend endpoints (fast, no UI dependency)
- Unique dynamic data — generate per-test with
crypto.randomUUID() or faker-js to prevent collisions in parallel runs
- External JSON fixtures — for complex static structures (product catalogs, locale strings)
- UI-driven setup — last resort only; never for data that could be created via API
Fixture pattern for data lifecycle
export const test = base.extend<{ createdUser: User }>({
createdUser: async ({ request }, use) => {
const user = await request.post('/api/users', {
data: { email: `test-${crypto.randomUUID()}@example.com` }
}).then(r => r.json());
await use(user);
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,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI ? 'blob' : 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
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):
npx playwright test --shard=1/4
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('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100,
threshold: 0.2,
mask: [page.locator('.timestamp'), page.locator('.ad-banner')],
});
});
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:
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'first-focusable');
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 }) => {
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 }) => {
await request.post('/api/items', { data: { name: 'Widget' } });
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)
| Layer | Target | Tool | When to Write |
|---|
| Unit | ~70% of logic | Vitest/Jest | Individual functions, pure logic, edge cases |
| Integration/API | ~20% | Playwright request, API clients | Service contracts, DB interactions, API responses |
| E2E | ~10% critical paths | Playwright UI | Money 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
- Navigate →
mcp_playwright_browser_navigate
- Resize for viewport →
mcp_playwright_browser_resize
- Snapshot for structure →
mcp_playwright_browser_snapshot
- 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
- Navigate to target
- Snapshot to identify structure
- 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 tests | Role-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 scenarios | Independent tests from clean state |
| Assert text from text-based locator | Use toBeVisible() for text locators |
| Multiple tests per file | One test per file for AI predictability |
| Mix MCP and CLI in same session | Choose one approach per workflow |
| UI login in every test | storageState fixture via setup project |
| UI-driven test data setup | API request fixture for seeding |
| Static shared test accounts | Unique dynamic data per test / per-worker accounts |
| E2E tests for all business logic | E2E for critical paths only — unit/integration for the rest |
| Hardcoded credentials | process.env.* always |
Committing .auth/ files | Add 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.