Playwright Operations

SkillWeb & browsing

This skill helps your AI write and maintain Playwright tests, which check that a website works correctly by driving it in a real browser. Once added, your AI can build end-to-end browser tests, keep them running fast, and track down ones that fail unpredictably. It covers the full workflow, from selecting page elements to running tests automatically in CI.

Available today. Use it from your connected AI after setup.

Add the skill, then ask your AI to write or improve a Playwright test for a page in your project. For existing test suites, describe the problem, such as slow runs or random failures, and it will work on a fix.

Then ask your AI: use the Playwright Operations skill

What your AI can do with it

  • Write end-to-end browser tests with dependable selectors and fixtures
  • Mock network requests and handle sign-in flows inside tests
  • Run tests in parallel and split them across shards for faster results
  • Spot unintended visual changes with visual regression checks
  • Find and fix flaky tests using trace viewers
  • Set tests up to run automatically in CI

What this skill tells your AI

The instructions your AI receives, as published by 0xdarkmatter/claude-mods in skills/playwright-ops/SKILL.md and read by ahel’s review.

End-to-end testing with Playwright Test (@playwright/test, TS/JS). A Python flavor (pytest-playwright) exists with the same browser API but pytest-style fixtures — patterns here translate directly; runner config does not.

Quick Start

npm init playwright@latest          # scaffold config + example test + GH Actions workflow
npx playwright test                 # run all tests, all projects
npx playwright test --project=chromium --grep "@smoke"
npx playwright test --ui            # interactive UI mode (watch, time-travel)
npx playwright codegen https://app.local   # record actions -> generated locators
npx playwright show-report          # open last HTML report
npx playwright show-trace trace.zip # inspect a trace

Selector Strategy

Hierarchy — always prefer the highest tier that uniquely matches:

TierLocatorWhen
1page.getByRole('button', { name: 'Submit' })Anything with an ARIA role — buttons, links, headings, textboxes. Tests a11y for free
2page.getByLabel('Password')Form fields with labels
3page.getByPlaceholder('name@example.com')Inputs without labels (fix the label instead, when you can)
4page.getByText('Welcome back')Non-interactive text content
5page.getByTestId('cart-total')Stable hook when semantics don't disambiguate. Configure attribute via testIdAttribute
6page.locator('css=...') / xpath=Last resort. Coupled to DOM structure; breaks on refactor

Why: tiers 1–4 locate the way a user perceives the page — resilient to markup changes, and getByRole fails loudly when accessibility regresses. CSS/XPath encode implementation detail.

Narrowing without CSS:

page.getByRole('listitem')
    .filter({ hasText: 'Product 2' })
    .getByRole('button', { name: 'Add to cart' });

page.getByRole('row').filter({ has: page.getByRole('cell', { name: 'Alice' }) });

Web-First Assertions (no manual waits, ever)

// BAD — checks once, races the render; sleeps are flake factories
expect(await page.getByText('welcome').isVisible()).toBe(true);
await page.waitForTimeout(2000);

// GOOD — auto-retries until pass or timeout
await expect(page.getByText('welcome')).toBeVisible();
await expect(page.getByRole('list')).toHaveCount(3);
await expect(page).toHaveURL(/\/dashboard/);
await expect.soft(page.getByTestId('status')).toHaveText('Active'); // don't stop test on failure

Actions (click, fill) auto-wait for actionability (visible, stable, enabled). If you feel the need for waitForTimeout, you're missing an assertion or an await expect(...) on a state change. For async non-DOM conditions use expect.poll(() => fn()) or expect(async () => {...}).toPass().

Lint guard: enable @typescript-eslint/no-floating-promises — a missing await on an assertion is the most common silent-pass bug.

Config Skeleton

Full production template with comments: assets/playwright.config.template.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? 'blob' : 'html',
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    testIdAttribute: 'data-testid',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Fixtures Decision Tree

What do I need to share/setup?
│
├─ Per-test object (page object, seeded record)
│  └─ test.extend() test-scoped fixture — setup, await use(x), teardown
│
├─ Expensive, safe-to-share resource (DB pool, test account)
│  └─ Worker-scoped: [fn, { scope: 'worker' }] — once per worker process
│
├─ Side effect every test needs (log capture, network stub)
│  └─ Automatic: [fn, { auto: true }] — runs without being referenced
│
├─ Config-tunable value (locale, default item)
│  └─ Option: ['default', { option: true }] — override in projects[].use
│
├─ Fixtures from several modules
│  └─ mergeTests(testA, testB)
│
└─ Auth state per test file/role
   └─ test.use({ storageState: 'playwright/.auth/admin.json' })

POM-as-fixture (modern recommendation) — page objects are fine; instantiating them by hand in every test is not. Inject via fixture:

// fixtures.ts
import { test as base } from '@playwright/test';
import { TodoPage } from './pages/todo-page';

export const test = base.extend<{ todoPage: TodoPage }>({
  todoPage: async ({ page }, use) => {
    const todoPage = new TodoPage(page);
    await todoPage.goto();
    await use(todoPage);          // test body runs here
  },
});
export { expect } from '@playwright/test';

Page objects should expose locators and actions, not assertions wrapped in try/catch, and never store element handles. Details: references/fixtures-and-pom.md

Network & API

Network need?
│
├─ Stub a third-party API           → page.route('**/api/**', r => r.fulfill({ json }))
├─ Tweak a real response            → const res = await route.fetch(); route.fulfill({ response: res, json })
├─ Simulate failure / offline       → route.abort() / route.fulfill({ status: 500 })
├─ Many endpoints, real shapes      → HAR record + replay (page.routeFromHAR, update: true to record)
├─ Pure API test (no browser)       → request fixture / APIRequestContext
├─ Seed data fast, assert via UI    → hybrid: create via request, verify via page
└─ WebSocket traffic                → page.routeWebSocket(url, ws => ws.onMessage(...))

Hybrid seed-via-API, assert-via-UI — the single biggest speed win in most suites:

test('shows new project', async ({ request, page }) => {
  const res = await request.post('/api/projects', { data: { name: 'Apollo' } });
  expect(res.ok()).toBeTruthy();
  await page.goto('/projects');
  await expect(page.getByRole('link', { name: 'Apollo' })).toBeVisible();
});

Rule of thumb: mock third-party dependencies you don't own; exercise your own backend for real (or mock it deliberately in a separate "frontend-isolated" project). Details: references/network-and-api.md

Authentication

Standard pattern — login once in a setup project, reuse storageState everywhere:

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Username').fill(process.env.E2E_USER!);
  await page.getByLabel('Password').fill(process.env.E2E_PASS!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByTestId('user-menu')).toBeVisible();   // wait for auth to settle!
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
PatternUse when
One setup project + storageState in useOne shared account, tests don't mutate server-side user state
Per-role files (admin.json, user.json) + test.use({ storageState })Role-based behavior under test
Worker-scoped account fixture (testInfo.parallelIndex)Parallel tests mutate user state — one account per worker
API login (request.post + request.storageState)Login endpoint exists; 10x faster than UI login

Gotchas: add playwright/.auth/ to .gitignore. storageState captures cookies + localStorage — not sessionStorage (persist that manually via page.evaluate + init script). Always assert a logged-in signal before saving state, or you save a half-logged-in race.

Parallelism, Retries, Isolation

KnobSettingNotes
Workersworkers: process.env.CI ? 1 : undefinedLocal: half the logical CPU cores. CI runners are small — shard machines instead of oversubscribing
File-level parallelfullyParallel: trueAlso makes sharding split per-test, not per-file
Shardingnpx playwright test --shard=1/4One shard per CI machine; merge blob reports after
Retriesretries: process.env.CI ? 2 : 0Pair with trace: 'on-first-retry'; treat "flaky" status as a bug queue, not a fix
Serialtest.describe.configure({ mode: 'serial' })Smell — usually means hidden inter-test coupling

Isolation discipline: every test gets a fresh context/page (cookies, storage) — keep it that way. No test reads state written by another test; shared server-side state is reset via API in beforeEach or scoped per worker (test.info().parallelIndex in usernames/tenant IDs). A suite that only passes single-worker is broken, not "sensitive".

Flake diagnosis: trace: 'on-first-retry'npx playwright show-trace (DOM snapshots, network, console per action). Local: npx playwright test --ui or PWDEBUG=1 / page.pause(). Repro: --repeat-each=20 --workers=4. Playbook: references/flake-hunting.md

Triage a whole run without eyeballing the report — generate the JSON reporter output, then rank the offenders with the bundled triage tool (scripts/triage-flakes.py):

npx playwright test --reporter=json > results.json   # or reporter: [['json', { outputFile: 'results.json' }]]
scripts/triage-flakes.py results.json                # flaky tests first, then hard fails

It emits a ranked TSV (or --json envelope, schema claude-mods.playwright-ops.flake-triage/v1): flaky tests (passed only on retry) first — ordered by retry count then duration — followed by unexpected hard failures, each with file:line, the status sequence (failed->passed), and total duration. Exit 10 means flakes/fails were found (the triage signal — go fix them); exit 0 means a clean suite. --outcome all includes the passing tests for context; -n N caps rows.

CI (GitHub Actions)

- uses: actions/checkout@v5
- uses: actions/setup-node@v5
  with: { node-version: lts/* }
- run: npm ci
- run: npx playwright install --with-deps chromium   # only browsers you test
- run: npx playwright test
- uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with: { name: playwright-report, path: playwright-report/, retention-days: 30 }
DecisionGuidance
Container vs install-depsmcr.microsoft.com/playwright:vX.Y.Z-jammy image pins browser+OS (best for visual tests); install --with-deps is simpler and fine otherwise. Pin image tag to your @playwright/test version
Browser cachingCache ~/.cache/ms-playwright keyed on Playwright version; skip when using the container
Sharded reportsreporter: 'blob' on shards → upload blob-report/ → merge job: npx playwright merge-reports --reporter html ./all-blob-reports
Fail-fast vs full suitePRs: fail-fast: false + --max-failures=10 per shard — see all failures in one round-trip. Smoke gates: fail fast

Full workflows (sharding matrix, merge job, caching): references/ci-patterns.md

Visual Testing

await expect(page).toHaveScreenshot('landing.png', {
  maxDiffPixels: 100,                       // or maxDiffPixelRatio / threshold
  mask: [page.getByTestId('ad-banner')],    // black-box dynamic regions
  fullPage: true,
});
  • First run generates the baseline (test fails); update with npx playwright test --update-snapshots
  • Snapshots are named per browser and platform (landing-chromium-darwin.png) — baselines generated on macOS will not match Linux CI. Fix: generate baselines inside the same Docker image CI uses, or run visual tests only in the container
  • Disable animations: toHaveScreenshot defaults animations: 'disabled'; hide dynamic bits with mask or stylePath (CSS applied at capture time)
  • Global defaults: expect: { toHaveScreenshot: { maxDiffPixels: 100 } } in config
  • toMatchSnapshot() for non-image data (text/buffers)

Component Testing & When to Prefer Cypress

@playwright/experimental-ct-react (also vue/svelte) mounts components in a real browser — still experimental; for component-level work, Vitest browser mode or Testing Library are the safer default, with Playwright covering E2E.

FactorPlaywrightCypress
BrowsersChromium, Firefox, WebKit (real Safari engine)Chrome-family, Firefox; WebKit experimental
ParallelismFree, built-in, shardablePaid Cloud for parallel orchestration
Multi-tab / multi-origin / iframesNativeHistorically constrained
API testingBuilt-in request contextVia cy.request, less ergonomic
Component testingExperimentalMature, first-class
In-browser interactive DXUI mode (excellent)The original benchmark; some teams still prefer it

Reach for Cypress when component testing maturity or an existing Cypress investment dominates; otherwise Playwright is the default for new E2E suites. (Repo also has a sibling cypress-ops skill.)

Debugging & Codegen

ToolCommandUse
UI modenpx playwright test --uiWatch mode, time-travel, pick locators
InspectorPWDEBUG=1 npx playwright test or page.pause()Step through actions live
Codegennpx playwright codegen <url>Records actions, emits role-based locators — treat output as a draft, refactor into POMs/fixtures
Trace viewernpx playwright show-trace trace.zipPost-mortem: snapshots, network, console
Headed + slow--headed --debugEyeball a single test
VS Code extensionRun/debug tests, pick locators in-editor

An official Playwright MCP server (@playwright/mcp) also exists for agent-driven browser automation — distinct from the test runner; don't conflate browsing automation with the test suite.

References

FileContents
references/fixtures-and-pom.mdFixture scopes/options/merging, POM-as-fixture architecture, anti-patterns
references/network-and-api.mdroute/fulfill/abort, HAR replay, API testing, hybrid seeding, WebSocket
references/ci-patterns.mdFull GH Actions workflows: basic, sharded+merge, container, caching, reporters
references/flake-hunting.mdSystematic flake diagnosis: traces, repro loops, common causes + fixes
scripts/triage-flakes.pyParse a Playwright JSON report and rank flaky/failing tests (exit 10 = findings); see Flake diagnosis above
assets/playwright.config.template.tsCommented production config template

Signals

GitHub stars
36
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
playwright-ops
Source
github.com/0xdarkmatter/claude-mods