The complete guide to Claude Code setup. 100+ hours saved. 370x optimization. Production-tested patterns for skills, hooks, and MCP integration.
Chapter 19 - Browser automation and end-to-end testing with Playwright MCP
Created: December 2025
Source: Entry #224 (176/176 E2E tests passing)
Time to Implement: 30 minutes
Playwright MCP provides browser automation via accessibility tree inspection - no vision model needed. Combined with Playwright Test framework, you get powerful E2E testing capabilities.
| Approach | Use Case | Tools |
|---|---|---|
| Playwright MCP | Live debugging, manual testing | browser_navigate, browser_snapshot, browser_click |
| Playwright Test | Automated CI/CD testing | npx playwright test |
# WSL/Linux - Use bundled Chromium (CRITICAL for WSL)
claude mcp add --scope user playwright -- npx -y @playwright/mcp@latest --browser chromium
# First-time: Download Chromium (~165MB)
npx playwright install chromium
⚠️ WSL Note: Default Playwright MCP looks for Chrome at /opt/google/chrome/chrome which doesn’t exist in WSL. Always use --browser chromium flag.
npm install -D @playwright/test
npx playwright install
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./tests/e2e",
timeout: 60000,
use: {
baseURL: "http://localhost:8080",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "Mobile Chrome", use: { ...devices["Pixel 5"] } },
],
});
| Tool | Purpose | Example |
|---|---|---|
browser_navigate |
Go to URL | browser_navigate(url="https://app.example.com") |
browser_navigate_back |
Back button | browser_navigate_back() |
browser_close |
Close browser | browser_close() |
| Tool | Purpose | Returns |
|---|---|---|
browser_snapshot |
Get accessibility tree | Structured page content with refs |
browser_console_messages |
JS console output | Logs, errors, warnings |
browser_network_requests |
XHR/fetch requests | Network activity, status codes |
| Tool | Purpose | Example |
|---|---|---|
browser_click |
Click element | browser_click(element="Login button", ref="e15") |
browser_type |
Type into input | browser_type(element="Email input", ref="e11", text="user@test.com") |
browser_fill_form |
Fill multiple fields | browser_fill_form(fields=[...]) |
browser_select_option |
Dropdown selection | browser_select_option(element="Country", ref="e20", values=["Israel"]) |
| Tool | Purpose | Output |
|---|---|---|
browser_take_screenshot |
PNG screenshot | Image file |
browser_resize |
Change viewport | browser_resize(width=375, height=667) |
# WRONG: Navigate then immediately interact
browser_navigate(url="https://slow-site.com")
browser_click(element="Button") # May fail - page not loaded
# CORRECT: Navigate, snapshot to verify, then interact
browser_navigate(url="https://slow-site.com")
browser_snapshot() # Waits for page, returns structure
browser_click(element="Button", ref="e15")
# WRONG: CSS selector
browser_click(element="#btn-submit")
# CORRECT: Accessibility label
browser_click(element="Submit button", ref="e15")
Playwright MCP uses accessibility tree, not DOM selectors.
browser_navigate(url="https://app.example.com")
browser_console_messages(level="error")
Many bugs manifest as JavaScript errors before visible UI issues.
browser_navigate(url="https://app.example.com/dashboard")
browser_network_requests()
# Look for status >= 400
Catch backend issues before debugging frontend.
browser_resize(width=375, height=667) # iPhone SE
browser_snapshot() # Check mobile layout
// tests/e2e/example.spec.js
const { test, expect } = require("@playwright/test");
// Global auth bypass for all tests
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"authToken",
JSON.stringify({
token: "test-token-for-e2e",
user: { id: 1, username: "test", role_id: 1 },
timestamp: Date.now(),
}),
);
});
});
test("should load dashboard", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
await expect(page).toHaveTitle(/Dashboard/);
});
// WRONG: textContent includes script tags
const content = await page.textContent("body");
expect(content).not.toContain("synthetic"); // May fail - word in JS code
// CORRECT: innerText only visible text
const content = await page.evaluate(() => document.body.innerText);
expect(content).not.toContain("synthetic"); // Only checks visible text
test("should have RTL layout", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
const dir = await page.getAttribute("html", "dir");
expect(dir).toBe("rtl");
const lang = await page.getAttribute("html", "lang");
expect(lang).toBe("he");
});
test("should display Hebrew correctly", async ({ page }) => {
await page.goto("/dashboard");
const content = await page.textContent("body");
// Check for Hebrew characters
const hasHebrew = /[\u0590-\u05FF]/.test(content);
expect(hasHebrew).toBeTruthy();
// No encoding issues
expect(content).not.toContain("???????");
});
test("should receive 200 from API", async ({ page }) => {
const apiResponses = [];
page.on("response", (response) => {
if (response.url().includes("/api/")) {
apiResponses.push({
url: response.url(),
status: response.status(),
});
}
});
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
const errors = apiResponses.filter((r) => r.status >= 400);
expect(errors).toHaveLength(0);
});
1. browser_navigate("http://localhost:8080/problem-page")
2. browser_snapshot() # See page structure
3. browser_console_messages() # Check for JS errors
4. browser_network_requests() # Check for API failures
5. browser_take_screenshot() # Visual capture
| Issue | Solution |
|---|---|
| “Not connected” error | Restart Claude Code, or pkill -f chromium |
| “Browser already in use” | Call browser_close first |
| “Browser not installed” | Run npx playwright install chromium |
| Tests timeout | Add waitForLoadState('networkidle') |
| Auth redirect | Add page.addInitScript() with token |
# Run all E2E tests
npx playwright test
# Run specific file
npx playwright test tests/e2e/dashboard.spec.js
# Run with visible browser
npx playwright test --headed
# Debug mode (step through)
npx playwright test --debug
# Single browser
npx playwright test --project=chromium
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npm start &
- run: npx playwright test
Combine Playwright screenshots with comparison:
test("visual regression - dashboard", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Take screenshot
await page.screenshot({
path: "screenshots/dashboard.png",
fullPage: true,
});
// Compare with baseline (using external tool)
// pixelmatch, looks-same, or Percy
});
Production Results (December 2025):
--browser chromiumtests/e2e/npx playwright test