Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `references/running-axe.md`: how to run the automated pass when a project
has no scanner installed, by injecting axe-core into the running page
instead of adding a dependency. Covers a single page, a whole-sitemap run
via same-origin iframes, and validating the harness before trusting its
output. Documents five failure modes that each produce silently wrong
results: calling the parent's `axe.run()` on a node from another document,
the roughly 45 second cap on a CDP evaluate call, dev-server livereload
wiping the host page mid-run, host-tab degradation over a few hundred
pages, and a settle time too short for `color-contrast` to measure a
painted page.
- SKILL.md Method step 1 points at the new reference and states the
no-new-dependency default.
- validate.mjs emdash check now covers `references/running-axe.md` alongside
the four existing authored docs.
- CI `build-reference` job: shellchecks the setup script, installs pandoc,
regenerates the reference from the live W3C source, and re-validates it, so
the clone-to-setup path is exercised on every push and PR.
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The full normative text of every criterion (with definitions, exceptions, and no

Run all four passes. No single pass is sufficient.

1. **Automated scan.** axe-core (axe DevTools, `@axe-core/cli`, Playwright `@axe-core/playwright`), Lighthouse, or WAVE. Records the machine-detectable third. Zero violations here is the floor, not the ceiling.
1. **Automated scan.** axe-core (axe DevTools, `@axe-core/cli`, Playwright `@axe-core/playwright`), Lighthouse, or WAVE. Records the machine-detectable third. Zero violations here is the floor, not the ceiling. If the project has no scanner installed, do not add one as a dependency - inject axe-core into the running page instead. Runnable snippets for a single page, a whole-sitemap run, and validating the harness before trusting its output: `references/running-axe.md`.
2. **Keyboard-only pass.** Unplug the mouse. Tab through the entire flow: every interactive element reachable, visible focus ring at each stop, logical order, no trap, Esc closes overlays, focus not hidden behind sticky headers (2.4.11). Skip link works (2.4.1).
3. **Contrast + zoom.** Text 4.5:1 (large text/UI 3:1) - 1.4.3 / 1.4.11. Reflow at 320px / 400% zoom with no horizontal scroll (1.4.10). Text-spacing overrides don't clip content (1.4.12).
4. **Screen-reader / semantics pass.** VoiceOver, NVDA, or Orca. Correct headings, landmarks, list/button/link semantics; every image has meaningful or empty alt; every control has a programmatic label; dynamic changes announce via live regions (4.1.3). Semantic HTML first - ARIA only to fill gaps native elements cannot.
Expand Down
187 changes: 187 additions & 0 deletions references/running-axe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Running axe-core without installing it

The automated pass (Method step 1) needs axe-core. Adding it to a project's dependencies is usually unwanted: it is a dev-time audit tool, not a runtime dependency, and a lockfile change has to be reviewed and justified. Inject it into the running page instead.

This is the default approach when a project has no scanner installed. Check first: if the project already has `@axe-core/cli`, `@axe-core/playwright`, or `pa11y` in its dependencies, use that instead and skip this file.

## Single page

Point a browser automation tool at the page, then evaluate:

```js
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js';
s.onload = res;
s.onerror = () => rej(new Error('axe load failed'));
document.head.appendChild(s);
});

const r = await axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a','wcag2aa','wcag21a','wcag21aa','wcag22aa'] }
});

({
url: location.pathname,
violations: r.violations.map(v => ({ id: v.id, impact: v.impact, n: v.nodes.length, help: v.help })),
incomplete: r.incomplete.map(i => i.id),
passCount: r.passes.length
});
```

The `runOnly` tag filter matters. Without it axe also runs best-practice rules that are not WCAG criteria, and the resulting "violations" cannot be cited against a success criterion.

Always report `incomplete` alongside `violations`. Incomplete means axe could not decide, not that the check passed; `color-contrast` lands there routinely when text sits on a gradient, an image, or a translucent layer. Each incomplete item needs a manual check before any conformance claim.

## Whole site

Scanning many URLs by navigating once per page costs two tool calls per page. Instead, load each page into a same-origin iframe from a single host page. Same-origin frames give real layout and computed styles, so contrast rules stay valid.

```js
window.scanViaIframe = async function (path) {
const f = document.createElement('iframe');
f.style.cssText = 'position:fixed;left:0;top:0;width:1280px;height:900px;opacity:0.01;z-index:-1;border:0';
document.body.appendChild(f);
await new Promise(res => { f.onload = res; f.src = path; });

const d = f.contentDocument, w = f.contentWindow;
await new Promise((res, rej) => {
const s = d.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js';
s.onload = res;
s.onerror = () => rej(new Error('axe load failed in frame'));
d.head.appendChild(s);
});
await new Promise(r => setTimeout(r, 200));

const res = await w.axe.run(d, {
runOnly: { type: 'tag', values: ['wcag2a','wcag2aa','wcag21a','wcag21aa','wcag22aa'] }
});
const out = {
violations: res.violations.map(v => ({
id: v.id, impact: v.impact, n: v.nodes.length,
targets: v.nodes.slice(0, 3).map(nd => nd.target.join(' '))
})),
incomplete: res.incomplete.map(i => i.id),
passCount: res.passes.length
};
f.remove();
return out;
};
```

**Inject axe into the frame and call the frame's own `axe`.** Calling the parent's `axe.run()` with a node from another document throws `TypeError: axe.run arguments are invalid`. Passing `iframe.contentDocument` or `iframe.contentDocument.documentElement` to the parent instance fails the same way.

Set an explicit iframe width. Viewport-dependent rules (target size, reflow-adjacent layout) resolve against the frame's box, not the browser window, so an unsized frame gives results for a width nobody uses.

### Long runs

A CDP `Runtime.evaluate` call typically caps out around 45 seconds, which is far less than a site-wide scan needs. Start the loop without awaiting it, then poll:

```js
window.RESULTS = {}; window.DONE = false;
(async () => {
for (const p of window.ALL) {
try { window.RESULTS[p] = await window.scanViaIframe(p); }
catch (e) { window.RESULTS[p] = { error: String(e) }; }
}
window.DONE = true;
})();
'started, total=' + window.ALL.length;
```

Then in a later call:

```js
({ done: window.DONE, scanned: Object.keys(window.RESULTS).length });
```

### Survive a host-page reload

Anything on `window` dies if the host page reloads, and on a dev server it will. Hugo, Vite, and webpack dev servers all inject a livereload client that reloads every open page on any file change, including an edit someone else makes while your scan runs. Losing a 274-page run to a one-character commit is avoidable: persist after each page and make the loop resumable.

```js
const KEY = 'axeResults';
const load = () => JSON.parse(localStorage.getItem(KEY) || '{}');
const save = (o) => localStorage.setItem(KEY, JSON.stringify(o));

(async () => {
const acc = load();
for (const p of window.ALL) {
if (acc[p]) continue; // resume: skip what's already done
try { acc[p] = await window.scanViaIframe(p); }
catch (e) { acc[p] = { error: String(e) }; }
save(acc); // checkpoint every page
}
localStorage.setItem('axeDone', '1');
})();
```

After a reload, re-inject axe and the helpers, then re-run the same block: it picks up where it stopped. Clear `axeResults` and `axeDone` before an intentionally fresh run, or you will read a stale scan as a current one.

Neutralising livereload on the host page helps but is not sufficient on its own, since the reload can land between two evaluate calls:

```js
try { if (window.LiveReload && window.LiveReload.shutDown) window.LiveReload.shutDown(); } catch (e) {}
```

Symptom to recognise: an evaluate call returns `Inspected target navigated or closed`, or `window.RESULTS` comes back `undefined` after previously reporting progress. That is a reload, not a finished run. Do not read the empty state as a clean result.

### The host tab degrades over a long run

Each page costs an iframe plus an axe instance, and the cost accumulates. On a run of a few hundred pages the tab slows to a crawl: a rate of roughly two pages per second collapses to one page per 45 seconds, while the dev server still answers in single-digit milliseconds.

Diagnose before assuming the server is at fault. Time a request from the shell (`curl -s -o /dev/null -w '%{time_total}'`); if the server is fast, the tab is the bottleneck. Check `performance.now()` and `performance.getEntriesByType('navigation')[0].type` to confirm whether the page reloaded or has simply been alive a long time.

The fix is to reload the host tab and restart the loop. With localStorage checkpointing the run resumes from where it stopped and speed returns to normal. On a large site, plan to do this every couple of hundred pages rather than waiting for the stall.

### Getting the URL list

Prefer the site's own sitemap over a crawl, so coverage is checkable against a number the site publishes:

```js
window.ALL = await fetch('/sitemap.xml').then(r => r.text()).then(async t => {
const subs = [...t.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
const out = new Set();
for (const s of subs) {
const x = await fetch(new URL(s).pathname).then(r => r.text());
[...x.matchAll(/<loc>([^<]+)<\/loc>/g)].forEach(m => out.add(new URL(m[1]).pathname));
}
return [...out].sort();
});
window.ALL.length;
```

That two-level walk handles sitemap indexes. A flat sitemap returns its own URLs on the first pass.

### Settle before running, or contrast results are noise

`color-contrast` resolves computed colors against rendered pixels. Run it before webfonts and stylesheets have settled and axe measures a half-painted page, producing contrast failures that do not reproduce. A too-short delay does not fail loudly; it invents findings.

Wait for fonts explicitly, then add a real delay:

```js
try { await d.fonts.ready; } catch (e) {}
await new Promise(r => setTimeout(r, 500));
```

A 120ms settle produced four bogus `color-contrast` pages in a 274-page run; every one came back clean at 600ms, twice. Treat any contrast finding that appears in one pass and not the next as unproven until a second method confirms it.

**Re-verify every flagged page individually before reporting it.** A batch loop is the cheap wide net; it is not the evidence. Re-run each hit on its own with a longer settle, twice, and keep only what reproduces. Findings that survive should also have a named cause (a token, a rule, a computed pair), not just a rule id.

## Validate the harness before trusting it

A scan loop that silently returns empty results looks identical to a clean site. Before reporting anything:

1. Scan one page directly (`axe.run(document)`) and the same page through the iframe harness. The `violations` arrays must match. `passes` counts can differ, because some rules do not apply inside a frame.
2. Confirm the run surfaces at least one violation somewhere, or deliberately break a page (remove an `alt`, drop a label) and confirm the harness reports it.

Without step 2, "zero violations across the site" is unfalsifiable.

## What this does not cover

axe detects roughly a third of AA failures. An injected-axe run replaces neither the keyboard-only pass, the contrast and zoom pass, nor the screen-reader pass. Report it as one pass of four, and never phrase a clean axe run as conformance.

## Network access

The snippets fetch axe-core from a public CDN. On a network without egress, or where pulling third-party script into a page under audit is not acceptable, vendor `axe.min.js` locally and point `s.src` at the local copy. Do not add it to the project's dependency manifest to get around this.
2 changes: 1 addition & 1 deletion scripts/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ for (const id of ['2.4.11', '2.5.7', '2.5.8', '3.2.6', '3.3.7', '3.3.8']) {

// No emdashes in authored docs.
const authored = [['SKILL.md', skill]];
for (const f of ['README.md', 'AGENTS.md', 'CHANGELOG.md']) {
for (const f of ['README.md', 'AGENTS.md', 'CHANGELOG.md', 'references/running-axe.md']) {
authored.push([f, readFileSync(join(root, f), 'utf8')]);
}
for (const [name, text] of authored) {
Expand Down