Axe-Core Enterprise Configuration

Out of the box, axe-core is tuned for a developer running a one-off check in a single browser tab, not for a fleet of workers gating thousands of routes. Its defaults quietly include best-practice rules, evaluate the entire document including third-party frames, and serialize DOM handles that inflate every payload. Left unpinned, the same code produces different results on two CI runners because each one bundled a slightly different engine version. This guide is the control layer of the broader Automated Scanning & Dynamic Content Ingestion strategy: it shows how to constrain the engine into a deterministic, version-controlled configuration so that every scan — local, pre-production, or in a distributed pipeline — evaluates the same WCAG success criteria against the same scope and emits an identically shaped result.

The failure this page prevents is subtle. A misconfigured engine does not crash; it returns a green report for a page that is unusable with assistive technology, or a noisy report full of best-practice findings that teams learn to ignore. Both outcomes erode trust in the gate. Getting the configuration right is what separates a scanner that blocks real regressions from one that is muted within a sprint.

Prerequisites & Environment Parity

Configuration is only reproducible if the runtime around it is pinned. Before authoring a single rule override, lock the following so that results do not drift between a developer’s laptop and the pipeline:

  • axe-core, pinned to an exact version (e.g. axe-core@4.10.2) in package.json with a committed lockfile. A minor engine bump can add rules or change an impact label, which shifts violation counts under a fixed configuration.
  • Browser engine, pinned via the automation driver. Use the browser that ships with your pinned Playwright headless scanning workflows install (playwright install --with-deps chromium) rather than a system Chrome that updates independently.
  • A stable, hydrated DOM at evaluation time. Configuration governs what axe checks; it does not wait for the page to finish rendering. Single-page frameworks must reach a settled DOM — driven by the traversal layer — before axe.run() fires, or scoped selectors match nothing.
  • A defined conformance target. Decide whether the gate enforces WCAG 2.2 Level AA (the common enterprise baseline) before writing tags. The thresholds that decide which levels actually block a deployment are governed by the A/AA/AAA compliance level mapping, and the criterion-by-criterion breakdown of what is machine-decidable lives in the WCAG 2.2 vs 3.0 success criteria taxonomy.

Treat the configuration itself as infrastructure-as-code: a single axe-config.json in version control, shipped alongside application releases, injected identically into every environment. No environment-specific overrides, no inline tweaks in individual test files.

How the Configuration Surface Behaves

Axe-core accepts two independent inputs, and conflating them is the most common source of wrong results. The first is the context — the include and exclude selectors passed as the first argument to axe.run() — which decides where in the DOM the engine looks. The second is the options object, passed as the second argument, which decides which rules run and how results are shaped. The include/exclude selectors do not belong in the options object, and the tag/rule settings do not belong in the context; keeping them separate is what makes the configuration predictable.

Within the options object, the runOnly field is the sharpest edge. Tag values combine with OR, not AND. Adding a category tag like cat.forms alongside wcag2aa does not narrow the run to “AA forms rules” — it broadens it to every AA rule plus every forms rule, including best-practice checks that carry no conformance obligation. To get a conformance-only scan, keep runOnly to WCAG conformance tags exclusively (wcag2a, wcag2aa, and wcag22aa for the criteria new in 2.2), then enable or disable individual checks through the rules object. The rules object is an AND-style refinement layered on top of the tag selection; the tags choose the population, the rule overrides carve exceptions.

runOnly tags combine with OR: broadening versus conformance-only scoping Two stacked scenarios. Top, an anti-pattern in amber: tag chips wcag2aa plus cat.forms plus best-practice enter an OR gate whose union is every AA rule, every forms rule and best-practice noise. Bottom, the intended pattern in green: only wcag2a and wcag2aa enter the OR gate, producing a conformance-only population; an arrow leads to a rules object node that enables or disables individual checks, producing the final gated rule set. ANTI-PATTERN — a category tag BROADENS the run wcag2aa cat.forms best-practice OR Broadened population AA ∪ forms ∪ best-practice noise INTENDED — conformance tags only, then refine with rules { } wcag2a wcag2aa OR Conformance-only wcag2a ∪ wcag2aa refine rules { } + enable  − disable Gated rule set checks carved by hand

Context scoping is the second lever. The include selectors target the application containers your organization actually controls; exclude removes third-party iframes, authentication overlays, chat widgets, and marketing embeds that would otherwise attribute their violations to your build. Precise scoping is a prerequisite for the dynamic content boundary detection strategy, which formalizes where an audit’s responsibility begins and ends across multi-tenant surfaces.

Result shaping is the third. Fields such as elementRef, selectors, ancestry, and xpath control how much locator metadata each finding carries. Richer metadata makes downstream triage easier but multiplies payload size across thousands of routes — a direct trade-off you tune per pipeline stage. The flow below traces a versioned configuration from definition through scoped execution to the severity decision that gates a merge.

From versioned config to the severity gate that blocks a merge A left-to-right flow of five process steps followed by a decision. Load config (version-controlled), Split (context plus options), Wait for DOM (stabilized and hydrated), axe.run(context, options), Serialize (violations and incomplete). A diamond asks Critical or serious?; yes routes to a red Block merge and route ticket terminal, no routes to a green non-blocking backlog report. yes no Load config Split Wait for DOM axe.run() Serialize version-controlled context + options stabilized · hydrated context, options violations · incomplete Critical or serious? Block merge route ticket Non-blocking to backlog

Step-by-Step Configuration

The following sequence produces a reproducible enterprise configuration and injects it correctly. Each step is independently testable.

1. Author the version-controlled configuration

Separate context from options explicitly so the file maps one-to-one onto the two arguments of axe.run(). Restrict runOnly to conformance tags; use rules only for deliberate exceptions.

{
  "context": {
    "include": [["main#app-content"], ["[role='main']"]],
    "exclude": [["iframe[src*='ads']"], [".cookie-banner"], ["#chat-widget"]]
  },
  "options": {
    "runOnly": { "type": "tag", "values": ["wcag2a", "wcag2aa", "wcag22aa"] },
    "rules": {
      "color-contrast": { "enabled": true },
      "region": { "enabled": false }
    },
    "resultTypes": ["violations", "incomplete"],
    "elementRef": false,
    "selectors": true,
    "ancestry": false,
    "xpath": false,
    "frameWaitTime": 2000
  }
}

2. Split and inject the configuration

Load the file once at process start and pass its two halves as the two arguments to axe.run(). Injecting the engine source with page.add_script_tag(path=...) from the pinned node_modules build — never a browser extension — is what eliminates version drift across runners.

import json
from pathlib import Path
from playwright.async_api import Page

# Loaded once per worker so every route in the shard evaluates against an
# identical, auditable rule set — no per-test overrides.
AXE_CONFIG = json.loads(Path("config/axe-config.json").read_text())

async def run_axe(page: Page) -> dict:
    """Run the pinned enterprise configuration against a stabilized page."""
    # Inject the exact bundled engine, not an extension, to avoid version drift.
    await page.add_script_tag(path="node_modules/axe-core/axe.min.js")
    # context is the FIRST arg (where to look); options is the SECOND (what to run).
    return await page.evaluate(
        "([context, options]) => axe.run(context, options)",
        [AXE_CONFIG["context"], AXE_CONFIG["options"]],
    )

3. Suppress noise with targeted rule overrides

runOnly selects the population; the rules object trims it. Disable checks that are structurally irrelevant to your architecture (for example region on an app that intentionally has no landmark-wrapped shell) rather than muting whole tags. Keep every override annotated with the reason and an owner, because an undocumented disable is indistinguishable from a coverage gap during an audit.

def with_suppressions(options: dict, disabled: dict[str, str]) -> dict:
    """Merge audited rule suppressions into the options object.

    `disabled` maps a rule id to the review ticket that approved it, so the
    suppression is traceable and can be re-litigated when the engine updates.
    """
    merged = {**options, "rules": dict(options.get("rules", {}))}
    for rule_id, _ticket in disabled.items():
        merged["rules"][rule_id] = {"enabled": False}
    return merged

# Approved via architectural review — each key carries its justification ticket.
SUPPRESSIONS = {
    "region": "A11Y-412: shell renders no landmark wrapper by design",
    "scrollable-region-focusable": "A11Y-455: virtualized list manages its own focus",
}

4. Tune result shaping to the pipeline stage

Locator metadata is a payload cost. When the priority is minimizing serialized size across a large route set, set elementRef, ancestry, and xpath to false and keep only selectors: true so triage can still locate a node by CSS path. The configuring axe-core for enterprise-scale batch scanning guide deliberately enables elementRef instead — turn it on only when triage needs live DOM handles inside the same browser context, and leave it off here where results are serialized out of the page and shipped across the network.

5. Validate the configuration before it ships

Structural mistakes in the configuration are silent — an unknown rule id is ignored, a malformed tag simply matches nothing. Validate the file against a schema in CI so a typo fails the build instead of quietly dropping coverage. The same discipline that governs audit output under JSON Schema validation for accessibility data applies to the configuration itself.

from jsonschema import validate

CONFIG_SCHEMA = {
    "type": "object",
    "required": ["context", "options"],
    "properties": {
        "context": {
            "type": "object",
            "properties": {
                "include": {"type": "array"},
                "exclude": {"type": "array"},
            },
        },
        "options": {
            "type": "object",
            "required": ["runOnly"],
            "properties": {
                "runOnly": {
                    "type": "object",
                    "required": ["type", "values"],
                    "properties": {
                        "type": {"const": "tag"},
                        "values": {
                            "type": "array",
                            "items": {"type": "string"},
                            "minItems": 1,
                        },
                    },
                },
            },
        },
    },
}

def load_validated_config(path: str) -> dict:
    cfg = json.loads(Path(path).read_text())
    validate(instance=cfg, schema=CONFIG_SCHEMA)  # raises on structural drift
    return cfg

Configuration Reference

The fields below are the ones that matter for enterprise scale. Context fields belong to the first argument of axe.run(); the rest are options.

Field Type Default Description
include array of selector arrays whole document DOM subtrees to evaluate; scope to owned application containers.
exclude array of selector arrays none Subtrees to skip — third-party iframes, overlays, chat and analytics widgets.
runOnly object {type, values} all enabled rules Tag filter; values combine with OR. Keep to conformance tags only.
rules object {id: {enabled}} engine defaults Per-rule enable/disable layered on top of the tag selection.
resultTypes array of strings all four Limit to ["violations", "incomplete"] to skip building passes/inapplicable and shrink output.
elementRef boolean false Attach live DOM node handles; usable only inside the same browser context.
selectors boolean true Emit a CSS selector path per node so triage can relocate the element.
ancestry boolean false Emit the full ancestor chain for each node; increases payload size.
xpath boolean false Emit an XPath per node; rarely needed alongside selectors.
frameWaitTime number (ms) 60000 How long to wait for cross-frame responses before returning incomplete.
reporter string "v2" Result shape; keep "v2" for the standard violations/passes structure.

Note that DOM-hydration waiting is not an axe option — there is no timeout field that makes the engine wait for a single-page app to render. That waiting is the traversal layer’s job, performed before axe.run() is called. The frameWaitTime option only bounds cross-frame messaging, not application readiness.

Verification & Testing

Prove the configuration behaves as intended against fixtures with known outcomes, then assert on the shape of the result rather than eyeballing it. Two fixtures are enough to lock the contract: a clean page that must yield zero violations, and a deliberately broken page that must yield a specific known rule.

import pytest

@pytest.mark.asyncio
async def test_config_is_conformance_only(page):
    """A clean fixture must return no violations and no best-practice noise."""
    await page.goto("http://localhost:8080/fixtures/clean.html")
    result = await run_axe(page)
    assert result["violations"] == []
    # No finding should carry a best-practice tag under a conformance-only run.
    all_tags = {t for r in result["incomplete"] for t in r["tags"]}
    assert "best-practice" not in all_tags

@pytest.mark.asyncio
async def test_config_catches_known_failure(page):
    """A fixture missing an image alt must fail image-alt (SC 1.1.1)."""
    await page.goto("http://localhost:8080/fixtures/missing-alt.html")
    result = await run_axe(page)
    rule_ids = {v["id"] for v in result["violations"]}
    assert "image-alt" in rule_ids

In CI, run the same two tests as a fast pre-flight before scanning real routes. If the conformance-only assertion fails, the tag set has drifted — usually because a category tag crept into runOnly — and the run would otherwise flood the backlog with best-practice findings. Once verified, real findings flow into the error categorization and triage pipelines for deduplication, ownership routing, and severity gating.

Failure Modes & Troubleshooting

Tag OR-broadening. Symptom: violation count jumps after someone “narrowed” the scan by adding a category tag. Root cause: runOnly values are OR-combined, so cat.forms adds every forms rule instead of intersecting. Fix: keep runOnly to wcag2a/wcag2aa/wcag22aa and refine exclusively through the rules object.

Engine version drift across runners. Symptom: the same commit reports different counts on two CI machines. Root cause: axe-core was resolved from a floating range or injected via a browser extension that auto-updates. Fix: pin an exact version in the lockfile, inject from node_modules with add_script_tag, and assert result["testEngine"]["version"] matches the expected value in a pre-flight test.

Empty results from premature evaluation. Symptom: scoped scans return zero violations even on pages you know are broken. Root cause: the include selector (main#app-content) had not rendered when axe.run() fired, so the engine evaluated an empty scope. Fix: wait for the container to exist and the DOM to settle in the traversal layer before injecting axe — the readiness signals are covered in the Playwright workflows guide — and add a guard that fails loudly when the include scope matches nothing.

Dropped cross-frame findings. Symptom: violations inside legitimately owned iframes intermittently disappear or surface as incomplete. Root cause: frameWaitTime is too low for a slow frame, or the frame was mistakenly caught by an exclude glob. Fix: raise frameWaitTime, tighten exclude patterns to specific hosts, and treat incomplete results as review items rather than silent passes.

Payload bloat and memory pressure at scale. Symptom: worker heap climbs and result files balloon on large route sets. Root cause: ancestry, xpath, or full resultTypes are emitting metadata triage never reads. Fix: restrict resultTypes to ["violations", "incomplete"], disable ancestry/xpath, and let the batch validation architecture shard the workload so no single worker holds thousands of results in memory. Dynamic surfaces such as infinite-scroll feeds compound this and need the dedicated async crawling for infinite scroll pages strategy.

Frequently Asked Questions

Why did adding cat.forms to runOnly increase my violation count instead of narrowing the scan?

Because runOnly tag values combine with OR. ["wcag2aa", "cat.forms"] runs every AA rule plus every forms rule, including best-practice checks. To scope to conformance only, keep runOnly to wcag2a/wcag2aa/wcag22aa and disable specific checks through the rules object.

How do I keep the axe-core version identical across every CI runner?

Pin an exact version in package.json, commit the lockfile, and inject the engine from node_modules with page.add_script_tag(path=...) rather than a browser extension. Add a pre-flight assertion on result["testEngine"]["version"] so a mismatched engine fails the build instead of silently producing different counts.

Should elementRef be true or false?

It depends on where results are consumed. Enable it only when triage runs inside the same browser context and needs live DOM handles. When results are serialized out of the page and shipped over the network — the batch-scanning case — leave it false (handles cannot survive serialization anyway) and keep selectors: true so a node can still be relocated by CSS path.

Why do my scoped scans return zero violations on pages I know are broken?

The include selector almost certainly had not rendered when axe.run() fired. Configuration does not wait for hydration; the traversal layer must confirm the container exists and the DOM has settled before injecting the engine. Add a guard that fails when the include scope matches no elements so an empty scope is never mistaken for a clean page.

How do I run only the success criteria that are new in WCAG 2.2?

Set runOnly to { "type": "tag", "values": ["wcag22aa"] }. That isolates the AA additions such as target size and dragging movements, which is useful for a focused regression pass — but the standing gate should retain wcag2a and wcag2aa so earlier criteria stay enforced.