| name | playwright-conventions |
| description | The shared conventions every Playwright spec in house-manager obeys — the load-bearing ASK-FIRST rule (Pillar 4), selector strategy (`getByRole` > `getByTestId` > `getByText`), fixture patterns (fresh IDB, seeded IDB, offline, throttled), reduced-motion forced on, and the `playwright.config.ts` shape. Triggers on: playwright fixture, playwright selector, role selector, getByRole, test-id, fresh idb fixture, reduced motion playwright, ask first test. |
| license | MIT |
Sub-skill of playwright. The canonical home of the ASK-FIRST rule plus every cross-cutting Playwright convention in house-manager: selector preference order, fixture patterns (fresh IDB / seeded IDB / offline / throttled), reduced-motion forcing, the playwright.config.ts shape, and the unit-vs-E2E partition. The four other Playwright sub-skills defer here for these contracts.
When to invoke
- The user asks "how do I structure this Playwright test" — start by surfacing the ASK-FIRST rule.
- Authoring or editing
apps/web/playwright.config.ts.
- Creating a fixture (fresh IDB, seeded IDB, offline, throttled, reduced-motion override).
- Choosing a selector for an assertion.
- Routing between
bun test (unit) and Playwright (browser) — answering "can this be tested without a browser?"
Owns
Selectors (role-based first, then test-id, then text), fixtures, fresh-IDB-per-test, reduced-motion forcing, retry policy, and the load-bearing ASK-FIRST prompt rule for every test design decision.
Defers to
playwright (parent) — version pin and routing.
playwright-story-tests — the spec-authoring patterns for story-level tests.
playwright-app-tests — the spec-authoring patterns for app-level tests.
playwright-pwa-offline — the offline-deep-link contract this skill's offline fixture supports.
bun-test — for anything that does not need a real browser. The unit-vs-E2E partition is hard: pure logic, schema parses, atom reducers, IDB migration transform functions all belong in bun test. bun test does NOT require ASK-FIRST — go ahead and write the unit test directly. ASK-FIRST is Playwright-only.
idb — for the IDB schema + record shapes the seeded-IDB fixture writes against; for the hydration contract the fresh-IDB fixture resets.
animejs — for why reduced-motion is forced on (animations add flake; the useAnime hook short-circuits when reducedMotion: 'reduce').
tanstack-router-routing — for route URLs the app-level fixtures navigate to.
Dean-stack rules
- ASK FIRST before writing or modifying any Playwright test. This is a Pillar 4 (CLI-gate-first) manifestation — the structural choices for every Playwright test belong to the user, not the assistant. The choices include: which level (story-level vs app-level vs offline), what to assert (visible text, ARIA state, screenshot, IDB contents, network calls), which selector strategy, what to seed in IDB, what network state to use (online, throttled, offline), whether to force reduced motion. Surface these options as a short numbered list, wait for the user's answer, then write the test. Re-prompt for each new test or substantive modification.
- Pillar 1 (Storybook-first) means: every Storybook story has a Playwright story test (see
playwright-story-tests). Stories are the test surface, not just visual references.
- Pillar 4 (CLI-gate-first) means: Playwright is the final stage of
bun run check — biome ci → stylelint → tsgo --noEmit → bun test → build → playwright (storybook + app + app-offline). The pre-push hook runs the lighter bun run check:fast, which restricts Playwright to --project=storybook (the only project that can run HEAD-validly without a fresh dist/). The app and app-offline projects are CI-gated. A failing Playwright run is a red gate in either flavour. Never disable, never test.skip to make CI green.
- Locator-first, web-first assertions only.
await expect(locator).toBe…(). No point-in-time queries inside expect().
- Reduced motion is forced on in every project (
use: { reducedMotion: 'reduce' }) so animations don't add flake. The useAnime hook (see animejs) short-circuits when reduced motion is set; tests stay deterministic.
- IDB is never mocked — use real browser IndexedDB. Seed with
page.addInitScript; read with page.evaluate. See idb for the schema.
- Fresh IDB per test by default —
page.context().clearCookies() + IDB delete in a fixture, otherwise tests poison each other.
Patterns
The ASK-FIRST rule — what to actually do
When the user asks for a Playwright test, before writing any code, respond with something like:
Before I write this Playwright test, I need to confirm a few structural choices that you own per case. Could you tell me:
- Story-level (Storybook URL) or app-level (running app)? Or offline (PWA-deep-link contract)?
- What is the canonical assertion — visible text, ARIA state, screenshot, IDB contents, network calls?
- Selector strategy — role-based first (
getByRole)? Or do you want a test-id?
- IDB state at test start — fresh, or seeded with what?
- Network state — online, throttled, or offline?
- Reduced motion — forced on (default) or do you need a no-preference variant?
Tailor the list to what the user is actually asking — drop questions whose answers are obvious, add ones the specific scenario raises. Wait for the user's answer; then write the test. Re-prompt for each new test or substantive modification.
The rule lives here; the Playwright router (playwright) surfaces it on every Playwright prompt; each Playwright sub-skill's Patterns section opens with a one-liner pointing at this rule. Do NOT invoke this rule for bun test unit tests — those go ahead.
playwright.config.ts — the canonical shape
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["list"], ["html", { open: "never" }]],
use: {
trace: "on-first-retry",
screenshot: "only-on-failure",
reducedMotion: "reduce",
testIdAttribute: "data-test",
},
projects: [
{
name: "storybook",
testMatch: /.*\.story\.spec\.ts/,
use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:6006" },
},
{
name: "app",
testMatch: /.*\.app\.spec\.ts/,
use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:3000" },
},
{
name: "app-offline",
testMatch: /.*\.offline\.spec\.ts/,
use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:3000" },
},
],
webServer: [
{ command: "bun run storybook", url: "http://localhost:6006", reuseExistingServer: !process.env.CI, timeout: 180_000 },
{ command: "bun run preview", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 120_000 },
],
});
Three projects, three URL conventions, three test-name suffixes (*.story.spec.ts, *.app.spec.ts, *.offline.spec.ts). One config; the projects route specs by testMatch. Reduced motion is on at the use level — overridable per project / per test if a story explicitly tests motion.
Selector preference order
await page.getByRole("button", { name: /save/i }).click();
await page.getByRole("heading", { name: /level 1/i });
await page.getByLabel(/email/i).fill("kid@example.com");
await page.getByPlaceholder(/search/i).fill("maze");
await expect(page.getByText(/saved/i)).toBeVisible();
await page.getByTestId("level-progress-bar");
await page.getByRole("listitem").filter({ hasText: /maze/i }).locator("button.icon-only");
Role-based first, every time. getByRole doubles as accessibility coverage — if your selector finds the element, a screen reader will too. Pepper test-ids (data-test="...") only when the element genuinely has no accessible role/label.
Fixture: fresh IDB per test
import { test as base, expect } from "@playwright/test";
type Fixtures = {
freshIDB: void;
seededIDB: (seed: () => Promise<void>) => Promise<void>;
};
export const test = base.extend<Fixtures>({
freshIDB: [
async ({ page }, runFixture) => {
await page.addInitScript(async () => {
const dbs = (await indexedDB.databases?.()) ?? [];
await Promise.all(dbs.map((d) => d.name && indexedDB.deleteDatabase(d.name)));
});
await runFixture();
},
{ auto: true },
],
seededIDB: async ({ page }, runFixture) => {
const seed = async (writer: () => Promise<void>) => {
await page.addInitScript(`(async () => { (${writer.toString()})(); })();`);
};
await runFixture(seed);
},
});
export { expect };
auto: true means every test gets fresh IDB by default. Override per test by importing the seeded fixture instead. The seed runs before any page script — perfect for landing rows before the app's idbHydrationPromise resolves. See idb for the schema and playwright-app-tests for usage.
Fixture: offline (after SW install)
The offline fixture is canonical to the PWA-offline contract — see playwright-pwa-offline for the full pattern. The skeleton:
import { test as base } from "./fixtures";
export const test = base.extend({
offlineAfterInstall: async ({ page, context }, runFixture) => {
await page.goto("/");
await page.waitForFunction(async () => {
if (!("serviceWorker" in navigator)) return false;
const reg = await navigator.serviceWorker.getRegistration();
return reg?.active?.state === "activated";
});
await context.setOffline(true);
await page.route("**/*", async (route) => {
const sameOrigin = new URL(route.request().url()).origin === new URL(page.url()).origin;
if (sameOrigin) await route.continue();
else await route.abort();
});
await runFixture();
},
});
Fixture: throttled network
import { test as base } from "./fixtures";
export const test = base.extend({
throttled: [
async ({ context }, runFixture) => {
await context.route("**/*", async (route) => {
await new Promise((r) => setTimeout(r, 200));
await route.continue();
});
await runFixture();
},
{ auto: false },
],
});
For "still online but slow" — verifies the app stays usable while network is degraded. Not offline — see playwright-pwa-offline for that.
Web-first assertions — the only kind
await expect(page.getByRole("heading", { name: /level 1/i })).toBeVisible();
await expect(page.getByRole("button", { name: /save/i })).toBeEnabled();
await expect(page).toHaveURL(/games\/maze\/1/);
expect(await page.getByRole("button").isVisible()).toBe(true);
expect(await page.title()).toBe("house-manager");
await page.waitForTimeout(500);
Tracing for flake
trace: "on-first-retry",
trace: "retain-on-failure-and-retries",
Per-test reduced-motion override
test.use({ reducedMotion: "no-preference" });
test("the celebration animation plays after completion", async ({ page }) => {
await page.goto("/games/maze/1");
await page.getByRole("button", { name: /complete/i }).click();
await expect(page.locator("[data-test='celebration']")).toHaveAttribute("data-played", "true");
});
The default is reduce (deterministic, fast). Opt out for the rare test where the animation IS the assertion. See animejs for the useAnime hook's reduced-motion short-circuit.
Unit-vs-E2E partition (defer to bun-test)
Before writing a Playwright test, ask: "does this need a browser?" If the answer is "no" — pure functions, Zod schema edge cases, atom reducers, IDB migration transform functions, parsers, derived selectors — write a bun test instead. See bun-test. Faster inner-loop feedback; same gate. bun test does NOT require ASK-FIRST. ASK-FIRST is Playwright-only.
If the answer is "yes" — DOM, real IDB, real SW, real network — surface the ASK-FIRST prompt before writing the Playwright test.
Anti-patterns
- Don't write a Playwright test without surfacing the ASK-FIRST prompt — the user owns the structural choices per case. Skipping the prompt calcifies a design choice the user wanted to make.
- Don't surface the ASK-FIRST prompt for a
bun test unit test — the rule is Playwright-only. Unit tests go ahead.
- Don't pick a CSS selector when a role-based selector would work —
getByRole doubles as accessibility coverage. Reach for CSS only when the DOM has no semantics to grab onto.
- Don't assume
prefers-reduced-motion defaults — force it explicitly via use: { reducedMotion: 'reduce' }. Omitting it lets the browser's default leak into the test, and animations add flake (see animejs).
- Don't mock IDB — use real browser IndexedDB. Seed via
page.addInitScript; read via page.evaluate.
- Don't carry IDB state across tests — the default fixture wipes IDB. Tests must be independent, or parallel runs poison each other.
- Don't insert
page.waitForTimeout(...) — fix the locator/assertion. Web-first assertions retry until passing or timeout.
- Don't wrap point-in-time methods inside
expect() — expect(await locator.isVisible()).toBe(true) doesn't retry. Use await expect(locator).toBeVisible().
- Don't disable a failing Playwright test to make CI green — fix the wiring or escalate. The gate is zero-warning.
- Don't use Cypress, Puppeteer, Selenium, or Vitest browser mode — Playwright is the only browser-tier runner in this stack;
bun test is the only unit runner.
Triggers on
playwright fixture, playwright selector, role selector, getByRole, test-id, fresh idb fixture, reduced motion playwright, ask first test