axe-core Configuration & Setup for Reproducible Scans
Two engineers can scan the same commit with the same version of axe-core and report different violation counts, and both numbers can be honest: one of them ran 89 rules and the other ran three, and neither configuration said so out loud. That gap is the whole subject of this guide, which is part of Web Accessibility Testing Fundamentals & Tool Selection. It covers the five decisions that make an axe-core run reproducible enough to block a merge on — which rule tags run, where the configuration lives, what part of the page is in scope, which rules are turned off and for how long, and how much data comes back — with rule counts verified against axe-core 4.12.
Problem Statement
axe-core ships 105 rules. Which of them execute on a given run is decided by a runOnly tag list, a per-rule enabled map, a default exclusion list for experimental and deprecated rules, and each rule’s own selector against the scan context. None of those inputs appear in the output that a reviewer sees, so a green check reports nothing about how much was actually checked. A team that writes --tags wcag2aa believing it means “WCAG 2.0 Level AA” is running exactly three rules — color-contrast, meta-viewport and valid-lang — because axe-core tags are not cumulative: each tag carries only the rules that its own WCAG version and level introduced.
The second failure is configuration sprawl. A Playwright suite, a Cypress component run, a URL crawl with @axe-core/cli and a Storybook task each end up with their own options object, usually copied at different times. A violation then blocks a pull request in one runner and is invisible in another, which teaches developers that accessibility failures are a property of the tool rather than of the page. The fix is not discipline; it is a single module that every runner imports and nobody is allowed to fork.
The third is payload size. The default reporter returns every passing node with an HTML snippet for each, and a moderately large marketing page can produce a multi-megabyte JSON document per URL. Multiply that by 40 routes and three viewports and the artifact upload becomes the slowest step in the job, while the interesting 12 lines are buried. Trimming the payload deliberately — rather than by deleting results after the fact — keeps the report diffable and the runner inside its memory budget.
Key implementation targets:
- A conformance tier expressed as an explicit tag ladder from WCAG 2.0 A to 2.2 AA, with a rule count you can assert on.
- A single frozen configuration module consumed by Playwright, Cypress, the CLI and any local script.
- Scan contexts defined per page template with
includeandexclude, including shadow-piercing and frame-aware selectors. - Per-rule overrides that carry an owner, a ticket and an expiry date, enforced by a unit test rather than by a code review habit.
- A report shape that keeps
violationsandincompletecomplete and everything else cheap. - A gate whose exit code is derived from impact, not from a raw violation count.
Prerequisites
1. Pick the Tag Set and the Conformance Tier First
Every other decision depends on this one. axe-core labels each rule with the WCAG version and level that introduced its success criterion — wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa — plus a catalogue of orthogonal tags such as best-practice, experimental, deprecated, review-item, section508 and the cat.* categories. Naming a tag in runOnly restricts the run to rules carrying that tag. Because the tags describe origin rather than accumulation, the tier you actually want is a list, not a single value: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] resolves to 63 executing rules in axe-core 4.12, while any single tag from that ladder resolves to somewhere between zero and 57.
Two default exclusions change the arithmetic. axe-core carries a tagExclude list defaulting to experimental and deprecated, and a rule matching an excluded tag is dropped unless you name that tag in the include list yourself. That is why wcag21a contributes nothing today: its only rule, label-content-name-mismatch for WCAG 2.2 SC 2.5.3 (Label in Name), is still experimental. It is also why the old duplicate-id failures vanished from reports: that rule is tagged deprecated and wcag2a-obsolete, because WCAG 2.2 removed SC 4.1.1 (Parsing) entirely. Keep wcag21a in the ladder anyway — the day axe-core promotes a 2.1 Level A rule out of experimental, your gate picks it up without a config change.
The one rule that behaves unintuitively is target-size for WCAG 2.2 SC 2.5.8 (Target Size, Minimum). It ships with enabled: false, so a bare axe.run() with no runOnly never executes it — but naming the wcag22aa tag does execute it, because an explicit tag match takes precedence over the rule’s default state. In other words “no tags” is not a superset of “all tags”, and a team that dropped runOnly to “test everything” is quietly missing the only WCAG 2.2 rule axe-core has.
best-practice is the tag that most often ends up on a merge gate by accident, and it should not be there. Its 27 rules encode Deque’s house recommendations rather than any success criterion: region wants every visible node inside a landmark, landmark-one-main wants exactly one <main>, page-has-heading-one wants an <h1>, heading-order wants no skipped levels, skip-link wants the target of a fragment link to exist. All are good advice and none of them is a conformance failure, so blocking a merge on them means blocking a merge on a style opinion. There is no overlap at all between the WCAG tier tags and best-practice in 4.12, which makes the split clean: gate on the 63, report the 27.
The exception worth knowing is frame-tested, a best-practice and review-item rule whose only job is to tell you that an <iframe> on the page was never scanned because axe-core was not present inside it. Run a WCAG-only tag list and that warning disappears along with the rest of best-practice, so a page whose entire checkout flow lives in a frame reports zero violations and looks clean. Re-enable it by rule id, which takes precedence over the tag filter, and treat its incomplete results as a coverage report — the mechanics are in scanning shadow DOM and iframes with axe-core.
| Tag | Tagged | Runs | What it adds | Merge gate |
|---|---|---|---|---|
wcag2a |
62 | 57 | The 2.0 Level A baseline: names, alts, roles, labels | Yes |
wcag2aa |
3 | 3 | color-contrast, meta-viewport, valid-lang |
Yes |
wcag21a |
1 | 0 | One experimental rule; keep it for forward cover | Yes |
wcag21aa |
3 | 2 | autocomplete-valid, avoid-inline-spacing |
Yes |
wcag22aa |
1 | 1 | target-size for SC 2.5.8 |
Yes |
best-practice |
30 | 27 | Landmarks, heading order, frame-tested coverage |
Warning only |
experimental |
7 | — | Unstable rules, excluded unless named | No |
deprecated |
5 | — | Rules for withdrawn criteria such as SC 4.1.1 | No |
The two columns differ because seven experimental and five deprecated rules also carry tier tags, and axe drops them before the run: 57 + 3 + 0 + 2 + 1 is the 63 the gate executes, out of 70 rules that carry a tier tag at all. Put the ladder in a module of its own so the tier is a reviewable artifact rather than a literal buried in a spec file.
// a11y/tags.mjs — the only place the conformance tier is decided.
// axe-core tags are not cumulative: each carries only the rules its own
// WCAG version and level introduced, so the ladder is listed in full.
export const WCAG_22_AA = [
'wcag2a', // 62 tagged, 57 run — the WCAG 2.0 Level A baseline
'wcag2aa', // 3 tagged, 3 run — contrast, meta-viewport, valid-lang
'wcag21a', // 1 tagged, 0 run — experimental; kept for forward cover
'wcag21aa', // 3 tagged, 2 run — autocomplete-valid, inline-spacing
'wcag22aa', // 1 tagged, 1 run — target-size (SC 2.5.8), off untagged
];
// Reported, never gated: house style rather than a conformance claim.
export const ADVISORY = ['best-practice'];
// frame-tested is a best-practice rule, so a WCAG-only tag list stops
// reporting unscanned iframes. A rule id beats the tag filter, so keep
// this one on explicitly.
export const ALWAYS_ON = { 'frame-tested': { enabled: true } };
Decide the tier against numbers rather than against the tag’s name. The script below prints what each tag really contributes in the version you have installed, which takes ten seconds and settles most arguments about whether the gate is strict enough.
// a11y/print-rule-count.mjs — run: node a11y/print-rule-count.mjs
import axe from 'axe-core';
import { WCAG_22_AA, ADVISORY } from './tags.mjs';
const rules = axe.getRules();
const excluded = ['experimental', 'deprecated']; // axe's default tagExclude
const count = (tag) =>
rules.filter(
(r) => r.tags.includes(tag) && !r.tags.some((t) => excluded.includes(t)),
).length;
for (const tag of [...WCAG_22_AA, ...ADVISORY]) {
console.log(`${tag.padEnd(14)} ${String(count(tag)).padStart(3)} rules`);
}
const gated = rules.filter(
(r) =>
r.tags.some((t) => WCAG_22_AA.includes(t)) &&
!r.tags.some((t) => excluded.includes(t)),
);
console.log(`\ngate total ${gated.length} rules (axe-core ${axe.version})`);
2. Put the Options in One Module Every Runner Imports
A configuration that lives in four places is four configurations. The module below is the single source of truth: it composes the tag ladder, the override map from section four, the result-type selection from section five, and the impact threshold the gate uses. Object.freeze is not decoration — a runner that mutates a shared options object between scans produces results that depend on test execution order, which is the hardest kind of flake to reproduce.
// a11y/axe.config.mjs — imported by every runner; never forked per app.
import { WCAG_22_AA, ALWAYS_ON } from './tags.mjs';
import { activeRuleOptions } from './overrides.mjs';
export const AXE_VERSION = '4.12.1'; // must match the pinned dependency
export const runOptions = Object.freeze({
runOnly: { type: 'tag', values: WCAG_22_AA },
rules: { ...ALWAYS_ON, ...activeRuleOptions() },
resultTypes: ['violations', 'incomplete'], // other types trim to one node
reporter: 'v2', // v1 is identical plus a failureSummary string per node
ancestry: true, // adds a second selector path, useful for baseline diffs
selectors: true, // keep target[] so PR annotations can point at a node
elementRef: false, // never serialise live DOM nodes into a JSON report
});
export const blockingImpacts = Object.freeze(['critical', 'serious']);
Each runner gets a thin adapter that does nothing but hand the shared object over. In Playwright the adapter is also the right place to hide the difference between .include() and a raw context object, so specs never construct an AxeBuilder themselves — the wider Playwright wiring is covered in integrating axe-core Playwright into an existing project.
// tests/a11y/scan.ts — the only place AxeBuilder is constructed.
import type { Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { runOptions, blockingImpacts } from '../../a11y/axe.config.mjs';
export async function scan(page: Page, include = 'body') {
const results = await new AxeBuilder({ page })
.include(include)
.options(runOptions) // the same object the CLI and Cypress receive
.analyze();
const blocking = results.violations.filter((v) =>
blockingImpacts.includes(v.impact ?? 'minor'),
);
return { results, blocking };
}
The CLI path reads the same module rather than repeating the tag list on a command line, which is where drift usually starts. @axe-core/cli takes a comma-separated tag string, so the shell script asks Node for it instead of hard-coding one.
#!/usr/bin/env bash
# a11y/scan-urls.sh — usage: ./a11y/scan-urls.sh http://localhost:4173/
set -euo pipefail
TAGS="$(node --input-type=module -e "
import { WCAG_22_AA } from './a11y/tags.mjs';
console.log(WCAG_22_AA.join(','));
")"
# Comments live inside the array literal so no flag loses its annotation.
AXE_ARGS=(
--tags "$TAGS"
--exit # non-zero exit as soon as one violation is found
--load-delay 500 # settle time in ms after load, before rules run
--save a11y-cli.json # written next to the Playwright reports
)
npx axe "$@" "${AXE_ARGS[@]}"
The module needs its own test, because the thing most likely to change the gate is not an edit to the config — it is a dependency bump. A test that asserts the resolved rule count turns an axe-core upgrade into a visible diff that a reviewer has to approve, with the new and removed rule ids in the failure message. Run it before the browser scan: a rule-count surprise should cost six seconds, not a full Playwright run.
// a11y/axe.config.test.mjs — run: node --test a11y/axe.config.test.mjs
import assert from 'node:assert/strict';
import { test } from 'node:test';
import axe from 'axe-core';
import { runOptions, AXE_VERSION } from './axe.config.mjs';
const EXPECTED_RULE_COUNT = 63; // bump deliberately, in a commit that says why
test('the installed axe-core matches the pinned version', () => {
assert.equal(axe.version, AXE_VERSION);
});
test('the gate resolves to the expected rule set', () => {
const excluded = ['experimental', 'deprecated'];
const ids = axe
.getRules()
.filter((r) => r.tags.some((t) => runOptions.runOnly.values.includes(t)))
.filter((r) => !r.tags.some((t) => excluded.includes(t)))
.map((r) => r.ruleId)
.sort();
assert.equal(ids.length, EXPECTED_RULE_COUNT, ids.join(','));
assert.ok(ids.includes('target-size'), 'SC 2.5.8 must be in the gate');
assert.ok(!ids.includes('region'), 'best-practice must stay off the gate');
});
Framework-specific timing and selector work sits on top of this module rather than inside it. Hydration waits, StrictMode double renders and portal roots are all properties of the application, not of the rule set, and they belong in the adapter layer described in how to configure axe-core for React and Vue applications. In a workspace with several apps the module becomes a package, which is the shape worked through in setting up axe-core in a Next.js monorepo.
3. Scope the Run with include and exclude
A scan context answers “which part of this document is under test”. axe-core accepts a bare CSS selector string, an array of selectors, or an object with include and exclude arrays; the array-of-arrays form ([['#frame', 'main']]) is the frame-hopping syntax, where each element after the first descends one boundary. Modern axe also accepts labelled selector objects — { fromShadowDom: [...] } and { fromFrames: [...] } — which are far easier to read than nested arrays and are validated at run time rather than failing silently.
Exclusion is the more valuable half in practice. Third-party markup you cannot edit — a consent banner, a chat widget, an embedded map — generates violations that no pull request can fix, and leaving them in the report trains everybody to ignore the report. Exclude them by a selector you control, record why in the same place you record rule overrides, and scan the vendor surface separately if the vendor is contractually on the hook for it. What you must not do is exclude a container because a rule fires inside your own code; that is a suppression wearing a scoping costume.
That side note is the trap most teams hit within a week of adopting include. Several rules are written with the selector html:not(html *), meaning they match only the root element: document-title for WCAG 2.2 SC 2.4.2 (Page Titled), html-has-lang for SC 3.1.1 (Language of Page), bypass for SC 2.4.1 (Bypass Blocks), plus the landmark-one-main and page-has-heading-one advisories. Scope the run to main and all of them go quiet — not because the page passes, but because the rule has nothing to match. bypass is additionally flagged as page-level in the rule metadata and is skipped outright unless the context is the whole document. Run one root-context scan per page for document-level criteria, and use narrowed contexts only for component-level work.
// a11y/context.mjs — one exported context per page template.
// Document-level rules need the root; component work uses a subtree.
export const documentContext = { include: [':root'], exclude: ['iframe'] };
export const checkoutContext = {
include: ['main'],
exclude: [
'.cookie-consent', // vendor markup, no source access
'#chat-frame', // third-party iframe, scanned on its own
'[data-a11y-scan="skip"]', // opt-out marker; needs an override entry
],
};
// One array element per shadow boundary hop, outermost host first.
export const paymentWidgetContext = {
include: [{ fromShadowDom: ['checkout-app', 'payment-widget', 'form'] }],
};
// A frame nested inside a shadow root: the frame selector may contain a
// shadow selector, but neither form may be nested inside itself.
export const embeddedFrameContext = {
include: [{ fromFrames: [{ fromShadowDom: ['app-shell', 'iframe'] }, 'main'] }],
};
An include selector that matches nothing is not a quiet no-op: axe throws No elements found for include in page Context and the runner reports a scan error rather than a violation. That is the correct behaviour and a useful assertion — a context that stops resolving because a route was renamed should fail loudly instead of passing an empty page. It is also the cheapest way to prove a shadow-piercing selector works before you rely on it.
4. Disable a Rule Only With a Justification and an Expiry
Per-rule overrides go in the rules map: { 'color-contrast': { enabled: false } }. Two details matter. First, an explicit enabled boolean beats the tag filter in both directions — it turns a rule on that no tag selected, and off that a tag did select. Second, the map is silent: nothing in the results explains why 62 rules ran instead of 63. A bare enabled: false is therefore an undocumented, permanent change to what the product claims about itself, made in a file that gets reviewed once.
The fix is to make the override a record rather than a flag. Every entry carries the rule id, the reason, the owner, a ticket and an expiry date; the config derives the axe rules map from that record, and a unit test fails the build when a date passes. The gate does not weaken over time, because an override that nobody renews turns back into a failing rule on a known day, and the diff that renews it is a conversation.
// a11y/overrides.mjs — every disabled rule is a dated, owned record.
export const OVERRIDES = [
{
ruleId: 'color-contrast',
enabled: false,
reason:
'Brand palette AA remediation is landing in the design-token release; ' +
'axe cannot resolve contrast over the hero video poster frame.',
owner: 'design-systems',
ticket: 'A11Y-2841',
expires: '2026-09-30',
},
{
ruleId: 'nested-interactive',
enabled: false,
reason:
'Legacy data grid renders a button inside a row with role=link; the ' +
'grid is being replaced, not patched.',
owner: 'reporting-web',
ticket: 'A11Y-2903',
expires: '2026-08-15',
},
];
// Build the axe `rules` map from the records, ignoring expired entries so an
// unrenewed override fails the scan rather than silently living forever.
export function activeRuleOptions(today = new Date()) {
return Object.fromEntries(
OVERRIDES.filter((o) => new Date(o.expires) > today).map((o) => [
o.ruleId,
{ enabled: o.enabled },
]),
);
}
export function expiredOverrides(today = new Date()) {
return OVERRIDES.filter((o) => new Date(o.expires) <= today);
}
// a11y/overrides.test.mjs — run: node --test a11y/overrides.test.mjs
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { OVERRIDES, expiredOverrides } from './overrides.mjs';
test('every override carries an owner, a ticket and a reason', () => {
for (const o of OVERRIDES) {
assert.ok(o.owner, `${o.ruleId}: missing owner`);
assert.match(o.ticket, /^A11Y-\d+$/, `${o.ruleId}: missing ticket`);
assert.ok(o.reason.length > 40, `${o.ruleId}: reason is not a reason`);
}
});
test('no override is more than 180 days from today', () => {
const limit = Date.now() + 180 * 24 * 60 * 60 * 1000;
for (const o of OVERRIDES) {
assert.ok(new Date(o.expires) < limit, `${o.ruleId}: expiry too far out`);
}
});
test('no override has expired', () => {
const stale = expiredOverrides().map((o) => `${o.ruleId} (${o.ticket})`);
assert.deepEqual(stale, [], `renew or delete: ${stale.join(', ')}`);
});
Prefer narrowing over disabling wherever the failure is local. If color-contrast is only unresolvable over one hero image, exclude that one element and keep the rule live everywhere else; if a vendor widget trips aria-allowed-attr, exclude the widget rather than the rule. A disabled rule is global, and a global suppression for a local problem is how a gate ends up with 40 rules and a clean report. The judgement calls — which flags are genuine engine limitations and which are real defects wearing a false-positive label — are worked through in reducing false positives in automated accessibility scanners.
5. Keep the Payload Small with resultTypes and reporter
axe returns four result arrays: violations, passes, incomplete and inapplicable. resultTypes does not delete the ones you leave out — it truncates each of their rule entries to a single node, which is a subtle but important difference. The rule entries still appear, so a script that counts results.passes.length keeps working while a script that sums passes[].nodes.length starts reporting nonsense. Setting resultTypes: ['violations', 'incomplete'] typically removes the large majority of the payload on a content-heavy page, because passing nodes vastly outnumber failing ones and each carries an HTML snippet.
The reporter choice is the other lever. v2 is the default and returns the aggregated result object; v1 is identical plus a failureSummary string on every violation and incomplete node, which is genuinely useful in a terminal and pure weight in an artifact; no-passes forces resultTypes to violations only; raw returns the unaggregated per-rule structure and is the largest of all. For direct axe.run users, axe.configure({ noHtml: true }) replaces every node’s html snippet with null, which is usually the single biggest saving available. When a runner wraps axe and does not expose configure, get the same effect by projecting the result into your own report shape.
// a11y/report.mjs — project the run into a small, diffable report.
import { writeFile } from 'node:fs/promises';
export async function writeReport(results, path) {
const slim = {
engine: results.testEngine, // name + version, for traceability
url: results.url,
timestamp: results.timestamp,
violations: results.violations.map((v) => ({
id: v.id,
impact: v.impact,
help: v.help,
// target[] is what a PR annotation needs; html snippets are dropped.
nodes: v.nodes.map((n) => ({ target: n.target, ancestry: n.ancestry })),
})),
// Keep ids only: incomplete is triage material, not a failure list.
incomplete: results.incomplete.map((i) => ({
id: i.id,
nodes: i.nodes.length,
})),
};
const body = JSON.stringify(slim, null, 2);
await writeFile(path, body);
console.log(`${path}: ${(body.length / 1024).toFixed(1)} kB`);
return slim;
}
Two flags are worth leaving alone. selectors: false drops the target array and roughly halves what remains, but it also removes the only thing that lets a reviewer find the element, so it belongs in throughput benchmarks and nowhere near a gate. elementRef: true puts live DOM node references in the result, which is convenient in a browser console and throws or serialises to {} the moment the result crosses a process boundary. ancestry: true is the one addition worth its bytes: a second, structural selector path per node survives class-name churn and makes baseline diffing far less noisy.
Pipeline Integration
The gate has three steps in a deliberate order: prove the configuration still means what it claims, run the scan, then decide the exit code from impact rather than from a count. Putting the config tests first means a dependency bump or an expired override fails in seconds with an unambiguous message, instead of surfacing as a browser scan whose violation list looks like a product regression.
name: a11y-config-gate
on:
pull_request:
paths:
- 'a11y/**'
- 'src/**'
- 'tests/a11y/**'
- '.github/workflows/a11y-config-gate.yml'
concurrency:
group: a11y-config-gate-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Assert the config still means what it says
run: node --test a11y/ # rule-count drift and expired overrides
- run: npx playwright install --with-deps chromium
- name: Scan with the shared configuration
run: npx playwright test tests/a11y --reporter=line
- name: Rule-level summary in the run page
if: always()
run: node a11y/summarise.mjs a11y-results >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-report-${{ github.run_attempt }}
path: a11y-results/
retention-days: 7
The summary step is what makes the gate usable without downloading an artifact. It reads the slim reports written by writeReport and prints one table row per rule, so a reviewer sees link-name x3 rather than “the accessibility job failed”. Feeding the same JSON to a bot comment or a Slack message is the shape described in structuring JSON violation output for Slack and GitHub annotations.
// a11y/summarise.mjs — usage: node a11y/summarise.mjs a11y-results
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
const dir = process.argv[2];
const files = (await readdir(dir)).filter((f) => f.endsWith('.json'));
const counts = new Map();
let incomplete = 0;
for (const file of files) {
const report = JSON.parse(await readFile(join(dir, file), 'utf8'));
for (const v of report.violations) {
const key = `${v.id} (${v.impact})`;
counts.set(key, (counts.get(key) ?? 0) + v.nodes.length);
}
incomplete += report.incomplete.reduce((n, i) => n + i.nodes, 0);
}
console.log(`### Accessibility gate — ${files.length} page(s) scanned\n`);
console.log('| Rule | Failing nodes |');
console.log('|---|---|');
for (const [rule, n] of [...counts].sort((a, b) => b[1] - a[1])) {
console.log(`| ${rule} | ${n} |`);
}
console.log(`\n${incomplete} node(s) need manual review (incomplete).`);
Exit-code policy belongs in the adapter, not the workflow. blockingImpacts in the shared config is the whole rule: critical and serious fail the job, moderate and minor are reported, and incomplete never fails anything because it means “axe could not decide”, not “this is broken”. Teams adopting the gate on an existing codebase should pair that threshold with a ratchet rather than a big-bang cutover, which is what progressive threshold management is for.
Troubleshooting and Flaky-Test Mitigation
Three rules, green build. The symptom is a job that finishes suspiciously fast on a page you know is broken. Print the tag ladder’s resolved rule count with the script from section one; if it says 3, the runOnly value is a single tag. If it says 89, runOnly is missing entirely and best-practice is in the gate while target-size is not.
A rule exists, is spelled correctly, and never runs. It carries experimental or deprecated, both of which axe excludes by default through tagExclude. Adding the rule id to the rules map with enabled: true is the reliable fix, because an explicit rule option outranks tag filtering; adding experimental to the tag list works too but drags in six other unstable rules.
No elements found for include in page Context. The context selector matched nothing. On a single-page application this is almost always a timing problem — the scan ran before the route rendered main — and on a localised build it is often a selector tied to a translated aria-label. Assert the selector with the runner’s own locator before calling the scan, so the failure names the missing element instead of the scan.
Axe is already running. axe refuses concurrent runs in one document and throws with that message. It happens when a helper fires a scan without awaiting the previous one, typically inside a Promise.all over routes in a single tab. One page context, one scan at a time; parallelise across browser contexts or shards instead.
color-contrast returns incomplete on CI but violations locally. axe needs to read the computed background, and it cannot when the element sits over an image, a gradient or a cross-origin stylesheet the headless browser refused to expose. Serve CSS and fonts from the same origin in the test environment, and treat the remaining incomplete entries as a manual-review queue rather than trying to force them into a verdict.
Counts that differ between shards. Two runners scanning the same URL with different include contexts produce different totals, and the root-selector rules from section three are usually the difference: the shard that scanned :root reported document-title and html-has-lang, the one that scanned main could not. Pin one context per page template in context.mjs and never pass an ad-hoc selector from a spec.
Violation counts that drift without a code change. A caret range on axe-core means a minor upgrade can add rules mid-sprint. Pin the exact version, let the dependency bot open the upgrade as its own pull request, and let axe.config.test.mjs fail so the diff shows which rule ids arrived.
A hydration race that looks like a scanner bug. Scanning during hydration produces violations on placeholder markup and misses everything rendered after; scanning after a fixed waitForTimeout works until a slower runner appears. Wait on an application-observable fact instead, and keep the wait in the adapter so every runner inherits it.
Common Pitfalls
- Writing
--tags wcag2aaorrunOnly: ['wcag2aa']and believing the gate covers Level AA, when it covers three rules. - Leaving
best-practiceon the blocking gate, so a missing<h1>or an un-landmarked<div>blocks a release while nothing about WCAG conformance changed. - Dropping
runOnlyentirely to “run everything”, which silently omitstarget-sizebecause that rule is disabled by default and only a tag match turns it on. - Disabling
frame-testedalong with the rest ofbest-practice, which removes the only signal that an iframe was never scanned. - Narrowing
includeto a component and then reporting the result as a page-level conformance check, withdocument-title,html-has-langandbypassquietly inapplicable. - Disabling a rule globally to fix one element, when an
excludeselector on that element keeps the rule live everywhere else. - Recording a suppression as
{ enabled: false }with no owner, ticket or expiry, so the gate weakens permanently through a one-line diff. - Keeping the default result payload, then discovering the artifact upload is the slowest step in the pipeline and the runner is out of heap on the largest route.
- Allowing a caret dependency range on axe-core, so the rule set changes on an unrelated
npm install. - Letting each runner keep its own copy of the options object, which makes “does this violation block a merge” depend on which job found it.
FAQ
Why does adding wcag22aa to the tag list barely change the violation count?
Because it contributes exactly one rule in axe-core 4.12: target-size, for WCAG 2.2 SC 2.5.8 (Target Size, Minimum). WCAG 2.2 added nine success criteria and most of them — focus appearance, dragging movements, consistent help, redundant entry, accessible authentication — cannot be decided from a static DOM, so no automated rule exists. The tag is still worth listing, both for that one rule and so a future axe release lands in the gate automatically.
Should the gate use runOnly with tags or with explicit rule ids?
Tags for anything long-lived, rule ids only for a temporary experiment. A tag ladder keeps picking up new rules as axe-core grows, which is what you want from a conformance gate; a hard-coded list of 63 rule ids freezes the gate at the day it was written and quietly stops improving. Assert the resolved rule count in a test instead — that way the gate stays open to new rules while an upgrade still requires a human to acknowledge the change.
Is it safe to disable color-contrast in CI because it produces so many incomplete results?
Disabling it removes the most commonly failed AA criterion from the gate, so no. incomplete results are not failures and do not affect the exit code; they are axe declining to guess when it cannot read the background. Reduce them by making the test environment render like production — same-origin stylesheets, real fonts, no placeholder images — and route the remainder to manual review rather than deleting the rule.
Where should the shared configuration live in a monorepo? In a workspace package that the apps depend on, versioned like any other internal library, with the tag ladder and overrides in that package rather than in each app. Apps then contribute only their own contexts and URL lists. The tag change that upgrades the tier becomes a single reviewed commit with a visible blast radius, instead of six copies that drift over a quarter.
Does resultTypes make the scan faster or just the report smaller?
Mostly smaller, with a modest speed benefit from skipping aggregation work. All the rules still execute — axe has to evaluate a node to know it passed — so the browser work is unchanged. What shrinks is the serialisation and transfer of passing nodes, which is where a multi-megabyte report comes from; expect the payload rather than the runtime to fall.
Related
- Web Accessibility Testing Fundamentals & Tool Selection — the section comparing axe-core with Lighthouse CI, Pa11y, Playwright and Cypress.
- Scanning Shadow DOM and Iframes with axe-core — reaching content the default context cannot see, and spotting a silent zero-violation pass.
- How to Configure axe-core for React and Vue Applications — the hydration timing and portal-root scoping layered on top of this configuration.
- Reducing False Positives in Automated Accessibility Scanners — deciding which flags deserve an override and which are real defects.
- Setting Up axe-core in a Next.js Monorepo — publishing this configuration as a workspace package consumed by every app.