Write, run, and maintain browser-driven system tests for $ARGUMENTS. Apply all rules below strictly.
Composition
/system-tests owns the contract — black box, running system, coordinates as configuration,
isolation, total verdict reporting. This skill is its web realization: layout, configuration,
selector policy, test syntax, and the green oracle.
- The stack skill owns the application under test —
/web-components (SPA), /web-sprinkles,
or /web-static.
/ears-tests owns spec-derived expectations: one test.describe per requirement group, the
literal Rn.m id in the test title.
/bce owns where things live: one spec file per business component, named after it.
Versus the Chrome DevTools verification loop
/web-static drives the rendered page through Chrome DevTools MCP — console, accessibility
snapshot, viewport, Lighthouse, Baseline. That loop inspects the page as it is right now:
agent-driven, single-shot, leaving no artifact behind. This skill produces a committed suite
re-run by a machine on every change, in three engines, indefinitely.
Neither replaces the other. Rendering, accessibility tree, contrast, and Baseline compliance belong
to the DevTools loop; user-visible behavior, and its survival across changes, belongs here.
Where a project has both, green means both.
The public surface of a web UI is its accessibility tree
/system-tests permits assertions through the public surface only. For an HTTP service that surface
is the endpoint; for a web UI it is everything the user can perceive and operate — which is exactly
what the accessibility tree exposes. So the selector policy is a consequence of the contract, not a
style preference:
- Locate by role, label, text, or placeholder:
getByRole, getByLabel, getByText, getByPlaceholder.
- CSS selectors, ids, class names,
nth-child, DOM shape, shadow-root piercing, and store internals
are implementation. A test bound to them fails on a refactor that changed nothing a user can see —
which is a false alarm, and false alarms are what kill suites.
The payoff shows up when a locator finds nothing: an element with no accessible name is a defect in
the application — a missing <label>, a button whose text is an icon, a heading that is a styled
<div>. Fix the markup per /web-conventions. Reaching for a CSS selector instead hides a real
accessibility bug behind a passing test.
Project layout
project/
├── app/src/ # the application — stack skill's concern
└── tests/ # the suite; never shipped with the app
├── package.json
├── playwright.config.js
├── fixtures.js # only when coverage is enabled
└── tests/
└── <business-component>.spec.js
Setup, once:
npm install -D @playwright/test
npx playwright install # add --with-deps on CI
Tests live outside the served root so the suite is never deployed, and the application keeps its
zero-dependency, no-build-system property — the harness is not the artifact.
Configuration
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'java zws ../app/src --single',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
baseURL, and page.goto('/') in tests. A URL literal repeated in every test is the
hardcoded coordinate /system-tests forbids: the suite then runs against one machine only.
Port 3000 is the default because that is where the dev server already listens; BASE_URL
retargets the whole suite at a deployed preview or a migration target without touching a test.
webServer with reuseExistingServer. While authoring, your zws is already up on 3000 and
Playwright attaches to it — the suite exercises the same server you are clicking through, with
live reload intact. On CI nothing is listening, so Playwright starts it. One configuration, no
flag to remember, no second port.
--single serves index.html for extension-less unknown paths, which client-side routing
needs for deep links (/bookmarks/edit/3). Drop it for /web-static and /web-sprinkles sites:
there a real file backs every URL, and the fallback would turn a broken link into a false 200.
- Three engines. A stack built on web standards claims three engines, so it is tested in three.
A test that passes only in Chromium is evidence of a feature below Widely Available; the fix is
the
/web-conventions Baseline policy, never the removal of a project.
trace: 'on-first-retry' makes a CI failure debuggable without a reproduction attempt —
npx playwright show-trace replays the run.
Writing tests
- One
test per user-visible outcome, named for the outcome ('edits a bookmark label'), not for
the mechanics.
- Arrange through the UI. Reaching into
localStorage, the store, or a backend to plant state
is exactly the internal access the black-box contract excludes — and it silently stops testing the
path a user takes to create that state. When arranging through the UI is too slow to bear,
declare the seed data explicitly rather than assuming it.
- Assert with web-first assertions —
await expect(locator).toBeVisible(),
.toHaveText(), .toHaveValue(). They retry until they pass or time out, so the wait is bounded
by the actual event, not by a guess.
- No sleeps.
waitForTimeout is both slower than an auto-waiting assertion on a fast machine
and shorter than the truth on a loaded one — it is the standard source of flakiness. If a test
needs a wait that assertions cannot express, wait for the observable event (waitForURL,
expect(locator).toBeVisible()), never for a duration.
fill() by default; pressSequentially only when keystrokes matter. fill() sets the value
and emits one input event, which is all a plain store-bound handler needs. Per-keystroke entry
is required when the behavior under test depends on the intermediate states — typeahead,
debounce, as-you-type validation, character counters — and there the delay is part of the test,
not decoration.
- Isolation comes from the default fixture. Each test gets a fresh browser context, so cookies,
localStorage, and IndexedDB start empty; the /system-tests isolation rule holds as long as
tests do not share a page or a context between them. A suite that needs shared state between
tests is a suite whose tests are steps of one test.
- Stubbing marks the boundary.
page.route may stand in for a backend outside the system under
test. It redraws the black-box boundary, so state which service is stubbed in the test name or a
comment — an undeclared stub reads as end-to-end coverage that does not exist.
- Never commit
test.only — forbidOnly fails the CI run rather than quietly testing one case.
Spec-derived tests
When the expectations come from an /sbce capability spec, /ears-tests owns the transform: one
test.describe per requirement group Rn, one test per statement Rn.m, with the literal id
leading the test title so it appears in reports and greps against the spec. The concrete shape is
in that skill's references/realizations.md (Playwright section). This skill still governs how
those tests locate, assert, and run.
Coverage — one spec source, opt-in run
Coverage answers "what did the suite never touch". It informs; it never gates. Green is the suite
passing.
page.coverage is Chromium-only, so coverage is a separate, single-engine run over the same
spec files — never a forked copy of them. A duplicated suite drifts from its original within a
few commits, and the copy that silently stopped matching is the one still reporting green.
Keep the specs importing from a local module that decides what test means:
// tests/fixtures.js
module.exports = process.env.COVERAGE
? require('./coverage-fixtures')
: require('@playwright/test');
// tests/tests/bookmarks.spec.js
const { test, expect } = require('../fixtures');
coverage-fixtures.js extends the page fixture — start JS and CSS coverage with
resetOnNavigation: false before handing the page to the test, collect and add it to a
module-level Monocart report afterwards, and generate the report in globalTeardown. Consult the
monocart-coverage-reports documentation for the current API shape before writing it.
Overriding the fixture rather than the tests is what keeps the single spec source: the test bodies
never learn that coverage exists, and the isolation rule survives — no shared page, no serial mode.
Run it with COVERAGE=1 npx playwright test --project=chromium.
Running, and green
npx playwright test — the suite, all engines.
npx playwright test --ui — while authoring: time-travel, locator picker, watch mode.
npx playwright show-report — the last HTML report.
Green = every test passes in every configured project. Report per test, engine included; a skip
is a verdict that must be stated with its reason, never an omission. Anything less than all-green
is red.
Run the suite after every change to application code and fix before moving on — a suite consulted
only before a release stops being an oracle and becomes archaeology. When the stack skill also
defines a Chrome DevTools loop, both must be green.
What NOT to do
- Do not locate by CSS selector, id, class, DOM position, or through a shadow root
- Do not read or write the store,
localStorage, or a database to arrange or assert
- Do not use
waitForTimeout or any sleep
- Do not repeat a URL literal in tests —
baseURL plus page.goto('/')
- Do not fork the spec files for a second configuration
- Do not gate green on a coverage number
- Do not drive unit-level concerns through the browser — a pure function's edge cases are not a
system test, they are ten seconds of browser startup buying nothing
1---2name: web-system-tests3description: The web realization of the system-tests contract — browser-driven system tests with Playwright against a running frontend. Owns the project layout (`tests/`), the Playwright configuration (`baseURL` plus `webServer`, cross-engine projects), the role- and label-based selector policy, code coverage as an opt-in second run, and the green oracle plus its after-every-change loop. Composes with `system-tests`, `ears-tests` (spec-derived expectations), and the stack skill that owns the application (`web-components`, `web-sprinkles`, `web-static`). Use whenever a web frontend needs end-to-end, browser, or system tests written, run, reviewed, or repaired — triggers on "Playwright", "e2e test", "end-to-end test", "browser test", "system test for the frontend", "test the UI", "cross-browser test", "test coverage for the frontend", "run the e2e tests", "why is this test flaky". Not for auditing a rendered page's markup, accessibility, or Baseline compliance — that is the Chrome DevTools loop in `web-static`.4---56Write, run, and maintain browser-driven system tests for $ARGUMENTS. Apply all rules below strictly.78## Composition910- `/system-tests` owns the contract — black box, running system, coordinates as configuration,11 isolation, total verdict reporting. This skill is its **web realization**: layout, configuration,12 selector policy, test syntax, and the green oracle.13- The stack skill owns the application under test — `/web-components` (SPA), `/web-sprinkles`,14 or `/web-static`.15- `/ears-tests` owns spec-derived expectations: one `test.describe` per requirement group, the16 literal `Rn.m` id in the test title.17- `/bce` owns where things live: one spec file per business component, named after it.1819### Versus the Chrome DevTools verification loop2021`/web-static` drives the rendered page through Chrome DevTools MCP — console, accessibility22snapshot, viewport, Lighthouse, Baseline. That loop inspects **the page as it is right now**:23agent-driven, single-shot, leaving no artifact behind. This skill produces a **committed suite**24re-run by a machine on every change, in three engines, indefinitely.2526Neither replaces the other. Rendering, accessibility tree, contrast, and Baseline compliance belong27to the DevTools loop; user-visible **behavior, and its survival across changes**, belongs here.28Where a project has both, green means both.2930## The public surface of a web UI is its accessibility tree3132`/system-tests` permits assertions through the public surface only. For an HTTP service that surface33is the endpoint; for a web UI it is everything the user can perceive and operate — which is exactly34what the accessibility tree exposes. So the selector policy is a consequence of the contract, not a35style preference:3637- Locate by role, label, text, or placeholder: `getByRole`, `getByLabel`, `getByText`, `getByPlaceholder`.38- CSS selectors, ids, class names, `nth-child`, DOM shape, shadow-root piercing, and store internals39 are implementation. A test bound to them fails on a refactor that changed nothing a user can see —40 which is a false alarm, and false alarms are what kill suites.4142The payoff shows up when a locator finds nothing: an element with no accessible name is a defect in43the application — a missing `<label>`, a button whose text is an icon, a heading that is a styled44`<div>`. Fix the markup per `/web-conventions`. Reaching for a CSS selector instead hides a real45accessibility bug behind a passing test.4647## Project layout4849```50project/51├── app/src/ # the application — stack skill's concern52└── tests/ # the suite; never shipped with the app53 ├── package.json54 ├── playwright.config.js55 ├── fixtures.js # only when coverage is enabled56 └── tests/57 └── <business-component>.spec.js58```5960Setup, once:6162```63npm install -D @playwright/test64npx playwright install # add --with-deps on CI65```6667Tests live outside the served root so the suite is never deployed, and the application keeps its68zero-dependency, no-build-system property — the harness is not the artifact.6970## Configuration7172```js73// @ts-check74const { defineConfig, devices } = require('@playwright/test');7576module.exports = defineConfig({77 testDir: './tests',78 fullyParallel: true,79 forbidOnly: !!process.env.CI,80 retries: process.env.CI ? 2 : 0,81 workers: process.env.CI ? 1 : undefined,82 reporter: 'html',83 use: {84 baseURL: process.env.BASE_URL ?? 'http://localhost:3000',85 trace: 'on-first-retry',86 },87 webServer: {88 command: 'java zws ../app/src --single',89 url: 'http://localhost:3000',90 reuseExistingServer: !process.env.CI,91 },92 projects: [93 { name: 'chromium', use: { ...devices['Desktop Chrome'] } },94 { name: 'firefox', use: { ...devices['Desktop Firefox'] } },95 { name: 'webkit', use: { ...devices['Desktop Safari'] } },96 ],97});98```99100- **`baseURL`, and `page.goto('/')` in tests.** A URL literal repeated in every test is the101 hardcoded coordinate `/system-tests` forbids: the suite then runs against one machine only.102 Port 3000 is the default because that is where the dev server already listens; `BASE_URL`103 retargets the whole suite at a deployed preview or a migration target without touching a test.104- **`webServer` with `reuseExistingServer`.** While authoring, your `zws` is already up on 3000 and105 Playwright attaches to it — the suite exercises the same server you are clicking through, with106 live reload intact. On CI nothing is listening, so Playwright starts it. One configuration, no107 flag to remember, no second port.108- **`--single`** serves `index.html` for extension-less unknown paths, which client-side routing109 needs for deep links (`/bookmarks/edit/3`). Drop it for `/web-static` and `/web-sprinkles` sites:110 there a real file backs every URL, and the fallback would turn a broken link into a false 200.111- **Three engines.** A stack built on web standards claims three engines, so it is tested in three.112 A test that passes only in Chromium is evidence of a feature below Widely Available; the fix is113 the `/web-conventions` Baseline policy, never the removal of a project.114- **`trace: 'on-first-retry'`** makes a CI failure debuggable without a reproduction attempt —115 `npx playwright show-trace` replays the run.116117## Writing tests118119- One `test` per user-visible outcome, named for the outcome (`'edits a bookmark label'`), not for120 the mechanics.121- **Arrange through the UI.** Reaching into `localStorage`, the store, or a backend to plant state122 is exactly the internal access the black-box contract excludes — and it silently stops testing the123 path a user takes to create that state. When arranging through the UI is too slow to bear,124 declare the seed data explicitly rather than assuming it.125- **Assert with web-first assertions** — `await expect(locator).toBeVisible()`,126 `.toHaveText()`, `.toHaveValue()`. They retry until they pass or time out, so the wait is bounded127 by the actual event, not by a guess.128- **No sleeps.** `waitForTimeout` is both slower than an auto-waiting assertion on a fast machine129 and shorter than the truth on a loaded one — it is the standard source of flakiness. If a test130 needs a wait that assertions cannot express, wait for the observable event (`waitForURL`,131 `expect(locator).toBeVisible()`), never for a duration.132- **`fill()` by default; `pressSequentially` only when keystrokes matter.** `fill()` sets the value133 and emits one `input` event, which is all a plain store-bound handler needs. Per-keystroke entry134 is required when the behavior under test depends on the intermediate states — typeahead,135 debounce, as-you-type validation, character counters — and there the delay is part of the test,136 not decoration.137- **Isolation comes from the default fixture.** Each test gets a fresh browser context, so cookies,138 `localStorage`, and IndexedDB start empty; the `/system-tests` isolation rule holds as long as139 tests do not share a `page` or a context between them. A suite that needs shared state between140 tests is a suite whose tests are steps of one test.141- **Stubbing marks the boundary.** `page.route` may stand in for a backend outside the system under142 test. It redraws the black-box boundary, so state which service is stubbed in the test name or a143 comment — an undeclared stub reads as end-to-end coverage that does not exist.144- Never commit `test.only` — `forbidOnly` fails the CI run rather than quietly testing one case.145146## Spec-derived tests147148When the expectations come from an `/sbce` capability spec, `/ears-tests` owns the transform: one149`test.describe` per requirement group `Rn`, one `test` per statement `Rn.m`, with the literal id150leading the test title so it appears in reports and greps against the spec. The concrete shape is151in that skill's `references/realizations.md` (Playwright section). This skill still governs how152those tests locate, assert, and run.153154## Coverage — one spec source, opt-in run155156Coverage answers "what did the suite never touch". It informs; it never gates. Green is the suite157passing.158159`page.coverage` is Chromium-only, so coverage is a separate, single-engine run over **the same160spec files** — never a forked copy of them. A duplicated suite drifts from its original within a161few commits, and the copy that silently stopped matching is the one still reporting green.162163Keep the specs importing from a local module that decides what `test` means:164165```js166// tests/fixtures.js167module.exports = process.env.COVERAGE168 ? require('./coverage-fixtures')169 : require('@playwright/test');170```171172```js173// tests/tests/bookmarks.spec.js174const { test, expect } = require('../fixtures');175```176177`coverage-fixtures.js` extends the `page` fixture — start JS and CSS coverage with178`resetOnNavigation: false` before handing the page to the test, collect and add it to a179module-level Monocart report afterwards, and generate the report in `globalTeardown`. Consult the180`monocart-coverage-reports` documentation for the current API shape before writing it.181182Overriding the fixture rather than the tests is what keeps the single spec source: the test bodies183never learn that coverage exists, and the isolation rule survives — no shared page, no serial mode.184185Run it with `COVERAGE=1 npx playwright test --project=chromium`.186187## Running, and green188189- `npx playwright test` — the suite, all engines.190- `npx playwright test --ui` — while authoring: time-travel, locator picker, watch mode.191- `npx playwright show-report` — the last HTML report.192193**Green** = every test passes in every configured project. Report per test, engine included; a skip194is a verdict that must be stated with its reason, never an omission. Anything less than all-green195is red.196197Run the suite after every change to application code and fix before moving on — a suite consulted198only before a release stops being an oracle and becomes archaeology. When the stack skill also199defines a Chrome DevTools loop, both must be green.200201## What NOT to do202203- Do not locate by CSS selector, id, class, DOM position, or through a shadow root204- Do not read or write the store, `localStorage`, or a database to arrange or assert205- Do not use `waitForTimeout` or any sleep206- Do not repeat a URL literal in tests — `baseURL` plus `page.goto('/')`207- Do not fork the spec files for a second configuration208- Do not gate green on a coverage number209- Do not drive unit-level concerns through the browser — a pure function's edge cases are not a210 system test, they are ten seconds of browser startup buying nothing