Cypress
One command, anywhere in a spec. Checks the page as your test left it — signed in, form filled, three steps into checkout — and fails only on violations you have not already accepted.
npm install --save-dev @skiplink/cypress axe-core
// cypress.config.js
import { defineConfig } from 'cypress';
import { registerSkiplinkTasks } from '@skiplink/cypress/task';
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
registerSkiplinkTasks(on, config);
return config;
},
},
});
// cypress/support/e2e.js
import '@skiplink/cypress';
Then use it wherever a page is in the state you care about:
it('checkout is accessible', () => {
cy.visit('/checkout');
cy.get('[name=card]').type('4242424242424242');
cy.checkA11y();
});
Why there are two registration steps
Cypress splits a run across two processes. Your spec code executes in the browser, and only
setupNodeEvents runs in Node. The two things this adapter has to do — hash a
violation and read a baseline file — are both impossible in a browser: there is no
node:crypto and no filesystem.
So the browser does one job, which is to run axe-core and hand the raw results over. Identity, the baseline and the verdict all happen in Node, using the same code the CLI and the hosted API use. That is not a workaround for the split — it is the reason a baseline recorded through Cypress matches one recorded through the API.
Why not point the API at the URL instead
Because it cannot see the pages that break. The hosted scanner fetches a URL from our
infrastructure, so it only reaches pages that are public and readable without signing in. A
checkout form behind a login, a dashboard in a review app, anything on localhost
in CI — the SSRF guard refuses all of those, and it is right to.
One baseline, both paths
Public pages scanned by the API or the GitHub
Action and private ones scanned by your specs write to the same
.skiplink/baseline.json. A violation accepted in one place stays accepted in the
other, because violation identity is computed from shared code — and there is a test in our
repository that scans a page through two different routes and fails if the fingerprints
disagree.
Record what is already broken
Point this at an existing app and the first run fails on everything, which on a real page means around 56 violations. Nobody fixes 56 issues to unblock a deploy, so the check gets deleted. Record them once:
SKIPLINK_UPDATE_BASELINE=1 npx cypress run
Commit .skiplink/baseline.json. From then on a spec fails only on violations that
are new. Recording one page never discards the entries for pages that run did not open.
Options
| Option | Default | Notes |
|---|---|---|
baseline | .skiplink/baseline.json |
Path, or false to fail on any violation at all. Also SKIPLINK_BASELINE. |
failOn | 'new' |
Or an impact floor: critical, serious, moderate, minor, never. Impact floors still respect the baseline, or a site with accepted serious violations could never go green. |
standard | 'wcag22aa' |
Same list the API takes. all adds axe-core's best-practice rules, which are not WCAG success criteria. |
include / exclude | — | Selectors to restrict to or skip. Useful for a third-party widget you cannot change. |
disableRules | [] |
Rule ids to turn off entirely. |
// Only the checkout form, ignoring an embed you do not control.
cy.checkA11y({ include: '#checkout', exclude: '.vendor-embed' });
// Block on anything serious or worse that is new.
cy.checkA11y({ failOn: 'serious' });
Asserting on the result yourself
cy.skiplinkScan() runs the same check without the assertion or the baseline, and
yields the result object the hosted API returns.
cy.skiplinkScan().then((result) => {
expect(result.summary.critical).to.equal(0);
});
The axe-core version is yours
axe-core is a peer dependency, so the version in your results is the version in
your lockfile, and every result reports it as engine.axe_core. Rule behaviour
changes between axe versions; a scanner that hides which one it ran is a scanner whose results
you cannot reproduce.
A standard we do not recognise is an error rather than a silent full run. axe-core
reads an empty tag list as "run every rule", so a typo would quietly widen the scan instead of
failing — the standard is resolved in Node before axe runs.
What a passing spec means
No automatically detectable violations were found on the state the page was in. Keyboard order, focus management, whether alt text is accurate, and whether a custom widget behaves the way its role promises all need a person. The long version.