The test-writing discipline of the handsontable monorepo, ported to the runner's
stack (Playwright + node --test; no Jest, no Jasmine). This file is the single
place the rules live: AGENTS.md keeps only the quick list, and the two skills
(.claude/skills/runner-test-discipline/, .claude/skills/runner-playwright-e2e/)
point back here.
Green is not the goal — correct behavior is. A test that passes but asserts nothing, or asserts the buggy output, is worse than no test: it certifies the bug and reads as coverage.
- The test encodes intent, not the implementation. Write it from the requirement — what the user or the API is supposed to do — not from what the code currently does. Where feasible, write it first, so it is an oracle you cannot accidentally fit to a bug. For E2E, state the user-observable expectation before you wire a single locator.
- When a test is red, decide what is actually wrong — don't chase green. At the feature stage the code is the prime suspect, not the test. Expectation correct, code wrong → fix the code, leave the test alone. Expectation genuinely mis-encoded the intent → tighten the test toward the real behavior, never loosen it to match the current output. Can't tell which is wrong? Re-read the requirement — that is not a signal to relax the test.
- Bug fixes: write the failing test first. Reproduce the bug as a test, watch it fail for the right reason (the missing behavior, not a typo or a dead selector), then fix, then watch the same test pass. A regression test that was never red proves nothing. On a bugfix PR, name the spec that fails without the fix.
- Verify before you say "done". Run the exact test command fresh, read the full output and the exit code, and state the result with that evidence. Banned phrasings: "should work", "this fixes it" without a run, "tested manually, looks fine".
- No hollow assertions. Assert the behavior, not that the code ran. One
meaningful assertion beats five that restate the setup. The canonical local
failure: the MCP containment guard ("only demos the MCP created are updatable
through it") was tested against a local copy of its own predicate instead
of the route's exported check — deleting the guard from
index.tsleft the test green. A test of a security control that cannot fail when the control goes away. Fixed in #201: the predicate becameisMcpCreated()inmcp-create.ts, the route calls it, the test imports it, and a source-grep test pins the route to the export. - Don't mock the unit under test. Mock at the real boundary:
page.route()for the network, in-memory fakes for worker bindings (D1, KV, R2), a structural stub for@cloudflare/sandbox(itscloudflare:imports only exist inside workerd). When you must build a fake, fake the complete real data shape — an incomplete fake gives a false pass.
Never reach green by any of these:
- Deleting or loosening an assertion, or widening a timeout/tolerance, to match what the code emits.
test.skip/test.fixme/it.skip, or focusing withtest.only/it.only(focusing silently drops the rest of the suite).- Wrapping the body in try/catch to swallow a failure.
- Asserting whatever the code happened to produce (a "snapshot of the bug").
- Retries to paper over a real intermittent failure — in CI
retries: 2exists to absorb infrastructure noise, not your race condition. Root-cause it. - Adding an env gate (
E2E_LIVEand friends) to a spec so PR CI stops running it. Gates exist for external dependencies and spend, never for red tests.
Machine-enforced by the presence gate (runner/scripts/check-test-presence.mjs,
the presence job in ci.yml): a PR that changes
runner/{apps,packages,workers}/** source must also change a test.
| The change | The test | Where |
|---|---|---|
| Pure logic — parsers, importers, transpilers, theme codegen, worker helpers | node --test unit |
pipeline/<area>.test.mjs (runs via pnpm test; --experimental-strip-types lets it import .ts directly) |
| A worker route's promise — status codes, response shapes, auth guards | Route-level harness: drive the real default export of workers/api/src/index.ts under node --test, via module hooks (pipeline/fixtures/worker-hooks.mjs) and in-memory binding fakes |
The mcp-routes.test.mjs pattern (#201) |
| Anything a user sees or does in the app — editor, preview, sharing, panels | Playwright spec | e2e/<area>.spec.ts |
Generated content — *.generated.ts, public/ buckets |
Regenerate via the pipeline; the smoke tests (catalog-smoke, frameworks-generated, starter-catalog-smoke) pin the output |
Excluded from the presence gate as source |
A starter's own source, tsconfig*.json or dependencies (examples/<starter>/**) |
Its own package.json build, plus its typecheck script when it ships one — run in a copy outside the repo tree, because a types entry resolves through an ancestor node_modules and an in-tree build stays green on the very defect (DEV-2730) |
.github/workflows/examples-build.yml — frozen-lockfile lane on PRs, handsontable@latest lane weekly (DEV-2737). The fast static half is pipeline/starter-type-roots.test.mjs; build-command-drift.test.mjs keeps BUILD_CONFIG from dropping a type-check the starter performs |
| Pure refactor, no behavior change | No new test | Refactor-only: <reason> commit trailer |
| Tested, but the test lands elsewhere (a named follow-up PR, or an existing spec already proves it) | Declare where | Test-plan: <reason> commit trailer |
Both trailers require a non-empty reason, and reviewers hold you to it — the gate trusts the declaration, the review verifies it.
New spec vs. modify existing: a new feature or endpoint → a new spec; a bug fix → a failing case in the closest existing spec.
SSR hydration, when you touch what we inject into a preview document
(packages/runtime/src/{monitor,scheme,inject-html}.ts): unit tests can pin the
emitted bytes and the receiver's behaviour, but not whether a framework's hydrator
accepts the result — and remix hydrates with hydrateRoot(document, …) on React 18,
which throws the whole document away if <head> holds anything the server did not
render. Run node runner/scripts/ssr-hydration-probe.mjs against a locally served
starter; it puts the real injections and a real shell around it and exits non-zero on
a mismatch. The standing guard is the nightly starter matrix, which is where DEV-2580
was caught — four red remix cells, everything else green.
The default playwright test run is the deterministic PR suite: stubShell()
aborts every Sandpack host (versioned ones included), stubs /api/versions, and neuters the login
redirect. Everything beyond that world is gated. Pick the narrowest gate that
covers the dependency:
| Gate | Use when the spec needs | Runs in | Notes |
|---|---|---|---|
| (none) | Only the built SPA — shell, routing, stubbed network | ci.yml on every PR |
The default. If you can stub it, don't gate it. |
E2E_LIVE=1 |
A real preview mount — the hosted Sandpack bundler or a Tier-2 container | e2e-live.yml (manual + canary/smoke once #189 lands) |
Off by default so an external-bundler outage never blocks merges. |
E2E_BASE_URL |
Worker routes (/api, /d, /embed) — vite preview has none, so the spec self-skips without it |
e2e-live.yml pointed at a deployment |
share-view.spec.ts also needs the permanent fixture demo (FIXTURE_ID) — never revoke it. |
E2E_API_TOKEN |
An authed write round-trip against the deployed API (share-create-live.spec.ts, session-abandoned-create.spec.ts) |
e2e-live.yml |
A persistent API token minted on /api-tokens (ADR-0037). It does not expire, so a token that stops validating fails the run — it means revoked or broken. Absent, the step is skipped. |
E2E_AI=1 |
A live LLM answer (ai-live.spec.ts, lands with #187) |
e2e-live.yml weekly canary |
Real budget, shared 8/min-per-IP rate bucket — a 429 skips rather than fails. |
E2E_STARTER_MATRIX=1 |
Every starter × major through a live container session | e2e-starter-matrix.yml (manual + monthly) |
Serialized against the global container cap; never fold matrix cases into the PR suite. |
E2E_TELEMETRY=1 |
A VITE_TELEMETRY_LOCAL=1 build (contract §10) |
page.route-mocked specs (telemetry-faro.spec.ts, example-analytics.spec.ts) run in ci.yml's e2e-telemetry job, every PR. Combined with E2E_LIVE=1, telemetry-metrics.spec.ts needs a real local API worker + Docker instead, and runs in e2e-o11y-local.yml (manual + nightly + o11y-path PRs) |
Same gate, two very different cost profiles — mocked network vs. a real wrangler dev — hence two different workflow homes. |
E2E_O11Y_LOCAL=1 |
The whole local o11y stack: Docker compose (ClickHouse/MinIO), a real o11y wrangler dev, a real API wrangler dev, applied D1 migrations |
e2e-o11y-local.yml (manual + nightly + o11y-path PRs) |
o11y-local.spec.ts proves telemetry reaches the real ingest pipeline end to end, never mocked — the standing check that T02–T08's wiring still holds. |
| (none, plain Node + Docker) | The GrafanaBox image and its stop/restore ledger against a real compose stack | o11y-box-nightly.yml (manual + nightly + PRs touching containers/o11y/** or workers/o11y/src/box.ts) |
The stop-roundtrip job builds the box image and runs containers/o11y/local/stop-roundtrip.mjs (no pnpm install). The idle-stop job (schedule and manual only) runs containers/o11y/local/idle-stop.mjs: wrangler dev with O11Y_SLEEP_AFTER=20s, one visit, then idle until the container exits 0 and state/wakes/<id>/clean exists; it does not cover the 10-minute quiet-stop or the 4 h hard cap. |
Two rules that keep gates honest:
- A gated spec must self-skip with instructions:
test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to …")— a silent skip reads as a pass. - Every gate must have a workflow home. A gated spec no workflow names is
dead code that reads as coverage: the
E2E_LIVEspecs existed for nine days (2026-07-15 → 2026-07-24) beforee2e-live.ymlran them, and both DEV-2129 regressions shipped invisible to CI in exactly that window. When you add a gated spec, name it (or its gate) in the workflow that runs it, in the same PR.
These are the proven local patterns. Reach for them before inventing one.
getComputedStyleand a real pointer, not screenshots (ADR-0026). A syntheticmouseoverdoes not fire CSS:hover, and a screenshot cannot tell a subtle live hover from a dead one. Move the real pointer (page.mouse/hover()), then assert the computed style.- Prove by network count. Collect requests in the
page.route()handler and assert the exact fetch list —docs-examples.spec.tsproves cache isolation by asserting the manifest was fetched once per bucket, in order. "The data showed up" cannot distinguish a cache hit from a silent refetch. - Hook by the documented
data-*test contracts, never visible text. Preview readiness isdata-preview-statuson the Preview section (PreviewPane.tsx); the active editor pane is[data-pane-active="true"]; workspace contents come from thewindow.__HOT_FILES__hook (App.tsx). These are documented contracts — extend them in the component when a spec needs a new hook, and mark them as such. expect(...).toPass()/expect.poll()around debounces. The Style panel writes ride a 250 ms debounce; poll the condition, neverwaitForTimeout. Web-first assertions everywhere else — a missingawaitis the sneakiest flake.- Never read long files via
.cm-content. CodeMirror virtualizes long documents — the DOM holds only the lines on screen. Read file contents throughworkspaceFiles()(e2e/helpers.ts); type via the CodeMirror view dispatch, not synthetic keystrokes into a virtualized contenteditable. - Container-pool discipline. Tier-2 sessions share ten global slots with
real traffic (
Sandbox max_instances; it was five before DEV-2909, and the raise is headroom for visitors, not a test budget). Every spec that boots one usestrackSessions()and cleans up infinally— a leaked session squats a slot for its whole idle window. The same arithmetic is why deployed live runs are nevercancel-in-progress: a cancelled run strands its containers. - Persistence needs a reload-and-remeasure. A within-page test cannot see a
persistence bug. And never
addInitScripta storage clear — it re-runs onpage.reload()and silently defeats the assertion it was meant to isolate (each test already gets a fresh context). - Source-grep pin tests (
theme-codegen.test.mjsstyle): when a behavior test proves a control works, a companion grep-the-source test can pin the implementation to the exported control so an inline copy cannot drift back in. Use both layers — #201 verified them independently (re-inlining the check fails the grep test; neutering the guard behindfalse &&fails the 403 test).
Named, so nobody repeats them:
- The hollow guard test (fixed in
#201, DEV-2501 G1): the
containment test declared its own local
fromMcppredicate — a copy of the guard — instead of importing the route's check. It asserted a copy of itself; deleting the control kept it green. - The untested router (#201, G2): nothing imported the worker's router, so every route-level promise (status codes, 403s, 410, response shapes) was untested — while the file looked covered because its helpers were.
- Orphaned gated specs:
E2E_LIVEspecs with no workflow that ran them (see the taxonomy above). Gated-but-never-run equals deleted, with worse optics. - The empty failure artifact: CI uploaded
playwright-report/on failure for months, but thegithubreporter never writes that directory — the artifact was empty since the workflow was added (fixed in DEV-2203 by adding thehtmlreporter). Verify the evidence chain, not just the assertion. - The init-script storage clear: an
addInitScriptthat cleared a storage key to "isolate" a test also ran onpage.reload(), silently defeating the persistence assertion it served. - The falsified safety comment:
cancel-in-progress: truewas justified by "live tests hold no server-side sessions" — true when written, falsified by the very next commit without touching the sentence, stranding containers. A comment that encodes an invariant needs a test or a gate, not trust.
runner/scripts/check-test-presence.mjs — the presence job in ci.yml, on
every PR (skipped on workflow_call: a master push already passed it in its PR).
- Trigger: any changed
runner/{apps,packages,workers}/**/*.{ts,tsx}, excluding*.d.ts,*.generated.ts, andpublic/. - Satisfied by: any changed
runner/e2e/**/*.spec.tsorrunner/pipeline/**/*.test.mjs— added, edited, or deleted. - Escape hatch: a
Refactor-only: <reason>orTest-plan: <reason>trailer on any commit in the PR range. Non-empty reason required. The gate trusts the trailer; the reviewer verifies it — an abused trailer is a review comment, not a config change.
Run it locally before pushing: node runner/scripts/check-test-presence.mjs master.
cd runner
pnpm test # node --test units (builds the runtime first, by design)
pnpm e2e e2e/<your-spec>.spec.ts # only the spec you created or changed
node scripts/check-test-presence.mjs master # the gate, locallyRun only the specs you created or changed — the full deterministic suite is PR CI's job, and the gated suites belong to their workflows.