@surea11y/webdriverio 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -8
- package/package.json +11 -4
- package/src/A11yCoreBuilder.d.ts +34 -2
- package/src/A11yCoreBuilder.js +44 -24
- package/src/index.d.ts +11 -1
- package/src/index.js +2 -0
package/README.md
CHANGED
|
@@ -8,6 +8,10 @@ A WebdriverIO binding for [`@surea11y/core`](https://github.com/SureA11y/core)
|
|
|
8
8
|
npm install @surea11y/webdriverio webdriverio
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
`@surea11y/core` 1.12.0 or later, the engine, comes with it as a regular dependency, so the line above is all you need, whatever your package manager.
|
|
12
|
+
|
|
13
|
+
If your project uses core directly too, to write a SARIF log or an HTML report (`@surea11y/core/sarif`, `/report`), read the rule catalogue, or keep a baseline, keep one copy of it, so those read results with the same core that produced them. A compatible range in your own `package.json` (`"@surea11y/core": "^1.12.0"`) is enough: npm installs core once. When yours can't match the binding's range, as with a linked checkout (`file:../core`) or a fork, add `"overrides": { "@surea11y/core": "$@surea11y/core" }` beside it, and the binding uses your copy, which needs to be 1.12.0 or later. 1.4.0 made core a peer dependency instead; 1.4.1 undoes that, because Yarn, npm 6 and npm with `legacy-peer-deps` don't install peers, and a project with an older core pinned couldn't install the binding at all.
|
|
14
|
+
|
|
11
15
|
`webdriverio` v9 auto-manages a local `chromedriver` for you the first time it launches Chrome (no separate Selenium server, and no extra browser-download step needed) — so a fresh `npm install` is enough for `npm test` to launch a real headless Chrome on its own.
|
|
12
16
|
|
|
13
17
|
## Usage
|
|
@@ -34,7 +38,7 @@ const results = await new A11yCoreBuilder({ browser })
|
|
|
34
38
|
.analyze();
|
|
35
39
|
|
|
36
40
|
console.log(results.checksResults.filter(r => r.outcome === 'fail'));
|
|
37
|
-
console.log(results.engine.version); // the @surea11y/core release that scanned, e.g. "1.
|
|
41
|
+
console.log(results.engine.version); // the @surea11y/core release that scanned, e.g. "1.12.0"
|
|
38
42
|
await browser.deleteSession();
|
|
39
43
|
```
|
|
40
44
|
|
|
@@ -42,6 +46,8 @@ The builder takes `{ browser }` — the object WebdriverIO's `remote()` returns
|
|
|
42
46
|
|
|
43
47
|
`results` is `@surea11y/core`'s own native result shape — see its [`OUTPUT_SCHEMA.md`](https://github.com/SureA11y/core/blob/main/docs/OUTPUT_SCHEMA.md) — not the `violations`/`passes`/`incomplete`/`inapplicable` shape used by other popular accessibility testing tools. The builder's *method names* are modeled on common conventions in this space for migration familiarity; the richer result schema is kept as-is.
|
|
44
48
|
|
|
49
|
+
From 1.4.0 this package needs `@surea11y/core` 1.12.0 or later; see [What changes with `@surea11y/core` 1.12](#what-changes-with-surea11ycore-112).
|
|
50
|
+
|
|
45
51
|
Also see `examples/basic-scan.js` for a runnable script (`npm run example -- <url>`).
|
|
46
52
|
|
|
47
53
|
### A note on the protocol: prefer classic WebDriver for this workload
|
|
@@ -50,9 +56,42 @@ WebdriverIO v9 defaults to the WebDriver **BiDi** protocol. A long-lived session
|
|
|
50
56
|
|
|
51
57
|
### Composing the rule filters
|
|
52
58
|
|
|
53
|
-
`.withWcag(version, level)` selects by conformance target: `.withWcag('2.2', 'AA')` runs the rules for WCAG 2.2's Level A and AA criteria, by the level each criterion has in that version (4.1.1 Parsing, which 2.2 removed, is part of a `'2.1'` target and not of a `'2.2'` one). `.withBestPractices()` adds the rules that name no WCAG criterion, such as `region` and `heading-order`; on its own it runs only those. `.withRules()` and `.withTags()` add to a target's rules, and the disable methods apply after. A version other than `'2.0'`, `'2.1'` or `'2.2'`, or a level other than
|
|
59
|
+
`.withWcag(version, level)` selects by conformance target: `.withWcag('2.2', 'AA')` runs the rules for WCAG 2.2's Level A and AA criteria, by the level each criterion has in that version (4.1.1 Parsing, which 2.2 removed, is part of a `'2.1'` target and not of a `'2.2'` one). `.withBestPractices()` adds the rules that name no WCAG criterion, such as `region` and `heading-order`; on its own it runs only those. `.withRules()` and `.withTags()` add to a target's rules, and the disable methods apply after. The level is read in any case (`'aa'` is `'AA'`). A version other than `'2.0'`, `'2.1'` or `'2.2'`, or a level other than A, AA or AAA, throws a `TypeError` with `code: 'INVALID_RUN_ONLY'` at the call. Both need `@surea11y/core` 1.11 or later.
|
|
60
|
+
|
|
61
|
+
`withTags()`/`disableRules()` have counterparts: `.withRules([...])` (only run these specific rule IDs) and `.disableTags([...])` (never run rules carrying any of these tags). All four compose the same way similar allow/deny-list options do in other accessibility testing tools, with one non-obvious rule worth knowing: a "disable" always wins over a "with" on the same ID/tag (e.g. `.withRules(['a']).disableRules(['a'])` drops `'a'` entirely), and combining `.withRules()` **and** `.withTags()` together requires a rule to satisfy *both* (`@surea11y/core`'s default `includeMode: 'and'`), not either one, unless you ask for that with `.includeMode('or')` (below).
|
|
62
|
+
|
|
63
|
+
`.includeMode(mode)` decides how `.withRules()` and `.withTags()` combine when both are given: `'and'`, the default, runs only the rules that match both, and `'or'` runs those that match either. That's what lets a scan say "this area, plus these checks". A team that owns a design system's form components, say, can run every rule about forms and the contrast check too, in one scan:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
67
|
+
.withTags('forms')
|
|
68
|
+
.withRules('contrast-computable')
|
|
69
|
+
.includeMode('or') // every forms rule, plus the contrast check
|
|
70
|
+
.analyze();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Without `.includeMode('or')`, the same lines run no rule at all, since `contrast-computable` isn't tagged `forms`, and a scan of no rules looks clean. The same goes for a WCAG level tag beside a best practice: `.withTags('wcag2a').withRules('region')` runs nothing, and with `'or'` runs the `wcag2a` rules plus `region`. On its own, with no other selection, it changes nothing. A value other than `'and'` or `'or'` throws a `TypeError` with `code: 'INVALID_RUN_ONLY'` at the call. A `.withWcag()` target doesn't need it: its rules are always added to what tags and rules select.
|
|
74
|
+
|
|
75
|
+
`.wcagLevel(level)` sets the WCAG level of the criteria a scan reports, without changing which rules run: criteria above it are left out of `results.rulesResults`, while every selected rule still runs and reports in `results.checksResults`. It's what lets a scan that names a few checks say it is meant as WCAG AA. A team that gates each pull request on colour contrast alone, say, runs one check, which covers both 1.4.3 (AA) and 1.4.6 (AAA):
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
79
|
+
.withRules('contrast-computable')
|
|
80
|
+
.wcagLevel('AA') // report 1.4.3, not the AAA 1.4.6
|
|
81
|
+
.analyze();
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Without `.wcagLevel('AA')`, that scan reports 1.4.6 as well, so a site that meets AA can show a failing AAA criterion it never aimed for. With it, the scan reports 1.4.3 alone, and `results.engine.wcagLevel` says `'AA'`. A `.withWcag()` target already implies its level; `.wcagLevel()` is for selections that don't, and wins when both are given, as it does over a level given through `.options()`. `'A'`, `'AA'` or `'AAA'` in any case is accepted. Anything else throws a `TypeError` with `code: 'INVALID_ENGINE_OPTIONS'` at the call, and `null` clears it. Needs `@surea11y/core` 1.12.0 or later.
|
|
54
85
|
|
|
55
|
-
|
|
86
|
+
`.plannedChecks()` and `.plannedCriteria()` say what a scan *will* check before you run it: the rules it runs (entries with `ruleId`) and the criteria it reports in `rulesResults` (entries with `id`), worked out in Node from the same selection and options, with no browser. Use them to record what a run covered next to its result, or to catch a selection that runs nothing, which would otherwise look like a clean scan:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
const builder = new A11yCoreBuilder({ browser }).withTags('forms').withRules('contrast-computable');
|
|
90
|
+
if (builder.plannedChecks().length === 0) {
|
|
91
|
+
throw new Error('This selection runs no rule. Did you mean .includeMode("or")?');
|
|
92
|
+
}
|
|
93
|
+
const results = await builder.analyze();
|
|
94
|
+
```
|
|
56
95
|
|
|
57
96
|
A rule or tag list that names nothing `@surea11y/core` knows makes `analyze()` reject with `code: 'INVALID_RUN_ONLY'` (see [When a scan can't run as asked](#when-a-scan-cant-run-as-asked)), so a typo such as `.withTags(['wcag2.2aa'])` can't run no rule at all and pass. An unknown name beside known ones is ignored, with a warning in the page's console. The four methods also throw a `TypeError` with the same `code` straight away for anything but a string or an array of strings, such as `undefined` from a missing config value.
|
|
58
97
|
|
|
@@ -60,7 +99,7 @@ Rule IDs change rarely, but core 1.10.0 changed two: `label-title-only` is depre
|
|
|
60
99
|
|
|
61
100
|
`.exclude(selector)` above excludes globally. Pass a second argument to scope it to specific rule IDs instead: `.exclude('.mat-select', { rules: ['aria-required-children'] })` skips `.mat-select` for that rule only — every other rule still sees it. Global and rule-scoped `.exclude()` calls compose freely.
|
|
62
101
|
|
|
63
|
-
**Create one builder per scan.** `A11yCoreBuilder` is a mutable object with no reset between `.analyze()` calls — `include()`/`exclude()`/`withRules()`/`disableRules()`/`withTags()`/`disableTags()`/`options()`/`withCustomRules()`/`withPacks()` all push onto or merge into internal state that persists for the instance's lifetime. Calling one of them again before a second `.analyze()` call *accumulates* on top of the first scan's scope rather than replacing it (this is exactly what makes "call `.include()` several times for one scan," above, work — the same accumulation just also applies across separate scans if you reuse an instance). `.reportOnly()`/`.frames()`/`.elementRef()`/`.withWcag()`/`.withBestPractices()` are the exception: each call replaces the previous value instead of merging with it.
|
|
102
|
+
**Create one builder per scan.** `A11yCoreBuilder` is a mutable object with no reset between `.analyze()` calls — `include()`/`exclude()`/`withRules()`/`disableRules()`/`withTags()`/`disableTags()`/`options()`/`withCustomRules()`/`withPacks()` all push onto or merge into internal state that persists for the instance's lifetime. Calling one of them again before a second `.analyze()` call *accumulates* on top of the first scan's scope rather than replacing it (this is exactly what makes "call `.include()` several times for one scan," above, work — the same accumulation just also applies across separate scans if you reuse an instance). `.reportOnly()`/`.frames()`/`.elementRef()`/`.withWcag()`/`.withBestPractices()`/`.includeMode()`/`.wcagLevel()`/`.logger()` are the exception: each call replaces the previous value instead of merging with it.
|
|
64
103
|
|
|
65
104
|
This binding works against any browser WebdriverIO can drive; the tests and examples use headless Chrome via `chromedriver`, which WebdriverIO manages automatically.
|
|
66
105
|
|
|
@@ -102,9 +141,11 @@ A bare length/equality assertion alone gets you a *working* gate, but the failur
|
|
|
102
141
|
at #host >>> p > img
|
|
103
142
|
Add an alt attribute that says what the image shows, or alt="" if the image is purely decorative.
|
|
104
143
|
|
|
105
|
-
Scanned with @surea11y/core 1.
|
|
144
|
+
Scanned with @surea11y/core 1.12.0.
|
|
106
145
|
```
|
|
107
146
|
|
|
147
|
+
Before the version line, it also prints the result's notes (`getScanNotes()`, below), such as `The page was shown in forced colors (high contrast); contrast findings may differ with forced colors off.`
|
|
148
|
+
|
|
108
149
|
An element inside a shadow tree is located as `host >>> selector` (the second entry above); `formatOccurrenceLocation(occurrence)` gives the same text if you print occurrences yourself. `formatFailures(results.checksResults)` still works and prints only the entries. A `.frames(true)` result is not one result, so `formatFailures()` throws a `TypeError` for it: format `results.topFrame` and each entry of `results.frames` on its own.
|
|
109
150
|
|
|
110
151
|
Deliberately a plain function, not a custom matcher — no dependency on any particular assertion library, so it works the same with `node:assert`, WebdriverIO's `expect`, Jest, Vitest, or a hand-rolled `if`/`throw`. Defaults to `fail`/`cantTell` outcomes (the only two whose occurrences are findings: a `notApplicable` rule may carry one occurrence saying why it had nothing to judge); pass `{ outcomes: [...] }` to narrow further. A thrown rule (`occurrences: []`, `error` set) is still surfaced using its `error` message rather than silently dropped.
|
|
@@ -175,6 +216,60 @@ Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(tru
|
|
|
175
216
|
|
|
176
217
|
Not every occurrence has one target element — a page-wide finding (some `manual`/`cantTell` rules) can carry `selector: ""`, in which case `occurrence.element` is `null` rather than an element.
|
|
177
218
|
|
|
219
|
+
### Web components: the internals hook
|
|
220
|
+
|
|
221
|
+
Many web components keep their `ElementInternals` in a private field and name themselves, set their states or give themselves a role through it:
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
class SubscribeCheckbox extends HTMLElement {
|
|
225
|
+
#internals = this.attachInternals();
|
|
226
|
+
constructor() {
|
|
227
|
+
super();
|
|
228
|
+
this.#internals.ariaLabel = 'Subscribe';
|
|
229
|
+
this.#internals.ariaChecked = 'false';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The browser exposes that name, but no page script can read it back, so by default the scan asks about such a component (`cantTell`) instead of judging it. A component that's a `switch` only through `this.#internals.role` isn't even asked about: the scan doesn't see its role, so it isn't checked at all.
|
|
235
|
+
|
|
236
|
+
`A11yCoreBuilder.installInternalsHook(browser)` installs `@surea11y/core`'s opt-in hook, which records each component's internals for the scan to read. It has to run before the page's own scripts, so it uses:
|
|
237
|
+
|
|
238
|
+
- **`browser.addInitScript()`** when the session speaks WebDriver BiDi (WebdriverIO 9's default): Chrome, Edge and Firefox;
|
|
239
|
+
- otherwise, on **Chrome and Edge**, the DevTools protocol through chromedriver (`Page.addScriptToEvaluateOnNewDocument`). That covers the classic sessions this README [recommends for scanning](#a-note-on-the-protocol-prefer-classic-webdriver-for-this-workload), and WebdriverIO 8.
|
|
240
|
+
|
|
241
|
+
Either way it runs in every frame and after each navigation. Call it **before** `browser.url()`:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
await A11yCoreBuilder.installInternalsHook(browser); // 'bidi' or 'cdp'
|
|
245
|
+
await browser.url('https://example.com/');
|
|
246
|
+
|
|
247
|
+
const results = await new A11yCoreBuilder({ browser }).analyze();
|
|
248
|
+
results.engine.environment.internalsHook; // 'early': the hook ran before the page's scripts
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
In the WDIO testrunner, call it in a `before` hook. With it, the checkbox above passes, an unnamed component that attaches internals fails like any unnamed control, and the switch is checked as a switch, so it fails without a name. It resolves to `'bidi'` or `'cdp'`, the way it was installed.
|
|
252
|
+
|
|
253
|
+
- **A classic session on Firefox or Safari** has no way to run a script before the page loads: the call rejects. There, such components stay questions, and roles set through internals aren't checked. A BiDi session reaches Firefox.
|
|
254
|
+
- **Before the page loads.** On a page that's already loaded, the call has no effect until the next navigation (a refresh will do), and the result has no `internalsHook`.
|
|
255
|
+
- **For test and audit runs.** The hook wraps `HTMLElement.prototype.attachInternals` (components get the same internals back), adds a stack frame to errors thrown through it, and keeps its record under a global symbol, where any script on the page can read every component's internals. That's why it's opt-in.
|
|
256
|
+
- **Without it**, `analyze()` says so once per scan when it asked about such components (`1 custom element was asked about because the scan could not read...`), and `getScanNotes()` gives that note as `kind: 'internals-hook'`.
|
|
257
|
+
|
|
258
|
+
### Forced colors
|
|
259
|
+
|
|
260
|
+
A page shown in forced colors (Windows high contrast) draws in the user's colors, not its own. `results.engine.environment.forcedColors` says which it was: `'active'` or `'none'`. To check a page under forced colors on purpose in Chrome or Edge, emulate it through chromedriver's DevTools commands before scanning, in a classic or a BiDi session:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
await browser.sendCommand('Emulation.setEmulatedMedia', {
|
|
264
|
+
features: [{ name: 'forced-colors', value: 'active' }]
|
|
265
|
+
});
|
|
266
|
+
const results = await new A11yCoreBuilder({ browser }).analyze();
|
|
267
|
+
results.engine.environment.forcedColors; // 'active'
|
|
268
|
+
await browser.sendCommand('Emulation.setEmulatedMedia', { features: [] }); // back to normal
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`browser.sendCommand()` is one of WebdriverIO's Chromium commands, so there's no equivalent for Firefox or Safari. The contrast rules still measure the page's own colors under forced colors, since WCAG judges contrast in those, so results match a scan with forced colors off; a contrast finding the engine couldn't measure that way carries `details.forcedColors: true`. When forced colors were active, `formatFailures()` adds a line saying so, since it's easy to miss on a CI agent with high contrast turned on.
|
|
272
|
+
|
|
178
273
|
### Registering a custom rule at runtime
|
|
179
274
|
|
|
180
275
|
`@surea11y/core` supports registering additional rules per-scan via `engineOptions.customRules`. Use `.withCustomRules()` to register one:
|
|
@@ -207,6 +302,8 @@ const results = await new A11yCoreBuilder({ browser })
|
|
|
207
302
|
|
|
208
303
|
**Why `.withCustomRules()` instead of the raw `.options({ customRules })` passthrough** (still supported, and composes with this method if you use both): `runInPage`/`applicability` must reach the page as a function-source *string*, not a live `Function` — a WebdriverIO `browser.execute()` argument crosses a serialization boundary that can't carry a live function reference, only a string `@surea11y/core` can reconstruct with `new Function` on the page side. Passing a raw live function via `.options()` directly would silently fail to serialize; `.withCustomRules()` calls `.toString()` on a live function for you (patching the ES6 method-shorthand `.toString()` quirk automatically), so you can write a normal function and not have to remember that constraint yourself. A string is still accepted as-is if you already have one.
|
|
209
304
|
|
|
305
|
+
A custom rule you turn off for one scan (`.withCustomRules(rules).disableRules('org-banner-text')`) is listed in `skippedCustomRules` with `excluded: true` and isn't a scan gap, so it isn't warned about (core 1.12.0 and later).
|
|
306
|
+
|
|
210
307
|
Invalid input (a missing/empty `id`, or a `runInPage`/`applicability` that's neither a function nor a non-empty string) throws immediately from `.withCustomRules()` itself, rather than surfacing later as a skipped rule deep inside the page — easier to catch during development. (Note: a *raw* `.options({ customRules })` call bypasses this check entirely and defers to `@surea11y/core`'s own engine-side behavior, which skips an invalid descriptor rather than throwing. The result lists each skipped rule in `skippedCustomRules`, and `analyze()` warns about it — see below.)
|
|
211
308
|
|
|
212
309
|
### When a scan can't run as asked
|
|
@@ -234,11 +331,39 @@ try {
|
|
|
234
331
|
Two other cases don't throw but leave part of the page unchecked, so a result that reports nothing failing is not a clean page:
|
|
235
332
|
|
|
236
333
|
- **An `.include()` selector that matches nothing.** Since core 1.10.0 nothing is then scanned (it used to fall back to the whole page): every rule reports `notApplicable`, and `results.contextMatch` is `{ elementCount: 0, unmatchedSelectors: [...] }`. With several selectors, one that matches nothing is named there while the others are scanned.
|
|
237
|
-
- **A custom rule that did not run**, listed with its reason in `results.skippedCustomRules`.
|
|
334
|
+
- **A custom rule that did not run**, listed with its reason in `results.skippedCustomRules`. One you excluded yourself with `.disableRules()` is listed too, with `excluded: true`, and isn't a gap.
|
|
238
335
|
- **A pack that did not run**, listed with its reason in `results.skippedPacks` (see [Scanning with packs](#scanning-with-packs-standards-checklists-and-your-own-rules)).
|
|
239
336
|
|
|
240
337
|
`analyze()` prints each of these with `console.warn`, and `getScanGaps(results)` returns them as `[{ kind, message, ... }]` (`kind` is `context-not-found`, `context-partly-not-found`, `custom-rule-skipped` or `pack-skipped`), so a gate can fail on them, as the E2E example above does. `formatFailures(results)` lists them too.
|
|
241
338
|
|
|
339
|
+
That warning is the right default for a test run. A project that already handles gaps itself can take them over with `.logger(fn)`: `fn(gap, result)` is called once per gap, with the gap as `getScanGaps()` gives it and the result of the frame it came from, instead of `console.warn`. A scanning service that shows gaps to its users, say, can send them once to its own structured log, rather than also printing a loose line on the server's console for every scan:
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
343
|
+
.include('#checkout')
|
|
344
|
+
.logger((gap, frame) => log.warn({ url: frame.url, kind: gap.kind }, gap.message))
|
|
345
|
+
.analyze();
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A batch job that writes `getScanGaps(results)` into its own report can pass `.logger(() => {})` to keep its output to the report, and `.logger(null)` brings `console.warn` back. Anything other than a function or `null` throws a `TypeError` at the call, and a later call replaces the logger. Either way, `getScanGaps(results)` still lists every gap.
|
|
349
|
+
|
|
350
|
+
`getScanNotes(results)` says how the page was scanned, which a gap doesn't cover, and nothing was left out: `'forced-colors'` when the page was shown in [forced colors](#forced-colors), and `'internals-hook'` when the scan asked about web components it could have read with the [internals hook](#web-components-the-internals-hook). `analyze()` also prints the internals note, once per scan, and a `.logger()` receives it (check `kind` to tell a note from a gap).
|
|
351
|
+
|
|
352
|
+
### Checking against EN 301 549
|
|
353
|
+
|
|
354
|
+
EN 301 549, the standard the European Accessibility Act refers to, restates WCAG's Level A and AA criteria in its chapter 9. `.options({ profile })` runs the rules for its version, and each finding carries its EN 301 549 clause beside its WCAG criterion, in `meta.normativeMappings` (WCAG 1.1.1 is clause 9.1.1.1):
|
|
355
|
+
|
|
356
|
+
```js
|
|
357
|
+
// EN 301 549 V4.1.1 (WCAG 2.2 A and AA); 'en301549-v3.2.1' for V3.2.1 (WCAG 2.1 A and AA)
|
|
358
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
359
|
+
.options({ profile: 'en301549-v4.1.1' })
|
|
360
|
+
.analyze();
|
|
361
|
+
|
|
362
|
+
results.engine.profile; // 'en301549-v4.1.1'
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
`.withWcag()`, `.withRules()` and `.withTags()` select rules instead of a profile, so leave them out when scanning with one. As with any target, a scan with no failures means no rule found a violation, not that the page conforms: most criteria need a person to check them. A profile name core doesn't know is not an error: the scan runs without it, core warns in the page's console, and `results.engine.profile` is left out.
|
|
366
|
+
|
|
242
367
|
### Scanning with packs: standards, checklists and your own rules
|
|
243
368
|
|
|
244
369
|
A pack brings rules, variants of core's rules, a standard or a checklist, and their profiles and messages, from a package of its own: a national standard such as RGAA, or your organisation's accessibility policy. `@surea11y/core` runs a pack as it runs its own rules and standards, so its results, rollups and report entries look like core's. Packs need `@surea11y/core` 1.11.1 or later.
|
|
@@ -274,7 +399,23 @@ An element inside a shadow tree is different: its `selector` holds only inside i
|
|
|
274
399
|
|
|
275
400
|
## TypeScript
|
|
276
401
|
|
|
277
|
-
`src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships types for the whole builder API plus `@surea11y/core`'s native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.). The result shapes are built on the types `@surea11y/core` ships itself, so fields such as `engine.version`, `engine.environment`, `contextMatch`, `skippedCustomRules`, `margin` and `shadowHostSelectors` follow the engine; this package adds `occurrence.element` and the `.frames(true)` shape. `EngineError`, `EngineErrorCode` and `
|
|
402
|
+
`src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships types for the whole builder API plus `@surea11y/core`'s native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.). The result shapes are built on the types `@surea11y/core` ships itself, so fields such as `engine.version`, `engine.environment`, `contextMatch`, `skippedCustomRules`, `margin` and `shadowHostSelectors` follow the engine; this package adds `occurrence.element` and the `.frames(true)` shape. `EngineError`, `EngineErrorCode`, `ScanGap` and `ScanNote` come from `@surea11y/binding-base`, and so do the types of `withWcag()` and `wcagLevel()` (`WcagVersion`, `WcagLevelInput`), `plannedChecks()`/`plannedCriteria()` (`PlannedCheck`, `PlannedCriterion`), and `withPacks()`, which takes a pack made with `definePack` (`PackLike`). The static `installInternalsHook()` and `getScanNotes()` are typed too. Code that handles every `ScanGap` kind it knows and treats the rest in an `else` branch needs a branch for `'pack-skipped'`, which carries `pack` rather than `selectors`. `analyze()` is typed `Promise<A11yCoreResult | A11yCoreMultiFrameResult>` — narrow on `'topFrame' in results` (or cast, if you already know which mode you called) to get the specific shape back, since a fluent builder can't statically track that `.frames(true)` was called earlier in the chain. `occurrence.element` is typed `WebdriverIO.Element | null`. `webdriverio` is a `peerDependencies` entry (not just `devDependencies`) since the class's `browser` argument and `Occurrence#element` both come from its global `WebdriverIO` type namespace — consumers need their own `webdriverio` install for the types to resolve, same as they already do to construct a `browser` in the first place.
|
|
403
|
+
|
|
404
|
+
## What changes with `@surea11y/core` 1.12
|
|
405
|
+
|
|
406
|
+
This release needs `@surea11y/core` 1.12.0. What a scan through this binding shows differently (core's [`CHANGELOG.md`](https://github.com/SureA11y/core/blob/main/CHANGELOG.md) has the full list):
|
|
407
|
+
|
|
408
|
+
- **An empty heading now fails an unscoped scan.** `empty-heading` used to ask about a heading with no accessible name; it now fails it. It's a best practice, so it runs in a scan that selects nothing and with `.withBestPractices()`, not under `.withWcag()`, tags or a profile. A gate on a page with `<h2></h2>` that asserted no failures now fails: fix the heading, or scan a WCAG target.
|
|
409
|
+
- **Scans that pick rules report their criteria.** `.withRules('img-alt-present')`, a non-WCAG tag or `.withBestPractices()` now fill `rulesResults` with the WCAG criteria those rules belong to, where it was empty. A criterion whose other rules weren't selected is `cantTell` (`reasonCode` `composite.rollup.cantTell.notSelectedChild`), and each check's `rollupIds` names the criteria it was added for. `.wcagLevel()` keeps those criteria to a level.
|
|
410
|
+
- **Four rules count towards more criteria**, as W3C's ACT rules map them: `input-image-alt-present` adds 4.1.2, `aria-roles-valid` 1.3.1, `link-name-present` 2.4.9 (AAA), and `meta-refresh-timing-absent` 2.2.4 and 3.2.5 (AAA). The findings don't change; the 1.3.1 and 4.1.2 rollups can now fail where they passed.
|
|
411
|
+
- **Unnamed custom elements are questions.** A defined custom element with no visible name may name itself through `ElementInternals`, so it's `cantTell` (`name_internals`) where it used to fail. [`installInternalsHook()`](#web-components-the-internals-hook) lets the engine decide, where the session allows it.
|
|
412
|
+
- **`engine.wcagLevel`** records the level a scan was gated by, and [`.wcagLevel()`](#composing-the-rule-filters) sets it.
|
|
413
|
+
- **`engine.environment.forcedColors`** records whether the page was in [forced colors](#forced-colors).
|
|
414
|
+
- **A custom rule you exclude** isn't a scan gap any more, so `.disableRules()` of one prints no warning.
|
|
415
|
+
- **Rule IDs in another case select their rule**: `.withRules('IMG-ALT-PRESENT')` used to throw `INVALID_RUN_ONLY` or be ignored.
|
|
416
|
+
- **`runOnly.optInTags` on its own throws** `INVALID_RUN_ONLY`; it was accepted and did nothing. Opt-in rules are unlocked with `.options({ optInRules })`.
|
|
417
|
+
- **A finding in a shadow tree is located through its hosts**, as `#card >>> img`, in core's HTML report, SARIF, JUnit and baselines, as `formatFailures()` already did.
|
|
418
|
+
- **An inline `policyContract`** with an unknown value in `allowedOutcomes` or `allowedConfidence` drops that value with a warning, where it turned every check into `cantTell`.
|
|
278
419
|
|
|
279
420
|
## What changes with `@surea11y/core` 1.11
|
|
280
421
|
|
|
@@ -288,7 +429,7 @@ The binding passes rule IDs, tags and options through to the engine, so these sh
|
|
|
288
429
|
|
|
289
430
|
## Relationship to `@surea11y/playwright` and `@surea11y/puppeteer`
|
|
290
431
|
|
|
291
|
-
This binding's builder API is deliberately the same shape as [`@surea11y/playwright`](https://github.com/SureA11y/playwright)'s and `@surea11y/puppeteer`'s — same method names, same mutability contract, same result shapes — so the accessibility-gate logic reads almost identically across all three. The state-accumulation half of the builder (`include`/`exclude`/`withTags`/`disableTags`/`withRules`/`disableRules`/`withWcag`/`withBestPractices`/`options`/`withCustomRules`/`withPacks`/`reportOnly`) is not copied across bindings — `A11yCoreBuilder` here extends `A11yCoreBuilderBase` from [`@surea11y/binding-base`](https://github.com/SureA11y/binding-base), the single shared implementation every sibling binding depends on.
|
|
432
|
+
This binding's builder API is deliberately the same shape as [`@surea11y/playwright`](https://github.com/SureA11y/playwright)'s and `@surea11y/puppeteer`'s — same method names, same mutability contract, same result shapes — so the accessibility-gate logic reads almost identically across all three. The state-accumulation half of the builder (`include`/`exclude`/`withTags`/`disableTags`/`withRules`/`disableRules`/`withWcag`/`withBestPractices`/`includeMode`/`wcagLevel`/`logger`/`options`/`withCustomRules`/`withPacks`/`reportOnly`) is not copied across bindings — `A11yCoreBuilder` here extends `A11yCoreBuilderBase` from [`@surea11y/binding-base`](https://github.com/SureA11y/binding-base), the single shared implementation every sibling binding depends on.
|
|
292
433
|
|
|
293
434
|
Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
|
|
294
435
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@surea11y/webdriverio",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"description": "Accessibility (a11y, WCAG) checks for WebdriverIO tests: scan the live page with new A11yCoreBuilder({ browser }).analyze() and get only proven violations, with findings that need human review listed separately.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
7
7
|
"a11y",
|
|
8
|
+
"a11y-testing",
|
|
8
9
|
"wcag",
|
|
10
|
+
"wcag21",
|
|
9
11
|
"wcag22",
|
|
12
|
+
"en301549",
|
|
13
|
+
"en-301-549",
|
|
14
|
+
"european-accessibility-act",
|
|
10
15
|
"accessibility-testing",
|
|
11
16
|
"webdriverio",
|
|
12
17
|
"wdio",
|
|
13
18
|
"webdriver",
|
|
14
19
|
"e2e",
|
|
15
20
|
"e2e-testing",
|
|
21
|
+
"end-to-end",
|
|
16
22
|
"audit",
|
|
23
|
+
"aria",
|
|
17
24
|
"surea11y"
|
|
18
25
|
],
|
|
19
26
|
"main": "src/index.js",
|
|
@@ -40,8 +47,8 @@
|
|
|
40
47
|
"LICENSE"
|
|
41
48
|
],
|
|
42
49
|
"dependencies": {
|
|
43
|
-
"@surea11y/binding-base": "^1.
|
|
44
|
-
"@surea11y/core": "^1.
|
|
50
|
+
"@surea11y/binding-base": "^1.4.0",
|
|
51
|
+
"@surea11y/core": "^1.12.0"
|
|
45
52
|
},
|
|
46
53
|
"peerDependencies": {
|
|
47
54
|
"webdriverio": ">=8.0.0"
|
package/src/A11yCoreBuilder.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {} from 'webdriverio';
|
|
|
10
10
|
// { topFrame, frames } shape of .frames(true). The names below are the ones
|
|
11
11
|
// this file has always exported.
|
|
12
12
|
import type * as Core from '@surea11y/core';
|
|
13
|
-
import type { PackLike, WcagVersion,
|
|
13
|
+
import type { PackLike, WcagVersion, WcagLevelInput, ScanGap, ScanNote, PlannedCheck, PlannedCriterion } from '@surea11y/binding-base';
|
|
14
14
|
export type { PackLike, WcagVersion, WcagLevel } from '@surea11y/binding-base';
|
|
15
15
|
|
|
16
16
|
export type Outcome = Core.Outcome;
|
|
@@ -113,6 +113,15 @@ export class A11yCoreBuilder {
|
|
|
113
113
|
*/
|
|
114
114
|
constructor(opts: { browser: WebdriverIO.Browser; url?: string });
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Installs @surea11y/core's opt-in internals hook, so a scan can read
|
|
118
|
+
* names, states and roles web components set through a private
|
|
119
|
+
* ElementInternals. Call it before browser.url(). Uses addInitScript()
|
|
120
|
+
* in a BiDi session, else the DevTools protocol on Chrome and Edge, else
|
|
121
|
+
* rejects. Resolves to the way it was installed.
|
|
122
|
+
*/
|
|
123
|
+
static installInternalsHook(browser: WebdriverIO.Browser): Promise<'bidi' | 'cdp'>;
|
|
124
|
+
|
|
116
125
|
/**
|
|
117
126
|
* Scope the scan to one region. Call multiple times for a multi-region
|
|
118
127
|
* union. A selector that matches nothing scans nothing (see
|
|
@@ -144,9 +153,28 @@ export class A11yCoreBuilder {
|
|
|
144
153
|
* runs those of WCAG 2.2's A and AA criteria. withTags()/withRules() add
|
|
145
154
|
* to them. A later call replaces the target.
|
|
146
155
|
*/
|
|
147
|
-
withWcag(version: WcagVersion | 2.0 | 2.1 | 2.2, level?:
|
|
156
|
+
withWcag(version: WcagVersion | 2.0 | 2.1 | 2.2, level?: WcagLevelInput): this;
|
|
148
157
|
/** Also run the best-practice rules, those that name no WCAG criterion. */
|
|
149
158
|
withBestPractices(enabled?: boolean): this;
|
|
159
|
+
/**
|
|
160
|
+
* How withRules() and withTags() combine when both are given: 'and' (the
|
|
161
|
+
* default) runs the rules matching both, 'or' those matching either. A
|
|
162
|
+
* later call replaces the mode.
|
|
163
|
+
*/
|
|
164
|
+
includeMode(mode: 'and' | 'or'): this;
|
|
165
|
+
/**
|
|
166
|
+
* The WCAG level of the criteria the scan reports, without changing which
|
|
167
|
+
* rules run: `.withRules('contrast-computable').wcagLevel('AA')` reports
|
|
168
|
+
* 1.4.3 and not 1.4.6. Any case; null clears it. A later call replaces it.
|
|
169
|
+
*/
|
|
170
|
+
wcagLevel(level: WcagLevelInput | null): this;
|
|
171
|
+
/**
|
|
172
|
+
* Where analyze() sends each scan gap instead of console.warn: called once
|
|
173
|
+
* per gap, with the result of the frame it came from, and once per scan
|
|
174
|
+
* for the internals-hook note (tell them apart by `kind`). `() => {}`
|
|
175
|
+
* silences them, `null` goes back to console.warn. A later call replaces it.
|
|
176
|
+
*/
|
|
177
|
+
logger(fn: ((entry: ScanGap | ScanNote, result: A11yCoreResult) => void) | null): this;
|
|
150
178
|
/** Merge arbitrary engineOptions (locale, contrast.mode, policyContract, ...). */
|
|
151
179
|
options(partialEngineOptions: Record<string, unknown>): this;
|
|
152
180
|
/** Register one or more custom rules for just this scan. Call multiple times to accumulate. */
|
|
@@ -162,6 +190,10 @@ export class A11yCoreBuilder {
|
|
|
162
190
|
frames(enabled?: boolean): this;
|
|
163
191
|
/** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
|
|
164
192
|
elementRef(enabled?: boolean): this;
|
|
193
|
+
/** The rules this scan will run, worked out in Node with no page (@surea11y/core 1.12.0 and later). */
|
|
194
|
+
plannedChecks(): PlannedCheck[];
|
|
195
|
+
/** The criteria this scan will report in rulesResults, worked out in Node with no page. */
|
|
196
|
+
plannedCriteria(): PlannedCriterion[];
|
|
165
197
|
|
|
166
198
|
/**
|
|
167
199
|
* Runs the scan. Returns { topFrame, frames } instead of a single result
|
package/src/A11yCoreBuilder.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { runa11yCoreInPage } = require('@surea11y/core');
|
|
3
|
+
const { runa11yCoreInPage, internalsHookScript } = require('@surea11y/core');
|
|
4
4
|
const {
|
|
5
5
|
A11yCoreBuilderBase,
|
|
6
6
|
createInPageScan,
|
|
7
7
|
rethrowEngineError,
|
|
8
|
-
queryOccurrenceElement
|
|
9
|
-
getScanGaps
|
|
8
|
+
queryOccurrenceElement
|
|
10
9
|
} = require('@surea11y/binding-base');
|
|
11
10
|
|
|
12
11
|
// core's runa11yCoreInPage, wrapped so an engine error (INVALID_RUN_ONLY,
|
|
@@ -146,6 +145,47 @@ const inPageScan = createInPageScan(runa11yCoreInPage);
|
|
|
146
145
|
* exception: each call replaces the previous value rather than merging with it.
|
|
147
146
|
*/
|
|
148
147
|
class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
148
|
+
/**
|
|
149
|
+
* Installs @surea11y/core's opt-in internals hook, so a scan can read the
|
|
150
|
+
* names, states and roles web components set through a private
|
|
151
|
+
* ElementInternals. Call it before browser.url(). It uses:
|
|
152
|
+
*
|
|
153
|
+
* - browser.addInitScript() when the session speaks WebDriver BiDi
|
|
154
|
+
* (WebdriverIO 9's default);
|
|
155
|
+
* - otherwise, on Chrome and Edge, the DevTools protocol through
|
|
156
|
+
* chromedriver (Page.addScriptToEvaluateOnNewDocument, sent with
|
|
157
|
+
* browser.sendCommandAndGetResult()), as in a WebdriverIO 8 or
|
|
158
|
+
* classic session.
|
|
159
|
+
*
|
|
160
|
+
* Both run the script in every frame of the page, before its scripts, and
|
|
161
|
+
* after each navigation. Any other session gets an error saying it has no
|
|
162
|
+
* way to run a script before the page loads. Resolves to the way it was
|
|
163
|
+
* installed, 'bidi' or 'cdp'.
|
|
164
|
+
* @param {WebdriverIO.Browser} browser
|
|
165
|
+
* @returns {Promise<'bidi' | 'cdp'>}
|
|
166
|
+
*/
|
|
167
|
+
static async installInternalsHook(browser) {
|
|
168
|
+
if (!browser || typeof browser.execute !== 'function') {
|
|
169
|
+
throw new TypeError('A11yCoreBuilder.installInternalsHook() requires a WebdriverIO browser.');
|
|
170
|
+
}
|
|
171
|
+
const source = internalsHookScript();
|
|
172
|
+
if (browser.isBidi && typeof browser.addInitScript === 'function') {
|
|
173
|
+
// addInitScript() takes a function, and sends its source as a BiDi
|
|
174
|
+
// preload script.
|
|
175
|
+
await browser.addInitScript(new Function(source));
|
|
176
|
+
return 'bidi';
|
|
177
|
+
}
|
|
178
|
+
if (typeof browser.sendCommandAndGetResult === 'function') {
|
|
179
|
+
await browser.sendCommandAndGetResult('Page.addScriptToEvaluateOnNewDocument', { source });
|
|
180
|
+
return 'cdp';
|
|
181
|
+
}
|
|
182
|
+
throw new Error(
|
|
183
|
+
'A11yCoreBuilder.installInternalsHook(): this session has no way to run a script before the page loads. ' +
|
|
184
|
+
'Use a WebDriver BiDi session (WebdriverIO 9\'s default, in Chrome, Edge or Firefox), or Chrome or Edge, ' +
|
|
185
|
+
'whose DevTools protocol can.'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
149
189
|
/**
|
|
150
190
|
* @param {{ browser: import('webdriverio').Browser, url?: string }} opts
|
|
151
191
|
* `browser` must already be navigated to and settled at the URL to scan --
|
|
@@ -202,7 +242,7 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
202
242
|
const result = rethrowEngineError(
|
|
203
243
|
await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
|
|
204
244
|
);
|
|
205
|
-
this._warnScanGaps(result);
|
|
245
|
+
this._warnScanGaps(result, '@surea11y/webdriverio');
|
|
206
246
|
return this._elementRef ? this._attachElementRefs(result) : result;
|
|
207
247
|
};
|
|
208
248
|
|
|
@@ -299,26 +339,6 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
299
339
|
}
|
|
300
340
|
}
|
|
301
341
|
|
|
302
|
-
/**
|
|
303
|
-
* Prints what the scan left out (getScanGaps(): an include() scope that
|
|
304
|
-
* matched nothing, a custom rule or a pack that did not run) with console.warn.
|
|
305
|
-
* @surea11y/core warns about these too, but in the page's console, which a
|
|
306
|
-
* WebdriverIO run does not show; a result whose checksResults alone look
|
|
307
|
-
* clean would otherwise pass without a word.
|
|
308
|
-
*/
|
|
309
|
-
_warnScanGaps(result) {
|
|
310
|
-
let gaps;
|
|
311
|
-
try {
|
|
312
|
-
gaps = getScanGaps(result);
|
|
313
|
-
} catch (e) {
|
|
314
|
-
return; // not a scan result; the caller sees it as it is
|
|
315
|
-
}
|
|
316
|
-
const where = result.url ? ` (${result.url})` : '';
|
|
317
|
-
for (const gap of gaps) {
|
|
318
|
-
console.warn(`@surea11y/webdriverio${where}: ${gap.message}`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
342
|
/**
|
|
323
343
|
* Resolves each occurrence to a live WebdriverIO.Element, scoped to
|
|
324
344
|
* whatever frame the browser is currently switched into (execute() is
|
package/src/index.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ export {
|
|
|
3
3
|
EngineError,
|
|
4
4
|
ENGINE_ERROR_CODES,
|
|
5
5
|
getScanGaps,
|
|
6
|
+
getScanNotes,
|
|
6
7
|
formatOccurrenceLocation
|
|
7
8
|
} from '@surea11y/binding-base';
|
|
8
|
-
export type {
|
|
9
|
+
export type {
|
|
10
|
+
EngineErrorCode,
|
|
11
|
+
ScanGap,
|
|
12
|
+
ScanNote,
|
|
13
|
+
ScanResultLike,
|
|
14
|
+
OccurrenceLocation,
|
|
15
|
+
PlannedCheck,
|
|
16
|
+
PlannedCriterion,
|
|
17
|
+
WcagLevelInput
|
|
18
|
+
} from '@surea11y/binding-base';
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const {
|
|
|
6
6
|
EngineError,
|
|
7
7
|
ENGINE_ERROR_CODES,
|
|
8
8
|
getScanGaps,
|
|
9
|
+
getScanNotes,
|
|
9
10
|
formatOccurrenceLocation
|
|
10
11
|
} = require('@surea11y/binding-base');
|
|
11
12
|
|
|
@@ -15,5 +16,6 @@ module.exports = {
|
|
|
15
16
|
EngineError,
|
|
16
17
|
ENGINE_ERROR_CODES,
|
|
17
18
|
getScanGaps,
|
|
19
|
+
getScanNotes,
|
|
18
20
|
formatOccurrenceLocation
|
|
19
21
|
};
|