Playwright (1.x)
Playwright (1.x) rules for Cursor. Teaches semantic locators (getByRole/getByLabel over CSS), web-first assertions (toBeVisible over isVisible), the test.extend fixture model, storageState + setup-project auth, page.route mocking with proper continue/fallback, the Playwright POM-via-fixture pattern, ARIA snapshot a11y, sharded CI with merge-reports, and the macOS-vs-Linux baseline trap for visual regression. Catches 34 LLM regressions: page.click('text='), page.$ / page.$$ ElementHandles, waitForTimeout, isVisible booleanised, missing await on assertions, page.route after goto, hardcoded credentials, deprecated playwright-github-action, and more.
playwright-new-test
Scaffold a new Playwright test the modern way: semantic locators (getByRole over CSS), web-first assertions (await expect(loc).toBeVisible()), test.extend fixtures (not beforeEach), POM-via-fixture for repeated screens, async-arrow signature with destructured fixtures, and the canonical test file layout.
# Scaffold a New Playwright Test
## When to Use
Use when generating a new `*.spec.ts` file or adding a new test to an existing spec. The output should be CI-grade from line one - no `page.click("text=...")`, no `waitForTimeout`, no `beforeEach` for what should be a fixture.
## Output
For a screen with no shared setup:
```typescript
// tests/checkout.spec.ts
import { test, expect } from "@playwright/test";
test.describe("checkout flow", () => {
test("user can place an order", async ({ page }) => {
await page.goto("/products/widget");
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByRole("link", { name: "Cart" }).click();
await page.getByRole("button", { name: "Checkout" }).click();
// Card number from env (every PSP has its own test cards). Expiry/CVC
// are non-secret test-fixture values - inline literals are fine.
await page.getByLabel("Card number").fill(process.env.E2E_TEST_CARD!);
await page.getByLabel("Expiry").fill("12/30");
await page.getByLabel("CVC").fill("123");
await page.getByRole("button", { name: "Pay" }).click();
await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();
await expect(page).toHaveURL(/\/orders\/\w+/);
});
});
```
For a screen with repeated setup, use a fixture:
```typescript
// tests/fixtures.ts
import { test as base, expect } from "@playwright/test";
import { CheckoutPage } from "./pages/checkout.page";
type Fixtures = {
checkoutPage: CheckoutPage;
};
export const test = base.extend<Fixtures>({
checkoutPage: async ({ page }, use) => {
const checkoutPage = new CheckoutPage(page);
await checkoutPage.goto();
await use(checkoutPage);
},
});
export { expect };
```
```typescript
// tests/pages/checkout.page.ts
import type { Page, Locator } from "@playwright/test";
export class CheckoutPage {
readonly cardNumber: Locator;
readonly expiry: Locator;
readonly cvc: Locator;
readonly payButton: Locator;
constructor(private readonly page: Page) {
this.cardNumber = page.getByLabel("Card number");
this.expiry = page.getByLabel("Expiry");
this.cvc = page.getByLabel("CVC");
this.payButton = page.getByRole("button", { name: "Pay" });
}
async goto() {
await this.page.goto("/checkout");
}
async pay(card: string, expiry: string, cvc: string) {
await this.cardNumber.fill(card);
await this.expiry.fill(expiry);
await this.cvc.fill(cvc);
await this.payButton.click();
}
}
```
```typescript
// tests/checkout.spec.ts
import { test, expect } from "./fixtures";
test("user can pay", async ({ checkoutPage, page }) => {
await checkoutPage.pay(process.env.E2E_TEST_CARD!, "12/30", "123");
await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();
});
```
## Rules baked into the scaffold
1. **Imports**: `test`, `expect`, `Page`, `Locator` from `@playwright/test` only. Never from `playwright` or `playwright-core`.
2. **Locators**: `getByRole` first; `getByLabel`/`getByText`/`getByPlaceholder` next; `getByTestId` last. No CSS, no XPath, no `text=`.
3. **Assertions**: `await expect(locator).toBe...()`. Never `expect(await locator.isVisible()).toBeTruthy()`. Always `await` the assertion.
4. **No timing helpers**: no `page.waitForTimeout`, no `page.waitForSelector` followed by an action. Locator actions auto-wait.
5. **Fixtures over `beforeEach`**: if you'd write `beforeEach` to set up a value, write a fixture instead.
6. **POM as locator getters**: page objects expose `Locator` properties, not `ElementHandle`, and never call `expect` themselves.
7. **Secrets via env**: `process.env.E2E_USER!` not hardcoded strings.
8. **No `test.only`**: ever. `forbidOnly: !!process.env.CI` in config will fail CI.
9. **`test.describe` for grouping** when the file has more than one logical scenario; otherwise top-level `test()` is fine.
## Workflow
1. Identify the screen / flow under test.
2. Decide: standalone test, or POM-backed fixture? Use POM when the same locators appear in 2+ tests.
3. Pick semantic locators based on the rendered DOM (use `npx playwright codegen <url>` to bootstrap, then refactor away the `getByText` overuse).
4. Add web-first assertions at every meaningful state transition.
5. Run `npx playwright test --ui` locally before committing.
6. Confirm `npx playwright test --reporter=list path/to/spec.ts` passes headlessly.
## Common mistakes to refuse
- A `beforeEach` that constructs a POM and stores it in module-level state. Use a fixture.
- An `await page.waitForTimeout(N)` "just to be safe." Replace with the assertion that justifies the wait.
- A `try/catch` around an assertion to "make tests more robust." Web-first assertions handle their own retry. Catching the failure hides bugs.
- A locator like `page.locator("body > div:nth-child(3) > .foo")`. Refactor to a semantic locator.
- A hardcoded `"alice@example.com"` and `"hunter2"` in the test body. Move to env vars.playwright-setup-auth
Set up Playwright authentication via the setup-project + storageState pattern. Covers single shared session (read-only tests), per-worker storage state (mutating tests), multi-role projects (admin/user/guest), and JWT bypass tokens for the fastest deterministic auth.
# Set up Playwright Authentication
## When to Use
When the application under test requires login, and tests beyond a smoke check need an authenticated session. The naive approach is to log in via the UI in `beforeEach` - this is slow and flaky. Use one of the four patterns below depending on what your tests do.
## Decision tree
- **Read-only tests, single user is fine** → Pattern 1 (single shared `storageState`).
- **Tests that mutate user state** → Pattern 2 (per-worker `storageState`).
- **Tests across multiple roles (admin/user/guest)** → Pattern 3 (multi-role projects).
- **Want the fastest, most deterministic auth and the app can mint test JWTs** → Pattern 4 (JWT bypass).
## Pattern 1: Single shared storageState
### Step 1: write the setup file
```typescript
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
const authFile = "playwright/.auth/user.json";
setup("authenticate", async ({ page }) => {
fs.mkdirSync(path.dirname(authFile), { recursive: true });
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.E2E_USER!);
await page.getByLabel("Password").fill(process.env.E2E_PASS!);
await page.getByRole("button", { name: "Sign in" }).click();
// Wait for a real post-login indicator BEFORE saving state.
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await page.context().storageState({ path: authFile });
});
```
### Step 2: wire into config
```typescript
// playwright.config.ts (excerpt)
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "playwright/.auth/user.json",
},
dependencies: ["setup"],
},
],
});
```
### Step 3: gitignore the auth dir
```gitignore
# .gitignore
playwright/.auth/
```
### Step 4: set env vars
Local: `.env` (also gitignored), loaded via `dotenv` at the top of `playwright.config.ts`. CI: repo secrets.
## Pattern 2: Per-worker storageState
Use when tests create/update/delete data tied to the logged-in user. Sharing one user across workers causes data races.
```typescript
// tests/fixtures.ts
import { test as base, expect } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
type WorkerFixtures = { workerStorageState: string };
export const test = base.extend<{}, WorkerFixtures>({
// Override the built-in storageState with the worker-scoped one.
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [
async ({ browser }, use, workerInfo) => {
const file = path.resolve(
`playwright/.auth/${workerInfo.workerIndex}.json`,
);
if (!fs.existsSync(file)) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
const email = `e2e-worker-${workerInfo.workerIndex}@example.com`;
const password = process.env.E2E_PASS!;
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(
page.getByRole("heading", { name: "Dashboard" }),
).toBeVisible();
await ctx.storageState({ path: file });
await ctx.close();
}
await use(file);
},
{ scope: "worker" },
],
});
export { expect };
```
The pre-existing accounts (`e2e-worker-0@example.com`, `e2e-worker-1@example.com`, ...) must be seeded in your test database. A migration or a seed script before the test run is the simplest approach.
## Pattern 3: Multi-role projects
```typescript
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
async function authenticate(
page: import("@playwright/test").Page,
email: string,
password: string,
postLoginHeading: string,
file: string,
) {
fs.mkdirSync(path.dirname(file), { recursive: true });
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
// Always scope to a known post-login heading. A bare getByRole("heading")
// matches the /login page's own heading and saves pre-login cookies.
await expect(page.getByRole("heading", { name: postLoginHeading })).toBeVisible();
await page.context().storageState({ path: file });
}
setup("authenticate as admin", async ({ page }) => {
await authenticate(
page,
process.env.E2E_ADMIN_USER!,
process.env.E2E_ADMIN_PASS!,
"Admin dashboard",
"playwright/.auth/admin.json",
);
});
setup("authenticate as user", async ({ page }) => {
await authenticate(
page,
process.env.E2E_USER!,
process.env.E2E_PASS!,
"Dashboard",
"playwright/.auth/user.json",
);
});
```
```typescript
// playwright.config.ts (excerpt)
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium-admin",
testMatch: /.*\.admin\.spec\.ts/,
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/admin.json" },
dependencies: ["setup"],
},
{
name: "chromium-user",
testMatch: /.*\.user\.spec\.ts/,
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
];
```
For one-off role flips, override per-test instead:
```typescript
test.use({ storageState: "playwright/.auth/admin.json" });
test("admin can delete users", async ({ page }) => {
/* ... */
});
```
## Pattern 4: JWT bypass tokens
The fastest auth pattern - no UI, no flake. Requires app cooperation.
### App side (Node/TS example)
```typescript
// In the app, only mounted when E2E_TOKEN_SECRET is set.
if (process.env.E2E_TOKEN_SECRET) {
app.post("/test/token", (req, res) => {
if (req.headers["x-e2e-secret"] !== process.env.E2E_TOKEN_SECRET) {
return res.status(403).end();
}
const token = signSessionToken({ sub: req.body.sub, role: req.body.role });
res.json({ token });
});
}
```
### Test side
```typescript
// tests/fixtures.ts
import { test as base, expect, type Page } from "@playwright/test";
type Fixtures = { authedPage: Page };
export const test = base.extend<Fixtures>({
authedPage: async ({ browser }, use) => {
const ctx = await browser.newContext();
const res = await ctx.request.post("/test/token", {
data: { sub: "alice", role: "admin" },
headers: { "x-e2e-secret": process.env.E2E_TOKEN_SECRET! },
});
const { token } = await res.json();
await ctx.addCookies([
{
name: "session",
value: token,
url: process.env.E2E_BASE_URL!,
httpOnly: true,
secure: true,
sameSite: "Lax",
},
]);
const page = await ctx.newPage();
await use(page);
await ctx.close();
},
});
export { expect };
```
Constraints:
- The `/test/token` endpoint must be gated by an env var that is unset in production.
- It must refuse without the matching secret header.
- It must sign with the same key as the real auth flow.
## Common mistakes
- **Saving `storageState` before login completes** - file has only the pre-login cookies. Always assert a post-login indicator before saving.
- **Setup project failing silently** - dependents are skipped (not failed), and if your reporter only shows non-skipped, you see green CI with no tests run. Always inspect the setup project's report when downstream tests are mass-skipped.
- **Per-worker auth file collision** - two workers writing to the same path. Use `workerInfo.workerIndex` in the file path.
- **`storageState: "..."` pointing at a non-existent file** - tests run unauthenticated. Add `dependencies: ["setup"]` so the file is guaranteed to exist.
- **Committing `playwright/.auth/`** - leaks live session cookies. Always gitignore.
- **Logging the password** - it ends up in `trace.zip` and HTML reports. Never `console.log(process.env.E2E_PASS)`.playwright-validate
Scan a Playwright codebase for tracked anti-patterns. Most are reliably grep-detectable: text/CSS engine selectors, page.$/$$ ElementHandles, page.waitForTimeout, isVisible/textContent booleanised, page.route after goto, hardcoded credentials, missing forbidOnly, deprecated playwright-github-action, headless: false committed, mode 'serial' default. A few (assertion missing await, untyped test.extend) need manual review.
# Validate a Playwright Codebase
## When to Use
When auditing an existing Playwright suite, when reviewing a PR that touches `*.spec.ts` files, or before promoting a suite from "smoke" to "blocking CI." The grep patterns below catch the regressions documented in the `playwright-anti-patterns` rule.
## Scope
Run from the repo root. The patterns assume tests live under `tests/` or `e2e/`. Adjust the path if your project differs.
```bash
TEST_DIR=tests # or e2e, or wherever your *.spec.ts live
```
## CRITICAL: hardcoded credentials in test files
```bash
# Heuristic - email-like or password-like string literals on any fill() line.
# The two-step pipe handles both forms:
# page.fill("#email", "alice@acme.com") // email is the SECOND arg
# page.getByLabel("Email").fill("alice@acme.com") // email is the FIRST arg
grep -rnE '(\.fill\(|page\.fill\()' --include='*.ts' "$TEST_DIR" | grep -E '"[^"]+@[^"]+\.[^"]+"'
grep -rnE '(\.fill\(|page\.fill\()' --include='*.ts' "$TEST_DIR" | grep -iE '"(p[a@]ssw[o0]rd|hunter2|admin|secret)"'
```
Manual triage. The password grep will false-positive on `getByLabel("Password").fill(process.env.E2E_PASS!)` because `"Password"` matches the pattern and `.fill(` is on the same line. Ignore matches where the `.fill(` argument is a `process.env.*` reference. The email grep does not have this problem in practice. If the email literal is a fixture domain (`@example.com`), it may be intentional - but the password should still come from env.
## CRITICAL: missing await on web-first assertion
```bash
# Lines starting with `expect(` (no await) followed by a chained matcher.
# This is a heuristic - manual triage required because `expect(value).toBe(...)`
# on synchronous values is legitimate.
grep -rnE '^\s*expect\(.*\)\.(toBeVisible|toBeHidden|toHaveText|toHaveURL|toHaveTitle|toContainText|toHaveCount|toBeAttached|toBeEnabled|toBeDisabled|toBeFocused)' --include='*.ts' "$TEST_DIR"
```
Every line returned should have an `await` immediately before `expect(`. Anything without `await` is a no-op.
## ERROR: `page.waitForTimeout`
```bash
grep -rnE '\bpage\.waitForTimeout\(' --include='*.ts' "$TEST_DIR"
```
Every hit is wrong in a test body. Replace with the web-first assertion that justifies the wait.
## ERROR: text/CSS/XPath engine selectors
```bash
# `text=`, `css=`, `xpath=` engine selectors in click/fill/locator calls.
# The character class covers double-quote, single-quote (\x27), and backtick.
grep -rnE "(click|fill|locator)\((\"|\x27|\`)(text=|css=|xpath=)" --include='*.ts' "$TEST_DIR"
```
Replace with `page.getByRole / getByLabel / getByText / getByPlaceholder`.
## ERROR: `page.$()` / `page.$$()` ElementHandles
```bash
grep -rnE '\bpage\.\$\$?\(' --include='*.ts' "$TEST_DIR"
```
Both return `ElementHandle` (snapshot, racy). Replace with `page.locator(...)` or, better, a `getBy*` call.
## ERROR: `expect(await locator.isVisible()).toBeTruthy()`
```bash
grep -rnE 'expect\(await\s+\S+\.(isVisible|isHidden|isEnabled|isDisabled|isChecked|isEditable|textContent|innerText|inputValue)\(\)\)' --include='*.ts' "$TEST_DIR"
```
Every hit loses auto-retry. Replace with the web-first equivalent: `await expect(loc).toBeVisible()`, `toHaveText()`, etc.
## ERROR: `assert` from `node:assert`
```bash
grep -rnE 'from\s+("|\x27)node:assert("|\x27)' --include='*.ts' "$TEST_DIR"
grep -rnE 'require\(("|\x27)assert("|\x27)\)' --include='*.ts' "$TEST_DIR"
```
Use Playwright's `expect` so failures show up in trace and reporter.
## ERROR: missing `forbidOnly`
```bash
# In playwright.config.ts (or .js/.mts) - confirm forbidOnly is set.
# -r is required: BSD grep on macOS does NOT recurse without it, even with --include.
grep -rlE 'defineConfig\(' --include='playwright.config.*' . 2>/dev/null | while read -r f; do
grep -q 'forbidOnly' "$f" || echo "$f: forbidOnly missing"
done
```
`forbidOnly: !!process.env.CI` should be in the top-level `defineConfig({...})`.
## ERROR: missing `webServer` block
```bash
grep -rlE 'defineConfig\(' --include='playwright.config.*' . 2>/dev/null | while read -r f; do
grep -q 'webServer' "$f" || echo "$f: webServer missing - tests may race the dev server"
done
```
## ERROR: `Page` / `Locator` imported from `playwright` (not `@playwright/test`)
```bash
grep -rnE 'from\s+("|\x27)playwright(-core)?("|\x27)' --include='*.ts' "$TEST_DIR"
```
Use `import type { Page, Locator } from "@playwright/test"`.
## WARN: `page.waitForSelector` followed by an action
```bash
grep -rn -A 2 'page\.waitForSelector' --include='*.ts' "$TEST_DIR"
```
Manual triage. If the next line is a click/fill on the same selector, the wait is redundant.
## WARN: `page.route` after `page.goto` (race condition)
```bash
# Heuristic two-step: list files with both, then manually inspect order.
for f in $(grep -rl 'page\.route' --include='*.ts' "$TEST_DIR"); do
if grep -q 'page\.goto' "$f"; then
echo "=== $f ==="
grep -nE 'page\.(route|goto)' "$f"
fi
done
```
Manual triage. `page.route(...)` should appear before `page.goto(...)` for the relevant route.
## WARN: route handler with no `continue` or `fallback`
```bash
# Files with page.route but no continue/fallback at all
for f in $(grep -rl 'page\.route\|context\.route' --include='*.ts' "$TEST_DIR"); do
grep -qE 'route\.(continue|fallback)' "$f" || echo "$f: route handler with no continue/fallback - non-matching requests may hang"
done
```
## WARN: `headless: false` in config
```bash
grep -rnE 'headless\s*:\s*false' --include='playwright.config.*' --include='*.ts' .
```
Headless is the default. Override ad-hoc with `--headed`. Don't commit it.
## WARN: `trace: "on"` in config
```bash
grep -rnE 'trace\s*:\s*("|\x27)on("|\x27)' --include='playwright.config.*' .
```
Use `trace: "on-first-retry"` for CI. `"on"` produces large artifacts every run.
## WARN: untyped `test.extend`
```bash
# test.extend without a type parameter
grep -rnE '\bextend\(\s*\{' --include='*.ts' "$TEST_DIR" | grep -v '<'
```
Manual triage. Should be `base.extend<Fixtures>({...})`.
## WARN: `data-testid` overuse when role exists
This needs manual review - grep cannot tell whether a role-based locator would work. Spot-check `getByTestId(...)` calls; if the underlying element is a `<button>`, `<a>`, `<input>`, or has a heading role, prefer `getByRole`.
```bash
grep -rnE '\bgetByTestId\(' --include='*.ts' "$TEST_DIR"
```
## WARN: deprecated `microsoft/playwright-github-action`
```bash
grep -rnE 'microsoft/playwright-github-action' --include='*.yml' --include='*.yaml' .github/
```
Replace with raw `npx playwright install --with-deps` + `npx playwright test`.
## WARN: `test.describe.configure({ mode: 'serial' })` as default
```bash
grep -rnE "describe\.configure\(\s*\{\s*mode\s*:\s*('|\")serial" --include='*.ts' "$TEST_DIR"
```
Serial mode disables isolation and skips remaining tests on first failure. Use only when shared session is truly required - independent of any other check above.
## SUGGESTION: hardcoded viewport over `devices`
```bash
grep -rnE 'viewport\s*:\s*\{' --include='*.ts' "$TEST_DIR"
```
Manual triage. If the dimensions match a known device profile, use `...devices['iPhone 14']` or similar.
## Output format
Tag each finding with severity (CRITICAL / ERROR / WARN / SUGGESTION) and emit `file:line - one-line problem - one-line fix`. Group by file. End with `N critical, N errors, N warnings, N suggestions`.
## What this skill does NOT catch
- `data-testid` overuse where role is appropriate (manual review).
- Logic bugs in fixture lifecycle.
- POM design quality.
- Visual regression baseline OS mismatch (`__screenshots__/` exists for the wrong OS).
- Real flake from network timing.playwright-visual-regression
Set up Playwright visual regression with toHaveScreenshot: mask volatile regions, animations: 'disabled', maxDiffPixels, the macOS-vs-Linux baseline trap (the #1 visual-regression gotcha), the CI workflow for diff review, and when to reach for Argos / Chromatic / Percy instead of the built-in.
# Set up Playwright Visual Regression
## When to Use
When you want to catch unintended UI changes - layout shifts, color regressions, missing elements - that pass functional tests. Visual regression is a complement to web-first assertions, not a replacement.
## Decision tree
- **Single-browser project, small surface, can pin CI to one OS** → Built-in `toHaveScreenshot()` (this skill).
- **PR-attached visual diff approval flow, OSS-friendly** → Argos (`@argos-ci/playwright`).
- **Storybook design system** → Chromatic.
- **Need cross-OS rendering in the cloud (sidesteps macOS-vs-Linux)** → Percy.
This skill covers the built-in flow. For the third-party services, follow their docs.
## Step 1: write the visual test
```typescript
// tests/visual/home.visual.spec.ts
import { test, expect } from "@playwright/test";
test("home page", async ({ page }) => {
await page.goto("/");
// Always wait for the page to be visually stable BEFORE the screenshot.
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
await expect(page).toHaveScreenshot("home.png", {
fullPage: true,
mask: [
page.locator("[data-testid='timestamp']"),
page.locator(".live-counter"),
page.locator(".user-avatar"),
],
animations: "disabled",
maxDiffPixels: 100,
});
});
```
Key options:
- **`mask`** - locators whose pixels are replaced with magenta blocks before comparison. Use for volatile regions (timestamps, counters, avatars, ads).
- **`animations: "disabled"`** - freezes CSS animations and the text-input caret. The default in modern Playwright versions; explicit is fine.
- **`maxDiffPixels`** - absolute pixel-count tolerance. Unset by default.
- **`threshold`** - 0 to 1, YIQ pixelmatch sensitivity. Default 0.2. Lower = stricter.
- **`stylePath`** - inject CSS before capture (useful to hide volatile elements without DOM-level masking).
- **`fullPage`** - capture beyond the viewport.
## Step 2: a dedicated `visual` project (load-bearing)
This is where the macOS-vs-Linux gotcha lives. **Snapshot baselines from your dev macOS will not match CI Linux.** Solve it with a dedicated project that runs only on Linux, with baselines committed only from CI artifacts.
```typescript
// playwright.config.ts (excerpt)
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: [
// ... existing functional projects ...
{
name: "visual",
testMatch: /.*\.visual\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
// Only run when we explicitly opt in - avoids cross-OS baseline drift in dev.
// Two equivalent ways to express "skip unless RUN_VISUAL is set":
// testIgnore: process.env.RUN_VISUAL ? undefined : ['**'], // official option
// grep: process.env.RUN_VISUAL ? undefined : /__never__/, // sentinel pattern
testIgnore: process.env.RUN_VISUAL ? undefined : ["**"],
},
],
});
```
Locally: `RUN_VISUAL=1 npx playwright test --project=visual`. CI: set `RUN_VISUAL=1` only on Linux runners.
## Step 3: the baseline workflow
1. Developer pushes a UI change.
2. CI runs `npx playwright test --project=visual` and fails on diff.
3. Developer opens the HTML report (`playwright-report/index.html`) or the trace, reviews the diff (expected / actual / diff side-by-side).
4. If the diff is intentional, developer runs `RUN_VISUAL=1 npx playwright test --project=visual --update-snapshots` **on a Linux machine** (or downloads the CI artifact and commits the new PNG).
5. PR approver re-runs CI; baselines now match.
**Never auto-update snapshots in CI.** That defeats the purpose.
## Step 4: snapshot file layout
Snapshots go to `<testfile>-snapshots/<name>-<projectName>-<platform>.png`. Example: `tests/visual/home.visual.spec.ts-snapshots/home-visual-linux.png`.
Commit the `linux` PNGs only. Add to `.gitignore`:
```gitignore
# Reject non-Linux snapshots (commit only what CI produces)
**/*-snapshots/*-darwin.png
**/*-snapshots/*-darwin-arm64.png
**/*-snapshots/*-win32.png
```
## Step 5: CI workflow snippet
```yaml
# .github/workflows/visual.yml
name: visual
on:
pull_request:
paths:
- "src/**"
- "tests/visual/**"
- "playwright.config.ts"
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps chromium
- run: RUN_VISUAL=1 npx playwright test --project=visual --reporter=html
env:
RUN_VISUAL: "1"
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-visual-report
path: playwright-report/
retention-days: 14
```
## Common mistakes to refuse
- **Committing macOS baselines** - they will not match CI Linux. Always regenerate from a Linux runner.
- **Forgetting `mask:` for volatile regions** - timestamps, counters, avatars cause every run to fail. Mask them out.
- **Screenshot taken before the page is stable** - flake. Always assert the page reached its final state first (`await expect(...).toBeVisible()`).
- **`maxDiffPixels: 1000`** to "stop the flake" - hides real regressions. Lower the tolerance, fix the source of variance.
- **`threshold: 1`** ditto - that's "anything passes."
- **`fullPage: true` on a long scrolling page with lazy-loaded content** - bottom of the page is unloaded when the screenshot is taken. Either scroll-and-wait first, or screenshot the visible region only.
- **Auto-update in CI** - defeats the purpose. Only update locally (or by downloading the CI artifact) after a human eyeballs the diff.
## When to reach for a third-party service
The built-in is good when:
- You have one OS (Linux CI) and one browser project for visuals.
- The team can review diffs in the HTML report.
- You don't need a PR-attached approval flow.
Reach for **Argos** when you want a polished review UI free; **Chromatic** when you have a Storybook design system; **Percy** when you need cross-OS rendering in the cloud (sidesteps the baseline-OS problem entirely).
## Reference
- [Visual comparisons](https://playwright.dev/docs/test-snapshots)
- [SnapshotAssertions API](https://playwright.dev/docs/api/class-snapshotassertions)playwright-reviewer
Reviews Playwright TypeScript by severity. Critical: hardcoded credentials, missing await on assertion (no-op), trace artifacts containing passwords, secrets in test names. Error: ElementHandle via page.$, page.waitForTimeout, expect(await isVisible()), node:assert, page.route after goto, route handler with no continue/fallback, untyped test.extend, Page imported from playwright (not @playwright/test), missing forbidOnly, missing webServer, headless: false in config, page.accessibility.snapshot() (removed in 1.x). Warn: text/CSS/XPath selectors, .first()/.nth() for disambiguation, beforeEach for fixtures, login-via-UI per test, trace 'on', deprecated playwright-github-action, hardcoded viewport, page.waitForNavigation in Promise.all, mode 'serial' default. Suggestion: data-testid overuse when role exists, assertions inside POM, caching ms-playwright in CI.
# Playwright Reviewer
You are a Playwright (1.x stable) TypeScript reviewer. Read the diff or files referenced and emit findings grouped by severity.
## Critical (security, data loss, or test no-op)
- Hardcoded credentials (`fill("alice@acme.com")`, `fill("hunter2")`) in test files. They end up in `git log`, `trace.zip`, and HTML reports. Always `process.env.E2E_USER!`.
- `expect(...)` without `await` on a web-first assertion. The Promise is dropped and the test passes immediately. Fix: prepend `await`.
- Logging passwords or tokens (`console.log(process.env.E2E_PASS)`). Trace artifacts contain console output.
- Test name or `step` title containing a credential value (e.g. `test("login with hunter2", ...)`).
- Committing `playwright/.auth/*.json` - live session cookies. Add to `.gitignore`.
## Error (will not run, will produce wrong runtime, or hides bugs)
- `await page.$('selector')` / `await page.$$('selector')` - ElementHandle is racy and discouraged. Replace with `page.locator(...)` or `getBy*`.
- `page.waitForTimeout(N)` in a test body - sleep-based wait, flaky. Replace with the web-first assertion that justifies the wait.
- `expect(await locator.isVisible()).toBeTruthy()` (and equivalent for `isHidden`, `isEnabled`, `textContent`, `innerText`, `inputValue`) - point-in-time check, no auto-retry. Replace with `await expect(locator).toBeVisible()` etc.
- `import assert from "node:assert"` in a Playwright test. Use `expect` so failures show in trace and reporter.
- `page.route(...)` registered AFTER `page.goto(...)` for the relevant URL - initial fetch already fired, mock never applies.
- Route handler with no `route.continue()` / `route.fallback()` branch - non-matching requests hang until timeout.
- `import { Page, Locator } from "playwright"` (or `playwright-core`) - base library has no test types. Use `@playwright/test`.
- `test.extend({...})` without a type parameter - fixtures are typed as `any`. Use `base.extend<Fixtures>({...})`.
- `forbidOnly` missing in `playwright.config.ts` - a stray `test.only` will silently skip the rest of the suite. Set `forbidOnly: !!process.env.CI`.
- `webServer` block missing - tests race the dev server on cold start. Add `webServer` with `reuseExistingServer: !process.env.CI`.
- `headless: false` in `playwright.config.ts` (not behind a debug flag) - committed UI mode wastes CI cycles and changes test behavior.
- Login flow via UI in `beforeEach` (every test) - replace with the setup-project + `storageState` pattern.
- `page.context().storageState({ path })` called before a post-login indicator is visible - file is empty or has only pre-login cookies.
- `page.waitForSelector(s)` immediately followed by an action on the same selector - the wait is redundant. Locator actions auto-wait.
- `'@axe-core/playwright'` runs found that don't `await` the result, or that don't filter `violations`.
- `page.accessibility.snapshot()` - removed from Playwright in a 1.x release. On any current-stable install this is a TypeScript error. Replace with `await expect(locator).toMatchAriaSnapshot(...)` (1.49+).
## Warn (regression vs modern idioms)
- `page.click("text=Login")` / `page.click("css=...")` / `page.click("xpath=...")` - replace with `page.getByRole(...)`, `getByLabel`, `getByText`.
- `.first()` / `.nth(i)` / `.last()` used for disambiguation rather than to assert multiplicity. Tighten the locator with `.filter()` or scope inside another locator.
- `data-testid` used when a semantic role + name is unique. The official best-practices doc puts `getByTestId` last.
- `beforeEach` doing what should be a fixture (constructs an object, stores in module scope, used in test). Move to `test.extend<Fixtures>({...})`.
- Worker-scoped resource declared as test-scoped. Use the tuple form `[fn, { scope: "worker" }]`.
- `trace: "on"` in `playwright.config.ts` - large artifacts every run. Use `"on-first-retry"`.
- `retries: 5` (or higher) - hides flake. Cap at 2 in CI, 0 locally.
- `microsoft/playwright-github-action` in workflow - deprecated. Use raw `npx playwright install --with-deps && npx playwright test`.
- `viewport: { width: ..., height: ... }` + manual `userAgent` instead of `...devices['iPhone 14']`.
- `Promise.all([page.waitForNavigation(), page.click(...)])` - `waitForNavigation` is discouraged, click auto-waits, and the pattern is no longer required. Just `await page.getByRole(...).click()` then `await expect(page).toHaveURL(...)`.
- `test.describe.configure({ mode: "serial" })` as default - disables isolation and skips remaining tests on first failure. Use only when shared session is truly needed.
- Caching `~/.cache/ms-playwright` in CI - official guidance is to skip it.
- Tests with no assertion at all - the test body fires actions but doesn't verify anything.
- Assertions inside page object methods - keep `expect` in the test for clearer failure attribution. (Convention is split; if the codebase has chosen the inverse explicitly, accept it.)
## Suggestion (style / future-proofing)
- POM constructor that does work. Constructor should only assign locator getters; navigation belongs in a `goto()` method or a fixture.
- Long inline `route.fulfill({ json: ... })` payloads. Move to a fixture file (`tests/fixtures/users.json`) and import.
- Tests that re-set up auth that the setup project already covers.
- `test.step("...")` missing on multi-action flows that would benefit from trace structure.
- `page.locator("...")` raw CSS where a `getBy*` would work.
- Visual tests without `mask:` for known volatile regions.
- Visual snapshots committed from macOS - they will not match Linux CI.
- `page.unroute(url)` / `page.unrouteAll()` for explicit route teardown when fixtures hold long-lived pages.
## Per-file checks
For each `*.spec.ts` / `*.setup.ts` / `playwright.config.ts` changed:
1. **`*.spec.ts`** - imports from `@playwright/test`, web-first assertions are awaited, no `waitForTimeout`, no `page.$`, no hardcoded credentials, no engine selectors, no `beforeEach` for fixtures, no `test.only`.
2. **`*.setup.ts`** - waits for a real post-login indicator before `storageState({ path })`, env-driven credentials, file path under `playwright/.auth/`.
3. **`playwright.config.ts`** - `forbidOnly: !!process.env.CI`, `retries: process.env.CI ? 2 : 0`, `trace: "on-first-retry"`, `webServer` block present, `setup` project + `dependencies: ["setup"]` for auth-needing projects, `reporter: [["blob"]]` if sharded.
4. **Fixture files** - `base.extend<Fixtures>({...})` (typed), worker-scoped resources use the tuple form, no side effects in the synchronous part of the fixture body.
5. **POM files** - class constructor assigns `Locator` getters, no `await`, no `expect` inside POM methods.
6. **GitHub workflow** - no `microsoft/playwright-github-action`, browsers installed via `npx playwright install --with-deps`, sharded jobs use the `blob` reporter, a separate `merge-reports` job assembles the HTML.
## Output Format
Group findings by severity. For each:
**file:line** - **severity** - what's wrong - how to fix (with one-line code example).
End with: `N critical, N errors, N warnings, N suggestions`.Playwright anti-pattern detector. Catches text/CSS/XPath selectors over getByRole, page.$/$$ ElementHandles, page.waitForTimeout, isVisible/textContent booleanised, missing await on assertions, hand-rolled retry loops, page.route after navigation, route handlers without continue/fallback, hardcoded credentials, login-via-UI per test, beforeEach for fixtures, mode 'serial' default, headless: false committed, trace: 'on' in CI, missing forbidOnly, deprecated playwright-github-action, Page/Locator imported from playwright not @playwright/test, untyped test.extend, page.waitForNavigation in Promise.all, eager ElementHandle in POM constructors, assertions inside POM, hardcoded viewport over devices.
Playwright anti-pattern detector. Catches text/CSS/XPath selectors over getByRole, page.$/$$ ElementHandles, page.waitForTimeout, isVisible/textContent booleanised, missing await on assertions, hand-rolled retry loops, page.route after navigation, route handlers without continue/fallback, hardcoded credentials, login-via-UI per test, beforeEach for fixtures, mode 'serial' default, headless: false committed, trace: 'on' in CI, missing forbidOnly, deprecated playwright-github-action, Page/Locator imported from playwright not @playwright/test, untyped test.extend, page.waitForNavigation in Promise.all, eager ElementHandle in POM constructors, assertions inside POM, hardcoded viewport over devices.
# Playwright Anti-Patterns
Reject these in generated code. Each entry has a BAD example and the CORRECT replacement. Severity tags map to the reviewer agent's grouping.
## 1. Text/CSS/XPath engine selectors **WARN**
```typescript
// BAD
await page.click("text=Login");
await page.click("css=.btn-primary");
await page.click('xpath=//button[contains(., "Login")]');
// CORRECT
await page.getByRole("button", { name: "Login" }).click();
```
User-facing locators (`getByRole`, `getByLabel`, `getByText`, `getByPlaceholder`) survive DOM refactors. CSS/XPath are an escape hatch for legacy DOM with no semantics.
## 2. `page.$()` / `page.$$()` returning ElementHandle **ERROR**
```typescript
// BAD - ElementHandle is a snapshot, racy, officially discouraged
const btn = await page.$("button.submit");
await btn?.click();
// CORRECT
await page.getByRole("button", { name: "Submit" }).click();
```
`Locator` is lazy and re-resolves on every action. `ElementHandle` captures a single DOM node and goes stale on re-render.
## 3. Chained CSS strings instead of chained locators **WARN**
```typescript
// BAD
page.locator(".row .cell .actions .delete");
// CORRECT
page
.getByRole("row", { name: "Acme Corp" })
.getByRole("button", { name: "Delete" });
```
Each chained `Locator` step is independently retried and audited in trace.
## 4. `.first()` / `.nth(i)` / `.last()` for disambiguation **WARN**
```typescript
// BAD - position breaks on reorder
await page.locator("li").nth(3).click();
// CORRECT - filter or scope
await page.getByRole("listitem").filter({ hasText: "Invoices" }).click();
```
Positional selectors are an escape hatch for assertions where multiplicity is the contract under test.
## 5. `data-testid` overuse when semantic locators exist **SUGGESTION**
```typescript
// BAD - it's a <button>Log in</button>, role + name is unique
await page.getByTestId("login-submit-button").click();
// CORRECT
await page.getByRole("button", { name: "Log in" }).click();
```
`getByTestId` is the last resort, not the default.
## 6. Strict mode violation: locator matches multiple **ERROR**
```typescript
// BAD - throws "strict mode violation ... resolved to N elements"
await page.getByText("Save").click();
// CORRECT - scope until exactly one match
await page
.getByRole("dialog", { name: "Edit profile" })
.getByRole("button", { name: "Save" })
.click();
```
## 7. `page.waitForTimeout(N)` **ERROR**
```typescript
// BAD - sleep-based wait, flaky
await page.click("button.save");
await page.waitForTimeout(2000);
await expect(page.locator(".toast")).toHaveText("Saved");
// CORRECT - web-first assertion auto-retries
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("status")).toHaveText("Saved");
```
The official docs say: "Never wait for timeout in production. Tests that wait for time are inherently flaky."
## 8. `page.waitForSelector()` before an action **WARN**
```typescript
// BAD - locator actions auto-wait, the pre-wait is redundant
await page.waitForSelector("button.save");
await page.click("button.save");
// CORRECT
await page.getByRole("button", { name: "Save" }).click();
```
## 9. `if (await locator.isVisible())` polling loops **WARN**
```typescript
// BAD - point-in-time check, manual retry
for (let i = 0; i < 10; i++) {
if (await page.locator(".toast").isVisible()) break;
await page.waitForTimeout(500);
}
// CORRECT
await expect(page.getByRole("status")).toBeVisible();
```
`isVisible()` is point-in-time; `expect(...).toBeVisible()` polls until `expect.timeout`.
## 10. `expect(await locator.textContent()).toBe(...)` **ERROR**
```typescript
// BAD - one-shot read, no retry
expect(await page.locator(".total").textContent()).toBe("$42.00");
// CORRECT
await expect(page.getByTestId("total")).toHaveText("$42.00");
```
## 11. Boolean coercion of point-in-time getters **ERROR**
```typescript
// BAD
expect(await page.locator(".modal").isVisible()).toBeTruthy();
expect(await page.locator("input").isEnabled()).toBe(true);
// CORRECT
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("textbox")).toBeEnabled();
```
Any `isX()` getter wrapped in a boolean assertion loses auto-retry.
## 12. Forgotten `await` on the assertion **CRITICAL**
```typescript
// BAD - returns a Promise, test passes immediately
expect(page.getByRole("alert")).toBeVisible();
// CORRECT
await expect(page.getByRole("alert")).toBeVisible();
```
Web-first assertions are async. An un-awaited assertion is a no-op and a frequent LLM bug.
## 13. `assert` from `node:assert` **ERROR**
```typescript
// BAD
import assert from "node:assert";
assert.equal(await page.title(), "Home");
// CORRECT
await expect(page).toHaveTitle("Home");
```
Only Playwright's `expect` integrates with reporter, traces, and retry.
## 14. Hand-rolled retry loops **WARN**
```typescript
// BAD
for (let i = 0; i < 5; i++) {
if ((await page.locator(".count").textContent()) === "5") break;
await page.waitForTimeout(200);
}
// CORRECT
await expect(page.getByTestId("count")).toHaveText("5");
// CORRECT for arbitrary async values
await expect.poll(async () => fetchCount()).toBe(5);
```
## 15. Login via UI in every test **ERROR**
```typescript
// BAD - slow, flaky, repeats per test
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.fill("#email", "user@example.com");
await page.fill("#password", "hunter2");
await page.click("button[type=submit]");
});
// CORRECT - setup project + storageState (see /playwright-setup-auth skill)
// playwright.config.ts:
// { name: "setup", testMatch: /.*\.setup\.ts/ },
// { name: "chromium", use: { storageState: "playwright/.auth/user.json" }, dependencies: ["setup"] }
```
Log in once per project (or per worker) in a setup file, persist `storageState`, reuse it.
## 16. Hardcoded credentials in test files **CRITICAL**
```typescript
// BAD
await page.fill("#email", "admin@acme.com");
await page.fill("#password", "P@ssw0rd!");
// CORRECT
await page.getByLabel("Email").fill(process.env.E2E_USER!);
await page.getByLabel("Password").fill(process.env.E2E_PASS!);
```
Credentials in code end up in `git log`, in `trace.zip`, and sometimes in screenshot OCR. Use env vars; never log them.
## 17. `page.route` set up after navigation **ERROR**
```typescript
// BAD - the initial fetch already fired
await page.goto("/dashboard");
await page.route("**/api/me", (r) =>
r.fulfill({ json: { name: "Alice" } }),
);
// CORRECT
await page.route("**/api/me", (r) =>
r.fulfill({ json: { name: "Alice" } }),
);
await page.goto("/dashboard");
```
## 18. Route handler with no fallback **ERROR**
```typescript
// BAD - non-matching methods hang until timeout
await page.route("**/api/users", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({ status: 201, json: { id: 1 } });
}
});
// CORRECT
await page.route("**/api/users", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({ status: 201, json: { id: 1 } });
return;
}
await route.fallback();
});
```
Always handle the unmatched branch with `route.continue()` or `route.fallback()`.
## 19. `beforeEach` doing what a fixture should **WARN**
```typescript
// BAD
let todoPage: TodoPage;
test.beforeEach(async ({ page }) => {
todoPage = new TodoPage(page);
await todoPage.goto();
});
// CORRECT
export const test = base.extend<{ todoPage: TodoPage }>({
todoPage: async ({ page }, use) => {
const p = new TodoPage(page);
await p.goto();
await use(p);
},
});
test("...", async ({ todoPage }) => {
/* ... */
});
```
Fixtures are typed, lazy, and visible in trace. `beforeEach` is none of those.
## 20. Worker-scoped resource declared as test-scoped **WARN**
```typescript
// BAD - DB seed runs every test
seededDb: async ({}, use) => {
await seed();
await use(db);
};
// CORRECT
seededDb: [
async ({}, use) => {
await seed();
await use(db);
},
{ scope: "worker" },
];
```
## 21. Untyped `test.extend` **ERROR**
```typescript
// BAD - todoPage is `any` in tests
export const test = base.extend({
todoPage: async ({ page }, use) => {
/* ... */
},
});
// CORRECT
type Fixtures = { todoPage: TodoPage };
export const test = base.extend<Fixtures>({
todoPage: async ({ page }, use) => {
/* ... */
},
});
```
## 22. `Page` / `Locator` imported from the wrong package **ERROR**
```typescript
// BAD
import { Page, Locator } from "playwright"; // base library, no test types
import { Page } from "playwright-core";
// CORRECT
import type { Page, Locator } from "@playwright/test";
```
## 23. ElementHandle in POM constructors **ERROR**
```typescript
// BAD
class LoginPage {
emailInput: ElementHandle;
constructor(page: Page) {
// can't await in constructor; ElementHandle is racy
this.emailInput = await page.$("#email");
}
}
// CORRECT - lazy Locator getters
class LoginPage {
readonly email: Locator;
readonly password: Locator;
constructor(private readonly page: Page) {
this.email = page.getByLabel("Email");
this.password = page.getByLabel("Password");
}
}
```
## 24. Assertions inside page objects **SUGGESTION**
```typescript
// BAD - failure attributed to POM file, not the test
class CheckoutPage {
async assertTotal(amount: string) {
await expect(this.page.getByTestId("total")).toHaveText(amount);
}
}
// CORRECT - POM has actions + locators; the test asserts
test("totals up correctly", async ({ checkoutPage }) => {
await checkoutPage.addItem("widget", 2);
await expect(checkoutPage.total).toHaveText("$42.00");
});
```
Page objects encapsulate interactions, not assertions. Convention is split, but the dominant pattern keeps `expect` in tests for clearer failure attribution.
## 25. `headless: false` committed as default **WARN**
```typescript
// BAD
use: { headless: false }
// CORRECT - headless is the default; opt out ad-hoc with `--headed`
use: { /* no headless override */ }
```
## 26. Missing `forbidOnly` **ERROR**
```typescript
// BAD - test.only sneaks into CI, the rest of the suite never runs
export default defineConfig({
/* no forbidOnly */
});
// CORRECT
export default defineConfig({
forbidOnly: !!process.env.CI,
});
```
## 27. `trace: "on"` in CI **WARN**
```typescript
// BAD - huge artifacts on every run, even green ones
use: { trace: "on" };
// CORRECT
use: { trace: "on-first-retry" };
```
## 28. Missing `webServer` block **ERROR**
```typescript
// BAD - tests race the dev server, fail on cold start
export default defineConfig({
/* no webServer */
});
// CORRECT
export default defineConfig({
webServer: {
command: "pnpm dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
},
});
```
## 29. Aggressive retries hiding flake **WARN**
```typescript
// BAD - 5 retries hides the bug behind one lucky run
retries: 5;
// CORRECT
retries: process.env.CI ? 2 : 0;
```
## 30. `page.waitForNavigation` in `Promise.all` **WARN**
```typescript
// BAD - discouraged and unnecessary, click auto-waits
await Promise.all([
page.waitForNavigation(),
page.click("a.checkout"),
]);
// CORRECT
await page.getByRole("link", { name: "Checkout" }).click();
await expect(page).toHaveURL(/\/checkout/);
```
`page.waitForNavigation()` is discouraged in favor of `waitForURL` (the method is still in the API surface but marked discouraged in the docs). The `Promise.all` pattern is no longer required - `click` auto-waits.
## 31. Hardcoded viewport over `devices` **WARN**
```typescript
// BAD - duplicate of devices['iPhone 14'], drifts when Playwright updates
use: {
viewport: { width: 390, height: 844 },
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ...",
}
// CORRECT
import { devices } from "@playwright/test";
use: { ...devices["iPhone 14"] }
```
## 32. Caching `~/.cache/ms-playwright` in CI **SUGGESTION**
The official docs explicitly recommend against it - restore time is comparable to download time, and OS deps still need installing each run. If you do cache, key on the Playwright version, not just `package-lock.json`.
## 33. Deprecated `microsoft/playwright-github-action` **WARN**
```yaml
# BAD - the standalone action is deprecated
- uses: microsoft/playwright-github-action@v1
# CORRECT
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps
- run: npx playwright test
```
## 34. `test.describe.configure({ mode: "serial" })` as default **WARN**
```typescript
// BAD - blanket serial disables isolation; first failure skips the rest
test.describe.configure({ mode: "serial" });
test.describe("user flow", () => {
test("step 1", async ({ page }) => { /* ... */ });
test("step 2", async ({ page }) => { /* ... */ });
});
// CORRECT - keep tests independent; use serial only when state must persist
test.describe("user flow", () => {
test("step 1", async ({ page }) => { /* ... */ });
test("step 2", async ({ page }) => { /* ... */ });
});
```
`mode: "serial"` runs the describe block on a single worker and aborts the rest of the block on first failure. It is the right call for a multi-step flow that genuinely cannot be broken into independent tests (a multi-page wizard with no API for direct navigation). It is the wrong default - independent tests catch more bugs.Playwright authentication patterns: setup-project + storageState (single shared session), per-worker storage state for tests that mutate user state, multi-role projects (admin/user/guest), JWT bypass tokens for test-only auth, OAuth/SSO mocks via oauth2-mock-server, secrets always via process.env.
Playwright authentication patterns: setup-project + storageState (single shared session), per-worker storage state for tests that mutate user state, multi-role projects (admin/user/guest), JWT bypass tokens for test-only auth, OAuth/SSO mocks via oauth2-mock-server, secrets always via process.env.
# Playwright Authentication Patterns
This rule activates on auth setup files, fixture files, and the config. The patterns below are the canonical Playwright approach as of 1.x current stable.
## Pattern 1: Single shared storageState (read-only tests)
Best when tests only read user state (browse a dashboard, view settings, check reports).
```typescript
// auth.setup.ts
import { test as setup, expect } from "@playwright/test";
const authFile = "playwright/.auth/user.json";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.E2E_USER!);
await page.getByLabel("Password").fill(process.env.E2E_PASS!);
await page.getByRole("button", { name: "Sign in" }).click();
// Always wait for a real post-login indicator before saving state.
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await page.context().storageState({ path: authFile });
});
```
```typescript
// playwright.config.ts (excerpt)
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
];
```
`dependencies: ["setup"]` runs the `setup` project before each dependent project. If setup fails, dependents are **skipped**, not failed - check the setup project's report when you see mass-skip.
## Pattern 2: Per-worker storageState (tests that mutate user state)
When tests create/update/delete user-owned data, sharing one user across workers causes data races.
```typescript
// fixtures.ts
import { test as base, expect } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
type WorkerFixtures = { workerStorageState: string };
// First generic is test-fixtures, second is worker-fixtures. Replace the
// empty `{}` with your own TestFixtures type if this file also defines
// test-scoped fixtures.
export const test = base.extend<{}, WorkerFixtures>({
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [
async ({ browser }, use, workerInfo) => {
const file = path.resolve(`playwright/.auth/${workerInfo.workerIndex}.json`);
if (!fs.existsSync(file)) {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
const email = `e2e-worker-${workerInfo.workerIndex}@example.com`;
const password = process.env.E2E_PASS!;
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await ctx.storageState({ path: file });
await ctx.close();
}
await use(file);
},
{ scope: "worker" },
],
});
export { expect };
```
`workerInfo.workerIndex` (or `workerInfo.parallelIndex`) is the canonical way to derive per-worker identity. One account per worker, file under a stable path. The test seeds the user once per worker process.
## Pattern 3: Multiple roles (admin / user / guest)
Two viable shapes - one project per role, or per-test storage state.
### Shape A: Per-project (clean separation)
```typescript
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium-admin",
testMatch: /.*\.admin\.spec\.ts/,
use: { storageState: "playwright/.auth/admin.json" },
dependencies: ["setup"],
},
{
name: "chromium-user",
testMatch: /.*\.user\.spec\.ts/,
use: { storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
];
```
The `setup` project mints both files.
### Shape B: Per-test override
```typescript
test.use({ storageState: "playwright/.auth/admin.json" });
test("admin can delete users", async ({ page }) => {
/* ... */
});
```
Use Shape A when role determines test selection (admin specs vs user specs). Use Shape B for one-off tests that flip role.
## Pattern 4: JWT bypass tokens (fastest, most deterministic)
When the application can mint a signed JWT for tests (gated by an env var), skip the UI login entirely.
```typescript
// fixtures.ts
import { test as base, expect, type Page } from "@playwright/test";
type Fixtures = { authedPage: Page };
export const test = base.extend<Fixtures>({
authedPage: async ({ browser }, use) => {
const ctx = await browser.newContext();
// App exposes /test/token (gated by E2E_TOKEN_SECRET) that returns a signed JWT
const res = await ctx.request.post("/test/token", {
data: { sub: "alice", role: "admin" },
headers: { "x-e2e-secret": process.env.E2E_TOKEN_SECRET! },
});
const { token } = await res.json();
await ctx.addCookies([
{
name: "session",
value: token,
url: process.env.E2E_BASE_URL!,
httpOnly: true,
secure: true,
sameSite: "Lax",
},
]);
const page = await ctx.newPage();
await use(page);
await ctx.close();
},
});
```
The app's `/test/token` endpoint must:
- Be gated by an env var (`E2E_TOKEN_SECRET`) that is unset in production.
- Refuse if the secret header is missing or wrong.
- Sign with the same key as the real auth flow.
This is the fastest auth pattern and the most deterministic - no DOM, no flake. The downside is a code path in your app that must stay test-only.
## Pattern 5: OAuth / SSO
In order of preference:
1. **`oauth2-mock-server` running locally** - real signatures, full PKCE flow, controllable. Best for CI.
2. **Keycloak in Docker (dev mode)** - real OIDC/SAML provider, signs with real certs. Heavier but exercises the real protocol.
3. **Real production IdP** - rate limits, MFA, occasional outages. Use only for nightly smoke against staging.
For (1) and (2), point your app's OIDC config at the local provider's issuer URL during E2E runs.
## Secrets
```typescript
// WRONG
await page.fill("#email", "admin@acme.com");
await page.fill("#password", "P@ssw0rd!");
// CORRECT
await page.getByLabel("Email").fill(process.env.E2E_USER!);
await page.getByLabel("Password").fill(process.env.E2E_PASS!);
```
Never:
- Hardcode credentials in the test file (they end up in `git log`).
- Echo passwords into test titles, console.log, or screenshots (they end up in `trace.zip` and HTML reports).
- Commit `.env` files with real test credentials. Use repo secrets in CI, an untracked local `.env`.
## Storage state security
The `playwright/.auth/*.json` files contain valid session cookies. Add `playwright/.auth/` to `.gitignore`. Never commit them.
```gitignore
playwright/.auth/
playwright-report/
test-results/
blob-report/
```
If a workflow needs the storage state across jobs, upload as an artifact with short retention (1 day) and download in the dependent job.
## Common failure modes
- **`page.context().storageState({ path })` called before login completes** - file is empty or has only the pre-login cookies. Always assert a post-login indicator (`await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()`) before saving.
- **Setup project fails silently in CI** - dependents skip, you see green. Always inspect the setup project's report when downstream tests are mass-skipped.
- **Per-worker auth file collision** - two workers hit the same file path. Use `workerInfo.workerIndex` in the file path, not a global constant.
- **`storageState: "..."` with a path that doesn't exist** - all tests run unauthenticated. Add `dependencies: ["setup"]` so the file is guaranteed to exist before dependent projects start.playwright.config.ts patterns: defineConfig + projects + dependencies, devices for cross-browser, webServer + reuseExistingServer + wait regex, fullyParallel, sharded CI with the blob reporter and merge-reports, GitHub Actions matrix, browser binary install.
playwright.config.ts patterns: defineConfig + projects + dependencies, devices for cross-browser, webServer + reuseExistingServer + wait regex, fullyParallel, sharded CI with the blob reporter and merge-reports, GitHub Actions matrix, browser binary install.
# playwright.config.ts
This rule activates on the Playwright config file. The patterns here are non-negotiable for CI-grade test runs.
## Canonical shape
```typescript
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
// Leave workers undefined to use all cores. Set workers: 1 only on
// memory-constrained CI runners or when tests share state. For horizontal
// scaling, prefer `--shard=N/M` across multiple jobs (see CI section below).
reporter: process.env.CI
? [["blob"], ["github"]]
: [["list"], ["html", { open: "never" }]],
expect: { timeout: 5_000 },
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"], storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
{
name: "webkit",
use: { ...devices["Desktop Safari"], storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
],
webServer: {
command: "pnpm dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
```
## Reference for each option
### `fullyParallel: true`
Distributes tests within the same file across workers (default is parallel across files only). Combine with `workers: 1` in CI when you have a small machine, or remove the `workers` cap to use all cores.
### `forbidOnly: !!process.env.CI`
Fails the run if a `test.only(...)` slipped through review. The single most important guard for CI.
### `retries`
CI: 2. Local: 0. More than 2 retries hides flake; 0 in CI is too brittle for real networks. Never use `retries: 5` to make a flaky test green.
### `reporter`
- **CI**: `[["blob"], ["github"]]`. The `blob` reporter is essential for sharding (combined later by `merge-reports`). The `github` reporter posts inline annotations on failed tests.
- **Local**: `[["list"], ["html", { open: "never" }]]`. HTML report at `playwright-report/index.html`, no auto-open.
For sharded runs, configure `reporter: [["blob"]]` only and merge after.
### `use.trace`
Modes: `"off" | "on" | "retain-on-failure" | "on-first-retry" | "on-all-retries"`. Use `"on-first-retry"` for CI - free for green runs, traces only when retried. `"on"` produces large artifacts every run.
### `use.screenshot`
`"off" | "on" | "only-on-failure"`. `"only-on-failure"` is the canonical CI value. Avoid `"on"` - bloats `playwright-report/`.
### `use.video`
`"off" | "on" | "retain-on-failure" | "on-first-retry"`. `"retain-on-failure"` is the canonical CI value.
For programmatic access to a recorded video file, use `page.video()` (returns `null` if recording is off). The `use.video` config option drives `BrowserContext.recordVideo` under the hood.
### `use.baseURL`
Read from env so CI can target staging / preview / local. Then `page.goto("/path")` resolves against `baseURL`.
### `webServer`
Without this, tests race the dev server. `reuseExistingServer: !process.env.CI` runs against an already-up server locally; CI cold-starts.
In 1.57+, `wait` accepts a regex matched against stdout/stderr - more reliable than port-only polling for slow boot. Verify the exact `TestConfigWebServer.wait` shape against your installed types before using:
```typescript
webServer: {
command: "pnpm dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
// 1.57+: e.g. wait: { stdout: /ready - started server/ }
// The exact shape is in node_modules/@playwright/test/index.d.ts on
// `TestConfigWebServer` - it has shifted across 1.57-1.60.
}
```
`webServer` can be an array if you have multiple processes (frontend + backend).
### `projects` and `dependencies`
Multi-project setups for cross-browser, mobile, role-based auth. The `setup` project pattern + `dependencies: ["setup"]` is canonical for auth.
If `setup` fails, dependents are **skipped**, not failed. Look at the setup project's report when downstream tests are mass-skipped.
### `expect.timeout`
Default 5_000 ms. Web-first assertions retry until this elapses. Override per assertion: `await expect(loc).toBeVisible({ timeout: 10_000 })`. Don't bump the global timeout to mask slow pages - fix the page or add per-assertion overrides.
## Sharding in CI
The canonical pattern: N parallel jobs each running a shard, plus a merge job assembling one HTML report.
```yaml
# .github/workflows/test.yml
name: e2e
on: [push, pull_request]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14
```
For sharding, set `reporter: [["blob"]]` in `playwright.config.ts` (the `merge-reports` step picks the final reporter).
## Browser binary cache (the official guidance is "don't")
Per the official Playwright CI docs, caching `~/.cache/ms-playwright` is **not recommended** - restore time is comparable to download time, and you still need to run `playwright install-deps` for OS libraries. If you cache anyway, key on the Playwright version (parse `node_modules/@playwright/test/package.json`), not just on `package-lock.json` hash.
## Vercel preview gotcha
If you point `baseURL` at a Vercel preview URL and Vercel Deployment Protection is on, Playwright will be blocked at the auth wall. Either disable Protection on previews, or buy Vercel Protection Bypass for Automation and pass the bypass header in `extraHTTPHeaders`.
## Things to avoid in this file
- `headless: false` - that's a debugging override, not a default.
- `workers: 1` everywhere - slow.
- `timeout: 120_000` on individual tests to mask slow assertions - fix the assertion timeout per call instead.
- `retries: 5` - hides flake.
- `globalSetup` for auth - the setup-project pattern with `dependencies` is the modern replacement.Playwright (1.x) core: semantic locators (getByRole, getByLabel, getByText, getByTestId), web-first assertions (toBeVisible, toHaveText) over single-shot getters, test.extend fixtures with worker scope, storageState + setup-project auth, page.route mocking with route.continue/fallback, POM-via-fixture, trace 'on-first-retry'.
Playwright (1.x) core: semantic locators (getByRole, getByLabel, getByText, getByTestId), web-first assertions (toBeVisible, toHaveText) over single-shot getters, test.extend fixtures with worker scope, storageState + setup-project auth, page.route mocking with route.continue/fallback, POM-via-fixture, trace 'on-first-retry'.
# Playwright Core
Cursor: when you generate Playwright tests, follow these rules. Target `@playwright/test` 1.x current stable. The companion `playwright-anti-patterns.mdc` rule rejects the inverse patterns.
## Imports
```typescript
import { test, expect, type Page, type Locator } from "@playwright/test";
```
> Always import `Page` and `Locator` from `@playwright/test`, never from `playwright` or `playwright-core`. The base library has no test-runner types and no fixtures.
## Locators: semantic first, test-id last
Order of preference. Skip a tier only when the previous one is impossible.
1. `page.getByRole('button', { name: 'Save' })` - role + accessible name. This is the user-facing locator and survives DOM refactors.
2. `page.getByLabel('Email')` - form fields with `<label>`.
3. `page.getByPlaceholder('name@example.com')` - placeholder text.
4. `page.getByText('Welcome, Jens')` - visible text content. Use for non-interactive elements.
5. `page.getByAltText('Company logo')` - images.
6. `page.getByTitle('Tooltip text')` - title attribute.
7. `page.getByTestId('submit-button')` - last resort. `data-testid` is for elements with no semantic identity.
> Landmark roles (`form`, `region`, `navigation`, `main`, `complementary`) only match elements that carry an accessible name from `aria-label`, `aria-labelledby="<heading-id>"`, or `title`. ARIA's name-from-contents algorithm does NOT apply to landmarks - a bare `<h1>Payment</h1>` inside a `<form>` does not give the form an accessible name. `getByRole("form", { name: "Payment" })` finds zero elements against a bare `<form>` - the locator is correct, the HTML is missing `aria-label="Payment"`. This is an ARIA 1.2 rule, not a Playwright quirk.
```typescript
// CORRECT
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByLabel("Password").fill(pw);
// WRONG - text engine selector, no role context
await page.click("text=Sign in");
// WRONG - CSS, brittle on refactor
await page.click("button.btn-primary");
// WRONG - data-testid when role + name exists
await page.getByTestId("login-submit-button").click();
```
## Locator strictness: one match required
Locator actions throw `strict mode violation` when the locator matches multiple elements. Tighten the locator instead of reaching for `.first()`.
```typescript
// WRONG - resolves to N elements, throws or worse, picks the wrong one
await page.getByText("Save").click();
// CORRECT - scope inside another locator
await page
.getByRole("dialog", { name: "Edit profile" })
.getByRole("button", { name: "Save" })
.click();
// CORRECT - filter
await page
.getByRole("listitem")
.filter({ hasText: "Acme Corp" })
.getByRole("button", { name: "Delete" })
.click();
```
`.first()`, `.nth(i)`, `.last()` are escape hatches for assertions where multiplicity is the contract under test, not for normal disambiguation.
## Web-first assertions: always `await expect(locator)`
Web-first assertions auto-retry until the matcher passes or `expect.timeout` (default 5s) elapses. Single-shot getters do not retry.
```typescript
// CORRECT - retries until visible or timeout
await expect(page.getByRole("status")).toBeVisible();
await expect(page.getByTestId("total")).toHaveText("$42.00");
await expect(page).toHaveURL(/\/checkout/);
// WRONG - point-in-time check, no retry
expect(await page.locator(".modal").isVisible()).toBeTruthy();
expect(await page.locator(".total").textContent()).toBe("$42.00");
// WRONG - missing await, the Promise is dropped, test passes immediately
expect(page.getByRole("alert")).toBeVisible();
```
Common matchers (`LocatorAssertions`): `toBeAttached`, `toBeChecked`, `toBeDisabled`, `toBeEditable`, `toBeEnabled`, `toBeFocused`, `toBeHidden`, `toBeInViewport`, `toBeVisible`, `toContainClass` (1.60+), `toContainText`, `toHaveAttribute`, `toHaveClass`, `toHaveCount`, `toHaveCSS`, `toHaveId`, `toHaveJSProperty`, `toHaveRole`, `toHaveScreenshot`, `toHaveText`, `toHaveValue`, `toHaveValues`, `toMatchAriaSnapshot` (1.49+).
For `Page`: `toHaveURL`, `toHaveTitle`, `toHaveScreenshot`.
For arbitrary async values use `expect.poll(async () => ...).toBe(...)` rather than a hand-rolled retry loop.
## Auto-waiting: do not pre-wait
Locator actions wait for the element to be attached, visible, stable, enabled, and ready to receive events before acting. You do not need `waitForSelector` before a click.
```typescript
// WRONG
await page.waitForSelector("button.save");
await page.click("button.save");
// CORRECT
await page.getByRole("button", { name: "Save" }).click();
```
`waitForTimeout` is banned in test bodies. It hides slow systems on fast machines and flakes on slow CI.
## Fixtures: `test.extend`, not `beforeEach`
Fixtures are typed, lazy, composable, and visible in traces. `beforeEach` is none of those.
```typescript
import { test as base, expect } from "@playwright/test";
import { TodoPage } from "./pages/todo.page";
type TestFixtures = {
todoPage: TodoPage;
};
type WorkerFixtures = {
workerStorageState: string;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await use(todoPage);
// teardown after the test
},
workerStorageState: [
async ({ browser }, use, workerInfo) => {
const file = `playwright/.auth/${workerInfo.workerIndex}.json`;
// mint or load auth for this worker
await use(file);
},
{ scope: "worker" },
],
});
export { expect };
```
Re-export `expect` from the same module so tests get the matched fixture types.
Worker-scoped fixtures use the tuple form `[fn, { scope: "worker" }]`. Use them for expensive setup that can be safely shared across tests in the same worker (a seeded DB connection, a logged-in storage-state file).
Do not use `beforeAll` to set up per-worker resources unless you have an explicit reason - fixtures are the idiomatic surface and they appear in trace.
## Page Object Model via fixtures
Page objects encapsulate locators and actions. Assertions stay in the test for readable failures.
```typescript
// pages/todo.page.ts
import type { Page, Locator } from "@playwright/test";
export class TodoPage {
readonly newTodo: Locator;
readonly items: Locator;
constructor(private readonly page: Page) {
this.newTodo = page.getByPlaceholder("What needs to be done?");
this.items = page.getByTestId("todo-item");
}
async goto() {
await this.page.goto("/todos");
}
async add(text: string) {
await this.newTodo.fill(text);
await this.newTodo.press("Enter");
}
}
```
Locators are lazy in Playwright - declare them in the constructor as `Locator` instances, not by `await`-ing in the constructor. `ElementHandle` is discouraged and racy.
For tiny apps, skip POM and use locators in the test directly. POM exists to de-duplicate, not as ceremony.
## Network mocking
Register routes BEFORE navigation. Always handle non-matching cases with `route.continue()` or `route.fallback()` or the request hangs until timeout.
```typescript
await page.route("**/api/users", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({ json: [{ id: 1, name: "Alice" }] });
return;
}
await route.fallback();
});
await page.goto("/users");
```
`route.fulfill` for a mock response, `route.continue` for forward-with-modifications (terminal in the chain), `route.fallback` to defer to the next-registered handler (last registered wins).
For replay: `await page.routeFromHAR("./fixtures/api.har", { update: false, notFound: "fallback" })`.
## Authentication: setup project + storageState
Log in once via the UI in a `setup` project, persist `storageState`, then mark dependent projects with `dependencies: ["setup"]` and `use.storageState`.
```typescript
// auth.setup.ts
import { test as setup, expect } from "@playwright/test";
const authFile = "playwright/.auth/user.json";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").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.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await page.context().storageState({ path: authFile });
});
```
```typescript
// playwright.config.ts (excerpt)
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
];
```
Credentials live in `process.env`, never in the test file. For tests that mutate user state, use a per-worker storage-state file (`playwright/.auth/${workerInfo.workerIndex}.json`) so parallel workers do not clobber each other.
See the `/playwright-setup-auth` skill for the full pattern.
## Tracing, screenshots, video
```typescript
use: {
trace: "on-first-retry", // capture only when first retry happens
screenshot: "only-on-failure",
video: "retain-on-failure",
}
```
Other `trace` modes: `"off" | "on" | "retain-on-failure" | "on-first-retry" | "on-all-retries"`. `"on"` produces large artifacts on every run; reserve for debugging.
Open a trace: `npx playwright show-trace path/to/trace.zip`, or drag onto `trace.playwright.dev` (processes locally - nothing leaves the browser).
## Browser / device emulation
Use `devices` from `@playwright/test`, not hand-rolled viewport + UA combinations.
```typescript
import { devices } from "@playwright/test";
projects: [
{ name: "iphone", use: { ...devices["iPhone 14"] } },
{ name: "pixel", use: { ...devices["Pixel 7"] } },
];
```
Geolocation, locale, timezone, colorScheme, permissions are context-level options, not page-level.
## CLI surface (the parts you should suggest, not invent)
- `npx playwright test` - run all tests.
- `npx playwright test --ui` - UI mode with watch + trace.
- `npx playwright test --debug` - inspector + step-through.
- `npx playwright test --shard=1/4` - sharded run (combine with the `blob` reporter).
- `npx playwright codegen <url>` - record interactions into a starting test.
- `npx playwright show-trace trace.zip` - open a trace locally.
- `npx playwright merge-reports --reporter html ./all-blob-reports` - assemble HTML from sharded blob reports.
- `npx playwright install --with-deps` - install browsers + OS deps.
The legacy `microsoft/playwright-github-action` is not maintained - use the CLI directly in CI.
## Versioning notes (current as of 1.x stable)
- Bundled Chromium switched to **Chrome for Testing** in 1.57+. Don't tell users to install a separate Chromium.
- `webServer.wait` accepts a regex matched against process output in 1.57+ (more reliable than port polling for slow boot). The exact shape (e.g. `{ stdout: /pattern/ }`) has shifted across RCs - check `TestConfigWebServer` in your installed types.
- `expect(locator).toMatchAriaSnapshot(...)` (assertion, 1.49+) is first-class. Use it for accessibility-tree assertions.
- `page.ariaSnapshot()` / `locator.ariaSnapshot()` (raw snapshot, 1.54+) for inspecting the accessible tree without asserting.
- The old `page.accessibility.snapshot()` was removed in a Playwright 1.x release (sources disagree on the exact version - one cites 1.47, another 1.57). On any current-stable install it is a TypeScript error. Use `toMatchAriaSnapshot` instead.
- `page.routeWebSocket()` (1.48+) for WS mocking.
- `recordVideo` (config-level) and `page.video()` (per-page accessor) remain the supported video surfaces. Verify any "newer" video API claim against the changelog for the exact version you have installed before adopting it.
- For route teardown use `await page.unroute(url)` or `await page.unrouteAll()`. The `Disposable` shape of `page.route()` has changed across recent releases - check your installed `index.d.ts` before using `await using` on a route handle.Playwright network mocking: page.route + route.fulfill / route.continue / route.fallback, route.fetch for response-modify, page.routeFromHAR for replay, page.routeWebSocket (1.48+) for WS, the request fixture for API testing without the browser, when to mock vs hit a real backend, the route-after-goto race.
Playwright network mocking: page.route + route.fulfill / route.continue / route.fallback, route.fetch for response-modify, page.routeFromHAR for replay, page.routeWebSocket (1.48+) for WS, the request fixture for API testing without the browser, when to mock vs hit a real backend, the route-after-goto race.
# Playwright Network: route, mock, intercept
This rule is agent-requested. Pull it in when generating tests that mock APIs, intercept WebSockets, replay HAR files, or call APIs directly without a browser.
## `page.route()` and the `Route` object
`page.route(url, handler)` registers an interceptor. The handler receives a `Route` object with four terminal methods:
- `route.fulfill({ status, headers, body, json, contentType, path })` - mock the response.
- `route.continue({ url, method, headers, postData })` - forward to the network, optionally modified. Terminal.
- `route.fallback({ url, method, headers, postData })` - defer to the next-registered handler. Last-registered wins.
- `route.abort('failed' | 'timedout' | ...)` - fail the request.
- `route.fetch()` - perform the request and return a `Response` so you can mutate it before `fulfill`.
```typescript
// Mock a successful GET, forward everything else
await page.route("**/api/users", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({ json: [{ id: 1, name: "Alice" }] });
return;
}
await route.fallback();
});
```
Always handle the unmatched branch with `route.continue()` or `route.fallback()`. Without it, non-matching requests hang until the test times out.
## Register routes BEFORE navigation
```typescript
// WRONG - the initial fetch already fired
await page.goto("/dashboard");
await page.route("**/api/me", (r) =>
r.fulfill({ json: { name: "Alice" } }),
);
// CORRECT
await page.route("**/api/me", (r) =>
r.fulfill({ json: { name: "Alice" } }),
);
await page.goto("/dashboard");
```
Register at the `BrowserContext` level when you want the route to apply to all pages in the context:
```typescript
await context.route("**/api/**", (r) => r.fallback());
```
## Modifying a real response
Use `route.fetch()` then `route.fulfill()` with the modified body. Useful for "let the real backend do its work, but tweak the response."
```typescript
await page.route("**/api/posts", async (route) => {
const response = await route.fetch();
const json = await response.json();
json.forEach((p: { title: string }) => (p.title = `[mocked] ${p.title}`));
await route.fulfill({ response, json });
});
```
## HAR replay
Record a HAR once with `update: true`, commit, then replay deterministically:
```typescript
// Recording mode (commit the HAR)
await page.routeFromHAR("./fixtures/users.har", { update: true });
// Replay mode (CI default)
await page.routeFromHAR("./fixtures/users.har", {
update: false,
notFound: "fallback",
});
```
`notFound: "fallback"` lets unmatched requests fall through to the network. `notFound: "abort"` (default) is stricter.
## WebSocket mocking (1.48+)
```typescript
await page.routeWebSocket("wss://example.com/ws", (ws) => {
ws.onMessage((msg) => ws.send(`echo: ${msg}`));
// To proxy to a real server:
// ws.connectToServer();
});
```
Without `connectToServer()`, Playwright opens a fully mocked socket inside the page. Calling `onMessage` on either side stops automatic forwarding in that direction.
## API testing with the `request` fixture
`page.request` and `context.request` share cookies with the browser context (powerful for "log in via UI, assert via API" or vice versa). Standalone `request` fixture for tests that don't need a browser:
```typescript
import { test, expect } from "@playwright/test";
test("seed via API, assert via UI", async ({ page, request }) => {
const res = await request.post("/api/items", { data: { name: "foo" } });
expect(res.ok()).toBeTruthy();
await page.goto("/items");
await expect(page.getByText("foo")).toBeVisible();
});
```
When to use Playwright `request` for pure API tests:
- You already have a Playwright UI suite and want shared fixtures, auth, trace.
- You need cookie sharing with the browser context.
When to use a dedicated tool instead:
- **supertest** in-process when the app is a Node module and HTTP overhead matters.
- **Hurl / Bruno / Postman** for hand-curated contract tests that QA edits.
Don't build a separate API test suite in Playwright if you don't already have a UI suite - too heavy.
## When to mock vs hit a real backend
Mock:
- Third-party SaaS (Stripe, Auth0, analytics, email) - flaky, rate-limited, cost real money.
- Fault injection (5xx, slow, malformed JSON).
- Visual regression - mock data so the page renders deterministically.
Hit the real backend:
- Integration tests where the backend IS the contract under test.
- Smoke tests against staging.
- Tests that exercise data flow (DB writes, queue inserts).
Don't mock your own backend in tests that are supposed to verify integration. You'll catch fewer real bugs.
## Common failure modes
- **Route registered after `goto`** - initial fetch already fired, mock never applies. Register first.
- **No `route.continue/fallback` branch** - non-matching requests hang. Always cover the else.
- **`route.fulfill({ json })` with a non-serializable value** - silently produces `null`. Test the JSON shape, then pass it.
- **Forgetting `await` on `route.fulfill/continue/fallback`** - the handler returns before the response is queued. Lint catches this.
- **Mocking `**/api/**` then realising the app uses absolute URLs to a different host** - the glob doesn't match. Use the full URL pattern or `**` prefix.
- **Per-test `page.unroute()` not called when the next test re-uses the page** - shouldn't happen with the default `page` fixture (one per test), but a custom long-lived page leaks routes. Reset with `await page.unrouteAll()` in teardown.
## Reference
- [Network](https://playwright.dev/docs/network)
- [Mock APIs](https://playwright.dev/docs/mock)
- [Route](https://playwright.dev/docs/api/class-route)
- [WebSocketRoute](https://playwright.dev/docs/api/class-websocketroute)
- [API testing](https://playwright.dev/docs/api-testing)