Skiplink

Playwright

Check the page your test already has open — signed in, form filled, three steps into checkout. No token, no network call, no URL that has to be reachable from the internet.

npm install --save-dev @skiplink/playwright axe-core
import { test } from '@playwright/test';
import { expectNoNewViolations } from '@skiplink/playwright';

test('checkout is accessible', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('Card number').fill('4242424242424242');
  await expectNoNewViolations(page);
});

Why not just point the API at the URL

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 — our own SSRF guard refuses all of those, and it is right to.

Your test suite is already past that. So the engine runs there instead, and the results come back in the same shape, with the same violation identity, computed by the same code.

One baseline, both paths

Public pages scanned by the API or the GitHub Action and private ones scanned by your tests write to the same .skiplink/baseline.json. A violation accepted in one place stays accepted in the other, because the fingerprint is computed from shared code — and there is a test in our repository that scans a page through both paths 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 playwright test

Commit .skiplink/baseline.json. From then on the test fails only on violations that are new. Recording one page never discards the entries for pages that run did not visit.

Options

Options for expectNoNewViolations
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.

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.

Frames are included

The engine is injected into every frame, so an embedded payment form is checked too. That is usually the part you cannot fix yourself, and the part you most need to be able to raise with the vendor.

What a passing test 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.