Skiplink

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

Options for cy.checkA11y
OptionDefaultNotes
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.