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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- playwright-e2e Installs to ~/.agents/skills/playwright-e2e/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install playwright-e2e The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project playwright-e2e Installs to .agents/skills/playwright-e2e/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/playwright-e2e.zipunzip -o playwright-e2e.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) playwright-e2e PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The suite is on Cypress: use cypress-e2e
- You need pixel or screenshot comparisons: use visual-regression-testing
- You are diagnosing flakiness across many frameworks at once: use flaky-test-triage
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.
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
- Read
AGENTS.md,playwright.config.tsandpackage.jsonscripts. NotetestDir,projects,webServer,use.baseURL,retries,trace, and reporters. - Check the installed version:
npx playwright --version. Browsers must match it;npx playwright install --with-deps chromiumdownloads them and needs network (and sudo for system deps on Linux), so ask for approval in a sandboxed Codex session. - Read existing fixtures (
tests/fixtures.tsor similar) and page objects before adding new ones. - 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:
| Locator | Example |
|---|---|
| Role + accessible name | page.getByRole("button", { name: "Place order" }) |
| Label | page.getByLabel("Email") |
| Placeholder / text | page.getByText("Order confirmed") |
| Test id (stable contract) | page.getByTestId("cart-total") |
| CSS / XPath | last 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
retrieshide flakiness; check the report’s “flaky” count and treat it as failures to fix.fullyParallel: truebreaks tests that share a user or a record; isolate data first.baseURLpluspage.goto("/path")only; hard-coded hosts break preview environments.test.onlycommitted by accident is blocked byforbidOnlyin CI.- Visual comparisons belong to
$visual-regression-testing; keep E2E specs behavioral.
Verification
- Run the new or changed spec with
--repeat-each=10; zero failures. - Run the whole suite with the CI worker count.
- Confirm a deliberate break (wrong expected text) fails with a readable message and a trace.
- 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.yaml
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.md
# 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.