Skip to content

fix(axe): the axe runner could never resolve @playwright/test - #338

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/axe-runner-node-resolution
Aug 11, 2026
Merged

fix(axe): the axe runner could never resolve @playwright/test#338
rubenvdlinde merged 1 commit into
mainfrom
fix/axe-runner-node-resolution

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

gate-33 has never been able to run in any caller repo

enable-axe: true executes the runner by absolute path:

cd "server/apps/<app>"
node "${GITHUB_WORKSPACE}/gates/hydra-gates/scripts/axe-run.cjs"

The packages it requires are installed by the step above into server/apps/<app>/node_modules. CommonJS resolves a module's own require calls by walking up from the directory of the entry file — never from the process cwd — and gates/ is a sibling of server/. The walk goes gates/hydra-gates/scripts/node_modules, gates/hydra-gates/node_modules, gates/node_modules, <workspace>/node_modules, and stops. It never enters server/.

So require('@playwright/test') throws MODULE_NOT_FOUND on the first repo that ever opted in — ConductionNL/docudesk#423, where Playwright itself was green and all 82 e2e tests passed before the axe step died on a message that names neither accessibility nor the layout that caused it.

The control above it passed for the wrong reason

node -e "const m=require('@axe-core/playwright'); ... console.log('@axe-core/playwright resolved.');"

node -e resolves from the cwd the step had just cd-ed into. It measured whether the package was on disk, not whether the runner could reach it — and printed @axe-core/playwright resolved. on the very run whose next step could resolve nothing. It would print the same line on every future break of this kind.

The fix

  • NODE_PATH="${PWD}/node_modules" on the runner invocation — NODE_PATH is a global fallback consulted regardless of where the entry file sits, which is the property this cross-tree layout needs.
  • the control now resolves via createRequire anchored on the runner, and checks @playwright/test too. It fails exactly when the runner would.

Both directions, on the real axe-run.cjs

Reconstructed the runner's real directory layout (gates/ sibling to server/apps/dd/node_modules) and ran the actual file:

arm result
runner, no NODE_PATH Error: Cannot find module '@playwright/test'
runner, NODE_PATH set ::error::axe-runner: AXE_BASE_URL is empty.
old control, either arm PASS — this is the defect
new probe, no NODE_PATH exit 1, MODULE_NOT_FOUND
new probe, NODE_PATH set PASS

Row 2 is the proof the fix works: the runner gets past resolution and reaches its own env validation. Row 3 is why this was invisible.

Step size

The ~20 KB per-step limit that once made this workflow resolve to zero jobs is respected: the two touched run: blocks are 2.1 KB and 2.6 KB.

The runner is executed by absolute path from `${GITHUB_WORKSPACE}/gates/`
while the packages it needs are installed into
`server/apps/<app>/node_modules`. CommonJS resolves a module's own
`require` calls by walking up from the directory of the ENTRY FILE, not
from the process cwd, and those two paths are siblings — so the walk
never reaches the app's node_modules and `require('@playwright/test')`
throws MODULE_NOT_FOUND on the first repo that ever opted in.

That is docudesk#423: `enable-axe: true`, Playwright green, 82 e2e tests
passed, and then the axe step died on `Cannot find module
'@playwright/test'` — a failure with nothing to do with accessibility.

The step above it printed `@axe-core/playwright resolved.` on the same
run. It used `node -e`, which resolves from the cwd the step had just
`cd`-ed into, so it answered a question nobody asked: it measured
whether the package was on disk, never whether the runner could reach
it. It would have printed the same line on every future break of this
kind.

Two changes, one root cause:

* `NODE_PATH="${PWD}/node_modules"` on the runner invocation. NODE_PATH
  is a global fallback consulted regardless of where the entry file
  sits, which is exactly the property the cross-tree layout needs.
* the control now resolves through `createRequire` anchored ON THE
  RUNNER, and checks both `@axe-core/playwright` and `@playwright/test`.
  It therefore fails precisely when the runner would.

Measured on a reconstruction of the runner's real layout, with the
actual `axe-run.cjs`:

  runner, no NODE_PATH   -> Error: Cannot find module '@playwright/test'
  runner, NODE_PATH set  -> ::error::axe-runner: AXE_BASE_URL is empty.
  old control            -> PASS in both arms (this is the defect)
  new probe, no NODE_PATH-> exit 1, MODULE_NOT_FOUND
  new probe, NODE_PATH   -> PASS

The second line is the proof: the runner gets past resolution and
reaches its own env validation. The third is why this went unseen.

Both `run:` blocks stay far under the ~20 KB per-step limit that made
this workflow unresolvable fleet-wide (2.1 KB and 2.6 KB).
@rubenvdlinde
rubenvdlinde merged commit b8c7ead into main Aug 11, 2026
30 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/axe-runner-node-resolution branch August 11, 2026 05:12
rubenvdlinde added a commit to ConductionNL/zaakafhandelapp that referenced this pull request Aug 11, 2026
gate-33 (axe-core) has reported NOT APPLICABLE on every run this repo has ever
made, because `enable-axe` was never set. That is not a pass: runtime
accessibility — contrast, landmarks, ARIA validity, live regions — has never
been measured here at all, by a choice recorded in this file.

The recorded reason was "a vanilla Nextcloud 34 already carries
serious/critical violations from core's own UI". An exemption's reason is a
testable claim, and this one names a STATE OF THE WORLD rather than a test
artifact — so it rots. It has:

  1. ConductionNL/.github#161 (re-landed as #168) gave the axe runner an
     AxeBuilder.include SCOPE, so core's chrome is no longer in the
     measurement. It is a scope, not a mute, and the runner proves that on
     every run — it injects a deliberate violation INSIDE the container and
     asserts axe reports it, then injects the same violation OUTSIDE and
     asserts axe does not. A selector matching nothing is a hard failure, not
     an empty pass.
  2. ConductionNL/.github#338 fixed an axe runner that had NEVER resolved
     @playwright/test in any repo — so nobody could have enabled this before
     tonight even if they had wanted to.

Measured rather than inherited: docudesk runs with `enable-axe: true` today and
its gate run reports `[gate-33] axe-core: PASS` (run 31461514843). The blocker
is gone, so the excuse does not hold here either.

`axe-include-selector: '#zaakafhandelapp-app'` — NOT the `#content-vue, #content`
default. templates/index.php:34 renders exactly `<div id="zaakafhandelapp-app">`
and src/main.js:151 mounts there, because Vue 3's mount() renders INSIDE the
match and mounting on `#content` would nest the app inside Nextcloud's own
wrapper. Core's layout wraps our div in `#content`, so the DEFAULT selector
would match and drag core's navigation, header and app menu back into scope —
re-attributing core's violations to this app, which is exactly what the old
opt-out was worried about. This selector is this app's own rendered DOM and
nothing else.

No baseline is taken with this. DOM scoping excludes TERRITORY; a baseline
suppresses FINDINGS, including future ones on our own markup. If this run
reports violations in our own DOM they are ours, and the fix is the markup.
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.

2 participants