home / skills / ed3dai / ed3d-plugins / playwright-patterns
This skill helps you write robust Playwright automation by applying best practices for selector strategies, waiting patterns, and flaky test reduction.
npx playbooks add skill ed3dai/ed3d-plugins --skill playwright-patternsReview the files below or copy the command above to add this skill to your agents.
---
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
user-invocable: false
---
# 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:
1. **Role-based locators** (best - user-centric)
```javascript
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
```
2. **Other user-facing locators**
```javascript
await page.getByLabel('Password').fill('secret');
await page.getByPlaceholder('Search...').fill('query');
await page.getByText('Submit Order').click();
```
3. **Test ID attributes** (explicit contract)
```javascript
// Default uses data-testid
await page.getByTestId('submit-button').click();
// Can customize in playwright.config.ts:
// use: { testIdAttribute: 'data-pw' }
```
4. **CSS/ID selectors** (fragile, avoid if possible)
```javascript
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:
```javascript
// 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
```javascript
// 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**
```javascript
// BAD: Breaks when HTML structure changes
await page.click('div.container > div:nth-child(2) > button.submit');
```
✅ **Stable semantic selectors**
```javascript
// GOOD: Survives structural changes
await page.getByRole('button', { name: 'Submit' }).click();
```
❌ **XPath with positions**
```javascript
// BAD: Brittle
await page.locator('xpath=//div[3]/button[1]').click();
```
✅ **XPath with content**
```javascript
// 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.
```javascript
// Auto-waits for element to be visible, enabled, and stable
await page.click('button');
await page.fill('input[name="email"]', '[email protected]');
```
**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)
```javascript
// 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:
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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()
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// fill() - Recommended for most inputs (fast, atomic operation)
await page.fill('input[name="email"]', '[email protected]');
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('[email protected]');
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
```javascript
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
```javascript
// 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
```javascript
// 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
```javascript
// 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 of `await 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:**
1. **User-facing locators first** - Role, label, placeholder, text (not CSS)
2. **Web-first assertions** - `await expect(locator).toBeVisible()` not `expect(await ...)`
3. **Trust auto-waiting** - Don't add manual delays or deprecated patterns
4. **Strictness is your friend** - Fix ambiguous locators, don't use `first()`
5. **Batch extraction wisely** - Assert count before `all()`, use `evaluateAll()` for efficiency
Browser automation is inherently asynchronous and timing-dependent. Build in resilience from the start.
This skill provides practical Playwright automation patterns for writing robust tests, scrapers, and end-to-end flows. It focuses on selector strategies, waiting patterns, data extraction, and defensive coding to reduce flakiness and improve maintainability. Use these patterns to standardize locators, assertions, and navigation across projects.
The skill inspects common automation tasks and recommends a priority order for locators: role-based and other user-facing selectors first, test IDs next, and CSS/XPath as a last resort. It explains Playwright's auto-waiting and web-first assertions, offers extraction patterns (evaluateAll, textContent vs innerText), and provides error-handling, retry, and navigation best practices to keep tests stable.
When should I use test IDs versus role-based locators?
Use role-based and other user-facing locators first because they reflect the user experience. Use test IDs when a stable contract is needed or the UI lacks accessible semantic markers.
How do I avoid flaky extraction when element counts are dynamic?
Wait for a container or use an assertion like toHaveCount() when counts are known. For unknown counts, wait for the results container to be visible then use evaluateAll() or locator.evaluateAll() to extract data in a single round-trip.