
playwright-patterns
by ed3dai
Ed's repo of Claude Code plugins, centered around a research-plan-implement workflow. Only a tiny bit cursed. If you're lucky.
SKILL.md
name: playwright-patterns description: Use when writing Playwright automation code, building web scrapers, or creating E2E tests - provides best practices for selector strategies, waiting patterns, and robust automation that minimizes flakiness
Playwright Automation Patterns
Overview
Reliable browser automation requires strategic selector choice, proper waiting, and defensive coding. This skill provides patterns that minimize test flakiness and maximize maintainability.
When to Use
- Writing new Playwright scripts or tests
- Debugging flaky automation
- Refactoring unreliable selectors
- Building web scrapers that need to handle dynamic content
- Creating E2E tests that must be maintainable
When NOT to use:
- Simple one-time browser tasks
- When you need Playwright API documentation (use context7 MCP)
Selector Strategy
Priority Order
Use user-facing locators first (most resilient), then test IDs, then CSS/XPath as last resort:
-
Role-based locators (best - user-centric)
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com'); -
Other user-facing locators
await page.getByLabel('Password').fill('secret'); await page.getByPlaceholder('Search...').fill('query'); await page.getByText('Submit Order').click(); -
Test ID attributes (explicit contract)
// Default uses data-testid await page.getByTestId('submit-button').click(); // Can customize in playwright.config.ts: // use: { testIdAttribute: 'data-pw' } -
CSS/ID selectors (fragile, avoid if possible)
await page.locator('#submit-btn').click(); await page.locator('.btn.btn-primary.submit').click();
Strictness and Specificity
Locators are strict by default - operations throw if multiple elements match:
// ERROR if 2+ buttons exist
await page.getByRole('button').click();
// Solutions:
// 1. Make locator more specific
await page.getByRole('button', { name: 'Submit' }).click();
// 2. Filter to narrow down
await page.getByRole('button')
.filter({ hasText: 'Submit' })
.click();
// 3. Chain locators to scope
await page.locator('.product-card')
.getByRole('button', { name: 'Add to cart' })
.click();
// Avoid: Using first() makes tests fragile
await page.getByRole('button').first().click(); // Don't do this
Locator Filtering and Chaining
// Filter by text content
await page.getByRole('listitem')
.filter({ hasText: 'Product 2' })
.getByRole('button')
.click();
// Filter by child element
await page.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Product 2' }) })
.getByRole('button', { name: 'Buy' })
.click();
// Filter by NOT having text
await expect(
page.getByRole('listitem')
.filter({ hasNot: page.getByText('Out of stock') })
).toHaveCount(5);
// Handle "either/or" scenarios
const loginOrWelcome = await page.getByRole('button', { name: 'Login' })
.or(page.getByText('Welcome back'))
.first();
await expect(loginOrWelcome).toBeVisible();
Anti-Patterns to Avoid
❌ Fragile CSS paths
// BAD: Breaks when HTML structure changes
await page.click('div.container > div:nth-child(2) > button.submit');
✅ Stable semantic selectors
// GOOD: Survives structural changes
await page.getByRole('button', { name: 'Submit' }).click();
❌ XPath with positions
// BAD: Brittle
await page.locator('xpath=//div[3]/button[1]').click();
✅ XPath with content
// BETTER: More stable
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
Waiting Patterns
Built-in Auto-Waiting
Playwright auto-waits before most actions. Trust it.
// Auto-waits for element to be visible, enabled, and stable
await page.click('button');
await page.fill('input[name="email"]', 'test@example.com');
What auto-waiting checks:
- Element is attached to DOM
- Element is visible
- Element is stable (not animating)
- Element is enabled
- Element receives events (not obscured)
// Bypass checks (use with caution)
await page.click('button', { force: true });
// Test without acting (trial run)
await page.click('button', { trial: true });
Web-First Assertions
Use web-first assertions - they retry until condition is met:
// WRONG - no retry, immediate check
expect(await page.getByText('welcome').isVisible()).toBe(true);
// CORRECT - auto-retries until timeout
await expect(page.getByText('welcome')).toBeVisible();
await expect(page.getByText('Status')).toHaveText('Complete');
await expect(page.getByRole('listitem')).toHaveCount(5);
// Soft assertions - continue test even on failure
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await page.getByRole('link', { name: 'next' }).click();
// Test continues, failures reported at end
Explicit Waits for Dynamic Content
// Wait for specific element (modern - use web-first assertions)
await expect(page.locator('.results-loaded')).toBeVisible();
// Wait for network to be idle
await page.waitForLoadState('networkidle');
// Wait for custom condition
await page.waitForFunction(() =>
document.querySelectorAll('.item').length > 10
);
Handling Asynchronous Updates
// Known count - assert exact number
await expect(page.locator('.item')).toHaveCount(5);
// Unknown count - wait for container, then extract
await expect(page.locator('.search-results')).toBeVisible();
const items = await page.locator('.item').all();
// Loading spinner - wait for absence then presence
await expect(page.locator('.loading-spinner')).not.toBeVisible();
await expect(page.locator('.results')).toBeVisible();
// Wait for text content to appear
await expect(page.locator('.status')).toHaveText('Complete');
// At least one result (reject zero results)
await expect(page.locator('.item').first()).toBeVisible();
Data Extraction Patterns
Single Element
// textContent() - Gets all text including hidden elements
const title = await page.locator('h1').textContent();
// innerText() - Gets only visible text (respects CSS display)
const price = await page.locator('.price').innerText();
// getAttribute() - Get attribute value
const href = await page.locator('a.product').getAttribute('href');
// For assertions, prefer web-first assertions
await expect(page.locator('.price')).toHaveText('$99');
Multiple Elements
// IMPORTANT: locator.all() doesn't wait for elements
// This can be flaky if list is still loading
// Known count - assert first, then extract
await expect(page.locator('.item')).toHaveCount(5);
const items = await page.locator('.item').all();
const data = await Promise.all(
items.map(async item => ({
title: await item.locator('.title').textContent(),
price: await item.locator('.price').textContent(),
}))
);
// Unknown count - wait for container, then extract
await expect(page.locator('.results-container')).toBeVisible();
const data = await page.locator('.item').evaluateAll(items =>
items.map(el => ({
title: el.querySelector('.title')?.textContent?.trim(),
price: el.querySelector('.price')?.textContent?.trim(),
}))
);
// BEST: Use evaluateAll for batch extraction (single round-trip)
// Use when: extracting from locator-scoped elements (most common)
const data = await page.locator('.item').evaluateAll(items =>
items.map(el => ({
title: el.querySelector('.title')?.textContent?.trim(),
price: el.querySelector('.price')?.textContent?.trim(),
}))
);
Complex Extraction with evaluate()
// Use evaluate() when you need global page context
// (e.g., checking window variables, document state)
const data = await page.evaluate(() => {
return {
items: Array.from(document.querySelectorAll('.item')).map(el => ({
title: el.querySelector('.title')?.textContent?.trim(),
price: el.querySelector('.price')?.textContent?.trim(),
url: el.querySelector('a')?.href,
available: !el.classList.contains('out-of-stock')
})),
totalCount: window.productCount, // Access global variables
filters: window.appliedFilters // Page-level state
};
});
// Prefer evaluateAll() for locator-scoped extraction (more focused)
const items = await page.locator('.item').evaluateAll(els =>
els.map(el => ({ /* ... */ }))
);
Error Handling
Graceful Fallbacks
// Check if element exists before interacting
const cookieBanner = page.locator('.cookie-banner');
if (await cookieBanner.isVisible()) {
await cookieBanner.getByRole('button', { name: 'Accept' }).click();
}
Retry Logic
// Playwright retries automatically, but you can customize
await expect(async () => {
const status = await page.locator('.status').textContent();
expect(status).toBe('Complete');
}).toPass({ timeout: 10000, intervals: [1000] });
Timeout Configuration
// Set timeout for specific action
await page.click('button', { timeout: 5000 });
// Set timeout for entire test
test.setTimeout(60000);
// Set default timeout for page
page.setDefaultTimeout(10000);
Navigation Patterns
Wait for Navigation
// Modern pattern - click auto-waits for navigation
await page.click('a.next-page');
await page.waitForLoadState('networkidle'); // Only if needed
// Using modern locator
await page.getByRole('link', { name: 'Next Page' }).click();
Multi-Page Workflows
// Open new tab
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('a[target="_blank"]')
]);
await newPage.waitForLoadState();
// Work with newPage
await newPage.close();
Form Interaction Patterns
Basic Form Filling
// fill() - Recommended for most inputs (fast, atomic operation)
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'secret123');
// type() - For keystroke-sensitive inputs (slower, fires each key event)
await page.locator('input.search').type('Product', { delay: 100 });
// Modern approach with role-based locators
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
await page.getByRole('checkbox', { name: 'I agree' }).check();
await page.getByRole('button', { name: 'Submit' }).click();
File Uploads
await page.setInputFiles('input[type="file"]', '/path/to/file.pdf');
// Multiple files
await page.setInputFiles('input[type="file"]', [
'/path/to/file1.pdf',
'/path/to/file2.pdf'
]);
Autocomplete/Search Inputs
// Type and wait for suggestions (modern approach)
await page.getByPlaceholder('Search products').fill('Product Name');
await expect(page.locator('.suggestions')).toBeVisible();
// Click specific suggestion using role-based locator
await page.getByRole('option', { name: 'Product Name - Premium' }).click();
// Or filter suggestions
await page.locator('.suggestions')
.getByText('Product Name', { exact: false })
.first()
.click();
Screenshot and Debugging
Strategic Screenshots
// Full page screenshot
await page.screenshot({ path: 'screenshot.png', fullPage: true });
// Element screenshot
await page.locator('.chart').screenshot({ path: 'chart.png' });
// Screenshot on failure (in test)
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({
path: `failure-${testInfo.title}.png`,
fullPage: true
});
}
});
Debug Mode
// Pause execution for debugging
await page.pause();
// Slow down actions for observation
const browser = await chromium.launch({ slowMo: 1000 });
Common Patterns Reference
| Task | Pattern |
|---|---|
| Click button | await page.getByRole('button', { name: 'Text' }).click() |
| Fill input | await page.getByLabel('Field').fill('value') |
| Select option | await page.getByRole('combobox').selectOption('value') |
| Check checkbox | await page.getByRole('checkbox', { name: 'Label' }).check() |
| Wait for element | await expect(page.locator('.el')).toBeVisible() |
| Assert text | await expect(page.locator('.el')).toHaveText('text') |
| Extract text | const text = await page.locator('.el').textContent() |
| Extract multiple | await expect(locator).toHaveCount(5); const els = await locator.all() |
| Batch extract | const data = await page.locator('.el').evaluateAll(els => ...) |
| Run JS in page | await page.evaluate(() => /* JS code */) |
| Take screenshot | await page.screenshot({ path: 'shot.png' }) |
| Handle new tab | const newPage = await context.waitForEvent('page', () => page.click('a')) |
Anti-Pattern Checklist
Avoid these common mistakes:
- ❌ Using
page.waitForTimeout(5000)instead of web-first assertions - ❌ Using CSS class names or nth-child selectors instead of role-based locators
- ❌ Using
expect(await locator.isVisible()).toBe(true)instead ofawait expect(locator).toBeVisible() - ❌ Using deprecated
waitForNavigation()- clicks auto-wait now - ❌ Using
locator.all()without asserting count first - ❌ Using
first()when locator should be more specific - ❌ Not handling popups or cookie banners
- ❌ Hardcoding delays instead of waiting for conditions
- ❌ Taking screenshots for data extraction (use evaluate instead)
Remember
Robust automation priorities:
- User-facing locators first - Role, label, placeholder, text (not CSS)
- Web-first assertions -
await expect(locator).toBeVisible()notexpect(await ...) - Trust auto-waiting - Don't add manual delays or deprecated patterns
- Strictness is your friend - Fix ambiguous locators, don't use
first() - Batch extraction wisely - Assert count before
all(), useevaluateAll()for efficiency
Browser automation is inherently asynchronous and timing-dependent. Build in resilience from the start.
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
1ヶ月以内に更新
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon
