From 5d92172760d403dfada73046f785c3c500a22e05 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 26 Jul 2026 17:11:26 -0700 Subject: [PATCH] fix(web): serialize axe-core runs in the a11y route-smoke test (JEF-530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/web/test/a11y-routes.test.jsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/engine/web/test/a11y-routes.test.jsx b/engine/web/test/a11y-routes.test.jsx index bf091ad..9fd9f27 100644 --- a/engine/web/test/a11y-routes.test.jsx +++ b/engine/web/test/a11y-routes.test.jsx @@ -72,10 +72,29 @@ beforeEach(() => { }); afterEach(cleanup); +// axe-core keeps a single global "a run is in progress" flag and throws "Axe is already running" if a +// second `run()` starts before the first has finished (JEF-530). None of the `it`s below are +// `.concurrent`, so vitest itself never overlaps them — but 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 so never settles the promise). If axe-core's async rule evaluation +// ever hiccups on the CI runner, that leaves a call hanging past its test's timeout; vitest then moves +// on to the next `it` while axe-core's `_running` flag is still true, and the *next* view's fresh axe +// call trips the guard — cascading into unrelated tests. Routing every call through one queue makes +// axe-core's single-flight requirement true by construction: this file will never issue a second axe +// run before the previous one has settled, no matter what triggers the overlap. +let axeQueue = Promise.resolve(); +function runAxe(container) { + const run = axeQueue.then(() => axe(container, AXE_OPTS)); + // Chain the next call onto this one's settlement (success or failure) so a rejected run never wedges + // the queue for every test that follows it. + axeQueue = run.catch(() => undefined); + return run; +} + /** Run axe on a rendered container and assert zero serious/critical violations, naming any that fire * (rule id + impact + node count) so a regression points straight at the offending rule. */ async function expectAccessible(container) { - const results = await axe(container, AXE_OPTS); + const results = await runAxe(container); const blocking = results.violations.filter((v) => BLOCKING.has(v.impact)); const summary = blocking .map((v) => `${v.id} (${v.impact}, ${v.nodes.length} node(s)): ${v.help}`)