Usage with WebdriverIO
WebdriverIO is a Node.js test automation framework built on the WebDriver protocol, supporting both end-to-end and component testing. surea11y integrates through the @surea11y/webdriverio binding.
$ npm install @surea11y/core @surea11y/webdriverio webdriverio
WebdriverIO v9 auto-manages a local chromedriver the first time it launches Chrome — a fresh install is enough to launch a real headless Chrome on its own.
const { remote } = require('webdriverio');const { A11yCoreBuilder } = require('@surea11y/webdriverio');const browser = await remote({capabilities: {browserName: 'chrome','wdio:enforceWebDriverClassic': true, // recommended -- see below'goog:chromeOptions': { args: ['--headless=new'] }}});await browser.url('https://example.com/');const results = await new A11yCoreBuilder({ browser }).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.deleteSession();
.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.
Force classic WebDriver, not BiDi, for this workload. WebdriverIO v9 defaults to the BiDi protocol; a long-lived session issuing many browser.execute() calls (exactly what a scan does) degraded badly over BiDi in testing — per-scan latency climbed from ~2s into the minutes. Forcing classic WebDriver with 'wdio:enforceWebDriverClassic': true, as shown above, gave a steady ~150ms per scan.
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:
await A11yCoreBuilder.installInternalsHook(browser); // 'bidi' or 'cdp'await browser.url('https://example.com/');
Call it before browser.url(); in the WDIO testrunner, in a before hook. It uses browser.addInitScript() in a BiDi session, and otherwise the DevTools protocol through chromedriver on Chrome and Edge, which covers the classic session recommended above. On a classic session in Firefox or Safari, 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(browser); // before browser.url()await A11yCoreBuilder.installLiveRegionHook(browser);await browser.url('https://example.com/settings');await $('#save').click();const said = await A11yCoreBuilder.announcements(browser);// [{ text: 'Saved', politeness: 'polite', ..., selector: '#status' }]
Both install as the internals hook does, in the same sessions, before browser.url() (in the WDIO testrunner, in a before hook), and resolve to 'bidi' or 'cdp'.
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 classic and BiDi sessions; 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.