Regression Prevention After Accessibility Fixes
Fixing an accessibility defect is only half the work; the other half is making sure it never silently comes back. This guide is part of Automated Remediation & Accessibility Fixing Patterns, and it covers the mechanisms that lock a fix in place: snapshotting the computed accessibility tree, committing a baseline of known violations, and wiring CI gates that fail the build the moment either drifts. The goal is a pipeline where a regression is impossible to merge by accident, not merely discouraged.
Key implementation targets:
- Capture the accessibility tree as a committed snapshot and diff it on every run.
- Maintain a baseline violation file so known, accepted issues do not re-alert while new ones do.
- Fail CI deterministically on any unexpected change, with a clear update path for intentional ones.
- Integrate axe-core results and tree snapshots into one regression gate.
Problem Statement
A fix that removes a violation today gives no guarantee about tomorrow. A refactor can strip an aria-label, a design tweak can collapse a heading level, or a dependency bump can change a component’s rendered roles. Standard tests rarely cover the accessibility tree — the computed roles, names, and states that assistive technology actually consumes — so these regressions slide through code review unnoticed. Regression prevention closes that gap by treating the accessibility tree as a first-class, version-controlled artifact whose unexpected change fails the build.
Key Implementation Targets
Two complementary artifacts do the work. A baseline violation file records the exact set of issues currently accepted, so the gate alerts only on new violations rather than re-flagging known debt. An accessibility-tree snapshot captures the computed roles and names of key views, so structural regressions — a button that became a div, a label that vanished — fail even when no axe rule fires. Together they catch both rule-level and structural drift.
Prerequisites
1. Capture and Commit a Baseline Violation File
Run a full scan once, after your fixes land, and persist the fingerprint of each remaining violation — not the full report, which is too noisy to diff. A stable fingerprint is the rule ID plus a normalized target selector.
// write-baseline.js
import { chromium } from "playwright";
import AxeBuilder from "@axe-core/playwright";
import fs from "node:fs";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("http://localhost:3000");
await page.waitForLoadState("networkidle"); // stable DOM before scan
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
await browser.close();
// Fingerprint = ruleId + each node target. Sorted for a stable diff.
const fingerprints = violations
.flatMap((v) => v.nodes.map((n) => `${v.id}::${n.target.join(" ")}`))
.sort();
fs.mkdirSync("a11y-baseline", { recursive: true });
fs.writeFileSync("a11y-baseline/violations.json", JSON.stringify(fingerprints, null, 2));
console.log(`Baseline written: ${fingerprints.length} known issues`);
2. Fail CI on Any New Violation
On every run, regenerate the fingerprint set and compare it to the committed baseline. New fingerprints fail the build; disappeared ones are reported as candidates for tightening the baseline.
// check-regression.js
import fs from "node:fs";
// `current` produced by the same fingerprinting logic as write-baseline.js
import { fingerprintCurrent } from "./fingerprint.js";
const baseline = new Set(JSON.parse(fs.readFileSync("a11y-baseline/violations.json", "utf8")));
const current = new Set(await fingerprintCurrent("http://localhost:3000"));
const added = [...current].filter((f) => !baseline.has(f));
const removed = [...baseline].filter((f) => !current.has(f));
if (removed.length) {
console.log(`Fixed (update baseline to lock in): ${removed.length}`);
}
if (added.length) {
console.error("New accessibility regressions:");
added.forEach((f) => console.error(` + ${f}`));
process.exit(1); // blocks the merge
}
console.log("No regressions.");
3. Add a Tree Snapshot for Structural Drift
The violation baseline misses regressions that produce no rule failure — for example a control silently changing roles. An accessibility-tree snapshot catches those. The full implementation, including intentional-update handling, is in Snapshot Testing Accessibility Trees to Prevent Regressions.
// quick tree snapshot for a critical view
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("http://localhost:3000/checkout");
await page.waitForLoadState("networkidle");
const tree = await page.accessibility.snapshot(); // computed roles + names
await browser.close();
// compare `tree` against a committed JSON snapshot in your test runner
Pipeline Integration
Run both checks in one job. check-regression.js exits non-zero on a new violation; the snapshot test exits non-zero on structural drift. Wire the job as a required status check so neither can be merged past, mirroring Blocking Pull Requests on Critical Accessibility Violations. Upload the diff output as an artifact so reviewers can see precisely which fingerprint or tree node changed.
Troubleshooting & Flaky-Test Mitigation
Snapshot flakiness almost always traces to non-deterministic content — timestamps, randomized IDs, or A/B variants leaking into accessible names. Normalize these before snapshotting (replace dynamic substrings with a placeholder). If counts wobble between runs, you are scanning before hydration: always waitForLoadState('networkidle'). Pin browser versions in CI so an engine update does not shift the computed tree under you.
Common Pitfalls
- Snapshotting full axe reports. They contain volatile fields and produce unreadable diffs; fingerprint to rule-ID-plus-target instead.
- Never tightening the baseline. When a violation is fixed, remove it from the baseline immediately or it silently permits the issue’s return.
- Skipping the tree snapshot. A role change can pass the violation gate while still breaking assistive technology; structural snapshots catch it.
- Unnormalized dynamic content. Timestamps and random IDs in accessible names cause false regressions and erode trust in the gate.
FAQ
How is a baseline violation file different from just failing on all violations? A baseline acknowledges existing debt without blocking unrelated work — it alerts only on new issues while you burn down old ones. Failing on every violation is stricter but often impractical on a legacy codebase; the baseline lets you ratchet down over time.
When should I regenerate the baseline? Only deliberately, as a reviewed commit, when you have genuinely fixed issues (shrinking it) or accepted a documented exception. Never regenerate it automatically in CI, or regressions will quietly absorb into the new baseline.
Do I need both the violation baseline and the tree snapshot? Yes, they catch different failures. The baseline catches rule violations; the tree snapshot catches structural changes that produce no violation but still alter what assistive technology announces.
Related
- Automated Remediation & Accessibility Fixing Patterns — the parent section on fixing and locking in accessibility.
- Snapshot Testing Accessibility Trees to Prevent Regressions — the full tree-snapshot implementation.
- Progressive Threshold Management — tightening accepted-violation baselines over sprints.