Guides
Spec conventions
Committed specs are the durable asset. Write them so someone else can fix them in six months.
File layout and naming #
<spec_dir>/<feature>/<CASE-ID>.spec.tsThe test title must begin with the case ID. parse-report.mjs maps results back to cases by that prefix; without it a result cannot be published.
Keep spec paths to letters, digits, -, _, . and /. Playwright reads a path on its command line as a pattern, so a directory such as (shop) or [id] matches nothing and the spec silently does not run; CI fails the job when that happens.
What QA approves is the spec plus every local file it imports, and the case's steps, expected outcomes, preconditions and fixture. Editing a shared helper sends every spec that imports it back for review, and drops them from CI until QA re-approves. Keep helpers small and stable, and put each case's assertions in its own spec.
test('CHECKOUT-ORDER-001 places an order with a single in-stock item', async ({ page }) => { … });Shared fixture specs are the one exception, with exact titles: FIXTURE <name> for the setup and FIXTURE <name> teardown for the teardown. Any other title starting with FIXTURE is dropped as unmapped. See "Shared fixtures" below.
The write guard #
When a feature's mutation.policy is anything but unrestricted, or the run targets production, specs import test and expect from the write guard rather than @playwright/test. /qa-pilot:run-tests copies write-guard.mjs and write-guard.fixture.ts into the spec directory.
import { test, expect } from '../write-guard.fixture';Nothing else changes in the spec. Blocked clicks do nothing, blocked requests are aborted, and each is recorded in the attempt's writes.json, which the publish gate re-reads. Under read-only any recorded write refuses the run; under scoped-write a blocked one does.
Rules the guard imposes on specs:
- Drive the app through the page. Under any policy but
unrestricted, these are refused because the guard cannot see their requests: therequestfixture; the send methods ofpage.requestandcontext.request;request.newContext()(alsoplaywright.request.newContext()); Node's globalfetch();browser.newContext()andbrowser.newPage()(in tests and inbeforeAll/beforeEach); and every browser type'slaunch,launchPersistentContext,launchServer,connectandconnectOverCDP. Never usenode:http,node:httpsornode:netin a guarded spec: they are not refused, but a request through them is a write nobody records. A cross-app spec that needs a second context callsnewGuardedContext(options)from the fixture. Sharing one page across serial tests frombeforeAllis not possible; use the per-testpage. - Under
scoped-write, name what you create with the prefix. A write control is allowed only in a row or dialog that showsmutation.prefix, or on a page inside a scope the spec declared withwriteGuard.setScope({ url: '<regex>' }). Only the spec can set the scope; the page cannot widen it. - Your own route handlers still work.
route.fulfill(),route.fallback(),route.continue(),unroute(...)andunrouteAll()behave as usual; none removes the guard's own route. Acontinueorfallbackthat changes the method, URL or body is judged again first, so a handler cannot turn a read into a write. - The page cannot slip a write past the route. A
keepalivefetch loses its keepalive flag so it is routed and judged, andSharedWorkeris refused and recorded as a blocked write. - Every guarded test gets a browser context, even one that never asks for
page. - Native dialogs are declined under
read-only, since aconfirm()before a delete is a write path.
Shared fixtures #
A fixture is one entity several cases work inside, declared in cases.yaml under fixtures and named by each case's fixture: key.
<spec_dir>/<feature>/FIXTURE-<name>.setup.ts title: FIXTURE <name>
<spec_dir>/<feature>/FIXTURE-<name>.teardown.ts title: FIXTURE <name> teardown (teardown: delete only)// FIXTURE-shared-project.setup.ts
import { test, expect, saveFixture } from '../write-guard.fixture';
test('FIXTURE shared-project', async ({ page }) => {
await page.goto('/projects/new');
await page.getByTestId('project-name').fill(`QA_TEST_${Date.now()}`); // the scoped-write prefix
await page.getByRole('button', { name: 'Create' }).click();
await expect(page).toHaveURL(/\/projects\/[\w-]+$/);
saveFixture('shared-project', { url: page.url() });
});
// CHECKOUT-ORDER-003.spec.ts, a case with `fixture: shared-project`
import { test, expect, loadFixture } from '../write-guard.fixture';
test('CHECKOUT-ORDER-003 renames a line item inside the shared project', async ({ page }) => {
const { url } = loadFixture<{ url: string }>('shared-project');
await page.goto(url);
…
});saveFixture and loadFixture use $QA_PILOT_FIXTURE_DIR/<name>.json (the run's own fixtures/ directory) and throw when the variable is unset.
Setup and teardown run as Playwright projects with an explicit testMatch and retries: 0 (/qa-pilot:run-tests step 5). If a setup fails, its dependents are skipped and become blocked; the publish gate refuses a report where such a dependent carries a verdict. A failed teardown is a publish warning naming the entity left behind. Fixtures are not allowed under read-only.
Authentication #
Specs consume saved profiles, never login flows. Never script a login inside a feature spec; /qa-pilot:setup-profiles owns that.
test.use({ storageState: '.playwright/profiles/storefront-member.json' });Permission cases point at their role's profile.
Selectors #
Use the profile's selectors.testid_attribute:
page.getByTestId('checkout-submit')Order of preference when no test ID exists yet:
- Add the test ID to the app source.
- Role + accessible name:
page.getByRole('button', { name: 'Place order' }). - Label or associated text for form fields.
Never bind to CSS class names, DOM position (div > div:nth-child(3)), or generated IDs. The lint enforces this: playwright/no-raw-locators refuses page.locator('<css or xpath>'), no-nth-methods refuses .first(), .last() and .nth() (a pick by position is also how an AI "fix" loosens a check until it passes), and prefer-native-locators turns locator('[data-testid=x]') into getByTestId('x').
Component-library overlays. Menus, dialogs and select dropdowns (Vuetify, MUI) are teleported to the end of <body>. Scope those queries to the overlay root, not the trigger's ancestor, and wait for the overlay to be visible before interacting. The overlay root usually needs one CSS locator; add that exact selector to no-raw-locators' allowed list in eslint.qa-pilot.config.mjs rather than disabling the rule.
Assertions #
Read the profile before writing a single assertion. When assertions.network_events: forbidden, operations never appear as network events, so waitForResponse never resolves or the test passes unchecked:
// WRONG on such a host: this never fires and never fails.
await page.waitForResponse(r => r.url().includes('/orders'));
// RIGHT: assert what the user can see.
await expect(page.getByTestId('order-confirmation')).toBeVisible();
await expect(page.getByTestId('order-number')).toHaveText(/\d{4,}/);Assert absence as well as presence. A negative case that only checks the error appeared can pass while the action also succeeded:
await expect(page.getByTestId('checkout-error')).toHaveText('Your cart is empty');
await expect(page.getByTestId('order-confirmation')).toBeHidden();Web-first assertions only. expect(locator).toBeVisible() retries; expect(await locator.isVisible()).toBe(true) does not. Never use page.waitForTimeout to synchronize.
The lint enforces these rules. /qa-pilot:run-tests lints every spec and fixture spec with eslint.qa-pilot.config.mjs before a verdict run: playwright/expect-expect, no-wait-for-timeout, no-skipped-test and no-focused-test fail a test with no assertion, a fixed sleep, test.skip or test.only, alongside the selector rules above. Keep assertions in the test body; expect-expect does not see through a helper.
Test data under scoped-write #
Created data outlives the run, and every developer's runs share the environment:
- Name each entity uniquely per run: the prefix, the feature and a timestamp, such as
`QA_TEST_checkout_${Date.now()}`. Find it again by that full name, never by the bare prefix, which also matches every other run's leftovers. - Delete what the case created in its last step, or let a
teardown: deletefixture do it. - Never assert on counts or empty states ("the list shows 3 items", "no projects yet"). Leftovers from earlier runs make them fail from the second run on. Assert on the entity this run created.
Console evidence #
When the profile's evidence.extra includes console_log, attach console output to every test. The gate does not require it (the trace carries the console), but it lets a failure be read without opening the trace.
Put it in a fixture, built on the write guard's test when the feature is guarded. <spec_dir>/fixtures.ts:
import { test as base } from './write-guard.fixture'; // or '@playwright/test' when unguarded
export const test = base.extend<{ consoleLog: void }>({
consoleLog: [async ({ page }, use, testInfo) => {
const lines: string[] = [];
page.on('console', m => lines.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', e => lines.push(`[pageerror] ${e.message}`));
await use();
await testInfo.attach('console.log', {
body: lines.join('\n'),
contentType: 'text/plain',
});
}, { auto: true }],
});
export { expect, saveFixture, loadFixture, newGuardedContext } from './write-guard.fixture';Specs, which sit in <spec_dir>/<feature>/, import test from ../fixtures. Attach the full log, not a filtered one.
Cross-app and eventual consistency #
When a change in one app becomes visible in another after a delay, poll to the profile's declared ceiling:
await expect.poll(
async () => reader.getByTestId('published-item').count(),
{ timeout: profile.cross_app.propagation_window_s * 1000, intervals: [1000, 2000, 5000] }
).toBeGreaterThan(0);Use two browser contexts, one per app, each with its own storageState. These specs live in cross_app.spec_home's spec dir and run on a schedule, not per deploy.
Test data on a shared environment #
Collisions between developers read as flakiness.
- Prefix every entity a spec creates with the run ID:
qa-${runId}-project-1. Underscoped-writethe feature'smutation.prefixcomes first:QA_TEST_${runId}-project-1. - Create data under the executor's own account wherever the tenancy model allows.
- Clean up in
afterEach, written so it succeeds even when the test failed halfway. A read-only feature creates nothing. - When several cases need the same built-up entity, use a shared fixture.
- Never assert on a global count ("there are 3 projects"). Assert on the entity you created.