---
name: "playwright_agent_patterns"
description: "Browser automation and E2E testing with Playwright the way an agent should run it: resilient locators that survive redesigns, session reuse via storageState, web-first assertions, failure recovery, and flake triage. Triggers when writing Playwright tests, debugging flaky E2E suites, automating browser tasks for an agent, or auditing a Playwright codebase."
---

# Playwright Agent Patterns

## Purpose

Playwright is the standard tool for browser automation and end-to-end testing, and it is also one of the best tools an agent has for driving real websites. Most failures in Playwright work come from four places: brittle selectors, hand-rolled waits, re-logging-in per test, and retries that hide root causes. This skill gives you the patterns that eliminate all four. Detailed locator catalogs, recovery recipes, and session recipes live in `references/`; load only what the task needs.

## Workflow

1. **Pick the locator strategy first.** Before writing any action, decide how every element gets found. Default order of preference: `getByRole` with a name, then `getByLabel`, `getByPlaceholder`, `getByText`, `getByAltText`, `getByTitle`, and `getByTestId` last. Chain and filter to narrow scope: `page.getByRole('listitem').filter({ hasText: 'Product 2' }).getByRole('button', { name: 'Add to cart' })`. Never reach for CSS class chains, XPath traversals, or `nth-child` indexing unless the element has no accessible identity at all, and document why in a comment when you do. Locator catalog and strict-mode rules are in `references/locator-patterns.md`.
2. **Let Playwright wait for you.** Every action (click, fill, check) auto-waits for actionability; every web-first assertion (`toBeVisible`, `toHaveText`, `toHaveURL`) retries for up to 5 seconds. Write the assertion, not the wait. Never `page.waitForTimeout`. Never `waitForLoadState('networkidle')`; it is notoriously flaky. If you need a value, confirm the state with a retrying assertion first, then extract it. Assertion patterns are in `references/assertions-and-waiting.md`.
3. **Reuse sessions, isolate tests.** Each test gets a fresh browser context for free isolation. Log in once, save the session, and load it everywhere: `await page.context().storageState({ path: '.auth/session.json' })`, then `use: { storageState: '.auth/session.json' }` in config. Where the login API exists, authenticate through `context.request.post` and skip the UI entirely. Session recipes and auth patterns are in `references/session-management.md`.
4. **Plan failure recovery before it fails.** Set modest retries (`retries: 2` on CI, 0 locally) and keep first-failure evidence: `trace: 'on-first-retry'`, `screenshot: 'only-on-failure'`, `video: 'retain-on-failure'`. A test that fails then passes is labeled flaky, and flaky is a maintenance signal, not a green build. Classify every failure into the taxonomy (Timing/Async, Isolation, Environment, Infrastructure) and fix the root cause; do not raise the retry count to make it quiet. Recovery patterns and the triage command cheat sheet are in `references/recovery-and-debugging.md`.
5. **Run the audit before you ship.** Run `bin/pw_audit.py` against the spec files. Fix every finding it flags. Then burn-in the changed specs with `--repeat-each=10` to surface flakes before they reach CI.

## Output Contract

When this skill is done, you deliver:

- Spec files using user-facing locators (role/label/text) with no hard waits, no `networkidle`, and no positional `.first()`/`.nth()` used to silence strict-mode violations.
- Session handling: authenticated state saved once and reused, or a documented reason why a test logs in fresh.
- Config with CI retries, trace/screenshot/video capture on failure, and no `forbidOnly` gaps.
- A clean `pw_audit.py` run and a passing burn-in (`--repeat-each=10`) on the specs you touched.
- Any failure you could not classify: report it with the trace path, not a retry bump.

## Operating Rules

1. Locators mirror what a user perceives. If a screen reader would not use it to find the element, neither do you, unless there is no alternative and you say so.
2. Web-first assertions for every state check. Raw reads (`textContent`, `getAttribute`, `isVisible`) happen only after a retrying assertion has confirmed the state.
3. Fresh context per test is non-negotiable. Shared mutable state between tests is a bug, not a shortcut.
4. Retries are a diagnosis budget, not a fix. One retry with a trace teaches you something; three retries with no trace just costs CI minutes.
5. Auth credentials live in environment variables. Never hardcode usernames or passwords in specs, even test ones.
6. When a site offers a stable accessibility identity and a tempting CSS shortcut, take the identity. Redesigns move classes; they rarely rename buttons.

## Worked example: resilient checkout check

```ts
import { test, expect } from '@playwright/test';

test('adds product to cart', async ({ page }) => {
  await page.goto('/products');

  await page
    .getByRole('listitem')
    .filter({ hasText: 'Acme Widget' })
    .getByRole('button', { name: 'Add to cart' })
    .click();

  // Web-first: retries until the badge updates or 5s passes.
  await expect(page.getByRole('link', { name: /cart/i })).toContainText('1');

  // Only extract after the retrying assertion confirmed the state.
  const badge = await page.getByRole('link', { name: /cart/i }).textContent();
  expect(badge).toMatch(/1/);
});
```

## Worked example: one-time session save, reuse everywhere

```ts
// scripts/save-session.ts — run once (or in global setup)
import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_EMAIL!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/session.json' });
await browser.close();
```

```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: [['html', { open: 'never' }], ['list']],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    storageState: '.auth/session.json',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});
```

## Tooling

`bin/pw_audit.py` scans spec files for the patterns this skill forbids: hard `waitForTimeout` sleeps, `networkidle` waits, CSS class-chain locators, XPath traversals, positional `.first()`/`.nth()`, and non-retrying reads used as assertions. Run it from the repo root: `python3 <skill-dir>/bin/pw_audit.py tests/`. Exit code is non-zero when findings exist, so it fits in CI.

## Further reading

- Playwright best practices (official): https://playwright.dev/docs/best-practices
- Locators: https://playwright.dev/docs/locators
- Authentication: https://playwright.dev/docs/auth
- Trace viewer: https://playwright.dev/docs/trace-viewer
- The `microsoft/playwright-cli` project is a CLI built for coding agents and is more token-efficient than loading the full MCP tool schemas.
