Usage with Selenium

Selenium WebDriver is the long-standing standard for driving real browsers from any language — the most established cross-browser automation tool, commonly used for end-to-end testing. surea11y integrates through the @surea11y/selenium binding.

$ npm install @surea11y/core @surea11y/selenium selenium-webdriver

selenium-webdriver bundles Selenium Manager, which auto-downloads and manages the matching chromedriver the first time you build a driver — no separate Selenium server, no manual driver install (Chrome itself must already be on the machine).

const { Builder, Browser } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const { A11yCoreBuilder } = require('@surea11y/selenium');
 
const options = new chrome.Options().addArguments('--headless=new');
const driver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build();
await driver.get('https://example.com/');
 
const results = await new A11yCoreBuilder({ driver })
.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 driver.quit();

.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.

The builder takes { driver } (a Selenium WebDriver), where Puppeteer/Playwright take { page } — that's the one construction difference. Everything downstream is identical.

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 options = new chrome.Options().enableBidi(); // the webSocketUrl: true capability
const driver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build();
 
await A11yCoreBuilder.installInternalsHook(driver); // 'bidi' (or 'cdp' without BiDi on Chrome)
await driver.get('https://example.com/');

Call it before driver.get(). Classic WebDriver can't run a script before the page's own, so the hook uses WebDriver BiDi when the session has it (Chrome, Edge and Firefox, with BiDi enabled when the driver is built, as above), and otherwise the DevTools protocol on Chrome and Edge; it resolves to 'bidi' or 'cdp'. On a classic session in Safari, or in Firefox without BiDi, the call rejects.

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:

await A11yCoreBuilder.installListenerHook(driver); // before driver.get()
await A11yCoreBuilder.installLiveRegionHook(driver);
await driver.get('https://example.com/settings');
await driver.findElement(By.css('#save')).click();
 
const said = await A11yCoreBuilder.announcements(driver);
// [{ text: 'Saved', politeness: 'polite', ..., selector: '#status' }]

Both install as the internals hook does, before driver.get(): through WebDriver BiDi when the session has it, otherwise the DevTools protocol on Chrome and Edge. Each resolves to 'bidi' or 'cdp'. By comes from selenium-webdriver.

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. On Chrome and Edge both go through the DevTools protocol; in other browsers a width resizes the window, and a color scheme makes .analyze() reject before any scan. 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.