open-weight/skills/playwright-e2e/SKILL.md
Use when writing Playwright e2e tests for scenarios that genuinely require a real browser against a real backend — OAuth flows, cookie/session mechanics, file downloads, drag-and-drop, or a documented critical path where lower-level tests have failed to catch a regression. Do NOT use for form validation, error states, loading states, or any scenario fully coverable with RTL+MSW.
npx skillsauth add jon23d/skillz playwright-e2eInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
If the project has API endpoint tests and RTL+MSW component tests, the default answer to "should I write an e2e test?" is no. Those two layers already cover the seams e2e is meant to catch.
Only proceed if the scenario is one of these:
If none of these apply, write or improve the RTL+MSW test instead. Come back here only when you have a clear answer for which legitimate scenario this is.
Playwright has built-in auto-waiting — every locator action and expect assertion retries until it passes or times out. Use this instead of manual waits. Tests should locate elements the way users do: by role, label, or visible text. If you can't locate an element without adding a data-testid, the app likely has an accessibility gap — fix the markup instead.
If you write e2e tests, you run e2e tests. Do not defer to CI. Do not claim the environment is insufficient. The Playwright config includes a webServer block that starts the application automatically.
Run from the repo root:
npx playwright install --with-deps chromium # first time only — installs headless browser
npx playwright test # runs all e2e tests
If Playwright is not installed or browsers are missing, stop immediately and tell the user what needs to be installed. Do not skip tests, do not push, do not report success. The correct response is to surface the exact install commands needed and wait for confirmation that the environment is ready. Never silently skip tests because the tooling isn't set up.
All tests MUST pass locally before any code is pushed. This is non-negotiable — CI is not a substitute for local verification.
If the database is required, start it first (e.g. docker compose up -d db). If other services are needed, start those too. The test environment is your responsibility — "CI only" is not an acceptable answer.
Do not report back or invoke reviewers until e2e tests pass alongside unit/integration tests.
Two files are required at the project root.
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
package.json (relevant parts):
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.44.0",
"@types/node": "^20.0.0"
}
}
Key points:
baseURL. Tests use relative paths (/login, not http://localhost:3000/login).webServer block so npx playwright test starts the app automatically.reuseExistingServer: !process.env.CI allows local reuse but forces a fresh server in CI.firefox or webkit to projects only when cross-browser coverage is explicitly required.tests/ and are named *.spec.ts.getByRole('button', { name: 'Submit' }) — ARIA role + accessible namegetByLabel('Email') — form inputs associated with a labelgetByPlaceholder('Search...') — inputs that have no labelgetByText('Welcome back') — visible text contentgetByTestId('x') — last resort only. Needing a data-testid is a signal the element has no accessible name or role. Fix the markup first: add a <label>, an aria-label, or the correct ARIA role. Only fall back to data-testid when the element is genuinely non-interactive and has no visible text.Never use:
locator('.email') or locator('p.email') — CSS class selectors break on style refactorslocator('h2') — bare tag selectors are ambiguous; use getByRole('heading', { name: '...' })locator('#someId') — ID selectors couple tests to implementation detailswaitForTimeout// WRONG — arbitrary sleep, causes flakiness
await page.waitForTimeout(500);
// RIGHT — Playwright retries until the element appears
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
// RIGHT — wait for a button to become interactive before clicking
await expect(page.getByRole('button', { name: 'Proceed' })).toBeEnabled();
// RIGHT — wait for navigation to complete
await page.waitForURL('/dashboard');
If you feel like adding a sleep, that is a signal to find the right condition to wait on instead.
page.routeIf you feel the urge to mock an API response in an e2e test, stop and ask: can this be covered by an RTL+MSW test instead? Error states, validation responses, and loading states almost always can — and should be.
The legitimate uses of page.route in e2e are for external services that can't be intercepted at the MSW level: OAuth providers, payment iframes, third-party redirects, or cases where the real browser navigation is the thing being tested.
// Legitimate — intercepting an external OAuth redirect
test('completes login via SSO', async ({ page }) => {
await page.route('**/oauth/token', route =>
route.fulfill({ status: 200, json: { access_token: 'fake-token' } })
);
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
await page.waitForURL('/dashboard');
});
Always set up routes before navigating. Use **/api/path (with **/ prefix) rather than /api/path — the glob matches regardless of host or port so tests work across environments. route.fulfill({ json: ... }) automatically sets Content-Type: application/json.
page.goto() — never rely on state left by a previous test.test.beforeEach for setup.test.afterEach.This example tests the critical login path against a real backend — no mocked responses. The error-state test (shows error banner on invalid credentials) belongs in RTL+MSW, not here.
import { test, expect } from '@playwright/test';
test.describe('Login — critical path', () => {
test('redirects to dashboard after successful login', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('correct-password');
await page.getByRole('button', { name: 'Log In' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Hello, ada' })).toBeVisible();
});
});
/login) and set baseURL in config. Every hardcoded localhost:3000 is a portability bug.locator('.btn-primary') breaks when styles change. Use getByRole.locator('h2') matches any h2 on the page. Use getByRole('heading', { name: '...' }).waitForTimeout makes tests slow and still flaky. Find the right condition.page.route must be called before page.goto.data-testid too soon — if you need a testid to locate an element, the app is probably missing a label or ARIA role. Fix the app.webServer in config — tests fail with "connection refused" unless the dev server is already running manually.page.route — route('/api/login', ...) only matches that exact origin. Use route('**/api/login', ...) so tests work across environments.Before writing a test from scratch, consider using the playwright-cli skill to interactively record a browser session. Every action you take generates the corresponding Playwright TypeScript code, which you can paste directly into a test file and add assertions to.
See playwright-cli skill — specifically its test generation reference.
userEvent tests real user interactions. MSW intercepts real network calls. The gap between that and a full browser is smaller than it looks — and it's covered by the specific legitimate scenarios above.docker compose up -d and npx playwright install chromium is two commands.development
Use when adding or modifying environment variable handling in TypeScript projects or monorepos — especially when using process.env directly, missing startup validation, sharing env schemas across packages, or encountering "undefined is not a string" errors at runtime from missing env vars.
testing
Use when creating a new skill, editing an existing skill, writing a SKILL.md, or verifying a skill works before deployment.
development
React UI design principles and conventions. Load when building or modifying any user interface or React components. Covers application type detection, visual standards, component design and structure, Mantine (business apps) and Tailwind (consumer apps), accessibility, responsiveness, state management, data fetching, testing, and in-app help patterns.
development
Use when setting up ESLint and/or Prettier in a TypeScript project, adding linting to an existing TypeScript codebase, or configuring typescript-eslint, eslint-config-prettier, or related packages.