Testing
How the unit, story and e2e test layers fit together, the browser sweeps of the running site, and the traps that make a green gate lie.
The test stack
The site tests a component at three levels, each answering a different question. They are not redundant — a value that passes a jsdom unit test can still resolve wrongly in a real browser, and a story that renders can still trap focus. Know which layer owns which assertion before you reach for one.
| Layer | Runner | Environment | Answers |
|---|---|---|---|
Unit tests (*.test.tsx) |
Vitest + Testing Library | jsdom | Does it render the right DOM, wire props, and set attributes? |
Story tests (*.stories.tsx) |
Vitest browser mode (headless Chromium) | real browser | Do the stories mount, do play interactions pass, is axe clean? |
Component e2e (*.e2e.ts) |
Playwright (6 browser projects) | real browser + viewport | Do tokens resolve to the right computed values across engines and breakpoints? |
The dividing line is deliberate: unit/story tests stay in Vitest; e2e asserts only what needs a real end-to-end browser pass — computed token resolution, per-instance override resolution, interaction states, slot dimensions, RTL, and the Ghost breakpoints. Don't reassert in e2e what a jsdom render already proves.
Vitest unit tests (jsdom)
Live in @v4/ghost-core (and the sibling component packages) alongside each
component as {kebab-name}.test.tsx — never .spec.tsx, that is the house
convention. The environment is jsdom, wired in packages/ghost-core/vitest.config.ts:
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
},
vitest.setup.ts imports @testing-library/jest-dom/vitest, so every test has
the jest-dom matchers (toBeInTheDocument(), toHaveAttribute(),
toHaveClass()). Prefer them over raw DOM assertions — toBeInTheDocument()
reads better and fails better than expect(el).not.toBeNull(). See
Unit tests for the required-coverage checklist.
Vitest is the runner beyond the component packages too: every package with logic
— @v4/content-app, @v4/basketball-app, @v4/video-app, @v4/model,
@v4/competition-engine, the integrations (wsc, eltv, ecal, analytics,
cortex-sso) — and apps/web itself each have a test script running
vitest run, all picked up by pnpm test.
Storybook browser story tests
apps/storybook runs every story as a real-browser test through Vitest's
browser mode (headless Chromium via Playwright), configured in
apps/storybook/vitest.config.ts with the @storybook/addon-vitest plugin. A
story is therefore three things at once: documentation, a Chromatic
visual-regression baseline, and an executable test. Interactive stories carry a
play function; every component story sets parameters: { a11y: { test: "error" } },
so an axe violation fails the run. See
Stories & visual testing.
The setup file is apps/storybook/.storybook/vitest.setup.ts, and three things in
it decide what the suite measures:
- It pins the Ghost theme (
localStorage["ph-club-theme"] = "ghost"). The Storybook UI defaults to the Euro League theme; the story suite asserts the Ghost 2.0 contract. It also marks the table swipe hint as played (ph:table-nudge), so no table moves under a play function. - It marks
<html data-motion-final>, so the entrance hooks stand down and play functions and axe read FINAL states — never a card held at opacity 0. The motion itself is tested in the e2e, which never sets the mark. - It calls
setProjectAnnotations([a11yAddonAnnotations, previewAnnotations]). Dropping the a11y annotation silently turns everya11y: { test: "error" }into a no-op.
The first run needs the browser installed once:
pnpm --filter @v4/storybook exec playwright install chromium
Playwright component e2e
apps/e2e (@v4/e2e) drives the built Storybook story iframes with Playwright
across six browser projects. It asserts what jsdom cannot see: the resolved
computed value of a token after the whole --ph__* custom-property chain has
cascaded, at the real Ghost breakpoints, in more than one engine. See
Component e2e.
What to run
# Every package's unit suite (turbo-cached)
pnpm test
# One package
pnpm --filter @v4/ghost-core test
# A single component's unit tests
pnpm --filter @v4/ghost-core exec vitest run src/components/badge/
# Storybook story tests (browser mode; the script retries once: `vitest run || vitest run`)
pnpm --filter @v4/storybook test
# Component e2e — boots/reuses Storybook on :6006
pnpm --filter @v4/e2e test
pnpm test is the turbo-cached fan-out over package unit suites. The Storybook
browser suite and the Playwright e2e run under their own package scripts because
they need a browser and, for e2e, a served Storybook build.
Before you push
pnpm verify runs the cheap gates (Prettier, ESLint zero-warnings, types,
the package unit suites, the Figma bridge check check-bridges, composition
drift, the app codegens) — it does not run the browser suites (e2e and the
Storybook story suite). The unit suites were missing from verify until
29 Sep 2026, and it printed "all gates green" while a package suite was red.
In CI, a pull request runs the unit suites only (.github/workflows/ci.yml:
pnpm turbo test --filter=!@v4/e2e --filter=!@v4/storybook). The Storybook story
suite and the full e2e matrix (E2E_FULL_MATRIX=1, eight shards) run in the
nightly (.github/workflows/e2e-nightly.yml). A browser-suite regression
therefore surfaces the morning after — run the layer you touched locally before
relying on it.
None of the three layers looks at the running site. For that — text the same colour as its surface, clipped content, page overflow, console errors — see Browser sweeps.
Unit tests
Vitest + Testing Library, in jsdom, alongside every component as
{kebab-name}.test.tsx. The job of a unit test is narrow and cheap: prove the
component renders the right DOM, wires each prop, and lands its attributes. It
does not prove computed token values resolve correctly — that needs a real
browser and belongs in e2e.
File structure
Import the component from its barrel (./index), never the .component.tsx
directly; import mock props from the local .mock.ts; import TEST_ID from the
central packages/ghost-core/src/constants.ts.
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { TEST_ID } from "../../constants";
import { badgeMockProps } from "./badge.mock";
import { Badge } from "./index";
describe("Badge", () => {
it("renders with default props", () => {
render(<Badge {...badgeMockProps} />);
expect(screen.getByTestId(TEST_ID.BADGE.COMPONENT)).toBeInTheDocument();
});
});
Use import type for type-only imports — ESLint's consistent-type-imports is
enforced.
Required coverage checklist
Every component's test file covers, at minimum:
- Renders with default props.
- Renders each key content prop (title, description, text …).
- Conditionally renders optional props (a slot is absent until passed).
- Applies the
classNameprop. - Sets the
data-componentattribute correctly. - Token overrides via the
tokensprop — assert the--ph__{component}-*custom property lands on the root element. - Interactive components:
onClickfires when enabled, does not fire when disabled, andaria-disabledis set on the disabled state. - Themeable components: renders under a non-default
colourSetwithout error — and, where the component supports only a subset of the ramp, that an unsupported set degrades to the component's default (see below).
The tokens assertion reads the emitted custom property straight off the root,
which is exactly what a Euro League override flows through:
it("supports token overrides via the tokens prop", () => {
render(<Badge {...badgeMockProps} tokens={{ borderRadius: "12px" }} />);
const root = screen.getByTestId(TEST_ID.BADGE.COMPONENT);
expect(root.style.getPropertyValue("--ph__badge-border-radius")).toBe("12px");
});
Colour-set degradation
vanilla-extract recipes silently drop an unknown variant value and render the element unstyled, so a themeable component that supports only part of the ramp must resolve an unsupported set to its default before calling the recipe. Assert it, because the Storybook colour-set toolbar forces arbitrary sets onto every story constantly:
it("degrades unsupported colour sets to the default set", () => {
const { rerender } = render(<Badge {...badgeMockProps} colourSet="colourSet3" />);
const unsupportedClasses = screen.getByTestId(TEST_ID.BADGE.COMPONENT).className;
rerender(<Badge {...badgeMockProps} colourSet="default" />);
const defaultClasses = screen.getByTestId(TEST_ID.BADGE.COMPONENT).className;
expect(unsupportedClasses).toBe(defaultClasses);
});
Test IDs: the testIds() pattern
Test IDs are centralised, never invented inline. constants.ts builds each
component's IDs with testIds(componentName, elements) — the root is the kebab
name, children are root__child in BEM style:
export const TEST_ID = {
BADGE: testIds("badge", {
TEXT: "text",
LEADING_ELEMENT: "leadingElement",
TRAILING_ELEMENT: "trailingElement",
}),
};
Constants are SCREAMING_SNAKE_CASE; the generated IDs are kebab-case. A
component defaults testId = TEST_ID.X.COMPONENT and derives its child IDs from
the actual testId prop — falling back to the constants only when the
default is in play:
testId === TEST_ID.BADGE.COMPONENT ? TEST_ID.BADGE.TEXT : `${testId}__text`;
That is why the same component can be addressed both by its constant and by a caller-supplied id, and both derive their children consistently — assert both paths:
it("derives child test ids from an explicit testId prop", () => {
render(<Badge {...badgeMockProps} testId="hero-badge" leadingElement={<svg />} />);
expect(screen.getByTestId("hero-badge")).toBeInTheDocument();
expect(screen.getByTestId("hero-badge__leading-element")).toBeInTheDocument();
});
The full workflow — adding constants and wiring data-testid — is the
/add-test-ids skill.
Mock files
{kebab-name}.mock.ts exports one complete, valid props object, typed against
the component's props. Keep it .ts unless the mock genuinely needs JSX (then
.tsx). Use sports-themed sample data (basketball, as in @v4/ghost-basketball) and https://placehold.co/{w}x{h}
for image URLs — never brand or third-party URLs.
Mocks are public package exports — re-exported through the component barrel
(and therefore @v4/ghost-core) so stories and app tests consume the same
fixtures. They are dev fixtures, tree-shaken from any app bundle that doesn't
import them.
Storybook layers its own .mock.tsx on top of the package mock to add JSX slot
content; that Storybook mock spreads the package {camelName}MockProps and only
adds slot JSX — it never re-declares the plain data. See
Stories & visual testing.
Running a single component
# One component's unit tests
pnpm --filter @v4/ghost-core exec vitest run src/components/badge/
# Watch mode across the package
pnpm --filter @v4/ghost-core test:watch
# Type-check and lint the package
pnpm --filter @v4/ghost-core check-types
pnpm --filter @v4/ghost-core lint
Stories & visual testing
Stories live in apps/storybook, not the component package — a deliberate
carry-over. They are documentation, Chromatic visual-regression baselines, and
executable browser tests all at once: @storybook/addon-vitest runs every
story as a real-browser test in headless Chromium. The design-review gate is
against the component's Figma design — ELB's file.
Stories are grouped by owning package under apps/storybook/stories/:
ghost-core/, ghost-content/, ghost-video/, ghost-basketball/, plus
code-of-conduct/ and foundations/.
Club theme or Ghost?
The Storybook UI renders with the Euro League club theme by default (this is
the Euro League repo). The Playwright e2e pins Ghost: its storageState sets localStorage["ph-club-theme"] = "ghost" before the page loads, so it asserts the Ghost 2.0 contract. The vitest story suite sets the same flag in .storybook/vitest.setup.ts but still renders the Euro League theme — the preview reads the flag when it loads, before the setup runs (measured 29 Sep 2026: a story resolving g.primitive.color.primary["1"] rendered ELB's #FA5500, not Ghost's #1976DC). So an ELB palette change moves story-suite colour and contrast results, and the e2e's do not. So a colour an e2e asserts is not the one you see in the UI. To opt a
URL into the club theme, add &globals=clubTheme:euro-league; the URL global
wins over the stored flag.
The story matrix
Every component ships, at minimum:
Playground— a controls-driven default withargTypes.AllColourSets— every supported set, each on its representative background, including onedir="rtl"row so logical-properties compliance is exercised.All{Axis}for every variant axis (states, sizes, levels).- One story per slot / optional-content permutation — each slot alone, all slots together, and none.
WithTokenOverrides— exercising the per-instancetokensprop visibly.Responsive— for any component whose Figma set had a Breakpoint axis; the Ghost breakpoints (360 / 768 / 1024 / 1440 / 1920) are configured as viewport presets.
Slot demo content must be real generated icons imported from
@v4/tokens/icons — never ad-hoc inline SVG shapes, and never a hand-built HTML
imitation of a component that already exists in a package. If the sub-component
exists in @v4/ghost-core, import the real one.
Story IDs follow the title: Ghost-Core/Molecules/PageNavigation in
apps/storybook/stories/ghost-core/molecules/page-navigation/ has id
ghost-core-molecules-page-navigation--playground. e2e specs and the
visual-diff tool address stories by id, so a retitle is a breaking change —
update the specs and MDX in the same commit. Multi-word story exports kebab-case
in the id: WithTokenOverrides → --with-token-overrides, not
--withtokenoverrides.
Play functions
Interactive components ship play interactions — keyboard navigation, click
behaviour, disabled semantics — using import { expect } from "storybook/test"
and the canvas context. Display atoms add at least a render assertion.
play: async ({ canvas, userEvent }) => {
const nav = canvas.getByRole("navigation", { name: "Club pages" });
await expect(nav).toBeInTheDocument();
const current = canvas.getByRole("link", { name: "Fixtures" });
await expect(current).toHaveAttribute("aria-current", "page");
const next = canvas.getByTestId(TEST_ID.PAGE_NAVIGATION.PADDLE_NEXT);
if (next.getAttribute("aria-disabled") !== "true") {
await userEvent.click(next);
}
},
A play that clicks a real link must use a preventDefault-ing spy
(fn((evt) => evt?.preventDefault())), or the vitest browser navigates away and
kills the run.
Give interaction e2e a play-less story. A play function runs on mount, so an
e2e that navigates to that story and clicks the same control is racing it — a
disclosure that the e2e opens can be shut again by the play's own clicks, and the
failure reproduces convincingly as a broken component. Add a story with args and
no play (the house name is Interactive) and say in its docblock that it exists
for the e2e. When an interaction e2e fails, the story suite passing the same play
function is what proves the component works.
Motion stories
A component that animates ships a story that shows the motion with a replay
control, plus a play-less twin for the e2e (.claude/rules/frontend/motion.md
rule 9). The story suite and visual-diff render FINAL states — the suite marks
<html data-motion-final>, and snapshots render with reduced motion — so the
motion itself is asserted in the e2e. See the Motion guide.
The a11y gate
Component stories set parameters: { a11y: { test: "error" } }, so an axe
violation fails the browser story test. Interactive stories combine it with
a Figma design link and maturity tags:
const meta = {
title: "Ghost-Core/Molecules/PageNavigation",
component: PageNavigation,
parameters: {
layout: "padded",
a11y: { test: "error" },
},
tags: ["autodocs", "stable"],
} satisfies Meta<typeof PageNavigation>;
New components carry "pilot"; promote to "stable" after /review-against-figma
passes post-release. Token/reference pages may opt the a11y test out.
The a11y gate does not certify contrast over an image. axe returns
incomplete— never a pass, never a violation — for any text under abackground-image, including a transparent gradient.test: "error"fails only on violations, so those pairs sail through green having been checked by nothing. Measure them by hand withcontrast-table.mjs. This is the single most important caveat in the a11y story — it has the whole Gates & tooling reference behind it.
Companion MDX docs
Each component gets three companion pages alongside its stories, auto-discovered
by the stories/**/*.mdx glob:
{name}.figma.mdx— Figma embed, node map, state/variant tables, token bindings. Generated by the/generate-from-figmapipeline; regenerate rather than hand-edit.{name}.accessibility.mdx— WAI-ARIA pattern, keyboard interaction, focus management, contrast figures. Derived from the actual component source.{name}.theme.mdx— the token-contract table and the five override levels with a live per-instance example.
Visual diff against Figma
visual-diff.mjs scores a rendered story against a committed Figma reference
PNG with pixelmatch:
pnpm --filter @v4/storybook visual-diff -- \
--story ghost-core-atoms-badge--both-slots \
--ref ../../packages/ghost-core/src/components/badge/figma-screenshots/master.png \
--width 360
Media queries respond to the viewport, so a Breakpoint=S reference needs
--width 360 to render the mobile layout — score like-for-like content. Run it
from apps/storybook with Storybook already running on :6006.
Restart Storybook after a shared .css.ts dependency changes
vanilla-extract does not re-evaluate a .css.ts when a plain .ts module it
imports changes (a family style factory, a shared theme helper). The running dev
server keeps serving stale CSS. After editing anything under src/families/**
or a shared theme helper, restart Storybook before running visual-diff, story
tests or e2e, or you will chase phantom failures.
Component e2e
apps/e2e (@v4/e2e) drives the Storybook story iframes with Playwright to
assert what jsdom cannot: the resolved computed value of a token after the
whole --ph__* custom-property chain has cascaded, at the real Ghost
breakpoints, across more than one browser engine. Every component ships
apps/e2e/tests/{package}/{kebab-name}.e2e.ts.
Specs are foldered by owning package — tests/ghost-core/,
tests/ghost-content/, tests/ghost-video/, tests/ghost-basketball/,
tests/basketball-app/ — plus tests/shared/ for cross-package specs
(page-measure, theme-emission, docs-pages-render). testMatch is
**/*.e2e.ts, so a new folder is picked up without config. Helpers are imported
two levels up (../../helpers/*). A shared/ spec that fans out per package must
derive its cases from the packages actually present, never a hard-coded list.
Scope: computed token resolution against manifest values, per-instance override resolution, interaction states for interactive components, slot dimensions, RTL, and the breakpoint projects. Unit and story tests stay in Vitest — e2e asserts only what needs a real end-to-end browser pass.
# Boots/reuses Storybook on :6006, then runs the suite
pnpm --filter @v4/e2e test
# One spec
pnpm --filter @v4/e2e test -- tests/ghost-core/badge.e2e.ts
# Install the engines once
pnpm --filter @v4/e2e exec playwright install chromium webkit firefox
| Script | Runs |
|---|---|
test |
every project |
test:chromium |
desktop + ghost-s |
test:cross-browser |
webkit + firefox |
test:full-matrix |
every project with E2E_FULL_MATRIX=1 (the full spec set on WebKit and Firefox too) |
test:ui |
Playwright's UI mode |
The webServer serves a static Storybook build (turbo run build +
serve-static), not the dev server — so there are no on-demand Vite compiles and
workers no longer starve a shared compiler. reuseExistingServer still honours a
server you started manually on :6006, including a dev server, which is slower —
stop it for suite runs.
Whose Storybook is it?
reuseExistingServer adopts any server answering on the port — including a
Storybook left running from another checkout (the Publishing Hub template repo, say). That once
served a whole batch's e2e from the wrong tree, and every "not found" read as a
component defect. Before trusting a red run, check the listener's working
directory:
lsof -p $(lsof -t -iTCP:6006 -sTCP:LISTEN) | grep cwd
If it is not this repo, don't kill someone else's server — run on a free port.
E2E_PORT (default 6006) threads through to baseURL, the webServer URL and the
static server's PORT:
E2E_PORT=6016 pnpm --filter @v4/e2e test -- <spec>
The theme the e2e asserts
Every browser context starts with localStorage["ph-club-theme"] = "ghost" (and
ph:table-nudge = "1", so a table's swipe hint never moves under a geometry
assertion) through storageState in apps/e2e/playwright.config.ts. The e2e
therefore asserts the Ghost 2.0 contract values, not the Euro League ones the
Storybook UI shows. After a rebind, assert Ghost's value for the new token
name. A spec can still opt into the club theme with &globals=clubTheme:euro-league.
The six browser projects
| Project | Engine | Viewport | Runs |
|---|---|---|---|
desktop |
Chromium | 1280 | the FULL spec set |
ghost-s |
Chromium | 360 | the FULL spec set |
webkit |
WebKit | 1280 | the cross-browser risk subset only |
firefox |
Firefox | 1280 | the cross-browser risk subset only |
tablet |
Chromium | 834 | INTERMEDIATE_RUNG_SPECS only |
laptop |
Chromium | 1180 | INTERMEDIATE_RUNG_SPECS only |
desktop and ghost-s are the breakpoint matrix — every component's
token/manifest assertion runs at both the desktop and the Ghost-S (360px) rung.
webkit and firefox run only CROSS_BROWSER_SPECS, a curated list where
engines historically disagree: sequential focus and :focus-visible, focus
traps built on checkVisibility(), scrolling / scroll-snap / RTL scrollLeft,
dvh, :has(), inset-box-shadow rings, and sub-pixel layout rounding. Each
entry names the surface that put it there — it is not an arbitrary sample. A full
run of the full set on all engines roughly doubles the wall clock, so the subset is the default;
run the full set on WebKit and Firefox too with E2E_FULL_MATRIX=1 — the nightly
(.github/workflows/e2e-nightly.yml, eight shards) does — when a new engine
version lands or a broad refactor touches layout.
tablet and laptop sit inside the two bands the design does not draw (768–1023
and 1024–1439). They run only INTERMEDIATE_RUNG_SPECS
(**/page-measure.e2e.ts, **/*-bleed.e2e.ts) — specs whose expectations are
derived from the viewport. A spec that encodes the 1280 or 360 project's
values would fail there for no defect, so it must not be added to that list. The
gate itself is tests/shared/page-measure.e2e.ts; the rules are in
.claude/rules/frontend/intermediate-rungs.md and the
Responsive rungs guide.
Two harness rules from having more than one Chromium project
Ask about the viewport, never the project name. A spec must not branch on
test.info().project.name — that reads as "am I wide?" but means "am I the
project literally called desktop", so every new desktop-class project (webkit,
firefox — both 1280) silently asserts the 360px Small-set values against a 1280px
render. Use isDesktopProject() from apps/e2e/helpers/viewport.ts. It returns
true for every project except ghost-s (NARROW_PROJECTS), so tablet (834) and
laptop (1180) count as "desktop" — which is exactly why only viewport-derived
specs run on them:
import { isDesktopProject } from "../../helpers/viewport";
const expectedPadding = isDesktopProject() ? "16px" : "8px";
Reach focus with tabTo, not a bare Tab press, wherever the next
assertion is toBeFocused(). WebKit honours the macOS "Full Keyboard Access"
setting (off by default), so a raw Tab reaches text fields only. tabTo from
apps/e2e/helpers/keyboard.ts presses Tab for real, falls back to a programmatic
focus only if the engine declined to move focus, and annotates the run when
it does — so Chromium, Firefox and WebKit-on-Linux still assert genuine tab
reachability, while WebKit-on-macOS keeps running the rest of the test.
import { tabTo } from "../../helpers/keyboard";
await tabTo(page, button);
await expect(button).toBeFocused();
toBeDisabled() cannot see aria-disabled
The house disabled pattern uses aria-disabled="true" (not the HTML disabled
attribute) to keep the control keyboard-discoverable. Playwright's
toBeDisabled() / not.toBeDisabled() treat aria-disabled as disabled, so
they are blind to exactly that distinction — not.toBeDisabled() fails on a
correctly implemented button. Assert the DOM property alongside the attribute and
a real focus check:
await expect(el).toHaveAttribute("aria-disabled", "true");
expect(await el.evaluate((n) => (n as HTMLButtonElement).disabled)).toBe(false);
await tabTo(page, el);
await expect(el).toBeFocused();
page.evaluate does not auto-retry — gate it first
expect(locator) and locator.evaluate() auto-wait; page.evaluate() does not.
A test that navigates and reaches straight into document.querySelector inside
page.evaluate reads whatever the DOM holds at that instant — on a slow render,
an empty document, so every structural answer comes back false/null and reads
like a broken tree. Await one expect(locator).toBeVisible() on the subtree
first, or scope the evaluate to a locator. Pair every "all X are ready" wait with
a "there is at least one X" gate — [].every(…) is true, so a wait over a
collection that can legitimately be empty resolves instantly against a document
that has not rendered yet.
Also scope structural queries (headings, landmarks, [data-component]) to the
component root, not document — Storybook's own chrome contributes an
<h1>No Preview</h1> and an empty <h1> outside #storybook-root, so a
document-wide count over-reports.
Don't race a play function
A story's play function runs on mount. An e2e that navigates to that story and
drives the same control is competing with it, and loses non-deterministically — a
disclosure the e2e opened gets shut by the play's later click, and the failure
reproduces at both rungs, in isolation, looking every bit like a broken component.
Drive interaction specs against a play-less story (Interactive: args only, no
play). If the story suite passes the same play function, the component works.
Testing motion
Motion is tested here, not in the story suite (which marks <html data-motion-final> so entrances stand down — the e2e never sets that mark). For
each pattern, per .claude/rules/frontend/motion.md rule 9: one frame after the
trigger, getAnimations() on the element lists the expected animation with the
token's duration; under emulated reduced motion it lists none and the element
is already in its final state. The helpers are in apps/e2e/helpers/motion.ts
(animationsOf, reduceMotion, runningAnimationCount). See the
Motion guide.
Diagnosing a "stuck" or failing suite
Two field-learned diagnostics settle most red runs before you blame a component:
The timing split. A real assertion failure fails fast (~1s) — these
specs assert with expect(await computed(…)).toBe(…), plain comparisons with no
retry loop. An environmental miss (a story still compiling, a slow server) sits
on the full expect timeout (15s at the config root) and reports
element(s) not found, which reads exactly like a component defect. If the same
test passes on a re-run and the victim moves between runs, it was the harness,
not the component.
CPU, not process count. Playwright runs a fixed worker pool, so a static process count looks identical whether the suite is grinding or wedged. Measure work:
ps aux | grep -E "vitest|playwright" | grep -v grep | awk '{s+=$3} END {print "total CPU%:", s}'
Hundreds of percent across many processes means it is busy (a full run at this suite size legitimately takes a while). Near-0% with processes still alive is the real wedge — kill the suite, stop any Storybook dev server on :6006, and re-run against the static build.
When you change a token, change its e2e assertion
e2e specs assert resolved token values, so editing a component's theme
silently reddens its spec in a file you did not open. When you change a binding
(swapping a focus ring colour, moving a demo override in a WithTokenOverrides
story), grep apps/e2e/tests/{package}/<component>.e2e.ts for the old value or
token name in the same change, and run the specs of every component that composes
the one you changed. A story's per-instance tokens override value is an e2e
assertion too — a contrast sweep that moves a demo colour breaks the spec on every
project that runs it even though no component and no token moved.
Browser sweeps
The unit, story and e2e layers all test a component in isolation, and every one of them has passed while the site was visibly wrong: a gallery clipped by the narrower column it was composed into, body copy at 1.06:1 on a dark article format, a header row pushing the page sideways only between 1024 and 1104. Those defects exist only in a real browser, on a real page, at a real width — so three scripts at the repo root look there.
The two sweeps drive a running app. They are tools, not CI gates: pnpm verify boots no app. Both need Playwright's Chromium, which they load from
apps/e2e (pnpm --filter @v4/e2e exec playwright install chromium once).
page-audit — what only a browser can see
node scripts/page-audit.mjs # "/" and "/news" on :3000
node scripts/page-audit.mjs /en/news /en/news/<slug>
BASE=http://localhost:3155 RUNGS=1440,360 node scripts/page-audit.mjs
CONTRAST=1 node scripts/page-audit.mjs /en/news
| Environment | Default |
|---|---|
BASE |
http://localhost:3000 |
RUNGS |
1920,1440,1280,1180,1024,834,768,360 |
CONTRAST |
off; 1 adds every text pair below WCAG AA |
Each page is scrolled through in screenfuls at every rung and reports:
- invisible-text — text the same colour as the surface actually painted behind
it. The backdrop is read from the hit-test stack (
elementsFromPoint), not the ancestor chain, because a sibling band or an absolutely-positioned scrim is what is really behind the text; an ancestor walk reports legible copy as white-on-white. - zero-size — a
[data-component]rendering 0 wide or 0 tall. - broken-image — an
<img>that failed to load. - page-overflow — the document scrolls horizontally at this rung.
- child-overflow — an element wider than its parent's content box where the parent does not scroll, so the excess is unreachable. It names the element; a page overflow usually has one of these as its cause.
- console — page errors and warnings raised while rendering.
CONTRAST=1 is off by default because on a real page most sub-AA pairs are
palette decisions (the ELB brand orange is one), not defects, and they would drown
the structural findings.
clip-sweep — clipped and unreachable
node scripts/clip-sweep.mjs # "/" and "/news" on :3000
node scripts/clip-sweep.mjs /en/news/<slug>
BASE=http://localhost:3155 node scripts/clip-sweep.mjs
The signature it looks for is general: an element whose overflow-x is hidden
or clip and whose scrollWidth exceeds its clientWidth. Nothing scrolls,
so whatever sits past the edge cannot be reached by any means — valid CSS, no
console error, no axe violation. It runs at the same eight widths (fixed; BASE
is its only setting) and skips the intended cases: the 1px visually-hidden clip,
images, and the motion settle frame that holds a photo at 103% by design. This is
how the article gallery's last slide was found cut off: a carousel bound to a
fixed 880px width inside the article's 656px column. The rule behind it is in
.claude/rules/frontend/vanilla-extract-conventions.md ("A bound S.Layout.Grid-*
width is a CAP…").
Both scripts exit non-zero when they find anything, so they can be chained in a local check.
Paths and widths
- Live addresses carry the locale segment (
/en/news). A bare/newsis redirected to/en/newsby the middleware and is still audited, but pass the real address when you are targeting a page. - The default widths are the Ghost rung edges plus the widths real devices report (1280, 1180, 834): the bands the design does not draw are where these defects live. See Responsive rungs.
- The Game Centre and other heavy basketball pages run out of memory under
next dev; sweep them against a local production build (pnpm --filter @v4/web buildthenpnpm --filter @v4/web start -- -p 3155, andBASE=http://localhost:3155).
rung-census — which rungs the stylesheets step at
node scripts/rung-census.mjs # per package: components stepping at md / lg / xl / xxl
node scripts/rung-census.mjs --list # also names the lg-only components
Static — it reads the .css.ts files of ghost-core, ghost-content,
ghost-video and ghost-basketball, so run it from the repo root. It keeps the
number of components with no tablet step tracked rather than rediscovered; the
rules for adding one are in .claude/rules/frontend/intermediate-rungs.md.