Usage with Playwright

Playwright is a cross-browser end-to-end testing framework from Microsoft, driving real Chromium, Firefox, and WebKit engines from Node.js. surea11y integrates through the @surea11y/playwright binding.

$ npm install @surea11y/core @surea11y/playwright playwright
$ npx playwright install chromium

The second command matters — every test launches a real browser, and npm install alone doesn't fetch it.

const { chromium } = require('playwright');
const { A11yCoreBuilder } = require('@surea11y/playwright');
 
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com/');
 
const results = await new A11yCoreBuilder({ page })
.include('#main') // optional -- call multiple times for multi-region scans
.exclude('.cookie-banner') // optional
.withWcag('2.2', 'AA') // the rules for WCAG 2.2's A and AA criteria
.disableRules(['meta-refresh-no-exceptions'])
.analyze();
 
console.log(results.checksResults.filter(r => r.outcome === 'fail'));
await browser.close();

.withWcag(version, level) selects by conformance target: the rules for WCAG 2.2's Level A and AA criteria, by the level each criterion has in that version. .withBestPractices() adds the rules that test no WCAG criterion, such as region; on its own it runs only those.

.withPacks() registers a pack in the page before the scan, and .options({ profile }) chooses its profile. A pack that doesn't run is reported as a 'pack-skipped' scan gap, so a test that asserts on getScanGaps(results) fails rather than passing without it.

Works against all three Playwright engines, not just Chromium — verified with real Firefox and WebKit runs.

Web components. A component that keeps its ElementInternals in a private field, and names itself, sets its states or gives itself a role through it, can't be read back by any page script. By default the scan asks about such a component (cantTell) instead of judging it, and a role set only that way isn't checked at all. If you build or test a component library, install the binding's opt-in internals hook before the page's own scripts run, and those components are judged like any other control:

const context = await browser.newContext();
await A11yCoreBuilder.installInternalsHook(context); // before any page navigates
const page = await context.newPage();
await page.goto('https://example.com/');

Call it before page.goto(), on the page or on its browser context. It runs through addInitScript(), so one call covers every frame and every navigation of the page, or of every page the context opens, in Chromium, Firefox and WebKit. With @playwright/test, call it in a beforeEach on the context fixture.

The hook is for test and audit runs: it keeps what it records where any script on the page can read it, which is why it's opt-in. Without it, the scan says once when it asked about such components. The README's internals hook section has the details.

Click handlers and live regions. Since core 1.14 there are two more opt-in hooks. Most pages add their click handlers from script, where no page script can list them afterwards, so the scan can't see that a <div role="button"> with a click listener does nothing from the keyboard; with the listener hook, such a button fails (CLICK_HANDLER_NO_KEY_LISTENER). The live-region hook records what the page's live regions are given: the scan then asks about a status message added with its text already in it, which screen readers commonly don't announce, and announcements() returns what the page made available to announce, so a test can check that an action told the user something. Install them before the page's own scripts run, like the internals hook:

const context = await browser.newContext();
await A11yCoreBuilder.installListenerHook(context); // before any page navigates
await A11yCoreBuilder.installLiveRegionHook(context);
const page = await context.newPage();
await page.goto('https://example.com/settings');
await page.getByRole('button', { name: 'Save' }).click();
 
const said = await A11yCoreBuilder.announcements(page);
// [{ text: 'Saved', politeness: 'polite', ..., selector: '#status' }]

Both install as the internals hook does, on the page or on its browser context, before it navigates. With @playwright/test, install them in a beforeEach on the context fixture.

Like the internals hook, both are for test and audit runs: any script on the page can read what they record. announcements() is what the page made available to announce, not what a screen reader said, and null without the live-region hook. Without either hook, getScanNotes() says when findings it would have decided were judged without it. The README's sections on the listener hook and the live-region hook have the details.

Several profiles at once. With core 1.15 (binding 1.6.0), .options({ profile }) also takes a list, a pack's profile beside WCAG's or another pack's: .withPacks(waiAria).options({ profile: ['wcag22-aa', 'wai-aria-1.2'] }) judges the page by WCAG and by WAI-ARIA in one scan. A rule both run is one check: its outcome is the worst, and its verdicts say what each profile makes of it (see Engine options). A profile that didn't run is in results.engine.profilesNotRun, and getScanGaps() reports it as 'profile-not-run'.

Frames, read as the browser reads them. Since core 1.15, a framed document is no page of its own, so its title, language, viewport and landmark rules are notApplicable; a frame under aria-hidden or inert is read as such, one behind an open modal dialog is left out, and one in a shadow root is read through its host. And where only WAI-ARIA or the HTML content model is broken, the ARIA and HTML structure rules now ask instead of failing (what changed); the findings keep their reason codes, so baselines still match. A text-spacing-content-loss finding is also pictured with the spacing applied, with .screenshots().

Dark mode, phone widths and a severity gate. .conditions([{ colorScheme: 'dark' }, { width: 320 }]) scans the page as it is, then again under each condition, and returns one result: a rule's outcome is its worst across the scans, and each finding lists the conditions it was seen under. Each condition is a full scan, and .frames(true) can't be combined with it yet. It works in Chromium, Firefox and WebKit. getGatingFailures(results, { failOnSeverity: 'serious' }) returns the failing rules at that severity or above, so a test can block on serious and critical problems first while the rest are still reported; formatFailures() takes the same option.

Questions that lean, with steps. Since core 1.14, a cantTell finding can lean toward fail or pass, saying why, and carries howToCheck, the steps a person follows to answer it. formatFailures() lists the questions that likely fail right after the failures, and prints the steps with { howToCheck: true }. To fail a test on the likely failures too, add core's leaningFailures(results) to the gate.

The builder also has .wcagLevel(), which keeps the reported criteria to a WCAG level without changing which rules run; .includeMode('or'), which runs a tag's rules plus rules you name; .plannedChecks() and .plannedCriteria(), which list what a scan will check before it runs, so a selection that runs nothing can't pass as a clean scan; .logger(), which sends scan-gap warnings to your own log; .fetchStyleSheets(false), which stops .analyze() fetching the style sheets from other origins that the page can't read itself, for the rules that read CSS; and getScanNotes(results), which says how the page was scanned. The binding's README covers each.

Also see a runnable example in the package's own repo.

Next: choose which rules run, integrate the scan into CI, and review the full result schema.