Skip to content

fix(web): serialize axe-core runs in the a11y route-smoke test (JEF-530)#275

Merged
thejefflarson merged 3 commits into
mainfrom
thejefflarson/jef-530-web-a11y-route-smoke-tests-flake-axe-is-already-running-run
Jul 27, 2026
Merged

fix(web): serialize axe-core runs in the a11y route-smoke test (JEF-530)#275
thejefflarson merged 3 commits into
mainfrom
thejefflarson/jef-530-web-a11y-route-smoke-tests-flake-axe-is-already-running-run

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

Fixes the intermittent "Axe is already running. Only one instance can be running at a time." failures in engine/web/test/a11y-routes.test.jsx (the vitest-axe route-smoke from JEF-499).

Root cause (traced through node_modules/vitest-axe + node_modules/axe-core): none of the its in this file are .concurrent — vitest itself never overlaps them, and every call is already awaited. The overlap comes from vitest-axe's Promise wrapper around axe-core's callback-style run():

return new Promise((resolve) => {
  run(element, options2, (err, results) => {
    restore();
    if (err) throw err;   // <-- fires OUTSIDE the executor's synchronous frame
    resolve(results);
  });
});

There is no reject parameter at all. axe-core's success/error paths both reset its module-level axe._running flag before invoking this callback — so a normal awaited sequence can never race under ordinary conditions. But if axe-core's async rule evaluation ever throws (an environment-dependent hiccup, plausible on the constrained self-hosted CI runner — jsdom is already observed missing browser APIs elsewhere in this suite, e.g. Not implemented: navigation), the if (err) throw err above throws in an async continuation that is not the Promise executor's synchronous frame, so the wrapping Promise never settles. The affected test then hangs until vitest's own test timeout fires and moves on — but by then axe-core's internal continuation may still be live, and the next view's fresh axe() call can trip the !axe._running guard, producing "Axe is already running" and cascading into unrelated tests in the file.

Fix: route every axe() call in the file through a single in-process queue (a small promise-chain mutex), so axe-core's single-flight requirement holds by construction regardless of what triggers the overlap — this is the "serialize with a small mutex" option called out in the ticket. Route coverage and assertions are unchanged (still all 12 cases: every top-level view, the status strip, the App shell, and both AuthGate interstitials, still asserting zero serious/critical axe violations).

Test plan

  • npm ci
  • npm test — green, run total (5× full npm test, plus 2 standalone runs of test/a11y-routes.test.jsx) with zero recurrence of "Axe is already running"; 111/111 tests / 17/17 files each time
  • npm run lint — clean
  • npm run typecheck — clean
  • npm run build — clean
  • /simplify (single-pass, no Agent-tool fan-out available in this context) — simplified the mutex's settle-chaining from .then(ok, err) to .catch(); no other findings
  • Self-review against the repo's security checklist — this is a test-only change (an in-process promise queue in a test file); no user input, network, auth, or persistence surface touched. No Critical/High findings.
  • File stays well under the 1,000-line cap (252 lines)

Closes JEF-530

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

thejefflarson and others added 2 commits July 26, 2026 17:11
axe-core keeps a single global "run in progress" flag; vitest-axe's Promise
wrapper has no reject path for an errored run (its callback does `if (err)
throw err`, which fires outside the Promise executor's synchronous frame and
never settles the promise). Under CI's constrained runner, an axe-core
internal hiccup can leave a call hanging past its test's timeout — vitest
then moves on to the next `it` while `axe._running` is still true, and the
next view's fresh axe call trips the "Axe is already running" guard,
cascading into unrelated tests in the file.

Route every axe() call in the file through one queue so axe-core's
single-flight requirement holds by construction, regardless of what causes
the overlap. Route coverage and assertions are unchanged.

Closes JEF-530

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson enabled auto-merge (squash) July 27, 2026 00:45
@thejefflarson
thejefflarson merged commit 3c572b5 into main Jul 27, 2026
5 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-530-web-a11y-route-smoke-tests-flake-axe-is-already-running-run branch July 27, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant