@surea11y/webdriverio 1.2.0 → 1.4.0
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 +211 -16
- package/package.json +19 -10
- package/src/A11yCoreBuilder.d.ts +52 -3
- package/src/A11yCoreBuilder.js +52 -27
- 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 comes with it as a peer dependency, as WebdriverIO does: npm 7 and later, and pnpm 8 and later, install it for you, so the line above is all you need. With Yarn 1 or npm 6, which don't install peers, add `@surea11y/core` to that line.
|
|
12
|
+
|
|
13
|
+
Being a peer means your project and this binding share one copy of core. That matters as soon as your project uses core directly, to write a SARIF log or an HTML report (`@surea11y/core/sarif`, `/report`), read the rule catalogue, or keep a baseline: those read results with the same core that produced them, so a report never labels a finding with another release's rules. It holds when you bring your own core too: a linked checkout (`file:../core`), a monorepo, a fork, or a pinned release. Before 1.4.0, core was a regular dependency, and a project whose core differed from the binding's got a second copy without a word; such a project needed `"overrides": { "@surea11y/core": "$@surea11y/core" }` in its `package.json`, which it can now drop.
|
|
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
|
|
@@ -28,13 +32,13 @@ await browser.url('https://example.com/');
|
|
|
28
32
|
const results = await new A11yCoreBuilder({ browser })
|
|
29
33
|
.include('#main') // optional -- call multiple times for multi-region scans
|
|
30
34
|
.exclude('.cookie-banner') // optional
|
|
31
|
-
.
|
|
35
|
+
.withWcag('2.2', 'AA') // the rules for WCAG 2.2's A and AA criteria
|
|
32
36
|
.disableRules(['meta-refresh-no-exceptions'])
|
|
33
37
|
.options({ contrast: { mode: 'auditorAssist' } })
|
|
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,7 +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
|
-
|
|
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.
|
|
85
|
+
|
|
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
|
+
```
|
|
54
95
|
|
|
55
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.
|
|
56
97
|
|
|
@@ -58,7 +99,7 @@ Rule IDs change rarely, but core 1.10.0 changed two: `label-title-only` is depre
|
|
|
58
99
|
|
|
59
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.
|
|
60
101
|
|
|
61
|
-
**Create one builder per scan.** `A11yCoreBuilder` is a mutable object with no reset between `.analyze()` calls — `include()`/`exclude()`/`withRules()`/`disableRules()`/`withTags()`/`disableTags()`/`options()`/`withCustomRules()` 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()` 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.
|
|
62
103
|
|
|
63
104
|
This binding works against any browser WebdriverIO can drive; the tests and examples use headless Chrome via `chromedriver`, which WebdriverIO manages automatically.
|
|
64
105
|
|
|
@@ -77,14 +118,14 @@ describe('accessibility gate', () => {
|
|
|
77
118
|
const results = await new A11yCoreBuilder({ browser }).reportOnly(['fail']).analyze();
|
|
78
119
|
|
|
79
120
|
assert.strictEqual(results.checksResults.length, 0, formatFailures(results));
|
|
80
|
-
// An include() scope that matched nothing, or a custom rule
|
|
81
|
-
// run, leaves no failure behind: check for them too.
|
|
121
|
+
// An include() scope that matched nothing, or a custom rule or a pack
|
|
122
|
+
// that did not run, leaves no failure behind: check for them too.
|
|
82
123
|
assert.deepStrictEqual(getScanGaps(results), [], formatFailures(results));
|
|
83
124
|
});
|
|
84
125
|
});
|
|
85
126
|
```
|
|
86
127
|
|
|
87
|
-
See `examples/e2e-test-example.test.js` for a fuller, runnable version (`npm run example:e2e`), driven by `examples/wdio.conf.js` — one test proving real violations get caught (unlabeled button, missing `alt`), one proving a well-formed page passes cleanly. It uses the **WebdriverIO testrunner** (`@wdio/cli` + mocha), which is how real WebdriverIO suites are written. This project's own *unit* suite (`tests/*.test.js`) uses `node:test` + `remote()`, the right tool for exercising the builder API in isolation.
|
|
128
|
+
See `examples/e2e-test-example.test.js` for a fuller, runnable version (`npm run example:e2e`), driven by `examples/wdio.conf.js` — one test proving real violations get caught (unlabeled button, missing `alt`), one proving a well-formed page passes cleanly, and one gating a page on a pack's checklist (see [Scanning with packs](#scanning-with-packs-standards-checklists-and-your-own-rules)). It uses the **WebdriverIO testrunner** (`@wdio/cli` + mocha), which is how real WebdriverIO suites are written. This project's own *unit* suite (`tests/*.test.js`) uses `node:test` + `remote()`, the right tool for exercising the builder API in isolation.
|
|
88
129
|
|
|
89
130
|
Note the gate above uses `node:assert`, not WebdriverIO's bundled `expect`: WebdriverIO's `expect` takes **at most one argument** — there's no `expect(value, message)` custom-failure-message overload the way Playwright's `expect` has — so `node:assert`'s message parameter is what carries `formatFailures()`' readable block onto a failed assertion.
|
|
90
131
|
|
|
@@ -95,14 +136,16 @@ A bare length/equality assertion alone gets you a *working* gate, but the failur
|
|
|
95
136
|
```
|
|
96
137
|
1) button-name-present (serious): This button has no accessible name.
|
|
97
138
|
at html > body > button
|
|
98
|
-
|
|
99
|
-
2) img-alt-present (serious):
|
|
139
|
+
Give the button visible text, or name it with aria-label, so screen reader users know what it does.
|
|
140
|
+
2) img-alt-present (serious): This <img> has no alt attribute.
|
|
100
141
|
at #host >>> p > img
|
|
101
|
-
Add an alt attribute
|
|
142
|
+
Add an alt attribute that says what the image shows, or alt="" if the image is purely decorative.
|
|
102
143
|
|
|
103
|
-
Scanned with @surea11y/core 1.
|
|
144
|
+
Scanned with @surea11y/core 1.12.0.
|
|
104
145
|
```
|
|
105
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
|
+
|
|
106
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.
|
|
107
150
|
|
|
108
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.
|
|
@@ -122,7 +165,17 @@ for (const frame of results.frames) {
|
|
|
122
165
|
|
|
123
166
|
The mechanics here are genuinely different from those bindings, though, because WebdriverIO's frame model is different. Puppeteer/Playwright expose `page.frames()` — an array of independent `Frame` objects, each with its own `.evaluate()`/`.$()` — so a binding can iterate them freely. WebdriverIO instead has a single **stateful current context**: `browser.switchFrame(iframeElement)` changes which document `browser.execute()`/`browser.$()` run against, and `browser.switchFrame(null)` returns to the top-level frame. There is no array of frame objects. So `.frames(true)` enumerates each context's direct-child `<iframe>` elements with `browser.$$('iframe')`, switches into each, scans the now-current document, and recurses into that frame's own children (re-navigating from the top each time, since element references and BiDi context ids don't reliably survive a context switch). Cross-origin iframes are reached with no extra work — WebdriverIO's automation layer switches into them the same as same-origin ones, exactly like Puppeteer/Playwright's CDP does.
|
|
124
167
|
|
|
125
|
-
**`.include()` scopes the top frame only.** Each sub-frame is scanned whole, as `@surea11y/core`'s own `runa11yCoreAcrossFrames` does: the selectors belong to the top document, and since core 1.10.0 a selector that matches nothing scans nothing, so passing them down would leave every frame without them unscanned. `.exclude()` and the rule filters apply in every frame. An `
|
|
168
|
+
**`.include()` scopes the top frame only.** Each sub-frame is scanned whole, as `@surea11y/core`'s own `runa11yCoreAcrossFrames` does: the selectors belong to the top document, and since core 1.10.0 a selector that matches nothing scans nothing, so passing them down would leave every frame without them unscanned. `.exclude()` and the rule filters apply in every frame. An `EngineError` (see [When a scan can't run as asked](#when-a-scan-cant-run-as-asked)) rejects `analyze()` from the top frame, before any sub-frame is scanned; a sub-frame that fails for another reason is listed in `results.frames` as `{ url, error }`.
|
|
169
|
+
|
|
170
|
+
`@surea11y/core`'s reporters take this result as it is (core 1.11.1 and later): `renderSarifReport(results)`, `renderJunitReport(results)`, `renderHtmlReport(results)` and the baseline functions report every frame's findings, and a frame that couldn't be scanned as not scanned, so it never reads as a pass.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
const fs = require('node:fs');
|
|
174
|
+
const { renderSarifReport } = require('@surea11y/core/sarif');
|
|
175
|
+
|
|
176
|
+
const results = await new A11yCoreBuilder({ browser }).frames(true).analyze();
|
|
177
|
+
fs.writeFileSync('a11y.sarif', renderSarifReport(results));
|
|
178
|
+
```
|
|
126
179
|
|
|
127
180
|
Verified against a real cross-origin page (`https://example.org/` embedded in an unrelated origin) and against a real nested-iframe page (top → child → grandchild) — see `tests/builder.test.js`. Default off, so plain `.analyze()` is unaffected unless you opt in. Unlike script-injection-based accessibility tools (which need a `postMessage`-based protocol to reach cross-origin iframes, since they're injected as a plain `<script>` subject to the same-origin policy), this needs no extra `@surea11y/core` engine support at all.
|
|
128
181
|
|
|
@@ -140,6 +193,8 @@ console.log(results.checksResults); // only fail/cantTell entries, pass/notAppli
|
|
|
140
193
|
|
|
141
194
|
Valid outcome values are `'pass'`, `'fail'`, `'cantTell'`, `'notApplicable'`. This is pure binding-layer filtering — the engine itself still computes every rule; nothing about the scan itself changes. Combines with `.frames(true)`: the filter is applied to `results.topFrame` and each entry of `results.frames` independently.
|
|
142
195
|
|
|
196
|
+
A result trimmed this way is no longer the whole scan to `@surea11y/core`'s reporters: JUnit counts one test per rule it finds, and EARL makes one assertion per rule. To keep a result small and still hand it to them, ask the engine for a compact one instead, with `.options({ output: { detail: 'findings' } })`: a rule that passed or did not apply keeps only its outcome, and every reporter reads it as the full result. An empty page's result goes from about 238 KB to 82 KB.
|
|
197
|
+
|
|
143
198
|
### Getting a live element, not just a selector string
|
|
144
199
|
|
|
145
200
|
By default each occurrence carries a CSS selector + HTML snippet, not a live reference to the element. Opt in to a real `WebdriverIO.Element` with `.elementRef(true)`:
|
|
@@ -161,6 +216,60 @@ Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(tru
|
|
|
161
216
|
|
|
162
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.
|
|
163
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
|
+
|
|
164
273
|
### Registering a custom rule at runtime
|
|
165
274
|
|
|
166
275
|
`@surea11y/core` supports registering additional rules per-scan via `engineOptions.customRules`. Use `.withCustomRules()` to register one:
|
|
@@ -193,6 +302,8 @@ const results = await new A11yCoreBuilder({ browser })
|
|
|
193
302
|
|
|
194
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.
|
|
195
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
|
+
|
|
196
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.)
|
|
197
308
|
|
|
198
309
|
### When a scan can't run as asked
|
|
@@ -201,6 +312,8 @@ Invalid input (a missing/empty `id`, or a `runInPage`/`applicability` that's nei
|
|
|
201
312
|
|
|
202
313
|
- `INVALID_RUN_ONLY`: none of the rule IDs, or none of the tags, given to `.withRules()`/`.withTags()` is one the engine knows.
|
|
203
314
|
- `INVALID_CONTEXT_SELECTOR`: an `.include()` selector is not valid CSS; `error.selector` names it.
|
|
315
|
+
- `INVALID_ENGINE_OPTIONS`: with `.options({ strictOptions: true })`, an option the engine doesn't read or a value it can't use. The message names each, with the closest known option (`lcoale`: did you mean `locale`?). Without `strictOptions`, a likely typo is only warned about in the page's console.
|
|
316
|
+
- `PAGE_BUILTINS_BROKEN`: the page's own scripts broke a JavaScript built-in the engine relies on (`Array.prototype.filter`, `Map`, `JSON.stringify`, ...), so no result it gave could be trusted. The message names each one.
|
|
204
317
|
|
|
205
318
|
```js
|
|
206
319
|
const { A11yCoreBuilder, EngineError } = require('@surea11y/webdriverio');
|
|
@@ -218,9 +331,65 @@ try {
|
|
|
218
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:
|
|
219
332
|
|
|
220
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.
|
|
221
|
-
- **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.
|
|
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)).
|
|
336
|
+
|
|
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.
|
|
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:
|
|
222
340
|
|
|
223
|
-
|
|
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
|
+
|
|
367
|
+
### Scanning with packs: standards, checklists and your own rules
|
|
368
|
+
|
|
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.
|
|
370
|
+
|
|
371
|
+
`.withPacks()` registers packs in the page with `browser.execute()`, and in every frame with `.frames(true)`, before the scan; a pack's profile is chosen with `.options({ profile })`:
|
|
372
|
+
|
|
373
|
+
```js
|
|
374
|
+
const acmePolicy = require('@acme/a11y-policy'); // a pack
|
|
375
|
+
|
|
376
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
377
|
+
.withPacks(acmePolicy)
|
|
378
|
+
.options({ profile: 'acme-web-2026' })
|
|
379
|
+
.analyze();
|
|
380
|
+
|
|
381
|
+
results.engine.packs; // ['@acme/a11y-policy@1.0.0']
|
|
382
|
+
results.engine.profile; // 'acme-web-2026'
|
|
383
|
+
|
|
384
|
+
// A checklist's items, each one result, beside core's WCAG rollups:
|
|
385
|
+
results.rulesResults.filter((r) => r.meta && r.meta.standard === 'Acme web policy');
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
A pack's own rules appear in `checksResults` like core's, with ids that start with its namespace (`acme-no-autoplay`). Without its profile, a pack's rules run only when selected, with `.withRules()` or by a tag. `@surea11y/core`'s HTML, SARIF and JUnit reports show a standard's or a checklist's items in a section of their own. Pass an array, or call `.withPacks()` again, to scan with several packs.
|
|
389
|
+
|
|
390
|
+
**Writing a pack.** A pack is an object made with `definePack` from `@surea11y/core/pack`. [`examples/acme-policy-pack.js`](./examples/acme-policy-pack.js) is a small one: a house rule, a checklist profile (WCAG 2.2 A and AA, `region`, and the house rule) and two checklist items. `npm run example:pack` scans a page with it, and [`examples/e2e-test-example.test.js`](./examples/e2e-test-example.test.js) uses it as a test gate. For a pack of your own, `npx surea11y-pack new <folder>` (from `@surea11y/core`) writes a package that works as generated, with its tests, docs and lint; core's [`PACKS.md`](https://github.com/SureA11y/core/blob/main/docs/PACKS.md) is the guide.
|
|
391
|
+
|
|
392
|
+
**When a pack doesn't run.** `.withPacks()` builds the page's script with the `@surea11y/core` this binding loads, and checks each pack as it does: `.analyze()` throws before scanning for a pack that core can't run, such as one whose `core` range leaves out the installed core, or whose profile names a rule that doesn't exist. A pack the page then doesn't run (one named with a raw `.options({ packs })` and never registered, or a page running another core version, when two copies of core are installed) is listed in `results.skippedPacks` with the reason. `getScanGaps()` reports that as `'pack-skipped'`, and `.analyze()` warns about it, so a test gate that asserts on `getScanGaps(results)` fails rather than passing with the pack's checks never run. Install the pack beside the binding, so npm resolves one core for both.
|
|
224
393
|
|
|
225
394
|
### Element addressing beyond a CSS selector
|
|
226
395
|
|
|
@@ -230,11 +399,37 @@ An element inside a shadow tree is different: its `selector` holds only inside i
|
|
|
230
399
|
|
|
231
400
|
## TypeScript
|
|
232
401
|
|
|
233
|
-
`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`.
|
|
419
|
+
|
|
420
|
+
## What changes with `@surea11y/core` 1.11
|
|
421
|
+
|
|
422
|
+
The binding passes rule IDs, tags and options through to the engine, so these show in results without a change here (see core's [`CHANGELOG.md`](https://github.com/SureA11y/core/blob/main/CHANGELOG.md) for the full list):
|
|
423
|
+
|
|
424
|
+
- **Messages are reworded** in all five languages to say plainly what is wrong and what to do (`This <img> has no alt attribute.`). Message keys, rule IDs, reason codes and baselines are unchanged; a test that matches message text needs the new wording.
|
|
425
|
+
- **Contrast and target size measure what is on screen.** In a browser, the contrast rules check only text a reader can see, measure it against what is painted under it, and measure form field values and placeholders. `target-size-minimum` measures the area a pointer can hit, and exempts only links in a sentence. Expect new findings, and some old ones gone.
|
|
426
|
+
- **`aria-hidden-focus` maps to 4.1.2 only**, no longer to 2.4.7 Focus Visible, so `.withTags('wcag2aa')` alone no longer selects it.
|
|
427
|
+
- **A custom rule counts toward its WCAG criterion.** A `.withCustomRules()` rule with `meta.wcagSc` (or a WCAG `normativeMappings` entry) fails that criterion's rollup when it fails, and custom rules run after core's.
|
|
428
|
+
- **On/off options read as meant**: `.options({ perfStats: 'false' })` is off, where it was on. `.options({ strictOptions: true })` rejects any option the engine doesn't read (see [When a scan can't run as asked](#when-a-scan-cant-run-as-asked)).
|
|
234
429
|
|
|
235
430
|
## Relationship to `@surea11y/playwright` and `@surea11y/puppeteer`
|
|
236
431
|
|
|
237
|
-
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`/`options`/`withCustomRules`/`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.
|
|
238
433
|
|
|
239
434
|
Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
|
|
240
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.0",
|
|
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,23 +47,25 @@
|
|
|
40
47
|
"LICENSE"
|
|
41
48
|
],
|
|
42
49
|
"dependencies": {
|
|
43
|
-
"@surea11y/binding-base": "^1.
|
|
44
|
-
"@surea11y/core": "^1.10.0"
|
|
50
|
+
"@surea11y/binding-base": "^1.4.0"
|
|
45
51
|
},
|
|
46
52
|
"peerDependencies": {
|
|
53
|
+
"@surea11y/core": ">=1.12.0",
|
|
47
54
|
"webdriverio": ">=8.0.0"
|
|
48
55
|
},
|
|
49
56
|
"devDependencies": {
|
|
50
|
-
"@
|
|
51
|
-
"@wdio/
|
|
57
|
+
"@surea11y/core": "^1.12.0",
|
|
58
|
+
"@wdio/cli": "^9.32.0",
|
|
59
|
+
"@wdio/local-runner": "^9.32.0",
|
|
52
60
|
"@wdio/mocha-framework": "^9.32.0",
|
|
53
|
-
"@wdio/spec-reporter": "^9.
|
|
54
|
-
"typescript": "^
|
|
55
|
-
"webdriverio": "^9.
|
|
61
|
+
"@wdio/spec-reporter": "^9.32.0",
|
|
62
|
+
"typescript": "^7.0.2",
|
|
63
|
+
"webdriverio": "^9.32.0"
|
|
56
64
|
},
|
|
57
65
|
"scripts": {
|
|
58
66
|
"test": "node --test --test-concurrency=1 tests/*.test.js",
|
|
59
67
|
"example": "node examples/basic-scan.js",
|
|
60
|
-
"example:e2e": "wdio run examples/wdio.conf.js"
|
|
68
|
+
"example:e2e": "wdio run examples/wdio.conf.js",
|
|
69
|
+
"example:pack": "node examples/scan-with-pack.js"
|
|
61
70
|
}
|
|
62
71
|
}
|
package/src/A11yCoreBuilder.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ 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, WcagLevelInput, ScanGap, ScanNote, PlannedCheck, PlannedCriterion } from '@surea11y/binding-base';
|
|
14
|
+
export type { PackLike, WcagVersion, WcagLevel } from '@surea11y/binding-base';
|
|
13
15
|
|
|
14
16
|
export type Outcome = Core.Outcome;
|
|
15
17
|
export type OutcomeNormalized = Core.OutcomeNormalized;
|
|
@@ -111,6 +113,15 @@ export class A11yCoreBuilder {
|
|
|
111
113
|
*/
|
|
112
114
|
constructor(opts: { browser: WebdriverIO.Browser; url?: string });
|
|
113
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
|
+
|
|
114
125
|
/**
|
|
115
126
|
* Scope the scan to one region. Call multiple times for a multi-region
|
|
116
127
|
* union. A selector that matches nothing scans nothing (see
|
|
@@ -137,24 +148,62 @@ export class A11yCoreBuilder {
|
|
|
137
148
|
withRules(ruleIds: string | string[]): this;
|
|
138
149
|
/** Never run these specific rule IDs (applied after withRules). */
|
|
139
150
|
disableRules(ruleIds: string | string[]): this;
|
|
151
|
+
/**
|
|
152
|
+
* Run the rules for a WCAG conformance target: `withWcag('2.2', 'AA')`
|
|
153
|
+
* runs those of WCAG 2.2's A and AA criteria. withTags()/withRules() add
|
|
154
|
+
* to them. A later call replaces the target.
|
|
155
|
+
*/
|
|
156
|
+
withWcag(version: WcagVersion | 2.0 | 2.1 | 2.2, level?: WcagLevelInput): this;
|
|
157
|
+
/** Also run the best-practice rules, those that name no WCAG criterion. */
|
|
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;
|
|
140
178
|
/** Merge arbitrary engineOptions (locale, contrast.mode, policyContract, ...). */
|
|
141
179
|
options(partialEngineOptions: Record<string, unknown>): this;
|
|
142
180
|
/** Register one or more custom rules for just this scan. Call multiple times to accumulate. */
|
|
143
181
|
withCustomRules(rules: CustomRuleDescriptor | CustomRuleDescriptor[]): this;
|
|
182
|
+
/**
|
|
183
|
+
* Packs from `@surea11y/core/pack` (core 1.11 or later): registered in each
|
|
184
|
+
* frame before the scan, and named in `engineOptions.packs`.
|
|
185
|
+
*/
|
|
186
|
+
withPacks(packs: PackLike | PackLike[]): this;
|
|
144
187
|
/** Post-filter checksResults down to only the given outcomes. */
|
|
145
188
|
reportOnly(outcomes: Outcome | Outcome[]): this;
|
|
146
189
|
/** Opt in to also scanning every sub-frame on the page (including cross-origin and nested iframes). */
|
|
147
190
|
frames(enabled?: boolean): this;
|
|
148
191
|
/** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
|
|
149
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[];
|
|
150
197
|
|
|
151
198
|
/**
|
|
152
199
|
* Runs the scan. Returns { topFrame, frames } instead of a single result
|
|
153
200
|
* when .frames(true) was used. Rejects with an `EngineError` (from
|
|
154
201
|
* `@surea11y/binding-base`, re-exported here) whose `code` is
|
|
155
202
|
* `INVALID_RUN_ONLY` when a rule or tag list names nothing @surea11y/core
|
|
156
|
-
* knows,
|
|
157
|
-
* selector does not parse
|
|
203
|
+
* knows, `INVALID_CONTEXT_SELECTOR` (with `selector`) when an include()
|
|
204
|
+
* selector does not parse, `INVALID_ENGINE_OPTIONS` for an option
|
|
205
|
+
* `strictOptions: true` rejects, or `PAGE_BUILTINS_BROKEN` when the page's
|
|
206
|
+
* scripts broke a JavaScript built-in the engine relies on.
|
|
158
207
|
*/
|
|
159
208
|
analyze(): Promise<A11yCoreResult | A11yCoreMultiFrameResult>;
|
|
160
209
|
}
|
|
@@ -163,7 +212,7 @@ export class A11yCoreBuilder {
|
|
|
163
212
|
* Formats a result, or its checksResults array, into a short, human-readable
|
|
164
213
|
* block -- one entry per fail/cantTell occurrence, not per rule. Given the
|
|
165
214
|
* whole result, it also lists what the scan left out (an include() scope
|
|
166
|
-
* that matched nothing, a custom rule that did not run) and the
|
|
215
|
+
* that matched nothing, a custom rule or a pack that did not run) and the
|
|
167
216
|
* @surea11y/core release that produced it. Meant for an assertion
|
|
168
217
|
* library's failure-message parameter, e.g.
|
|
169
218
|
* `assert.strictEqual(fails.length, 0, formatFailures(results))`.
|
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,
|
|
@@ -136,16 +135,57 @@ const inPageScan = createInPageScan(runa11yCoreInPage);
|
|
|
136
135
|
*
|
|
137
136
|
* Create one builder per scan. This is a mutable object with no reset
|
|
138
137
|
* between analyze() calls: include()/exclude()/withRules()/disableRules()/
|
|
139
|
-
* withTags()/disableTags()/options()/withCustomRules() all push onto or
|
|
138
|
+
* withTags()/disableTags()/options()/withCustomRules()/withPacks() all push onto or
|
|
140
139
|
* merge into internal state that persists for the instance's lifetime, so
|
|
141
140
|
* calling one of them again before a second analyze() call accumulates on
|
|
142
141
|
* top of the first scan's scope rather than replacing it (intentional for
|
|
143
142
|
* "call include() several times for one scan" -- see above -- but a footgun
|
|
144
143
|
* if you hold one instance across multiple assertions).
|
|
145
|
-
* reportOnly()/frames()/elementRef() are the
|
|
146
|
-
* the previous value rather than merging with it.
|
|
144
|
+
* reportOnly()/frames()/elementRef()/withWcag()/withBestPractices() are the
|
|
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 --
|
|
@@ -192,12 +232,17 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
192
232
|
// inPageScan takes the same four arguments as runa11yCoreInPage; an
|
|
193
233
|
// engine error comes back as a plain object, which rethrowEngineError()
|
|
194
234
|
// throws again here as an EngineError with its `code`.
|
|
235
|
+
// The script that registers withPacks()'s packs in a frame, which the
|
|
236
|
+
// scan names in engineOptions.packs; null without packs. execute() runs
|
|
237
|
+
// a string as a function body, so the script runs as written.
|
|
238
|
+
const packScript = this._packScript();
|
|
195
239
|
const scanCurrent = async (scope) => {
|
|
196
240
|
const frameUrl = this._url || (await this._browser.execute(() => document.location.href));
|
|
241
|
+
if (packScript) await this._browser.execute(packScript);
|
|
197
242
|
const result = rethrowEngineError(
|
|
198
243
|
await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
|
|
199
244
|
);
|
|
200
|
-
this._warnScanGaps(result);
|
|
245
|
+
this._warnScanGaps(result, '@surea11y/webdriverio');
|
|
201
246
|
return this._elementRef ? this._attachElementRefs(result) : result;
|
|
202
247
|
};
|
|
203
248
|
|
|
@@ -294,26 +339,6 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
294
339
|
}
|
|
295
340
|
}
|
|
296
341
|
|
|
297
|
-
/**
|
|
298
|
-
* Prints what the scan left out (getScanGaps(): an include() scope that
|
|
299
|
-
* matched nothing, a custom rule that did not run) with console.warn.
|
|
300
|
-
* @surea11y/core warns about these too, but in the page's console, which a
|
|
301
|
-
* WebdriverIO run does not show; a result whose checksResults alone look
|
|
302
|
-
* clean would otherwise pass without a word.
|
|
303
|
-
*/
|
|
304
|
-
_warnScanGaps(result) {
|
|
305
|
-
let gaps;
|
|
306
|
-
try {
|
|
307
|
-
gaps = getScanGaps(result);
|
|
308
|
-
} catch (e) {
|
|
309
|
-
return; // not a scan result; the caller sees it as it is
|
|
310
|
-
}
|
|
311
|
-
const where = result.url ? ` (${result.url})` : '';
|
|
312
|
-
for (const gap of gaps) {
|
|
313
|
-
console.warn(`@surea11y/webdriverio${where}: ${gap.message}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
342
|
/**
|
|
318
343
|
* Resolves each occurrence to a live WebdriverIO.Element, scoped to
|
|
319
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
|
};
|