
Playwright
- 376 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
playwright is a Claude Code skill that builds Playwright end-to-end tests for authentication, navigation, and critical UI flows with fixtures and CI-ready headless runs for developers shipping web applications.
About
playwright is a Claude Code skill for authoring browser end-to-end tests with the Playwright test framework. It helps developers cover authentication flows, page navigation, and critical UI paths using fixtures, page objects, and headless configurations suitable for CI pipelines. The skill guides selector strategies, async waiting patterns, and test isolation so suites run reliably in GitHub Actions or similar runners. Reach for it when a web app needs automated regression coverage beyond unit tests or when auth-gated flows require repeatable browser verification before release.
- E2E browser tests
- Auth flows
- Fixtures
- Headless CI
- Selector stability
Playwright by the numbers
- 376 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #662 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill playwrightAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 376 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
How do you write Playwright E2E tests for auth flows?
Build Playwright E2E tests for auth, navigation, and critical UI flows with fixtures and CI-ready headless runs.
Who is it for?
Frontend and full-stack developers adding browser E2E coverage for auth, navigation, and release-critical UI paths.
Skip if: Backend-only API testing without a browser or teams standardized on Cypress or Selenium instead of Playwright.
When should I use this skill?
A developer asks to add Playwright tests, cover login flows, configure headless CI runs, or test critical navigation paths.
What you get
Playwright test files, reusable fixtures, and CI-ready headless test configuration for critical UI flows.
- Playwright test suite
- CI headless configuration
Files
Playwright
Overview
Playwright is a browser automation framework for Node.js and Python supporting Chromium, Firefox, and WebKit with a single API. It provides auto-waiting, web-first assertions, and full test isolation for reliable end-to-end testing.
When to use: Browser automation, web scraping, screenshot/PDF generation, API testing, configuring Playwright Test, troubleshooting Playwright errors, stealth mode and anti-bot bypass.
When NOT to use: Simple HTTP requests (use fetch), unit testing (use Vitest/Jest), serverless scraping at scale (consider Cloudflare Browser Rendering). For E2E test architecture (Page Object Models, CI sharding, test organization, authentication patterns), use the e2e-testing skill.
Quick Reference
| Pattern | API / Config | Key Points |
|---|---|---|
| Basic test | test('name', async ({ page }) => {}) | Auto-wait, web-first assertions, test isolation |
| Locator | page.getByRole() / page.locator() | Prefer role/label/text selectors over CSS |
| Assertion | expect(locator).toBeVisible() | Auto-retrying, configurable timeout |
| API testing | request fixture / apiRequestContext | Send HTTP requests, validate responses |
| Aria snapshot | expect(locator).toMatchAriaSnapshot() | Validate accessibility tree structure via YAML |
| Class assertion | expect(locator).toContainClass('active') | Match individual CSS class names (v1.52+) |
| Visible filter | locator.filter({ visible: true }) | Match only visible elements (v1.51+) |
| Test step | test.step('name', async (step) => {}) | Timeout, skip, and attachments (v1.50+) |
| Stealth mode | playwright-extra + stealth plugin | Patches 20+ detection vectors |
| Authenticated session | context.cookies() + addCookies() | Save/restore cookies and IndexedDB for persistence |
| Screenshot | page.screenshot({ fullPage: true }) | Wait for key elements to load first |
| PDF generation | page.pdf({ format: 'A4' }) | Chromium only, set printBackground: true |
| Clock API | page.clock | Freeze, fast-forward, or simulate time in tests |
| A11y assertions | toHaveAccessibleName, toHaveRole | Native assertions without axe-core dependency |
| Viewport assertion | expect(locator).toBeInViewport() | Assert element is within the visible viewport |
| Changed tests only | --only-changed=$GITHUB_BASE_REF | Run only test files changed since base branch |
| Docker | mcr.microsoft.com/playwright:v1.58.2-noble | Use --init --ipc=host flags |
| Debug methods | page.consoleMessages() / page.requests() (v1.56+) | No event listeners needed |
| Speedboard | HTML reporter (v1.57+) | Identifies slow tests and bottlenecks |
| Playwright Agents | npx playwright init-agents | Planner, generator, healer for LLM-driven testing |
| Flaky test detection | --fail-on-flaky-tests (v1.50+) | Exit code 1 on flaky tests in CI |
| Modify live responses | route.fetch() + route.fulfill() | Intercept real response, tweak JSON, return it |
| Soft assertions | expect.soft(locator) | Don't stop test on failure, report all at end |
| Retry block | expect(async () => {}).toPass() | Default timeout is 0 (forever) — always set one |
| Custom matchers | expect.extend() / mergeExpects() | Define or combine custom assertion methods |
| Actionability matrix | Per-action auto-wait checks | click: all 5 checks, fill: 3, focus/blur: none |
| Test modifiers | test.fixme() / test.fail() / test.slow() | fixme=skip+track, fail=assert failure, slow=3x |
| Parallel modes | test.describe.configure({ mode: 'serial' }) | serial, parallel, or default per-describe block |
| Teardown projects | teardown option on setup projects | Auto-cleanup after all dependents finish |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Using CSS selectors over role selectors | Prefer getByRole, getByLabel, getByText for resilience |
| Not closing browser | Always await browser.close() in finally block |
Using setTimeout for waits | Use locator auto-wait or waitForLoadState |
page.pause() left in CI code | Guard with if (!process.env.CI) — hangs CI indefinitely |
| Clicking without waiting | Use locator().click() with built-in auto-wait |
| Shared state between tests | Each test gets fresh context via fixtures |
| Testing implementation details | Assert user-visible behavior, not DOM structure |
| Hardcoded waits for dynamic content | Wait for selector appearance or content stabilization |
Missing await on assertions | All expect() assertions return promises — must be awaited |
| Same user agent for all scraping | Rotate user agents for high-volume scraping |
Using setTimeout for time-dependent tests | Use page.clock API to freeze/fast-forward time |
| Installing axe-core for simple a11y checks | Use native toHaveAccessibleName/toHaveRole assertions |
Using toPass() without explicit timeout | Always pass { timeout: 10_000 } — default is 0 (forever) |
Service worker silently blocking page.route() | Set serviceWorkers: 'block' in context config when using MSW |
Using fill() for autocomplete/debounce inputs | Use pressSequentially() with optional delay for per-keystroke handling |
storageState losing sessionStorage | storageState only saves cookies + localStorage — inject sessionStorage via addInitScript() |
Delegation
- Selector troubleshooting: Use
Exploreagent - Test pattern review: Use
Taskagent - Code review: Delegate to
code-revieweragent
For E2E test architecture, Page Object Model patterns, CI sharding strategies, authentication flows, visual regression workflows, or test organization, use the e2e-testing skill.References
- Quick start and installation
- E2E testing patterns and assertions
- Selector strategies and best practices
- Configuration and Docker deployment
- Docker and CI
- Debug methods and performance analysis
- Common automation patterns
- Stealth mode and anti-bot bypass
- Known issues and solutions
- Site-specific blocking and bypasses
- Troubleshooting common problems
- Network testing, mocking, and API patterns
- Input patterns, actionability, and test modifiers
- Advanced assertions, polling, custom matchers, and annotations
- Advanced topics: MCP, AI agents, parallel contexts
Advanced Assertions
Auto-Retrying vs Non-Retrying
| Category | Behavior | Examples |
|---|---|---|
| Auto-retrying (async) | Retry until timeout, must await | toBeVisible, toHaveText, toHaveURL, toHaveValue, toBeEnabled |
| Non-retrying (sync) | Immediate check, no retry | toBe, toEqual, toHaveLength, toContain, toBeTruthy |
import { test, expect } from '@playwright/test';
test('auto-retrying vs non-retrying', async ({ page }) => {
await page.goto('/dashboard');
// Auto-retrying — waits up to default timeout
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page).toHaveURL(/\/dashboard/);
// Non-retrying — runs once against a resolved value
const count = await page.getByRole('listitem').count();
expect(count).toBeGreaterThan(0);
});Soft Assertions
expect.soft() continues the test on failure. All failures are reported at the end.
test('verify dashboard widgets', async ({ page }) => {
await page.goto('/dashboard');
await expect.soft(page.getByTestId('revenue')).toHaveText('$1,200');
await expect.soft(page.getByTestId('users')).toHaveText('340');
await expect.soft(page.getByTestId('orders')).toHaveText('79');
});Bulk Soft Mode with expect.configure()
test('bulk soft assertions', async ({ page }) => {
await page.goto('/settings');
const softExpect = expect.configure({ soft: true });
await softExpect(page.getByLabel('Name')).toHaveValue('Jane');
await softExpect(page.getByLabel('Email')).toHaveValue('jane@example.com');
await softExpect(page.getByLabel('Role')).toHaveValue('Admin');
});Custom Timeout with expect.configure()
test('slow page load', async ({ page }) => {
await page.goto('/heavy-report');
const slowExpect = expect.configure({ timeout: 15_000 });
await slowExpect(page.getByRole('table')).toBeVisible();
});Custom Error Messages
Pass a string as the second argument to expect() for descriptive failure output.
test('login flow', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(
page.getByTestId('avatar'),
'should be visible after login',
).toBeVisible();
});Polling with expect.poll()
Repeatedly invokes an async callback until the chained matcher passes.
test('API becomes healthy', async ({ page }) => {
await expect
.poll(
async () => {
const response = await page.request.get('/api/health');
return response.status();
},
{
message: 'API should return 200',
timeout: 30_000,
},
)
.toBe(200);
});Custom Intervals
await expect
.poll(
async () => {
const response = await page.request.get('/api/status');
return response.status();
},
{
// Default intervals: [100, 250, 500, 1000]
intervals: [1_000, 2_000, 10_000],
timeout: 60_000,
},
)
.toBe(200);Soft Polling
const softExpect = expect.configure({ soft: true });
await softExpect
.poll(
async () => {
const response = await page.request.get('/api/status');
return response.status();
},
{
timeout: 10_000,
},
)
.toBe(200);Retry Blocks with toPass()
Retries an entire block of code until all inner assertions pass.
WARNING: `toPass()` has a default timeout of 0 -- it retries forever. Always specify `timeout`.
test('wait for data sync', async ({ page }) => {
await expect(async () => {
const response = await page.request.get('/api/status');
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.synced).toBe(true);
}).toPass({ timeout: 10_000 });
});Custom Intervals with toPass()
await expect(async () => {
const response = await page.request.get('/api/ready');
expect(response.status()).toBe(200);
}).toPass({
// Default intervals: [100, 250, 500, 1000]
intervals: [1_000, 2_000, 10_000],
timeout: 60_000,
});Custom Matchers with expect.extend()
Define domain-specific assertion methods that integrate with Playwright's retry mechanism.
import { expect as baseExpect } from '@playwright/test';
import type { Locator } from '@playwright/test';
export { test } from '@playwright/test';
export const expect = baseExpect.extend({
async toHaveAmount(
locator: Locator,
expected: number,
options?: { timeout?: number },
) {
const assertionName = 'toHaveAmount';
let pass: boolean;
let matcherResult: any;
try {
const expectation = this.isNot
? baseExpect(locator).not
: baseExpect(locator);
await expectation.toHaveAttribute(
'data-amount',
String(expected),
options,
);
pass = true;
} catch (e: any) {
matcherResult = e.matcherResult;
pass = false;
}
if (this.isNot) {
pass = !pass;
}
const message = pass
? () =>
this.utils.matcherHint(assertionName, undefined, undefined, {
isNot: this.isNot,
}) +
`\n\nLocator: ${locator}\n` +
`Expected: not ${this.utils.printExpected(expected)}\n` +
(matcherResult
? `Received: ${this.utils.printReceived(matcherResult.actual)}`
: '')
: () =>
this.utils.matcherHint(assertionName, undefined, undefined, {
isNot: this.isNot,
}) +
`\n\nLocator: ${locator}\n` +
`Expected: ${this.utils.printExpected(expected)}\n` +
(matcherResult
? `Received: ${this.utils.printReceived(matcherResult.actual)}`
: '');
return {
message,
pass,
name: assertionName,
expected,
actual: matcherResult?.actual,
};
},
});Usage in tests:
import { test, expect } from './fixtures';
test('cart has correct amount', async ({ page }) => {
await page.goto('/cart');
await expect(page.getByTestId('cart')).toHaveAmount(5);
await expect(page.getByTestId('cart')).not.toHaveAmount(0);
});Merging Custom Matchers with mergeExpects()
Combine custom matchers from multiple fixture files into a single expect.
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as dbTest, expect as dbExpect } from 'database-test-utils';
import { test as a11yTest, expect as a11yExpect } from 'a11y-test-utils';
export const expect = mergeExpects(dbExpect, a11yExpect);
export const test = mergeTests(dbTest, a11yTest);import { test, expect } from './fixtures';
test('dashboard passes all checks', async ({ page, database }) => {
await expect(database).toHaveDatabaseUser('admin');
await expect(page).toPassA11yAudit();
});Test Annotations
Inline Annotation on test()
import { test, expect } from '@playwright/test';
test(
'test login page',
{
annotation: {
type: 'issue',
description: 'https://github.com/org/repo/issues/123',
},
},
async ({ page }) => {
await page.goto('/login');
},
);Multiple Annotations
test(
'test full report',
{
annotation: [
{ type: 'issue', description: 'https://github.com/org/repo/issues/123' },
{ type: 'docs', description: 'https://example.com/docs/reports' },
],
},
async ({ page }) => {
await page.goto('/reports');
},
);Runtime Annotations via testInfo
test('dashboard', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/org/repo/issues/456',
});
await page.goto('/dashboard');
await expect(page.getByRole('heading')).toHaveText('Dashboard');
});Describe-Level Annotations
Apply tags and annotations to an entire test.describe group.
import { test, expect } from '@playwright/test';
test.describe(
'reporting suite',
{
tag: '@report',
annotation: {
type: 'docs',
description: 'https://example.com/docs/reporting',
},
},
() => {
test('report header', async ({ page }) => {
await page.goto('/reports');
await expect(page.getByRole('heading')).toHaveText('Reports');
});
test(
'full report',
{
tag: ['@slow', '@vrt'],
},
async ({ page }) => {
await page.goto('/reports/full');
await expect(page.getByRole('table')).toBeVisible();
},
);
},
);Run tagged tests:
npx playwright test --grep @report
npx playwright test --grep @slowAdvanced Topics
Playwright Agents (v1.56+)
Playwright provides three custom agent definitions designed to guide LLMs through building and maintaining tests:
- Planner -- explores the app and produces a Markdown test plan
- Generator -- transforms the plan into Playwright Test files
- Healer -- executes the test suite and automatically repairs failing tests
npx playwright init-agentsThis generates agent configuration files for VS Code Copilot, Claude Desktop, and other AI coding tools.
Playwright MCP Server
Microsoft provides an official Playwright MCP Server for AI agent integration:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}Key features:
- Uses accessibility tree instead of screenshots (faster, more reliable)
- LLM-friendly structured data format
- Model Context Protocol (MCP) compliant
- Works with Claude Desktop, VS Code Copilot, and other MCP clients
Copy Prompt for AI Debugging (v1.51+)
When a test fails, the HTML reporter and UI mode show a "Copy prompt" button that generates a structured debugging prompt. Paste it into an AI tool for assistance fixing the failure.
Disable in config if needed:
export default defineConfig({
reporter: [['html', { copyPrompt: false }]],
});Parallel Browser Contexts
Use separate contexts for isolation when scraping multiple URLs:
import { chromium } from 'playwright';
async function scrapeConcurrently(urls: string[]) {
const browser = await chromium.launch();
const results = await Promise.all(
urls.map(async (url) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url);
const title = await page.title();
await context.close();
return { url, title };
}),
);
await browser.close();
return results;
}Separate contexts provide cookie/storage isolation between concurrent operations.
Network Interception
Route Requests
// Block images and fonts for faster scraping
await page.route('**/*.{png,jpg,jpeg,gif,svg,woff,woff2}', (route) =>
route.abort(),
);
// Modify request headers
await page.route('**/api/**', (route) =>
route.continue({
headers: { ...route.request().headers(), 'X-Custom': 'value' },
}),
);Note: as of v1.52, glob patterns in page.route() no longer support ? and []. Use regex instead:
await page.route(/\/api\/items\/\d+/, (route) => route.fulfill({ body: '{}' }));Intercept and Modify Responses
await page.route('**/api/config', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.featureFlag = true;
await route.fulfill({
response,
body: JSON.stringify(json),
});
});Browser Fingerprinting Defense
async function setupStealthContext(browser) {
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
viewport: {
width: 1920 + Math.floor(Math.random() * 100),
height: 1080 + Math.floor(Math.random() * 100),
},
locale: 'en-US',
timezoneId: 'America/New_York',
screen: { width: 1920, height: 1080 },
geolocation: { longitude: -74.006, latitude: 40.7128 },
permissions: ['geolocation'],
});
return context;
}Randomize viewport dimensions slightly to avoid fingerprint consistency.
Service Worker Routing (v1.57+, Chromium)
Service Worker network requests are now reported and routable through BrowserContext:
// Route service worker requests
await context.route('**/api/**', (route) => route.continue());
// Listen to service worker console messages
context.on('serviceworker', (worker) => {
worker.on('console', (msg) => console.log('SW:', msg.text()));
});Claude Code Integration
Scraping Workflow
// scrape.ts
import { chromium } from 'playwright';
async function scrape(url: string) {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url);
const data = await page.evaluate(() => ({
title: document.title,
headings: Array.from(document.querySelectorAll('h1, h2')).map(
(el) => el.textContent,
),
}));
await browser.close();
console.log(JSON.stringify(data, null, 2));
}
scrape(process.argv[2]);Claude Code workflow: write script, run via npx tsx scrape.ts URL, capture JSON, analyze results.
Screenshot Review Workflow
import { chromium } from 'playwright';
async function captureForReview(url: string) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url);
await page.screenshot({ path: '/tmp/review.png', fullPage: true });
await browser.close();
console.log('Screenshot saved to /tmp/review.png');
}
captureForReview(process.argv[2]);Claude Code can run the script, read the screenshot, analyze visual layout, and suggest improvements.
Cookie Partitioning (v1.54+)
Save and restore partitioned cookies (CHIPS):
const cookies = await context.cookies();
// cookies may include partitionKey for cross-site partitioned cookies
await context.addCookies(
cookies.map((c) => ({
...c,
partitionKey: c.partitionKey,
})),
);Media Emulation
// Emulate color scheme
await page.emulateMedia({ colorScheme: 'dark' });
// Emulate reduced motion
await page.emulateMedia({ reducedMotion: 'reduce' });
// Emulate contrast preference (v1.51+)
await page.emulateMedia({ contrast: 'more' });Dynamic Content Handling
Wait for content to stabilize (no changes for 2 seconds):
async function waitForDynamicContent(page, selector: string) {
const locator = page.locator(selector);
await locator.waitFor();
let previousContent = '';
let stableCount = 0;
while (stableCount < 4) {
await page.waitForTimeout(500);
const currentContent = await page.locator(selector).textContent();
if (currentContent === previousContent) {
stableCount++;
} else {
stableCount = 0;
}
previousContent = currentContent;
}
return previousContent;
}Site-Specific Blocking and Bypasses
Cloudflare
Detection: navigator.webdriver, timing analysis, TLS fingerprinting, JavaScript challenge.
import { chromium } from 'playwright-extra';
import stealth from 'puppeteer-extra-plugin-stealth';
chromium.use(stealth());
const browser = await chromium.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled'],
});
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
await page.goto(url);
await page.waitForTimeout(8000); // Mimics human reading time — critical for CloudflareSuccess rate: ~85% with residential IP, ~40% with datacenter IP.
Detection: Query rate limiting, account throttling, captcha challenges.
for (const query of queries) {
await page.goto(
`https://www.google.com/search?q=${encodeURIComponent(query)}`,
);
const results = await page.locator('h3').allTextContents();
// 10-15 second delay between searches
await page.waitForTimeout(10000 + Math.random() * 5000);
}Better alternative: Google Custom Search API (official, legal).
Detection: Login state verification, rate limiting, behavior analysis.
await page.goto('https://www.linkedin.com/login');
await page.locator('#username').fill(process.env.LINKEDIN_EMAIL);
await page.locator('#password').fill(process.env.LINKEDIN_PASSWORD);
await page.locator('[type="submit"]').click();
await page.waitForURL('**/feed/', { timeout: 10000 });
// Respect limits: 80-100 profile views/day, 20 connections/day, 3-5s between actionsLinkedIn Terms prohibit automated scraping. Use official API.
Amazon
Detection: Bot protection via Cloudflare/Akamai, price scraping detection.
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
extraHTTPHeaders: {
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
Referer: 'https://www.amazon.com/',
},
});
// 1 request per 3-5 seconds
await page.waitForTimeout(3000 + Math.random() * 2000);Better alternative: Product Advertising API.
General E-Commerce
Respect robots.txt crawl-delay:
const robotsTxt = await fetch(new URL('/robots.txt', url)).then((r) =>
r.text(),
);
const delayMatch = robotsTxt.match(/Crawl-delay:\s*(\d+)/i);
const delay = delayMatch ? parseInt(delayMatch[1]) * 1000 : 2000;
for (const product of products) {
await page.goto(product.url);
await page.waitForTimeout(delay);
}Captcha Solving
hCaptcha / reCAPTCHA Services
- 2captcha.com (~$2.99/1000 solves)
- anti-captcha.com
- capsolver.com
Avoiding Captcha Triggers
- Use residential IP
- Add human-like delays
- Limit request rate
- Solve interactively once, persist session
IP Rotation
Residential Proxies
const proxies = [
'http://user:pass@proxy1.example.com:8080',
'http://user:pass@proxy2.example.com:8080',
];
for (const url of urls) {
const proxy = proxies[Math.floor(Math.random() * proxies.length)];
const context = await browser.newContext({ proxy: { server: proxy } });
const page = await context.newPage();
await page.goto(url);
await context.close();
}Datacenter proxies are cheaper but more likely to be blocked.
Legal Considerations
| Site | Scraping Policy | Legal Alternative |
|---|---|---|
| Terms prohibit scraping | Custom Search API | |
| Terms prohibit scraping | Twitter API v2 | |
| Terms prohibit scraping | LinkedIn API | |
| Amazon | Terms prohibit scraping | Product Advertising API |
| Terms prohibit scraping | Instagram Graph API | |
| Terms prohibit scraping | Facebook Graph API |
Always check robots.txt and Terms of Service before scraping.
When All Else Fails
1. Contact the site — ask for API access or data export 2. Check for RSS feeds 3. Use official APIs — usually free tier available 4. Consider alternative data sources 5. Respect rate limits — don't overwhelm servers
Common Patterns
Pattern 1: Authenticated Session Scraping
import { chromium } from 'playwright';
import fs from 'fs/promises';
async function scrapeWithAuth() {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
// Login
await page.goto('https://example.com/login');
await page.locator('input[name="email"]').fill(process.env.EMAIL);
await page.locator('input[name="password"]').fill(process.env.PASSWORD);
await page.locator('button[type="submit"]').click();
await page.waitForURL('**/dashboard', { timeout: 10000 });
// Save session for reuse
const cookies = await context.cookies();
await fs.writeFile('session.json', JSON.stringify(cookies));
// Navigate to protected page
await page.goto('https://example.com/protected-data');
const data = await page.locator('.data-table').textContent();
await browser.close();
return data;
}Pattern 2: Infinite Scroll with Deduplication
async function scrapeInfiniteScroll(page, selector) {
const items = new Set();
let previousCount = 0;
let noChangeCount = 0;
while (noChangeCount < 3) {
const elements = await page.locator(selector).all();
for (const el of elements) {
const text = await el.textContent();
items.add(text);
}
if (items.size === previousCount) {
noChangeCount++;
} else {
noChangeCount = 0;
}
previousCount = items.size;
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(1500);
}
return Array.from(items);
}Use for: Twitter feeds, product listings, news sites with infinite scroll.
Pattern 3: Multi-Tab Orchestration
async function scrapeMultipleTabs(urls: string[]) {
const browser = await chromium.launch();
const context = await browser.newContext();
const results = await Promise.all(
urls.map(async (url) => {
const page = await context.newPage();
await page.goto(url);
const title = await page.title();
await page.close();
return { url, title };
}),
);
await browser.close();
return results;
}10 URLs in parallel takes ~same time as 1 URL.
Pattern 4: Screenshot Full Page
async function captureFullPage(url: string, outputPath: string) {
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 1920, height: 1080 },
});
await page.goto(url, { waitUntil: 'networkidle' });
await page.screenshot({ path: outputPath, fullPage: true, type: 'png' });
await browser.close();
}Pattern 5: PDF Generation
async function generatePDF(url: string, outputPath: string) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
});
await browser.close();
}Chromium only — Firefox and WebKit do not support page.pdf().
Pattern 6: Form Automation with Validation
async function fillFormWithValidation(page) {
await page.locator('input[name="firstName"]').fill('John');
await page.locator('input[name="lastName"]').fill('Doe');
await page.locator('input[name="email"]').fill('john@example.com');
await page.locator('select[name="country"]').selectOption('US');
await page.locator('input[name="terms"]').check();
await page.locator('button[type="submit"]').click();
await expect(page.locator('.success-message')).toBeVisible({
timeout: 10000,
});
}Pattern 7: Retry with Exponential Backoff
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries = 3,
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s
console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
// Usage
await retryWithBackoff(async () => {
await page.goto('https://unreliable-site.com');
});Pattern 8: Dynamic Content Stabilization
Wait for content to stop changing before extracting:
async function waitForStableContent(page, selector: string) {
const locator = page.locator(selector);
await locator.waitFor();
let previousContent = '';
let stableCount = 0;
while (stableCount < 4) {
await page.waitForTimeout(500);
const currentContent = await page.locator(selector).textContent();
if (currentContent === previousContent) {
stableCount++;
} else {
stableCount = 0;
}
previousContent = currentContent;
}
return previousContent;
}Configuration
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30000,
expect: { timeout: 5000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 15'] },
},
],
});Key settings:
trace: 'on-first-retry'-- captures full trace for debugging failed testsscreenshot: 'only-on-failure'-- saves disk spacefullyParallel: true-- runs tests in parallel across files and within files
Multi-Project Setup with Auth
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
},
],
});Per-Project Workers (v1.52+)
export default defineConfig({
workers: 4,
projects: [
{
name: 'fast-tests',
workers: 4,
},
{
name: 'slow-tests',
workers: 1,
},
],
});The global workers limit still applies as a ceiling.
Flaky Test Detection
export default defineConfig({
retries: 2,
failOnFlakyTests: true,
});CLI equivalent: npx playwright test --fail-on-flaky-tests
Web Server Configuration
Basic
export default defineConfig({
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});Dynamic Port Detection (v1.57+)
Wait for web server output using regex:
export default defineConfig({
webServer: {
command: 'npm run dev',
wait: {
stdout: '/Server running on port (?<SERVER_PORT>\\d+)/',
},
},
use: {
baseURL: `http://localhost:${process.env.SERVER_PORT ?? 3000}`,
},
});Named capture groups become environment variables. Handles dynamic ports from Vite, Next.js, and other dev servers.
Graceful Shutdown (v1.50+)
export default defineConfig({
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
});Reporter Configuration
export default defineConfig({
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'results.xml' }],
['list'],
],
});View HTML report: npx playwright show-report
Docker Deployment
FROM mcr.microsoft.com/playwright:v1.58.2-noble
RUN groupadd -r pwuser && useradd -r -g pwuser pwuser
USER pwuser
WORKDIR /app
COPY --chown=pwuser:pwuser . .
RUN npm ci
CMD ["npx", "playwright", "test"]Run with recommended flags:
docker run -it --init --ipc=host my-playwright-tests| Flag | Purpose |
|---|---|
--init | Prevents zombie processes (handles PID=1) |
--ipc=host | Prevents Chromium memory exhaustion |
--cap-add=SYS_ADMIN | Only for local dev (enables sandbox) |
Available tags:
:v1.58.2-noble-- Ubuntu 24.04 LTS (recommended):v1.58.2-jammy-- Ubuntu 22.04 LTS
Python image: mcr.microsoft.com/playwright/python:v1.58.2-noble
Security:
- Always create a non-root user inside the container
- Root user disables Chromium sandbox (security risk)
- Pin to specific version tags (avoid
:latest)
Browser Launch Options
| Option | Value | Purpose |
|---|---|---|
headless | true/false | Show browser UI |
slowMo | 100 (ms) | Slow down for debugging |
args | ['--no-sandbox'] | Disable sandbox (Docker) |
executablePath | /path/to/chrome | Use custom browser |
downloadsPath | ./downloads | Download location |
Common CI Environment Variables
| Variable | Purpose |
|---|---|
CI=true | Detected by Playwright for CI-specific config |
PLAYWRIGHT_FORCE_TTY | Control TTY behavior for reporters |
Dependencies
Required:
@playwright/test-- Test runner and assertions- Node.js 20+ (Node.js 18 deprecated) or Python 3.9+
Optional (stealth):
playwright-extra-- Plugin systempuppeteer-extra-plugin-stealth-- Anti-detection
{
"devDependencies": {
"@playwright/test": "^1.58.0"
}
}Breaking Changes Reference
| Version | Change |
|---|---|
| v1.58 | Removed _react and _vue selectors; removed :light suffix |
| v1.57 | Chrome for Testing replaces Chromium; page.accessibility removed |
| v1.55 | Glob patterns in page.route() no longer support ? and [] |
| v1.55 | route.continue() cannot override Cookie header |
| v1.54 | Node.js 16 removed, Node.js 18 deprecated |
Browser Versions (v1.58)
Chromium 145.0.7632.6 | Firefox 146.0.1 | WebKit 26.0
webServer.url vs use.baseURL
webServer.url-- readiness probe; Playwright polls until 2xx or 403, then starts testsuse.baseURL-- what tests use for relativepage.goto('/')calls- Usually the same value but independent settings
Multiple Web Servers
export default defineConfig({
webServer: [
{
name: 'app',
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
{
name: 'mock-smtp',
command: 'npm run mock:smtp',
url: 'http://localhost:1080',
reuseExistingServer: !process.env.CI,
},
],
});The name field labels log lines when stdout: 'pipe' is set.
Parallel Execution Modes
Per-describe overrides with test.describe.configure():
test.describe.configure({ mode: 'parallel' });
test.describe.configure({ mode: 'serial' });
test.describe.configure({ mode: 'default' });parallel-- individual tests in parallelserial-- sequential, skip rest if one fails; retries the entire block togetherdefault-- opt out offullyParallel
maxFailures
export default defineConfig({ maxFailures: 5 });CLI: --max-failures=5 or -x (stop after first failure).
Teardown Projects
projects: [
{ name: 'setup db', testMatch: '**/db.setup.ts', teardown: 'cleanup db' },
{ name: 'cleanup db', testMatch: '**/db.teardown.ts' },
{ name: 'tests', dependencies: ['setup db'] },
],Teardown runs after all dependents complete, regardless of pass/fail.
globalTimeout
export default defineConfig({ globalTimeout: 30 * 60 * 1000 });Hard cap on entire test run. Prevents runaway CI jobs.
Codegen Options
npx playwright codegen http://localhost:3000
npx playwright codegen --load-storage=playwright/.auth/user.json http://localhost:3000
npx playwright codegen --device="iPhone 15 Pro Max" http://localhost:3000
npx playwright codegen --color-scheme=dark http://localhost:3000Debug Methods and Performance
Console Messages (v1.56+)
Capture console output without event listeners:
import { test, expect } from '@playwright/test';
test('capture console output', async ({ page }) => {
await page.goto('https://example.com');
const messages = page.consoleMessages();
const errors = messages.filter((m) => m.type() === 'error');
const logs = messages.filter((m) => m.type() === 'log');
console.log(
'Console errors:',
errors.map((m) => m.text()),
);
});Page Errors (v1.56+)
Get uncaught JavaScript exceptions:
test('check for JavaScript errors', async ({ page }) => {
await page.goto('https://example.com');
const errors = page.pageErrors();
expect(errors).toHaveLength(0);
});Network Requests (v1.56+)
Inspect all network activity:
test('inspect API calls', async ({ page }) => {
await page.goto('https://example.com');
const requests = page.requests();
const apiCalls = requests.filter((r) => r.url().includes('/api/'));
console.log('API calls made:', apiCalls.length);
const failed = requests.filter((r) => r.failure());
expect(failed).toHaveLength(0);
});Use for: debugging test failures, verifying no console errors, auditing network activity.
Worker Console Events (v1.57+)
Monitor console output from Web Workers and Service Workers:
test('capture worker console messages', async ({ page }) => {
page.on('worker', (worker) => {
worker.on('console', (msg) => {
console.log(`Worker [${worker.url()}]: ${msg.text()}`);
});
});
await page.goto('/app-with-workers');
});Trace Viewer
Record traces for post-mortem debugging:
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});View traces:
npx playwright show-trace trace.zipTrace Viewer shows: action timeline, DOM snapshots, network requests, console logs, and source code context. UI Mode includes search (Cmd/Ctrl+F) in code editors and auto-formatted JSON responses.
Advanced Mouse Control (v1.57+)
The steps option provides fine-grained control over mouse movement:
Click with Steps
// Smooth, human-like movement (10 intermediate steps)
await page.locator('button.submit').click({ steps: 10 });
// Fast click (fewer steps)
await page.locator('button.cancel').click({ steps: 2 });Drag with Steps
const source = page.locator('#draggable');
const target = page.locator('#dropzone');
// Smooth drag animation
await source.dragTo(target, { steps: 20 });
// Quick drag
await source.dragTo(target, { steps: 5 });Anti-detection benefit: many bot detection systems look for instantaneous mouse movements. Using steps: 10 or higher simulates realistic human behavior.
Speedboard Performance Analysis (v1.57+)
The HTML reporter includes Speedboard -- a dedicated tab for identifying slow tests. In v1.58+, merged reports also show a Timeline visualization.
Enable:
export default defineConfig({
reporter: 'html',
});View:
npx playwright test --reporter=html
npx playwright show-reportWhat Speedboard shows:
- All tests sorted by execution time (slowest first)
- Breakdown of wait times
- Network request durations
- Inefficient selectors and unnecessary waits
Use cases:
- Optimize test suite runtime
- Find tests with excessive
waitForTimeout()calls - Identify slow API responses affecting tests
- Prioritize refactoring for slowest tests
Debugging Tips
| Method | When to Use |
|---|---|
PWDEBUG=1 npx playwright test | Step through test in inspector |
page.pause() | Pause at specific point (headed) |
--headed | See browser during test |
--debug | Open inspector at start |
trace: 'on' | Capture full trace every run |
screenshot: 'on' | Screenshot after every test |
video: 'on' | Record video of every test |
page.consoleMessages() | Check console output (v1.56+) |
locator.describe('reason') | Label locators in traces (v1.53+) |
Docker and CI
Official Docker Images
Playwright provides pre-built Docker images with browsers and system dependencies pre-installed:
docker pull mcr.microsoft.com/playwright:v1.58.2-nobleAvailable images:
| Image | Base | Use Case |
|---|---|---|
mcr.microsoft.com/playwright:v1.58.2-noble | Ubuntu 24.04 | Node.js tests |
mcr.microsoft.com/playwright:v1.58.2-jammy | Ubuntu 22.04 | Node.js (legacy compat) |
mcr.microsoft.com/playwright/python:v1.58.2-noble | Ubuntu 24.04 | Python tests |
Always pin to a specific version tag. The image version must match the installed @playwright/test package version.
Dockerfile for Playwright Tests
Using Official Image (Recommended)
FROM mcr.microsoft.com/playwright:v1.58.2-noble
RUN groupadd -r pwuser && useradd -r -g pwuser -G audio,video pwuser
RUN mkdir -p /home/pwuser && chown -R pwuser:pwuser /home/pwuser
WORKDIR /app
COPY --chown=pwuser:pwuser package.json package-lock.json ./
RUN npm ci
COPY --chown=pwuser:pwuser . .
USER pwuser
CMD ["npx", "playwright", "test"]Using Node.js Base Image
When the official image is too large or a custom base is needed:
FROM node:20-bookworm
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
RUN npx playwright install --with-deps chromium
COPY . .
RUN groupadd -r pwuser && useradd -r -g pwuser -G audio,video pwuser
RUN chown -R pwuser:pwuser /app
USER pwuser
CMD ["npx", "playwright", "test"]Use --with-deps to install browser system dependencies alongside browser binaries.
Required Docker Run Flags
docker run --rm --init --ipc=host my-playwright-tests| Flag | Purpose |
|---|---|
--init | Reaps zombie processes (Node.js does not handle PID 1) |
--ipc=host | Prevents Chromium crashes from insufficient shared memory |
Alternative to --ipc=host when host IPC sharing is not allowed:
docker run --rm --init --shm-size=2gb my-playwright-testsSecurity: Always create a non-root user. Running as root disables the Chromium sandbox, which is a security risk when testing untrusted sites.
Docker Compose for Local Testing
services:
playwright:
build: .
init: true
ipc: host
environment:
- CI=true
- BASE_URL=http://app:3000
depends_on:
app:
condition: service_healthy
volumes:
- ./test-results:/app/test-results
- ./playwright-report:/app/playwright-report
app:
build:
context: .
dockerfile: Dockerfile.app
ports:
- '3000:3000'
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000']
interval: 5s
timeout: 3s
retries: 10Mount test-results and playwright-report volumes to access artifacts from the host.
Update playwright.config.ts to use the compose service URL:
export default defineConfig({
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
},
});GitHub Actions CI
Basic Workflow
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v5
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30Use if: ${{ !cancelled() }} to upload artifacts even when tests fail.
Fail-Fast with Changed Tests
Run only changed test files on PRs for faster feedback:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run changed tests
if: github.event_name == 'pull_request'
run: npx playwright test --only-changed=$GITHUB_BASE_REF
- name: Run all tests
run: npx playwright testRequires fetch-depth: 0 for git history access.
CI-Optimized Config
import { defineConfig } from '@playwright/test';
export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'blob' : 'html',
use: {
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'on-first-retry',
},
});Reporter strategy:
blobin CI -- generates mergeable.zipfiles for sharded runsgithubin CI -- generates inline annotations on PR diffshtmllocally -- interactive report with Speedboard
Sharding Across CI Workers
Split tests across parallel jobs using --shard:
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1Set fail-fast: false so all shards complete even if one fails.
Merging Sharded Reports
jobs:
merge-reports:
if: ${{ !cancelled() }}
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v5
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14Artifact Collection
Recording Options
export default defineConfig({
use: {
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'on-first-retry',
},
outputDir: './test-results',
});| Option | Values | Recommended CI Setting |
|---|---|---|
screenshot | 'off', 'on', 'only-on-failure' | 'only-on-failure' |
trace | 'off', 'on', 'retain-on-failure', 'on-first-retry' | 'on-first-retry' |
video | 'off', 'on', 'retain-on-failure', 'on-first-retry' | 'on-first-retry' |
Artifacts are stored in test-results/ by default. Upload this directory in CI:
- uses: actions/upload-artifact@v5
if: ${{ !cancelled() }}
with:
name: test-results
path: test-results/
retention-days: 7Viewing Traces
npx playwright show-trace trace.zipTraces can also be viewed at trace.playwright.dev by uploading the zip file.
Parallel Test Execution
Config-Level Parallelism
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 2 : undefined,
});fullyParallel: true-- runs tests within a single file in parallelworkers-- number of parallel worker processes (default: half of CPU cores)- CI runners typically have 2 cores; set
workers: 2to match
Per-Project Workers (v1.52+)
export default defineConfig({
workers: 4,
projects: [
{ name: 'fast-tests', workers: 4 },
{ name: 'serial-tests', workers: 1 },
],
});The global workers value acts as a ceiling across all projects.
File-Level Serial Execution
Force serial execution within a specific test file:
test.describe.configure({ mode: 'serial' });
test('step 1', async ({ page }) => {
// runs first
});
test('step 2', async ({ page }) => {
// runs after step 1
});Input and Actions
Actionability Auto-Wait
Every action method auto-waits for specific checks before proceeding. If an element is detached during checks, the action retries from locator resolution.
| Action | Visible | Stable | Enabled | Receives Events | Editable |
|---|---|---|---|---|---|
click, dblclick, check, uncheck, tap | Yes | Yes | Yes | Yes | -- |
setChecked | Yes | Yes | Yes | Yes | -- |
hover, dragTo | Yes | Yes | -- | Yes | -- |
fill, clear | Yes | -- | Yes | -- | Yes |
selectOption | Yes | -- | Yes | -- | -- |
screenshot, selectText | Yes | -- | -- | -- | -- |
setInputFiles | -- | -- | -- | -- | -- |
focus, blur, press, type | -- | -- | -- | -- | -- |
dispatchEvent | -- | -- | -- | -- | -- |
opacity: 0 passes the visibility check — Playwright considers it visible. Only display: none and visibility: hidden fail the check.
Text Input
fill() — Default Choice
Focuses the element, clears existing content, and sets the value in one step. Triggers input and change events.
await page.getByLabel('Username').fill('jane');
await page.getByLabel('Start date').fill('2025-06-15');
await page.locator('[contenteditable]').fill('Rich text content');Works on <input>, <textarea>, date/time inputs, and [contenteditable] elements.
pressSequentially() — Per-Keystroke Input
Types one character at a time, firing full keydown/keypress/keyup events per character. Use only when the app has per-keystroke handling (autocomplete, debounced search, character validation).
await page.getByLabel('Search').pressSequentially('playwright', { delay: 100 });fill() is faster and more reliable — default to it unless you specifically need keystroke events.
Checkboxes and Radio Buttons
setChecked() — Idempotent, Preferred
await page.getByLabel('I agree').setChecked(true);
await page.getByLabel('Newsletter').setChecked(false);check() / uncheck() — When You Know the State
await page.getByRole('checkbox', { name: 'Terms' }).check();
await page.getByRole('checkbox', { name: 'Marketing' }).uncheck();
await expect(page.getByLabel('Terms')).toBeChecked();Select Dropdowns
// By value attribute
await page.getByLabel('Country').selectOption('us');
// By visible label text
await page.getByLabel('Country').selectOption({ label: 'United States' });
// By zero-based index
await page.getByLabel('Country').selectOption({ index: 2 });
// Multiple selections
await page.getByLabel('Colors').selectOption(['red', 'green', 'blue']);File Upload
Static Input
import path from 'path';
await page
.getByLabel('Upload')
.setInputFiles(path.join(__dirname, 'report.pdf'));
await page
.getByLabel('Upload')
.setInputFiles([
path.join(__dirname, 'file1.txt'),
path.join(__dirname, 'file2.txt'),
]);
// Clear selection
await page.getByLabel('Upload').setInputFiles([]);
// Upload a directory
await page
.getByLabel('Upload directory')
.setInputFiles(path.join(__dirname, 'mydir'));In-Memory Buffer
await page.getByLabel('Upload').setInputFiles({
name: 'data.csv',
mimeType: 'text/csv',
buffer: Buffer.from('id,name\n1,Alice\n2,Bob'),
});Dynamic File Chooser
When the file input is created dynamically or triggered by a click:
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Upload' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'myfile.pdf'));Click Variants
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', { name: 'Submit' }).dblclick();
await page.getByRole('button', { name: 'Options' }).click({ button: 'right' });
// Modifier keys
await page.getByRole('link', { name: 'Docs' }).click({ modifiers: ['Shift'] });
await page.getByText('Select me').click({ modifiers: ['ControlOrMeta'] });
// Click at specific position relative to top-left of element
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
// Force click (skip actionability checks)
await page.getByRole('button', { name: 'Hidden' }).click({ force: true });Hover and Focus
// Trigger tooltip or dropdown menu
await page.getByRole('link', { name: 'Products' }).hover();
await expect(page.getByRole('menu')).toBeVisible();
// Trigger focus/blur validation
await page.getByLabel('Email').focus();
await page.getByLabel('Email').blur();
await expect(page.getByText('Email is required')).toBeVisible();Drag and Drop
High-Level API
const source = page.getByTestId('drag-item');
const target = page.getByTestId('drop-zone');
await source.dragTo(target);Low-Level Fallback
For custom drag implementations that don't work with the high-level API:
await page.getByTestId('drag-item').hover();
await page.mouse.down();
await page.getByTestId('drop-zone').hover();
await page.mouse.up();Keyboard
// Press a key on a focused element
await page.getByLabel('Name').press('Tab');
// Global keyboard actions
await page.keyboard.press('Escape');
await page.keyboard.press('Control+a');
await page.keyboard.press('Meta+c');
// Key combinations
await page.keyboard.press('Shift+ArrowDown');
// Type and submit
await page.getByLabel('Search').fill('query');
await page.getByLabel('Search').press('Enter');Scrolling
// Scroll element into view (auto-called by most actions)
await page.getByTestId('footer').scrollIntoViewIfNeeded();
// Mouse wheel scroll
await page.mouse.wheel(0, 500);
// Programmatic scroll
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));File Downloads
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Export CSV' }).click();
const download = await downloadPromise;
const filename = download.suggestedFilename();
await download.saveAs(path.join('downloads', filename));Downloaded files are deleted when the browser context closes — always call saveAs() before the test ends.
New Tabs and Popups
target="\_blank" Links
const pagePromise = context.waitForEvent('page');
await page.getByRole('link', { name: 'External docs' }).click();
const newPage = await pagePromise;
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/docs/);window.open() Popups
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open preview' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();List Open Pages
const allPages = context.pages();Iframes
frameLocator() — Scoped Locators
const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Card number').fill('4242424242424242');
await frame.getByRole('button', { name: 'Pay' }).click();Imperative Access
const frame = page.frame('checkout');
const frame2 = page.frame({ url: /stripe\.com/ });Dialog Handling
Dialogs must be handled — failing to call accept() or dismiss() hangs the action that triggered the dialog.
// Alert / confirm
page.once('dialog', async (dialog) => {
await dialog.accept();
});
await page.getByRole('button', { name: 'Delete' }).click();
// Dismiss instead of accept
page.once('dialog', async (dialog) => {
await dialog.dismiss();
});
// Prompt with input
page.once('dialog', async (dialog) => {
await dialog.accept('My answer');
});
await page.getByRole('button', { name: 'Rename' }).click();Register the handler before the action that triggers the dialog. Use page.once() to avoid handling the next dialog unintentionally.
beforeunload Dialogs
// Triggers beforeunload — dialog must be handled separately
await page.close({ runBeforeUnload: true });Test Modifiers
import { test, expect } from '@playwright/test';
// Skip unconditionally
test.skip('not yet implemented', async ({ page }) => {});
// Skip conditionally
test('mobile feature', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Mobile-only feature');
// ...test body
});
// Mark as known failure — test runs and asserts it DOES fail
test('known regression', async ({ page }) => {
test.fail();
await page.goto('/broken-page');
await expect(page.getByText('Welcome')).toBeVisible();
});
// Mark as fixme — skipped but tracked in report as needing fix
test.fixme('flaky date picker', async ({ page }) => {});
// Triple the timeout for slow tests
test('large data export', async ({ page }) => {
test.slow();
// ...test with 3x default timeout
});Conditional Skip in beforeEach
test.beforeEach(async ({ page }, testInfo) => {
if (testInfo.project.name === 'firefox') {
test.skip();
}
await page.goto('/');
});| Modifier | Behavior |
|---|---|
test.skip() | Skips the test, shown as skipped in report |
test.fixme() | Skips the test, shown as "fixme" in report |
test.fail() | Runs the test and asserts it fails — fails if it passes |
test.slow() | Triples the test timeout |
Known Issues
Issue 1: Target Closed Error
- Error:
Protocol error (Target.sendMessageToTarget): Target closed. - Source: GitHub Issue #2938
- Cause: Page closed before action completed, or browser crashed
- Fix:
try {
await page.goto(url, { timeout: 30000 });
} catch (error) {
if (error.message.includes('Target closed')) {
await browser.close();
browser = await chromium.launch();
}
}Issue 2: Element Not Found
- Error:
TimeoutError: waiting for selector "button" failed: timeout 30000ms exceeded - Cause: Element doesn't exist, wrong selector, or page hasn't loaded
- Fix:
// Locators auto-wait for elements to appear
await page.locator('button.submit').click();
// With custom timeout
await page.locator('button.submit').click({ timeout: 10000 });Issue 3: Navigation Timeout
- Error:
TimeoutError: page.goto: Timeout 30000ms exceeded. - Cause: Slow page load, infinite spinner, or firewall blocking
- Fix:
try {
await page.goto(url, {
waitUntil: 'domcontentloaded', // Less strict than networkidle
timeout: 60000,
});
} catch (error) {
if (error.name === 'TimeoutError') {
const title = await page.title();
if (title) console.log('Page loaded despite timeout');
}
}Issue 4: Detached Frame Error
- Error:
Error: Execution context was destroyed, most likely because of a navigation. - Source: GitHub Issue #3934
- Cause: SPA navigation re-rendered the element
- Fix: Use locators (they re-query automatically) and wait for navigation
async function safeClick(page, selector) {
await page.locator(selector).click();
await page.waitForLoadState('domcontentloaded');
}Issue 5: Bot Detection (403/Captcha)
- Symptom: Page returns 403 or shows captcha
- Cause: Site detects
navigator.webdriver, datacenter IP, or fingerprint mismatch - Fix: Use stealth mode + residential IP (see stealth-mode.md)
Issue 6: File Download Not Completing
- Cause: Download event not awaited, file stream not closed
- Fix:
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('a.download-link').click(),
]);
await download.saveAs('./downloads/' + download.suggestedFilename());Issue 7: Infinite Scroll Not Loading More
- Cause: Scroll event not triggered correctly, or scrolling too fast
- Fix:
let previousHeight = 0;
while (true) {
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
if (currentHeight === previousHeight) break;
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(2000);
previousHeight = currentHeight;
}Issue 8: WebSocket Connection Failed
- Error:
WebSocket connection to 'ws://...' failed - Cause: Browser launched without
--no-sandboxin restrictive environments - Fix:
const browser = await chromium.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});Issue 9: page.pause() Disables Timeout in Headless Mode
- Error: Tests hang indefinitely in CI when
page.pause()is present - Source: GitHub Issue #38754
- Cause:
page.pause()is ignored in headless mode but disables test timeout - Impact: HIGH — causes CI pipelines to hang indefinitely
- Fix:
if (!process.env.CI && !process.env.HEADLESS) {
await page.pause();
}Issue 10: Permission Prompts Block Extension Testing in CI
- Error: Tests hang on permission prompts when testing browser extensions
- Source: GitHub Issue #38670
- Cause:
launchPersistentContextwith extensions shows non-dismissible permission prompts - Impact: HIGH — blocks automated extension testing in CI/CD
- Fix:
// Use regular context instead of persistent context for CI
const context = await browser.newContext({
permissions: [
'clipboard-read',
'clipboard-write',
'notifications',
'geolocation',
],
});Network Testing
Route Handler Methods
Three methods on route objects control how requests are handled:
Fulfill — Return Mock Response
import { test, expect } from '@playwright/test';
test('mock API response', async ({ page }) => {
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Test User' }]),
});
});
await page.goto('/users');
await expect(page.getByText('Test User')).toBeVisible();
});Abort — Block Resources
test('block images to speed up test', async ({ page }) => {
await page.route('**/*.{png,jpg,jpeg,gif,webp}', (route) => route.abort());
await page.goto('/heavy-page');
});Continue — Forward with Modifications
test('add auth header to requests', async ({ page }) => {
await page.route('**/api/**', async (route) => {
await route.continue({
headers: {
...route.request().headers(),
'x-custom-token': 'test-value',
},
});
});
await page.goto('/dashboard');
});Modify Live Responses with route.fetch()
Intercept the real response, modify it, and return the modified version:
test('modify live API response', async ({ page }) => {
await page.route('**/api/accounts', async (route) => {
const response = await route.fetch();
const json = await response.json();
json[0].balance = -999.99;
await route.fulfill({ response, json });
});
await page.goto('/accounts');
await expect(page.getByText('-999.99')).toBeVisible();
});Patch a single field while preserving the rest of the real response:
await page.route('**/api/feature-flags', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.darkMode = true;
await route.fulfill({ response, json });
});Glob Pattern Rules for page.route()
| Pattern | Matches |
|---|---|
* | Any characters EXCEPT / |
** | Any characters INCLUDING / |
? | A LITERAL question mark (NOT "any single char") |
{} | Alternatives: **/*.{png,jpg} |
? matching a literal question mark is a Playwright-specific difference from shell globs (changed in v1.55).
Patterns must match the ENTIRE URL. Use RegExp for partial matching:
await page.route('**/api/users', handler);
await page.route(/\/api\/users\/\d+/, handler);HAR Record and Replay
Record network traffic to a HAR file:
npx playwright test --save-har=hars/api.har --save-har-glob="**/api/**"Replay from HAR in tests:
test('replay from HAR', async ({ page }) => {
await page.routeFromHAR('hars/api.har', { url: '**/api/**' });
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});Update HAR when API changes:
test('update HAR on miss', async ({ page }) => {
await page.routeFromHAR('hars/api.har', {
url: '**/api/**',
update: true,
});
await page.goto('/dashboard');
});Waiting for Network Events
Set up the wait BEFORE triggering the action that causes the network request:
Wait for Response
test('wait for API response', async ({ page }) => {
await page.goto('/app');
const responsePromise = page.waitForResponse('**/api/submit');
await page.getByRole('button', { name: 'Submit' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
});Wait for Response with Predicate
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/api/search') && response.status() === 200,
);
await page.getByRole('button', { name: 'Search' }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.results.length).toBeGreaterThan(0);Wait for Request
const requestPromise = page.waitForRequest('**/api/analytics');
await page.getByRole('button', { name: 'Track' }).click();
const request = await requestPromise;
expect(request.method()).toBe('POST');Network Lifecycle Events
Confirm Fire-and-Forget Requests
test('beacon request completes', async ({ page }) => {
const requestFinished = page.waitForEvent('requestfinished', (req) =>
req.url().includes('/api/analytics'),
);
await page.goto('/page-with-beacon');
await page.getByRole('link', { name: 'Leave' }).click();
await requestFinished;
});Detect Failed Requests
test('handle request failure', async ({ page }) => {
const failures: string[] = [];
page.on('requestfailed', (request) => {
failures.push(`${request.url()} - ${request.failure()?.errorText}`);
});
await page.goto('/app');
expect(failures).toHaveLength(0);
});Service Worker Caveat
If Mock Service Worker (MSW) or any Service Worker is active, it intercepts requests BEFORE page.route() sees them, making route handlers silently ineffective.
Fix in config:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
serviceWorkers: 'block',
},
});Or per-context:
const context = await browser.newContext({
serviceWorkers: 'block',
});Context-Level Routing
page.route() only covers the page it is called on. For popups opened via window.open(), use context.route():
test('intercept popup requests', async ({ context, page }) => {
await context.route('**/api/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ mocked: true }),
});
});
await page.goto('/app');
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Open window' }).click(),
]);
await expect(popup.getByText('mocked')).toBeVisible();
});API Testing in E2E
Shared Cookie Context via page.request
The request fixture on a page shares the browser context cookies:
test('verify server state after UI action', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
const response = await page.request.get('/api/profile');
expect(response.ok()).toBeTruthy();
const profile = await response.json();
expect(profile.email).toBe('user@test.com');
});Isolated API Context
import { test, expect, request } from '@playwright/test';
test('standalone API test', async () => {
const apiContext = await request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
Authorization: 'Bearer test-token',
},
});
const response = await apiContext.get('/users');
expect(response.ok()).toBeTruthy();
await apiContext.dispose();
});Data Setup and Teardown
test.describe('order workflow', () => {
let orderId: string;
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/orders', {
data: { product: 'widget', quantity: 1 },
});
orderId = (await response.json()).id;
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/orders/${orderId}`);
});
test('displays order', async ({ page }) => {
await page.goto(`/orders/${orderId}`);
await expect(page.getByText('widget')).toBeVisible();
});
});Global HTTP Headers
Set headers attached to every browser request via config:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
extraHTTPHeaders: {
'x-request-id': 'playwright-test',
Accept: 'application/json',
},
},
});Or per-context:
const context = await browser.newContext({
extraHTTPHeaders: {
'x-api-key': 'test-key',
},
});Quick Start
Installation
Node.js:
npm install -D playwright
npx playwright install chromiumPython:
pip install playwright
playwright install chromiumplaywright installdownloads browser binaries (~400MB for Chromium)- Install only needed browsers:
chromium,firefox, orwebkit - Binaries stored in
~/.cache/ms-playwright/
Basic Page Scrape
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com');
const title = await page.title();
const content = await page.locator('body').textContent();
await browser.close();
console.log({ title, content });Critical rules:
- Always close browser with
await browser.close()to avoid zombie processes - For SPAs, wait for specific elements rather than
networkidle(flaky with WebSockets/long-polling) - Default timeout is 30 seconds — adjust with
timeout: 60000if needed
Local vs Cloudflare Browser Rendering
| Feature | Playwright Local | Cloudflare Browser Rendering |
|---|---|---|
| IP Address | Residential (your ISP) | Datacenter (easily detected) |
| Stealth Plugins | Full support | Not available |
| Rate Limits | None | 2,000 requests/day free tier |
| Cost | Free (your CPU) | $5/10k requests after free tier |
| Browser Control | All Playwright features | Limited API |
| Session Persistence | Full cookie/storage control | Limited session management |
Use Cloudflare: Serverless environments, simple scraping, cost-efficient at scale. Use Local: Anti-bot bypass needed, residential IP required, complex automation.
Critical Rules
Always Do
- Use locator API (
page.getByRole(),page.locator()) — locators auto-wait for elements - For SPAs, wait for specific elements (
expect(locator).toBeVisible()) instead ofnetworkidle - Close browsers with
await browser.close()to prevent memory leaks - Wrap automation in try/catch/finally blocks
- Set explicit timeouts for unreliable sites
- Save screenshots on errors for debugging
- Test with
headless: falsefirst, then switch toheadless: true
Never Do
- Use
page.click('selector')— usepage.locator('selector').click()orpage.getByRole().click() - Rely on fixed
setTimeout()for waits — locators auto-wait, usewaitForLoadStateif needed - Use
waitUntil: 'networkidle'for SPAs — flaky with WebSockets and long-polling apps - Scrape without rate limiting — add delays between requests
- Use same user agent for all requests — rotate agents
- Ignore navigation errors — catch and retry with backoff
- Store credentials in code — use environment variables
Selector Strategies
Selector Hierarchy (Best to Worst)
1. Role — accessible, semantic (getByRole, getByLabel) 2. Label / Text — visible text content (getByText, getByPlaceholder) 3. data-testid — stable, survives refactors (getByTestId) 4. CSS — class or attribute selectors (page.locator('.btn')) 5. XPath — last resort (xpath=//button)
CSS Selectors
// Basic
await page.locator('button').click(); // Tag
await page.locator('#submit').click(); // ID
await page.locator('.btn-primary').click(); // Class
await page.locator('[type="submit"]').click(); // Attribute
// Combinators
await page.locator('form > button').click(); // Child
await page.locator('form button').click(); // Descendant
await page.locator('label + input').click(); // Adjacent sibling
// Pseudo-classes
await page.locator('button:first-child').click();
await page.locator('button:nth-child(2)').click();
await page.locator('input:checked').click();
// Attribute patterns
await page.locator('[href^="https"]').click(); // Starts with
await page.locator('[href$=".pdf"]').click(); // Ends with
await page.locator('[href*="example"]').click(); // ContainsXPath Selectors
Prefix with xpath=:
await page.locator('xpath=//button[text()="Submit"]').click();
await page.locator('xpath=//button[contains(text(), "Sub")]').click();
await page.locator('xpath=//button[@class="submit"]').click();
await page.locator('xpath=(//button)[1]').click(); // First
await page.locator('xpath=(//button)[last()]').click(); // Last
await page
.locator('xpath=//button[@type="submit" and contains(text(), "Save")]')
.click();Text Selectors
await page.locator('text=Submit').click(); // Exact match
await page.locator('text="Submit form"').click(); // Full string
await page.locator('text=/submit/i').click(); // Case-insensitive
await page.locator('text=/^Submit/').click(); // RegexRole Selectors (getByRole)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');Playwright-Specific Pseudo-Classes
// has-text: element containing text
await page.locator('button:has-text("Submit")').click();
await page.locator('article:has-text("Breaking News")').click();
// has: element containing selector
await page.locator('article:has(img.thumbnail)').click();
await page.locator('div:has(> button.primary)').click();
// is: filter match
await page.locator('button:is(.submit, .confirm)').click();
// not: exclude match
await page.locator('button:not(.disabled)').click();Chaining Selectors
// Parent → child
await page.locator('article').locator('button.submit').click();
// Text context → child
await page.locator('div:has-text("Settings")').locator('button').click();
// Role within section
await page
.locator('section.checkout')
.getByRole('button', { name: 'Pay' })
.click();List Item Selection
await page.locator('.product-card').nth(2).click(); // 3rd item (0-indexed)
await page.locator('.product-card').first().click();
await page.locator('.product-card').last().click();Shadow DOM
Playwright pierces shadow DOM automatically:
await page.click('custom-element .shadow-button');Debugging Selectors
// Count matches
const count = await page.locator('button').count();
console.log(`Found ${count} buttons`);
// Get all matching elements
const elements = await page.locator('.item').all();
for (const el of elements) {
console.log(await el.textContent());
}
// Visual inspector
// PWDEBUG=1 npx playwright testPerformance
| Selector | Speed | Notes |
|---|---|---|
| ID | Fastest | Browser-native |
| Class | Fast | Browser-native |
| Tag | Fast | Browser-native |
| XPath | Slower | Parsed by Playwright |
| Text | Slower | Must traverse text nodes |
For high-volume scraping, prefer CSS selectors.
Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Selector finds multiple | Not specific enough | Add parent context or unique attribute |
| Element not found | Wrong selector or timing | Use locator API (auto-waits for elements) |
| Stale element | DOM re-rendered | Re-query after navigation |
| Slow selectors | Complex XPath | Simplify or use CSS |
| Flaky tests | Timing issues | Add explicit waits |
Stealth Mode
Advanced detection systems (Cloudflare Bot Management, PerimeterX, DataDome) now include behavioral analysis, TLS fingerprinting, canvas/WebGL fingerprinting, and HTTP/2 fingerprinting. Stealth plugins are a good starting point, not a complete solution.
Step 1: Install Stealth Plugin
npm install playwright-extra puppeteer-extra-plugin-stealthStep 2: Configure Stealth Mode
import { chromium } from 'playwright-extra';
import stealth from 'puppeteer-extra-plugin-stealth';
chromium.use(stealth());
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-setuid-sandbox',
],
});
const context = await browser.newContext({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York',
});Step 3: Mask WebDriver Detection
await page.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{
name: 'Chrome PDF Viewer',
filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
},
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
],
});
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) =>
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
});Step 4: Human-Like Mouse Movement
async function humanClick(page, selector) {
const element = await page.locator(selector);
const box = await element.boundingBox();
if (box) {
const x = box.x + box.width * Math.random();
const y = box.y + box.height * Math.random();
await page.mouse.move(x, y, { steps: 10 });
await page.waitForTimeout(100 + Math.random() * 200);
await page.mouse.click(x, y, { delay: 50 + Math.random() * 100 });
}
}Step 5: Rotate User Agents
const userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
];
const randomUA = userAgents[Math.floor(Math.random() * userAgents.length)];Match user agent to actual Chrome version.
Step 6: Cookie and Session Persistence
import fs from 'fs/promises';
// Save session
const cookies = await context.cookies();
await fs.writeFile('session.json', JSON.stringify(cookies, null, 2));
await context.close();
// Restore session
const savedCookies = JSON.parse(await fs.readFile('session.json', 'utf-8'));
const newContext = await browser.newContext();
await newContext.addCookies(savedCookies);Step 7: Verify Stealth
Test at: https://bot.sannysoft.com/
await page.goto('https://bot.sannysoft.com/', { waitUntil: 'networkidle' });
await page.screenshot({ path: 'stealth-test.png', fullPage: true });Check: navigator.webdriver should be undefined (not false), Chrome detected, plugins populated, no red flags.
Detection Vectors
| Vector | What It Checks | Defense |
|---|---|---|
navigator.webdriver | Automation flag | Stealth plugin / init script |
| Missing plugins | Real browsers have 3-5 plugins | Mock plugins via init script |
| Chrome DevTools | window.chrome presence | Delete in init script |
| WebGL fingerprint | Renderer/vendor strings | Context config with screen |
| Canvas fingerprint | Consistent pixel output | Stealth plugin handles this |
| TLS fingerprint | JA3/JA4 signatures | Real browser helps |
| Timing patterns | Instant actions = bot | Random delays + steps |
| IP reputation | Datacenter IPs flagged | Residential proxy |
Common Stealth Mistakes
| Mistake | Fix |
|---|---|
| Same UA every time | Rotate user agents |
| Fixed viewport | Randomize slightly |
| Instant actions | Add random delays |
| No cookies | Accept + persist cookies |
| Missing referer | Set referer header |
| Datacenter IP | Use residential proxy if needed |
Residential Proxies
When stealth plugins aren't enough:
const context = await browser.newContext({
proxy: {
server: 'http://proxy-server:8080',
username: 'user',
password: 'pass',
},
});Providers: Bright Data, Oxylabs, Smartproxy.
Verification Tools
https://bot.sannysoft.com/— Comprehensive bot detection testhttps://arh.antoinevastel.com/bots/areyouheadless— Headless detectionhttps://pixelscan.net/— Fingerprint analysishttps://browserleaks.com/webrtc— WebRTC leak check
Testing Patterns
Basic Test Structure
import { test, expect } from '@playwright/test';
test('user can submit a form', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Thank you')).toBeVisible();
});Playwright auto-waits for elements before acting and auto-retries assertions.
Test Organization
import { test, expect } from '@playwright/test';
test.describe('checkout flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/shop');
});
test('add item to cart', async ({ page }) => {
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('remove item from cart', async ({ page }) => {
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('0');
});
});Locator Priority (Best to Worst)
1. page.getByRole('button', { name: 'Submit' }) -- accessible, resilient 2. page.getByLabel('Email') -- form inputs by label 3. page.getByPlaceholder('Search...') -- placeholder text 4. page.getByText('Welcome') -- visible text content 5. page.getByTestId('submit-btn') -- data-testid attributes 6. page.locator('button.submit') -- CSS selectors (last resort)
Common Assertions
import { test, expect } from '@playwright/test';
test('assertion examples', async ({ page }) => {
await page.goto('/dashboard');
// Visibility
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Loading')).not.toBeVisible();
// Text content
await expect(page.getByRole('status')).toHaveText('Active');
await expect(page.getByRole('status')).toContainText('Act');
// CSS class (v1.52+)
await expect(page.getByRole('tab', { name: 'Settings' })).toContainClass(
'active',
);
// Input values
await expect(page.getByLabel('Email')).toHaveValue('jane@example.com');
// Attributes
await expect(page.getByRole('link', { name: 'Docs' })).toHaveAttribute(
'href',
'/docs',
);
// URL and title
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/Dashboard/);
// Count
await expect(page.getByRole('listitem')).toHaveCount(3);
// Viewport visibility
await expect(page.getByRole('button', { name: 'Submit' })).toBeInViewport();
await expect(page.getByRole('footer')).toBeInViewport({ ratio: 0.5 });
// URL with predicate
await expect(page).toHaveURL((url) => url.searchParams.has('token'));
// Accessible error message
await expect(page.getByLabel('Email')).toHaveAccessibleErrorMessage(
'Invalid email',
);
});Aria Snapshot Assertions (v1.50+)
Validate accessibility tree structure using YAML templates:
test('page has correct accessible structure', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toMatchAriaSnapshot(`
- banner:
- heading "My App" [level=1]
- navigation:
- link "Home"
- link "About"
- main:
- heading "Welcome" [level=2]
`);
});Generate snapshots automatically with test generator or locator.ariaSnapshot().
Filtering Locators
test('filter visible elements', async ({ page }) => {
// Only match visible elements (v1.51+)
const visibleButtons = page.getByRole('button').filter({ visible: true });
// Filter by text content
const saveButton = page.getByRole('button').filter({ hasText: 'Save' });
// Filter by child locator
const cardWithImage = page
.locator('.card')
.filter({ has: page.locator('img') });
await saveButton.click();
});Test Steps with Timeout and Skip (v1.50+)
test('multi-step checkout', async ({ page }) => {
await test.step('fill shipping address', async (step) => {
await page.getByLabel('Street').fill('123 Main St');
await page.getByLabel('City').fill('Springfield');
await page.getByRole('button', { name: 'Continue' }).click();
});
await test.step(
'enter payment details',
async (step) => {
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay' }).click();
},
{ timeout: 15000 },
);
await test.step('verify confirmation', async (step) => {
await expect(page.getByText('Order confirmed')).toBeVisible();
// Attach screenshot to step for reporting
const screenshot = await page.screenshot();
await step.attach('confirmation', {
body: screenshot,
contentType: 'image/png',
});
});
});Authentication with Storage State
Authenticate once and reuse across tests:
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill(process.env.PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Dashboard')).toBeVisible();
// Save authentication state (includes cookies and localStorage)
await page.context().storageState({ path: '.auth/user.json' });
});// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'tests',
dependencies: ['setup'],
use: { storageState: '.auth/user.json' },
},
],
});IndexedDB can also be persisted (v1.51+):
await page.context().storageState({
path: '.auth/user.json',
indexedDB: true,
});API Testing
Test APIs directly without browser overhead:
import { test, expect } from '@playwright/test';
test('create and verify user via API', async ({ request }) => {
// Create user
const response = await request.post('/api/users', {
data: { name: 'Jane', email: 'jane@example.com' },
});
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(201);
const user = await response.json();
expect(user.name).toBe('Jane');
// Verify user exists
const getResponse = await request.get(`/api/users/${user.id}`);
expect(getResponse.ok()).toBeTruthy();
});API Preconditions in UI Tests
import { test, expect } from '@playwright/test';
let apiContext;
test.beforeAll(async ({ playwright }) => {
apiContext = await playwright.request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});
});
test.afterAll(async () => {
await apiContext.dispose();
});
test('new item appears in list', async ({ page }) => {
// Set up precondition via API
const response = await apiContext.post('/api/items', {
data: { title: 'Test Item' },
});
expect(response.ok()).toBeTruthy();
// Verify via UI
await page.goto('/items');
await expect(page.getByText('Test Item')).toBeVisible();
});Network Mocking
test('display mocked data', async ({ page }) => {
// Mock API response
await page.route('**/api/users', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock User' }]),
}),
);
await page.goto('/users');
await expect(page.getByText('Mock User')).toBeVisible();
});
test('simulate server error', async ({ page }) => {
await page.route('**/api/data', (route) =>
route.fulfill({ status: 500, body: 'Server Error' }),
);
await page.goto('/data');
await expect(page.getByText('Something went wrong')).toBeVisible();
});Custom Fixtures
import { test as base, expect } from '@playwright/test';
type MyFixtures = {
adminPage: import('@playwright/test').Page;
};
const test = base.extend<MyFixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: '.auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
test('admin can manage users', async ({ adminPage }) => {
await adminPage.goto('/admin/users');
await expect(adminPage.getByRole('heading')).toHaveText('User Management');
});
export { test, expect };Visual Regression Testing
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
test('component visual regression', async ({ page }) => {
await page.goto('/components');
await expect(page.getByTestId('card')).toHaveScreenshot('card.png', {
maxDiffPixelRatio: 0.05,
});
});Update snapshots: npx playwright test --update-snapshots
Flaky Test Detection (v1.50+)
npx playwright test --fail-on-flaky-testsIn config:
export default defineConfig({
retries: 2,
failOnFlakyTests: true,
});Tests that fail then pass on retry are flagged as flaky, and the run exits with code 1.
Accessibility Assertions
Native assertions for checking accessible properties without external dependencies:
test('element accessibility', async ({ page }) => {
await page.goto('/form');
await expect(page.getByRole('textbox')).toHaveAccessibleName('Email address');
await expect(page.getByRole('textbox')).toHaveAccessibleDescription(
'Enter your work email',
);
await expect(page.locator('#submit-btn')).toHaveRole('button');
});Use these for simple accessibility checks. For full WCAG audits, use @axe-core/playwright with AxeBuilder.
Clock API
Control time in tests with page.clock:
test('shows expiration warning', async ({ page }) => {
await page.clock.install({ time: new Date('2025-01-01T10:00:00') });
await page.goto('/session');
await page.clock.fastForward('25:00');
await expect(page.getByText('Session expiring soon')).toBeVisible();
});
test('countdown timer', async ({ page }) => {
await page.clock.install();
await page.goto('/timer');
await page.getByRole('button', { name: 'Start' }).click();
await page.clock.fastForward(5000);
await expect(page.getByTestId('timer')).toHaveText('00:05');
});| Method | Purpose |
|---|---|
clock.install() | Override Date, setTimeout, setInterval |
clock.fastForward(ms) | Advance time and fire pending timers |
clock.setFixedTime(date) | Freeze time at a specific moment |
clock.pauseAt(date) | Pause at a time, resume with fastForward |
clock.resume() | Resume real-time progression |
clock.setSystemTime(date) | Set current time without firing timers |
Common Test Patterns
| Pattern | Implementation |
|---|---|
| Wait for navigation | await page.waitForURL('/next-page') |
| Wait for response | page.waitForResponse('**/api/data') |
| File upload | page.getByLabel('Upload').setInputFiles('file.pdf') |
| File download | const dl = await page.waitForEvent('download') |
| Dialog handling | page.on('dialog', d => d.accept()) |
| Emulate mobile | use: { ...devices['iPhone 15'] } |
| Emulate dark mode | use: { colorScheme: 'dark' } |
| Geolocation | use: { geolocation: { latitude, longitude } } |
| Timezone | use: { timezoneId: 'America/New_York' } |
Troubleshooting
"Executable doesn't exist" Error
npx playwright install chromium
# Or for all browsers:
npx playwright installSlow Performance in Docker
Add shared memory size:
RUN playwright install --with-deps chromiumdocker run --shm-size=2gb your-imageBlank Screenshots
Wait for content to load before capturing:
await page.goto(url);
await page.locator('main').waitFor(); // Wait for key content element
await page.screenshot({ path: 'output.png' });"Page crashed" Errors
Reduce concurrency or add memory:
const browser = await chromium.launch({
args: ['--disable-dev-shm-usage'], // Use /tmp instead of /dev/shm
});Captcha Always Appears
1. Verify stealth mode is active (check bot.sannysoft.com) 2. Rotate user agents 3. Add random delays between actions 4. Use residential proxy if needed
Ubuntu 25.10 Installation Fails
Error: Unable to locate package libicu74, Package 'libxml2' has no installation candidate Source: GitHub Issue #38874
# Use Ubuntu 24.04 Docker image (officially supported)
docker pull mcr.microsoft.com/playwright:v1.58.2-noble
# Temporary workaround (if Docker not an option)
sudo apt-get update
sudo apt-get install libicu72 libxml2Setup Checklist
- [ ] Playwright installed (
npm list playwrightorpip show playwright) - [ ] Browsers downloaded (
npx playwright install chromium) - [ ] Basic script runs successfully
- [ ] Stealth mode configured (if needed)
- [ ] Session persistence works
- [ ] Screenshots save correctly
- [ ] Error handling includes retries
- [ ] Browser closes properly (no zombie processes)
- [ ] Tested with
headless: falsefirst - [ ] Production script uses
headless: true
Related skills
FAQ
What flows does the playwright skill cover?
The playwright skill covers authentication, navigation, and critical UI flows. It helps developers write Playwright tests with fixtures and headless CI configuration for repeatable browser regression.
Is the playwright skill CI-ready?
The playwright skill configures headless Playwright runs suitable for CI pipelines. It emphasizes fixtures and isolation patterns so tests pass reliably in automated build environments.