agentsclimarketplace

Playwright patterns

Skill marzun9620/agent_skills/testing/skills/playwright-patterns

My personal Claude Code skills — 59 of them. Use any, fork the repo, or contribute yours. Install with /plugin marketplace add marzun9620/agent_skills

Install
npx -y skills add marzun9620/agent_skills --skill playwright-patterns

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Patterns for writing Playwright E2E tests — POM structure, selector hierarchy, flow specs, waits, role contexts, dialog handling. Use when authoring or reviewing anything under /e2e.

SKILL.md

11.5 KB, as published. Nobody here has run it

Playwright Patterns

Codified playbook for a Playwright E2E suite (typically in /e2e). Distilled from production e2e work so the suite stays consistent as it grows. This skill covers how to write tests; pair it with an in-repo AUTHORING.md that covers where and what runs when.

Quick Reference: Critical Rules

CategoryDODON'T
SelectorsgetByRolegetByLabelgetByTestId('data-pw=…')getByTextpage.locator('.css-class') or positional nth(n)
Heading / landmarkgetByRole('main') for authed shellgetByRole('heading', { level }) for shadcn CardTitle (it's a <div>)
Page load signaltoHaveTitle (canonical routes) or form-field / <main> visible (alias routes)Asserting only toHaveURL after page.goto(url) — tautological
POM goto()End with expectLoaded() assertionReturn before the page is observable
Waitspage.waitForResponse, expect.poll, auto-waiting locatorspage.waitForTimeout(ms)
Flow specstest.describe.serial + phased sub-describes with beforeAll/afterAll contextsParallel flow specs against shared staging DB
Retries on flowretries: 0Retries on serial multi-step tests — step-1 replay leaks data
Multi-role testsSeparate BrowserContext per role (adminPage + publicPage)One context that signs in/out between steps
Test dataTimestamp + TEST_PARALLEL_INDEX suffixHardcoded names — collides across parallel / retried runs
Secretsprocess.env.E2E_ADMIN_PASSWORD read by lib/config.tsHardcoding in specs or committing .env
File uploadssetInputFiles(path) with fixtures from e2e/fixtures/*Inline base64 or manual clicks on hidden inputs
Dialog handlingHandle both "dialog appeared" and "step advanced" branchesBlind click() then waitForTimeout

Page Object Model

POMs live in e2e/pages/<name>.page.ts. One file per page or major form.

Contract

import { type Page, expect } from "@playwright/test";

export class <Name>Page {
    constructor(private readonly page: Page) {}

    // 1. Stable accessors as getters — let callers assert without extra POM methods
    get submitBtn() {
        return this.page.getByRole("button", { name: /submit|保存/i });
    }

    // 2. goto() always ends with an expectLoaded assertion
    async goto() {
        await this.page.goto("/your-route");
        await this.expectLoaded();
    }

    // 3. Copy-stable landing signal. For authed routes prefer getByRole('main').
    //    For public routes with real meta() titles, toHaveTitle works.
    async expectLoaded() {
        await expect(this.page.getByRole("main")).toBeVisible({ timeout: 30_000 });
    }

    // 4. Action methods named by USER INTENT, not selector.
    //    GOOD: async submitContactForm(...)
    //    BAD:  async clickSubmit()
    async submitForm(payload: { email: string; body: string }) {
        await this.page.getByLabel("メールアドレス").fill(payload.email);
        await this.page.getByLabel("メッセージ").fill(payload.body);
        await this.submitBtn.click();
    }
}

When to split a POM into multiple files

  • One page, multiple complex surfaces — separate files: job-form.page.ts, job-view.page.ts, job-list.page.ts
  • Multi-step wizard — one POM per wizard with private per-step helpers (fillStep1, fillStep2…) and a public fillAllSteps(data) entry point. See pages/*-form.page.ts patterns in the reference.

Fixture registration

Every POM gets a fixture in e2e/fixtures/index.ts so specs inject via destructuring:

export const test = base.extend<Fixtures>({
    signinPage: async ({ page }, use) => {
        await use(new SigninPage(page));
    },
});

Specs then:

test("does the thing", async ({ signinPage, page }) => {
    await signinPage.goto();
    await signinPage.signIn(email, password);
});

Flow Specs (Multi-Step Journeys)

Flow specs go under e2e/specs/<area>/<journey>.spec.ts (or any non-smoke folder — picked up by the staging-catchall project). They must be serial, write carefully, and never retry.

Multi-phase template

import { type BrowserContext, type Page, expect, test } from "@playwright/test";
import { adminStorageStatePath } from "../../lib/storage-state";
import { generateTestEmail } from "../../lib/email";

test.describe.serial("<journey name>", () => {
    let adminContext: BrowserContext;
    let adminPage: Page;
    const testEmail = generateTestEmail();
    let entityId: string;  // shared state across phases

    test.beforeAll(async ({ browser }) => {
        adminContext = await browser.newContext({ storageState: adminStorageStatePath });
        adminPage = await adminContext.newPage();
    });

    test.afterAll(async () => {
        await adminContext?.close();
    });

    test.describe.serial("Phase 1: setup", () => {
        test("Step 1: create entity", async () => {
            test.setTimeout(60_000);
            // ... use adminPage ...
            entityId = await ...;
        });
    });

    test.describe.serial("Phase 2: public interaction", () => {
        test("Step 2: public submits something", async ({ browser }) => {
            test.setTimeout(60_000);
            const publicContext = await browser.newContext();
            const publicPage = await publicContext.newPage();
            try {
                // ... use publicPage ...
            } finally {
                await publicContext.close();
            }
        });
    });

    test.describe.serial("Phase 3: verify & cleanup", () => {
        test("Step 3: admin verifies result", async () => {
            await adminPage.goto(`/admin/entity/${entityId}`);
            // ... assertions ...
        });
    });
});

Why phased sub-describes

  • test.describe.serial on outer + inner means every step runs in order
  • Inner describes group related steps for reporting — you can scan the run output and see "Phase 2 failed" without reading 15 step titles
  • beforeAll / afterAll survive across phase boundaries, so shared state (adminPage, entityId) stays intact

Why separate publicContext

  • The storage state makes adminPage authed. To exercise the unauthed public flow you need a fresh context — a single page can't switch identities cleanly
  • Always try { ... } finally { publicContext.close() } — unclosed contexts leak Chromium processes

Retries on flow specs: always 0

// playwright.config.ts
{
    name: "staging-flow",
    retries: 0, // Serial suites retry from Step 1, creating orphan data
    fullyParallel: false,
    ...
}

If step 3 fails and Playwright retries, it re-runs steps 1–3. Steps 1–2 already wrote data. You now have orphan companies / jobs / applicants in shared staging.

Selectors

See references/selector-patterns.md for full hierarchy. TL;DR:

  1. getByRole('button', { name }) — accessibility-first
  2. getByLabel('メールアドレス') — best for form fields
  3. getByTestId('page-admin-dashboard') — once data-pw lands in the app
  4. getByText(...) — last resort
  5. page.locator('.css-class')forbidden

For alias routes (/signin, /dashboard) where meta() isn't re-exported, title is empty — use getByRole('main') or a form field instead.

Waits

Never page.waitForTimeout(ms). Three real tools:

1. Auto-waiting locators

expect(locator).toBeVisible({ timeout }) auto-retries. Use liberally.

2. Response-wait for backend mutations

When an action triggers an API call you need to settle before asserting:

const responsePromise = page.waitForResponse(
    (resp) => resp.url().includes("/api/documents/applicant-info"),
    { timeout: 30_000 },
);
await submitBtn.click();
await responsePromise;

Set the promise before the triggering action — otherwise you race the request.

3. expect.poll for async backend processes

When a scanner / cron / pipeline moves the record into a new state asynchronously:

await expect
    .poll(
        async () => {
            const count = await page.getByText("クリーン").count();
            if (count >= 2) return count;
            await page.reload();
            return count;
        },
        {
            intervals: [15_000],   // check every 15s
            timeout: 540_000,       // give up at 9 minutes
            message: "Expected both documents to reach クリーン status",
        },
    )
    .toBeGreaterThanOrEqual(2);

Dialog handling

Validation dialogs can appear on "Next" / "Save" clicks. Handle both outcomes:

async clickNextStep() {
    const dialog = this.page.getByRole("dialog");
    // Pre-click: close any stale dialog
    if ((await dialog.count()) > 0) {
        await dialog.locator("button").first().click();
        await expect(dialog).toBeHidden({ timeout: 3_000 });
    }
    await this.nextButton.click();
    // Post-click: dialog might appear OR step advances
    const dialogAppeared = await dialog
        .waitFor({ state: "visible", timeout: 1_500 })
        .then(() => true)
        .catch(() => false);
    if (dialogAppeared) {
        await dialog.locator("button").first().click();
        await expect(dialog).toBeHidden({ timeout: 3_000 });
    }
}

File uploads

Binary fixtures go under e2e/fixtures/* (e.g. test-photo.png, test.pdf). Reference them via fileURLToPath in the spec:

import path from "node:path";
import { fileURLToPath } from "node:url";

const currentDir = path.dirname(fileURLToPath(import.meta.url));
const testPdf = path.resolve(currentDir, "../../fixtures/test.pdf");

await page.locator("#document").setInputFiles(testPdf);

For multi-file uploads:

const inputs = page.locator("input[type='file']");
const count = await inputs.count();
for (let i = 0; i < count; i++) {
    await inputs.nth(i).setInputFiles(testPdf);
}

Test data generation

Always collision-free:

const workerIndex = process.env.TEST_PARALLEL_INDEX ?? "0";
const ts = `${Date.now()}-${workerIndex}`;
export const testEntity = {
    name: `E2E-Entity-${ts}`,
    email: `e2e-${ts}@mailsac.com`,
} as const;

For emails that need Mailsac polling, use generateTestEmail() from lib/email.ts — it already bakes in the worker index.

Anti-patterns (reject in review)

  • CSS class selectors — break on any style refactor
  • Positional selectors without semantic contextnth(3) is meaningless
  • waitForTimeout — fixed sleeps hide real timing problems
  • Shared state between tests in the same project (non-serial) — leaks, order-dependence
  • Retries > 0 on flow specs — orphan data in shared DB
  • Mutation-heavy smoke specs — smoke must be idempotent and parallel-safe
  • page.click() before expect(locator).toBeVisible() — skips auto-wait
  • Per-step sign-in/out — use separate contexts instead

Deep dives (references/)

Gives 0 of the 12 instructions most e2e browser skills give

Counted across 407 of the 410 authors here whose files we hold, read 2026-08-06

  • use page object model patternin 35 of 407, across 25 files
  • Snapshot to get element refsin 24 of 407, across 14 files
  • keep tests independentin 23 of 407, across 18 files
  • Interact using refs from the latest snapshotin 23 of 407, across 11 files
  • clean up test data after each testin 21 of 407, across 15 files
  • test user behavior not implementationin 20 of 407, across 14 files
  • quarantine flaky tests explicitlyin 19 of 407, across 10 files
  • wait for specific network conditionsin 18 of 407, across 8 files
  • re-snapshot after navigation or dom changesin 17 of 407, across 10 files
  • Detect running dev servers before writing test codein 17 of 407, across 7 files
  • use web-first assertionsin 17 of 407, across 14 files
  • capture screenshots or videos on test failurein 17 of 407, across 14 files

Said here and by no other author read

  • end page object goto methods with loaded assertion
  • use separate browser contexts for different roles
  • set retries to zero for serial flow specs
  • use timestamp and worker index for test data
  • read secrets from environment variables
  • use set input files for binary uploads

Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.