Summary
axe-run.cjs hard-fails with returned HTTP 0 on any route after the first
one that shares a document with it — which, for a hash-routed SPA, is every
route after the first. Since every Conduction leaf app uses
createWebHashHistory(), this means enable-axe: true can cover exactly one
route per app, and any attempt to list more turns the Playwright job red with
a message that blames the caller's config for a route the app serves correctly.
Where
hydra-gates/scripts/axe-run.cjs, lines ~228-235:
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
const status = resp ? resp.status() : 0;
...
if (status === 0 || status >= 400) {
die('route ' + url + ' returned HTTP ' + status + '. axe would have analysed an '
+ 'error page and reported it as clean. Set the axe-routes input to routes '
+ 'this app actually serves.');
}
page.goto() returns null for a same-document navigation. Going from
/index.php/apps/openconnector/ to /index.php/apps/openconnector/#/sources
changes only the fragment, so no new document is fetched, no Response object
exists, and status becomes 0. The guard then reads that as "the route 404s".
The guard itself is right — it exists so axe cannot analyse an error page and
call it clean. It just cannot distinguish "no response because the server did
not serve this" from "no response because the browser did not need to ask".
Reproduction — the same route passes or dies depending only on its position
Measured against a dedicated Nextcloud 34 with openconnector + openregister
installed and the register seeded (tests/e2e/ci-seed.sh), axe-core 4.12.1,
gates package ConductionNL/.github@main (b8c7ead).
A — #/sources listed FIRST. Serves fine:
AXE_ROUTES='/index.php/apps/openconnector/#/sources'
scope on /index.php/apps/openconnector/#/sources: #content-vue=1, #content=1
axe http://…/#/sources -> HTTP 200 | passes=17 violations=0 incomplete=1
axe scope control OK …
Wrote report.json: 1 route(s), 0 violation(s)
AXE EXIT=0
B — the SAME route, listed second, after the app root. Hard-dies:
AXE_ROUTES='/index.php/apps/openconnector/,/index.php/apps/openconnector/#/sources,…'
axe http://…/apps/openconnector/ -> HTTP 200 | passes=19 violations=0 incomplete=1
axe scope control OK …
::error::axe-runner: route http://…/#/sources returned HTTP 0. axe would have
analysed an error page and reported it as clean. Set the axe-routes input to
routes this app actually serves.
AXE EXIT=2
Nothing about the route changed between A and B. Only what the browser had
loaded beforehand.
Why this matters beyond a config annoyance
- It is invisible as a defect. The error names the caller's config and
tells them to fix their routes. The obvious response is to delete routes
from axe-routes until the job goes green — which silently narrows
accessibility coverage to the landing page and looks like a config fix.
enable-axe is currently an opt-in to one route. Every app in this
fleet is hash-routed, so gate-33 — once enabled — can only ever see the
route the app happens to land on. That is not what the input description
promises ("the app's routes").
- It is a wiring failure, not a finding:
AXE EXIT=2 means no report is
written at all, so with enable-axe: true the hydra-gates job fails for a
reason that has nothing to do with accessibility.
Proposed fix
Distinguish the two causes of a null response instead of collapsing them.
A same-document navigation is legitimate iff the document currently loaded
is the one the route belongs to. Two options, both small:
(a) Force a cross-document load between routes. Simplest and keeps the
guard exactly as strict as it is today:
// A fragment-only navigation returns null from goto() because no document is
// fetched. Reset to about:blank first so every route in the list is a real
// document load and its HTTP status is observable.
await page.goto('about:blank');
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
(b) Accept a null response only when it is provably a fragment navigation
of the page already loaded, and verify the document separately:
let status = resp ? resp.status() : null;
if (status === null) {
const sameDoc = new URL(page.url()).href.split('#')[0] === new URL(url, base).href.split('#')[0];
if (!sameDoc) die(...); // genuinely no response — keep the hard failure
status = 200; // fragment navigation of a document already fetched
}
(a) is preferable: it needs no reasoning about URL equality and it also
guarantees each route's SPA state is built from scratch rather than inherited
from the previous route — which is the more honest thing to hand to axe anyway.
Whichever is chosen, please also assert it: a route list of
['/app/', '/app/#/x'] must produce two analysed routes, and a genuinely
absent route must still die.
Impact on this repo
openconnector is turning enable-axe: true on now. Because of this defect its
axe-routes has to be a single route, so gate-33 there will report on the app
root only until this is fixed. Measured on that route today: 0 violations,
19 passes, runner self-test and scope control both OK. #/sources measured
separately: 0 violations, 17 passes. Both are clean, so widening the list
once this lands is expected to stay green — the blocker is the runner, not the
app's accessibility.
Summary
axe-run.cjshard-fails withreturned HTTP 0on any route after the firstone that shares a document with it — which, for a hash-routed SPA, is every
route after the first. Since every Conduction leaf app uses
createWebHashHistory(), this meansenable-axe: truecan cover exactly oneroute per app, and any attempt to list more turns the Playwright job red with
a message that blames the caller's config for a route the app serves correctly.
Where
hydra-gates/scripts/axe-run.cjs, lines ~228-235:page.goto()returnsnullfor a same-document navigation. Going from/index.php/apps/openconnector/to/index.php/apps/openconnector/#/sourceschanges only the fragment, so no new document is fetched, no
Responseobjectexists, and
statusbecomes0. The guard then reads that as "the route 404s".The guard itself is right — it exists so axe cannot analyse an error page and
call it clean. It just cannot distinguish "no response because the server did
not serve this" from "no response because the browser did not need to ask".
Reproduction — the same route passes or dies depending only on its position
Measured against a dedicated Nextcloud 34 with openconnector + openregister
installed and the register seeded (
tests/e2e/ci-seed.sh), axe-core 4.12.1,gates package
ConductionNL/.github@main(b8c7ead).A —
#/sourceslisted FIRST. Serves fine:B — the SAME route, listed second, after the app root. Hard-dies:
Nothing about the route changed between A and B. Only what the browser had
loaded beforehand.
Why this matters beyond a config annoyance
tells them to fix their routes. The obvious response is to delete routes
from
axe-routesuntil the job goes green — which silently narrowsaccessibility coverage to the landing page and looks like a config fix.
enable-axeis currently an opt-in to one route. Every app in thisfleet is hash-routed, so gate-33 — once enabled — can only ever see the
route the app happens to land on. That is not what the input description
promises ("the app's routes").
AXE EXIT=2means no report iswritten at all, so with
enable-axe: truethe hydra-gates job fails for areason that has nothing to do with accessibility.
Proposed fix
Distinguish the two causes of a null response instead of collapsing them.
A same-document navigation is legitimate iff the document currently loaded
is the one the route belongs to. Two options, both small:
(a) Force a cross-document load between routes. Simplest and keeps the
guard exactly as strict as it is today:
(b) Accept a null response only when it is provably a fragment navigation
of the page already loaded, and verify the document separately:
(a) is preferable: it needs no reasoning about URL equality and it also
guarantees each route's SPA state is built from scratch rather than inherited
from the previous route — which is the more honest thing to hand to axe anyway.
Whichever is chosen, please also assert it: a route list of
['/app/', '/app/#/x']must produce two analysed routes, and a genuinelyabsent route must still die.
Impact on this repo
openconnector is turning
enable-axe: trueon now. Because of this defect itsaxe-routeshas to be a single route, so gate-33 there will report on the approot only until this is fixed. Measured on that route today: 0 violations,
19 passes, runner self-test and scope control both OK.
#/sourcesmeasuredseparately: 0 violations, 17 passes. Both are clean, so widening the list
once this lands is expected to stay green — the blocker is the runner, not the
app's accessibility.