Skip to content

axe-run.cjs: any hash route after the first dies with 'returned HTTP 0' — enable-axe covers exactly one route per SPA #351

Description

@rubenvdlinde

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

  1. 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.
  2. 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").
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions