Not a bug this time

Cutting the E2E suite's billed CI cost by fixing an idle CPU core and moving tests to cheaper layers, then chasing down two loose ends, one real and one a false alarm.

The end-to-end test suite was the slowest required check in CI by a wide margin, several minutes against roughly forty seconds for the production build and forty-five for type-checking and linting. It also runs on every push, against a private repository, where those minutes are billed rather than free. Two separate things were compounding that cost: the job wasn’t using the CPU capacity it already had, and a large share of what its eighty-six Playwright tests exercised was already covered, more cheaply, by tests one layer down.

The CPU problem came from a stale assumption. Playwright’s default worker count derives from the runner’s logical cores, and the suite’s original setup assumed the four-core tier GitHub gives public repositories. This is a private repository, on the two-core tier, so that default silently rounded down to one worker, leaving a full core idle on every single run. Setting workers: 2 explicitly fixed it, and the job’s own log confirmed it: “Running N tests using 2 workers” where it used to say one.

The bigger piece was moving coverage down to whichever layer could verify the same fact for less. Of the eighty-six tests across thirteen spec files, roughly fifty-two either left the suite outright as pure duplicates or moved somewhere cheaper: existing or new Vitest component tests, a new Playwright project scoped to request-only checks that talk to the real server over plain HTTP without ever opening a browser, and, for two narrow-viewport layout checks that an earlier fix had first covered in a full end-to-end spec, this repository’s first Storybook interaction tests. What was left, around thirty-three tests that still need a real browser, ran in three minutes thirty-one seconds on the same commit that used to take the better part of five.

Two loose ends#

Two things were deliberately left open when that work merged, and both got closed out over the following day. The harder one was driving the daily challenge all the way to a real game-over inside a plain component test, something earlier attempts had put off as needing a live browser. The actual unlock was noticing that the page’s game state lives in a module-level singleton the real /daily route also subscribes to, so a test driving that singleton directly updates the rendered page with no mock component standing in for it. Getting there safely took working through several gotchas the bootstrap path hides:

The second loose end resolved the opposite way. A leaderboard deduplication test had been reported as reliably reproducing a real bug, and the tempting move would have been to trust that report and start changing dedup logic. Checked directly against the database instead: two independent rows sharing no dedup key, for the same player, both persisted correctly and both appeared in the full deduplicated result set exactly as the deduplication design already documents. The actual cause was narrower and had nothing to do with correctness. The test’s assertion only reads the top twenty deduplicated rows by score, and a fresh CI database every run only ever has a handful of entries in it, while a long-lived local development database, reused across an entire working session, had accumulated over sixty. Against that database, the test’s own two weak submissions legitimately fell outside the top twenty and vanished from the response, which is exactly what a correct cap is supposed to do. Nothing needed fixing. The rows and the ranking were both right, so the change was a comment on the test explaining why it can behave differently against a database that never gets reset.

With both loose ends closed, the whole effort landed clean: the CPU fix, the layer restructuring, and the two follow-ups it left behind. Whether the new numbers hold up as more real traffic runs through the suite is the only open question left.