Playwright E2E skill for Codex

Role locators, one login, a trace for every flake. Codex writes Playwright specs that locate elements by role and label, wait through web-first assertions instead of sleeps, log in once through a setup project, and seed data via API fixtures. When a test flakes it reads the trace and fixes the cause, and it wires sharded CI runs with merged HTML reports.

  • Testing & QA
  • $playwright-e2e
  • 218 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- playwright-e2e

Installs to ~/.agents/skills/playwright-e2e/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $playwright-e2e, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds and stabilizes Playwright Test end-to-end suites in TypeScript - role-based locators, web-first assertions, custom fixtures, auth via setup projects and storageState, network mocking with page.route, clock control, traces, and CI sharding with blob reports. Use when writing or fixing Playwright specs, when E2E tests are flaky or slow, a login step repeats in every test, a failure only happens in CI, or playwright.config.ts needs projects, webServer or retries set up.

Use it when

  • Writing or reviewing Playwright Test specs
  • E2E tests are flaky, slow or repeat the login flow
  • Setting up playwright.config.ts projects, webServer or retries
  • Running Playwright in CI with sharding and traces

Use something else when

How to check the result

The changed spec passes 10 times with --repeat-each=10, the full suite passes at CI worker count, and a deliberate break produces a readable failure with a trace.

The mistake it prevents

Sprinkling waitForTimeout and raising global timeouts to hide a race that the trace would show is a locator matching the wrong element or an unfinished request.

~/.agents/skills/playwright-e2e/SKILL.md

--- name: playwright-e2e description: Builds and stabilizes Playwright Test end-to-end suites in TypeScript - role-based locators, web-first assertions, custom fixtures, auth via setup… license: MIT ---

A trustworthy Playwright suite finds elements the way users do (role, label, text), never sleeps, logs in once per worker or run instead of per test, records a trace for every retried failure, and gives the same result locally and in CI.

Inspect first

  1. Read AGENTS.md, playwright.config.ts and package.json scripts. Note testDir, projects, webServer, use.baseURL, retries, trace, and reporters.
  2. Check the installed version: npx playwright --version. Browsers must match it; npx playwright install --with-deps chromium downloads them and needs network (and sudo for system deps on Linux), so ask for approval in a sandboxed Codex session.
  3. Read existing fixtures (tests/fixtures.ts or similar) and page objects before adding new ones.
  4. Find how test data is created: API seeding, database scripts, or UI clicks. UI-only setup is the first thing to replace.

Baseline config

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

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

Add playwright/.auth/ to .gitignore; storage state files contain session cookies.

Locators

Priority, best first:

LocatorExample
Role + accessible namepage.getByRole("button", { name: "Place order" })
Labelpage.getByLabel("Email")
Placeholder / textpage.getByText("Order confirmed")
Test id (stable contract)page.getByTestId("cart-total")
CSS / XPathlast resort; breaks on markup changes
const row = page.getByRole("row").filter({ hasText: "INV-0042" });
await row.getByRole("button", { name: "Download" }).click();
  • Locators are lazy and strict: an action on a locator matching two elements throws. Narrow with filter, getByRole(..., { exact: true }), or scope within a parent.
  • Avoid .nth() and .first() unless order is the behavior under test.
  • If role locators fail because the markup lacks labels, that is an accessibility bug; report it instead of falling back to CSS silently.

Assertions

Use web-first assertions; they retry until the timeout.

await expect(page.getByRole("heading", { name: "Checkout" })).toBeVisible();
await expect(page).toHaveURL(/\/orders\/\d+$/);
await expect(page.getByTestId("cart-total")).toHaveText("€42.00");
await expect(page.getByRole("listitem")).toHaveCount(3);
await expect.poll(async () => (await api.get("/jobs/7")).status).toBe("done");

Never: expect(await locator.isVisible()).toBe(true) (no retry), page.waitForTimeout(2000), or waitForLoadState("networkidle") as a sync point.

Auth once

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

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_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page.getByRole("button", { name: "Account" })).toBeVisible();
  await page.context().storageState({ path: "playwright/.auth/user.json" });
});

Credentials come from env vars or a seeded test account, never from source. Faster still: log in through the API with request.post and save its storage state. Tests that mutate per-user state need a user per worker; see the fixtures reference.

Fixtures

// e2e/fixtures.ts
import { test as base } from "@playwright/test";

type Fixtures = { seededOrder: { id: string } };

export const test = base.extend<Fixtures>({
  seededOrder: async ({ request }, use) => {
    const res = await request.post("/api/test/orders", { data: { items: 2 } });
    const order = await res.json();
    await use(order);
    await request.delete(`/api/test/orders/${order.id}`);
  },
});
export { expect } from "@playwright/test";

Create data through APIs in fixtures; drive the UI only for the behavior under test.

Network and time control

await page.route("**/api/recommendations", (route) =>
  route.fulfill({ json: [{ id: 1, title: "Mock item" }] }),
);
await page.route("**/*.{png,jpg}", (route) => route.abort());   // speed, when images are irrelevant

// Fixed "now" for date rendering; timers keep running normally
await page.clock.setFixedTime(new Date("2026-03-01T10:00:00Z"));

// Or full control of timers: install before navigation, then jump ahead
await page.clock.install({ time: new Date("2026-03-01T10:00:00Z") });
await page.goto("/account");
await page.clock.fastForward("05:00");                            // session timeout banner

Mock third-party services and slow non-critical endpoints; keep your own backend real in at least one smoke project so contracts are exercised.

Steps and readability

test("customer reorders a past purchase", async ({ page, seededOrder }) => {
  await test.step("open order history", async () => {
    await page.goto("/account/orders");
  });
  await test.step("reorder", async () => {
    await page.getByRole("row").filter({ hasText: seededOrder.id })
      .getByRole("button", { name: "Reorder" }).click();
  });
  await expect(page.getByRole("status")).toHaveText("Added 2 items to cart");
});

Page objects are optional; a few helper functions or fixtures usually suffice. If you use them, expose locators and intent-level actions, not assertions hidden inside.

Running and debugging

npx playwright test e2e/checkout.spec.ts -g "reorders"
npx playwright test --project=chromium --headed
npx playwright test --last-failed
npx playwright test --repeat-each=20 e2e/checkout.spec.ts     # flake hunting
npx playwright test --debug                                    # inspector, local only
npx playwright show-trace test-results/<test>/trace.zip
npx playwright show-report

In Codex, run headless (default) and read test-results/ and traces; --ui and --debug need a display.

Flake triage order: open the trace, find the action before the failure, check whether the locator matched something different, whether a request was still in flight, or whether data from another test collided. Fix the cause; do not raise timeouts globally.

CI

For sharding, merged reports, Docker images and per-worker accounts, read references/ci-and-scaling.md when setting up or changing a pipeline.

Gotchas

  • retries hide flakiness; check the report’s “flaky” count and treat it as failures to fix.
  • fullyParallel: true breaks tests that share a user or a record; isolate data first.
  • baseURL plus page.goto("/path") only; hard-coded hosts break preview environments.
  • test.only committed by accident is blocked by forbidOnly in CI.
  • Visual comparisons belong to $visual-regression-testing; keep E2E specs behavioral.

Verification

  1. Run the new or changed spec with --repeat-each=10; zero failures.
  2. Run the whole suite with the CI worker count.
  3. Confirm a deliberate break (wrong expected text) fails with a readable message and a trace.
  4. Check that no test depends on another test’s data or order.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Playwright E2E"
  short_description: "Role locators, one login, a trace for every flake"
  default_prompt: "$playwright-e2e Write a Playwright test for the checkout flow using our seeded test user"
references/ci-and-scaling.md122 lines, 4.3 KB
# Playwright in CI and at scale

Read this when wiring Playwright into a pipeline, sharding a slow suite, or isolating
users across parallel workers.

## GitHub Actions with sharding and merged report

```yaml
name: e2e
on: [pull_request]
jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --shard=${{ matrix.shard }}
        env:
          E2E_USER: ${{ secrets.E2E_USER }}
          E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: blob-report-${{ strategy.job-index }}
          path: blob-report
          retention-days: 3

  merge-report:
    if: ${{ !cancelled() }}
    needs: [e2e]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - 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, path: playwright-report, retention-days: 14 }
```

- Sharding splits by test (with `fullyParallel: true`) or by file (without). Without
  `fullyParallel`, one huge spec file pins a shard; split large files.
- The `blob` reporter must be configured for sharded runs (see the config in SKILL.md).
- Upload artifacts with `!cancelled()` so failed shards still publish traces.

## Docker for parity

Run tests inside the official image whose tag matches the installed
`@playwright/test` version, e.g. `mcr.microsoft.com/playwright:v<version>-noble`.
A mismatch between the package version and the image's browsers fails at launch.
Use the image when local and CI rendering must match, and always for screenshot tests.

## Caching browsers

Caching `~/.cache/ms-playwright` keyed on the Playwright version saves download time,
but system dependencies still need `install-deps` on fresh runners. Many teams skip the
cache and use the container image instead.

## One account per worker

When tests mutate user state (cart, settings), a shared storage state causes collisions
under parallel workers. Use a worker-scoped fixture:

```ts
import { test as base, type BrowserContext } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";

export const test = base.extend<{}, { workerStorageState: string }>({
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  workerStorageState: [
    async ({ browser }, use) => {
      const id = test.info().parallelIndex;
      const file = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);
      if (!fs.existsSync(file)) {
        const page = await browser.newPage({ storageState: undefined });
        const account = await createTestAccount(`e2e-worker-${id}`); // your API helper
        await page.goto("/login");
        await page.getByLabel("Email").fill(account.email);
        await page.getByLabel("Password").fill(account.password);
        await page.getByRole("button", { name: "Sign in" }).click();
        await page.waitForURL("/dashboard");
        await page.context().storageState({ path: file });
        await page.close();
      }
      await use(file);
    },
    { scope: "worker" },
  ],
});
```

`parallelIndex` is stable per worker slot, so accounts are reused across restarts.

## Flake budget

- Track the "flaky" count from the report per run. A test that is flaky twice in a week
  gets fixed or quarantined with `test.fixme()` plus a ticket, not left on retries.
- `--fail-on-flaky-tests` turns any flaky pass into a failure; use it on the main branch
  once the suite is clean.
- `--only-changed=origin/main` runs specs affected by changed files on PRs; keep a full
  run on main.

## Preview environments

Point `BASE_URL` at the preview deployment and drop `webServer` for that job. Wait for
the deployment's health endpoint before starting tests; do not rely on retries for a
cold start.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem