No route to the evidence

A devcontainer firewall gap blocked exactly the two tools needed to diagnose a CI-only failure, and fixing it surfaced a real flaky test hiding behind the same access gap.

Diagnosing a CI-only test failure ran into a wall, twice over. The failure did not reproduce locally, and two different tools that should have helped close that gap were both blocked by the devcontainer’s own outbound firewall: installing the Node major version CI actually runs under, and gh api’s job-logs endpoint for reading what CI printed. Both calls came back the same way: no route to host.

The Node gap was straightforward. CI’s workflow pins a newer major version than the devcontainer’s default install, and the site nvm downloads Node releases from was unreachable from inside the sandbox, so there was no way to install a matching version and rule out a version-specific cause locally. The logs gap was subtler. gh api repos/.../actions/jobs/<id>/logs does not serve log content from api.github.com directly, it redirects to a signed URL on an externally hosted storage backend that GitHub rotates per run, and that backend was not reachable either. This compounds a separate, already-known limitation where the same token cannot read check-run results through the GraphQL API at all. Between the two, there was no way to see a failing job’s real output short of asking a human to paste it in by hand.

Both hosts went into the firewall scripts, and the two verifications passed: the matching Node version installed, and gh api’s job-logs call started returning real content instead of a connection failure.

That same pull request’s own CI run then failed on something unrelated: a duel-mode unit test expecting a Norwegian announcement string got a stale English one instead. The cause turned up in the test file rather than the game code. The suite shares one duelGame store instance across every test in the file, and a successful word submission schedules a real, un-mocked setTimeout to fire the turn announcement fifty milliseconds later. An earlier test in the same file submitted a word and moved on without waiting for that timer, leaving it dangling and free to fire mid-way through a later, unrelated test:

await duelGame.submitWord();
expect(duelGame.pendingCombos).toEqual([]);
// A successful submission schedules announce()'s real setTimeout(…, 50).
// Drain it before the test ends so it can't fire mid-way through a later
// test and clobber that test's own assertion, this shared duelGame
// singleton persists across the whole file.
await new Promise((r) => setTimeout(r, 60));
vi.unstubAllGlobals();

Nothing about the flaky test was related to the firewall gap that led to it. It surfaced only because the CI run that would have caught it earlier could finally be inspected properly, instead of being read as an opaque pass or fail. The immediate diagnostic gap is closed for this kind of failure; whether the same shared-singleton pattern leaves other dangling timers elsewhere in the duel test suite is not yet checked.