Back to list
ed3dai

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.

72🍴 3📅 Jan 23, 2026

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:

  1. Role-based locators (best - user-centric)

    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
    
  2. Other user-facing locators

    await page.getByLabel('Password').fill('secret');
    await page.getByPlaceholder('Search...').fill('query');
    await page.getByText('Submit Order').click();
    
  3. 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' }
    
  4. 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);

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

TaskPattern
Click buttonawait page.getByRole('button', { name: 'Text' }).click()
Fill inputawait page.getByLabel('Field').fill('value')
Select optionawait page.getByRole('combobox').selectOption('value')
Check checkboxawait page.getByRole('checkbox', { name: 'Label' }).check()
Wait for elementawait expect(page.locator('.el')).toBeVisible()
Assert textawait expect(page.locator('.el')).toHaveText('text')
Extract textconst text = await page.locator('.el').textContent()
Extract multipleawait expect(locator).toHaveCount(5); const els = await locator.all()
Batch extractconst data = await page.locator('.el').evaluateAll(els => ...)
Run JS in pageawait page.evaluate(() => /* JS code */)
Take screenshotawait page.screenshot({ path: 'shot.png' })
Handle new tabconst 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.

Score

Total Score

65/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

+10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

+5

Reviews

💬

Reviews coming soon