@surea11y/webdriverio 1.1.0 → 1.3.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 +116 -19
- package/package.json +11 -9
- package/src/A11yCoreBuilder.d.ts +100 -143
- package/src/A11yCoreBuilder.js +95 -28
- package/src/index.d.ts +7 -0
- package/src/index.js +14 -1
package/README.md
CHANGED
|
@@ -28,12 +28,13 @@ await browser.url('https://example.com/');
|
|
|
28
28
|
const results = await new A11yCoreBuilder({ browser })
|
|
29
29
|
.include('#main') // optional -- call multiple times for multi-region scans
|
|
30
30
|
.exclude('.cookie-banner') // optional
|
|
31
|
-
.
|
|
31
|
+
.withWcag('2.2', 'AA') // the rules for WCAG 2.2's A and AA criteria
|
|
32
32
|
.disableRules(['meta-refresh-no-exceptions'])
|
|
33
33
|
.options({ contrast: { mode: 'auditorAssist' } })
|
|
34
34
|
.analyze();
|
|
35
35
|
|
|
36
36
|
console.log(results.checksResults.filter(r => r.outcome === 'fail'));
|
|
37
|
+
console.log(results.engine.version); // the @surea11y/core release that scanned, e.g. "1.11.1"
|
|
37
38
|
await browser.deleteSession();
|
|
38
39
|
```
|
|
39
40
|
|
|
@@ -49,11 +50,17 @@ WebdriverIO v9 defaults to the WebDriver **BiDi** protocol. A long-lived session
|
|
|
49
50
|
|
|
50
51
|
### Composing the rule filters
|
|
51
52
|
|
|
52
|
-
|
|
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 `'A'`, `'AA'` or `'AAA'`, throws a `TypeError` with `code: 'INVALID_RUN_ONLY'` at the call. Both need `@surea11y/core` 1.11 or later.
|
|
54
|
+
|
|
55
|
+
`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.
|
|
56
|
+
|
|
57
|
+
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
|
+
|
|
59
|
+
Rule IDs change rarely, but core 1.10.0 changed two: `label-title-only` is deprecated (it reports `notApplicable`; every field it flagged, `form-control-programmatic-label-quality` flags too) and keeps resolving until 2.0.0, so a `.disableRules(['label-title-only'])` still works but no longer does anything; and `landmark-role-name-present` is new, a `best-practice` rule that asks about an element with `role="region"` or `role="form"` and no accessible name.
|
|
53
60
|
|
|
54
61
|
`.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.
|
|
55
62
|
|
|
56
|
-
**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.
|
|
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.
|
|
57
64
|
|
|
58
65
|
This binding works against any browser WebdriverIO can drive; the tests and examples use headless Chrome via `chromedriver`, which WebdriverIO manages automatically.
|
|
59
66
|
|
|
@@ -63,7 +70,7 @@ The pattern above works unchanged inside a real WebdriverIO testrunner spec, whi
|
|
|
63
70
|
|
|
64
71
|
```js
|
|
65
72
|
const assert = require('node:assert');
|
|
66
|
-
const { A11yCoreBuilder, formatFailures } = require('@surea11y/webdriverio');
|
|
73
|
+
const { A11yCoreBuilder, formatFailures, getScanGaps } = require('@surea11y/webdriverio');
|
|
67
74
|
|
|
68
75
|
describe('accessibility gate', () => {
|
|
69
76
|
it('has no accessibility violations', async () => {
|
|
@@ -71,29 +78,36 @@ describe('accessibility gate', () => {
|
|
|
71
78
|
|
|
72
79
|
const results = await new A11yCoreBuilder({ browser }).reportOnly(['fail']).analyze();
|
|
73
80
|
|
|
74
|
-
assert.strictEqual(results.checksResults.length, 0, formatFailures(results
|
|
81
|
+
assert.strictEqual(results.checksResults.length, 0, formatFailures(results));
|
|
82
|
+
// An include() scope that matched nothing, or a custom rule or a pack
|
|
83
|
+
// that did not run, leaves no failure behind: check for them too.
|
|
84
|
+
assert.deepStrictEqual(getScanGaps(results), [], formatFailures(results));
|
|
75
85
|
});
|
|
76
86
|
});
|
|
77
87
|
```
|
|
78
88
|
|
|
79
|
-
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.
|
|
89
|
+
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.
|
|
80
90
|
|
|
81
91
|
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.
|
|
82
92
|
|
|
83
93
|
### Readable console/CI output on failure
|
|
84
94
|
|
|
85
|
-
A bare length/equality assertion alone gets you a *working* gate, but the failure message is a raw, deeply-nested object diff — hundreds of lines for a handful of violations. `formatFailures(
|
|
95
|
+
A bare length/equality assertion alone gets you a *working* gate, but the failure message is a raw, deeply-nested object diff — hundreds of lines for a handful of violations. `formatFailures(results)` turns that into a short, scannable block (one entry per occurrence, numbered, with rule ID/severity/location/hint) that you hand to your assertion library's own failure-message parameter, as above. Given the whole result, it also says what the scan left out and which `@surea11y/core` release ran it. A real failure then prints:
|
|
86
96
|
|
|
87
97
|
```
|
|
88
98
|
1) button-name-present (serious): This button has no accessible name.
|
|
89
99
|
at html > body > button
|
|
90
|
-
|
|
91
|
-
2) img-alt-present (serious):
|
|
92
|
-
at
|
|
93
|
-
Add an alt attribute
|
|
100
|
+
Give the button visible text, or name it with aria-label, so screen reader users know what it does.
|
|
101
|
+
2) img-alt-present (serious): This <img> has no alt attribute.
|
|
102
|
+
at #host >>> p > img
|
|
103
|
+
Add an alt attribute that says what the image shows, or alt="" if the image is purely decorative.
|
|
104
|
+
|
|
105
|
+
Scanned with @surea11y/core 1.11.1.
|
|
94
106
|
```
|
|
95
107
|
|
|
96
|
-
|
|
108
|
+
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
|
+
|
|
110
|
+
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.
|
|
97
111
|
|
|
98
112
|
### Scanning every frame, including cross-origin and nested iframes
|
|
99
113
|
|
|
@@ -110,6 +124,18 @@ for (const frame of results.frames) {
|
|
|
110
124
|
|
|
111
125
|
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.
|
|
112
126
|
|
|
127
|
+
**`.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 }`.
|
|
128
|
+
|
|
129
|
+
`@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.
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const fs = require('node:fs');
|
|
133
|
+
const { renderSarifReport } = require('@surea11y/core/sarif');
|
|
134
|
+
|
|
135
|
+
const results = await new A11yCoreBuilder({ browser }).frames(true).analyze();
|
|
136
|
+
fs.writeFileSync('a11y.sarif', renderSarifReport(results));
|
|
137
|
+
```
|
|
138
|
+
|
|
113
139
|
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.
|
|
114
140
|
|
|
115
141
|
### Trimming the result to just violations
|
|
@@ -126,6 +152,8 @@ console.log(results.checksResults); // only fail/cantTell entries, pass/notAppli
|
|
|
126
152
|
|
|
127
153
|
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.
|
|
128
154
|
|
|
155
|
+
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.
|
|
156
|
+
|
|
129
157
|
### Getting a live element, not just a selector string
|
|
130
158
|
|
|
131
159
|
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)`:
|
|
@@ -138,14 +166,14 @@ await failing.occurrences[0].element.saveScreenshot('./flagged.png');
|
|
|
138
166
|
await failing.occurrences[0].element.click();
|
|
139
167
|
```
|
|
140
168
|
|
|
141
|
-
This
|
|
169
|
+
This finds each occurrence's element in the page and attaches it as **`occurrence.element`** — instead of leaving you to re-resolve a possibly-stale selector string yourself. An element inside a shadow tree is found through its `shadowHostSelectors` (see below), where `browser.$(occurrence.selector)` would find another element or none. `element` is `null` when nothing matches any more. Default off — resolving an element per occurrence is a real page query per occurrence, so it costs more than a plain `.analyze()`.
|
|
142
170
|
|
|
143
171
|
Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(true)`, both real and worth knowing:
|
|
144
172
|
|
|
145
173
|
- **The field is `occurrence.element`, not `occurrence.elementHandle`.** WebdriverIO has no "handle" concept — `browser.$()` returns a `WebdriverIO.Element`. Its API differs too: read a property with `.getProperty('id')` (not Puppeteer's `.evaluate(el => el.id)`), screenshot with `.saveScreenshot(path)` (not `.screenshot({ path })`). See the [WebdriverIO element API](https://webdriver.io/docs/api/element).
|
|
146
174
|
- **A sub-frame element is only usable while switched into its frame.** WebdriverIO element references are bound to whichever frame was the current context when they were resolved. When you combine `.frames(true).elementRef(true)`, a *sub-frame* occurrence's `element` is resolved against that frame's own document (correct), but after `analyze()` returns the browser is back at the top-level frame — so to act on a sub-frame element you must `browser.switchFrame()` back into its frame first (`browser.switchFrame((await browser.$$('iframe'))[i])`). Top-frame occurrences have no such caveat. This is a direct consequence of WebdriverIO's stateful context model and has no analogue in Puppeteer/Playwright, whose handles are bound to an execution context, not a mutable "current frame."
|
|
147
175
|
|
|
148
|
-
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.
|
|
176
|
+
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.
|
|
149
177
|
|
|
150
178
|
### Registering a custom rule at runtime
|
|
151
179
|
|
|
@@ -179,23 +207,92 @@ const results = await new A11yCoreBuilder({ browser })
|
|
|
179
207
|
|
|
180
208
|
**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.
|
|
181
209
|
|
|
182
|
-
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
|
|
210
|
+
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
|
+
|
|
212
|
+
### When a scan can't run as asked
|
|
213
|
+
|
|
214
|
+
`analyze()` rejects with an `EngineError` (exported, along with `ENGINE_ERROR_CODES`) when `@surea11y/core` can't use what it was given. Its `code` says why:
|
|
215
|
+
|
|
216
|
+
- `INVALID_RUN_ONLY`: none of the rule IDs, or none of the tags, given to `.withRules()`/`.withTags()` is one the engine knows.
|
|
217
|
+
- `INVALID_CONTEXT_SELECTOR`: an `.include()` selector is not valid CSS; `error.selector` names it.
|
|
218
|
+
- `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.
|
|
219
|
+
- `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.
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
const { A11yCoreBuilder, EngineError } = require('@surea11y/webdriverio');
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await new A11yCoreBuilder({ browser }).withTags(['wcag2.2aa']).analyze();
|
|
226
|
+
} catch (e) {
|
|
227
|
+
if (e instanceof EngineError && e.code === 'INVALID_RUN_ONLY') {
|
|
228
|
+
// a typo in the tag list, not a broken page
|
|
229
|
+
}
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
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
|
+
|
|
236
|
+
- **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`.
|
|
238
|
+
- **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
|
+
|
|
240
|
+
`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
|
+
|
|
242
|
+
### Scanning with packs: standards, checklists and your own rules
|
|
243
|
+
|
|
244
|
+
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.
|
|
245
|
+
|
|
246
|
+
`.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 })`:
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
const acmePolicy = require('@acme/a11y-policy'); // a pack
|
|
250
|
+
|
|
251
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
252
|
+
.withPacks(acmePolicy)
|
|
253
|
+
.options({ profile: 'acme-web-2026' })
|
|
254
|
+
.analyze();
|
|
255
|
+
|
|
256
|
+
results.engine.packs; // ['@acme/a11y-policy@1.0.0']
|
|
257
|
+
results.engine.profile; // 'acme-web-2026'
|
|
258
|
+
|
|
259
|
+
// A checklist's items, each one result, beside core's WCAG rollups:
|
|
260
|
+
results.rulesResults.filter((r) => r.meta && r.meta.standard === 'Acme web policy');
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
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.
|
|
264
|
+
|
|
265
|
+
**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.
|
|
266
|
+
|
|
267
|
+
**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.
|
|
183
268
|
|
|
184
269
|
### Element addressing beyond a CSS selector
|
|
185
270
|
|
|
186
|
-
Every occurrence already carries `selector` and (with `.elementRef(true)`, above) a live `element`. It also carries `structuralPath` — a sibling-index path from the document root down to the flagged element (e.g. `[1, 0, 2]`) — a more robust identity than a selector string alone, since it survives some DOM changes a selector wouldn't (an id/class rename, for instance). No opt-in needed; it's already on every `fail`/`cantTell` occurrence today.
|
|
271
|
+
Every occurrence already carries `selector` and (with `.elementRef(true)`, above) a live `element`. It also carries `structuralPath` — a sibling-index path from the document root down to the flagged element (e.g. `[1, 0, 2]`) — a more robust identity than a selector string alone, since it survives some DOM changes a selector wouldn't (an id/class rename, for instance). No opt-in needed; it's already on every `fail`/`cantTell` occurrence today.
|
|
272
|
+
|
|
273
|
+
An element inside a shadow tree is different: its `selector` holds only inside its shadow root, its `shadowHostSelectors` lists the shadow hosts that lead to it (outermost first), and its `structuralPath` is `null`. `formatOccurrenceLocation(occurrence)` joins them as `host >>> selector` for display. See [`OUTPUT_SCHEMA.md`](https://github.com/SureA11y/core/blob/main/docs/OUTPUT_SCHEMA.md) for the full field description.
|
|
187
274
|
|
|
188
275
|
## TypeScript
|
|
189
276
|
|
|
190
|
-
`src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships
|
|
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 `ScanGap` come from `@surea11y/binding-base`, and so do the types of `withWcag()` (`WcagVersion`, `WcagLevel`) and `withPacks()`, which takes a pack made with `definePack` (`PackLike`). 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.
|
|
278
|
+
|
|
279
|
+
## What changes with `@surea11y/core` 1.11
|
|
280
|
+
|
|
281
|
+
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):
|
|
282
|
+
|
|
283
|
+
- **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.
|
|
284
|
+
- **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.
|
|
285
|
+
- **`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.
|
|
286
|
+
- **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.
|
|
287
|
+
- **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)).
|
|
191
288
|
|
|
192
289
|
## Relationship to `@surea11y/playwright` and `@surea11y/puppeteer`
|
|
193
290
|
|
|
194
|
-
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.
|
|
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.
|
|
195
292
|
|
|
196
293
|
Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
|
|
197
294
|
|
|
198
|
-
- **`analyze()`'s injection call** is variadic like Puppeteer's (`browser.execute(
|
|
295
|
+
- **`analyze()`'s injection call** is variadic like Puppeteer's (`browser.execute(inPageScan, url, ctx, opts, runOnly)`, where `inPageScan` is `@surea11y/core`'s `runa11yCoreInPage` wrapped by `@surea11y/binding-base` so an engine error keeps its `code`) — no single-arg wrapper the Playwright binding needs.
|
|
199
296
|
- **`.frames(true)`** can't iterate a `page.frames()` array (there isn't one) — it drives WebdriverIO's stateful `switchFrame()` context model instead, recursing to reach nested frames. See above.
|
|
200
297
|
- **`.elementRef(true)`** attaches `occurrence.element` (a `WebdriverIO.Element`), not `occurrence.elementHandle`, with the sub-frame-context caveat described above.
|
|
201
298
|
- **Protocol:** classic WebDriver is strongly preferred over WebdriverIO v9's default BiDi for this execute()-heavy workload (see above).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@surea11y/webdriverio",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "WebdriverIO binding for surea11y -- scan real, live-rendered pages for accessibility issues.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
@@ -40,22 +40,24 @@
|
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@surea11y/binding-base": "^1.1
|
|
44
|
-
"@surea11y/core": "^1.
|
|
43
|
+
"@surea11y/binding-base": "^1.3.1",
|
|
44
|
+
"@surea11y/core": "^1.11.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"webdriverio": ">=8.0.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@wdio/cli": "^9.
|
|
51
|
-
"@wdio/local-runner": "^9.
|
|
52
|
-
"@wdio/mocha-framework": "^
|
|
53
|
-
"@wdio/spec-reporter": "^9.
|
|
54
|
-
"
|
|
50
|
+
"@wdio/cli": "^9.32.0",
|
|
51
|
+
"@wdio/local-runner": "^9.32.0",
|
|
52
|
+
"@wdio/mocha-framework": "^9.32.0",
|
|
53
|
+
"@wdio/spec-reporter": "^9.32.0",
|
|
54
|
+
"typescript": "^7.0.2",
|
|
55
|
+
"webdriverio": "^9.32.0"
|
|
55
56
|
},
|
|
56
57
|
"scripts": {
|
|
57
58
|
"test": "node --test --test-concurrency=1 tests/*.test.js",
|
|
58
59
|
"example": "node examples/basic-scan.js",
|
|
59
|
-
"example:e2e": "wdio run examples/wdio.conf.js"
|
|
60
|
+
"example:e2e": "wdio run examples/wdio.conf.js",
|
|
61
|
+
"example:pack": "node examples/scan-with-pack.js"
|
|
60
62
|
}
|
|
61
63
|
}
|
package/src/A11yCoreBuilder.d.ts
CHANGED
|
@@ -3,150 +3,70 @@
|
|
|
3
3
|
// not as named module exports) -- see webdriverio's own build/types.d.ts.
|
|
4
4
|
import type {} from 'webdriverio';
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// The result shapes come from @surea11y/core's own types (shipped since
|
|
7
|
+
// 1.9.0 and checked there against real scan results), so they follow the
|
|
8
|
+
// engine instead of a copy here. This file adds what the binding puts on
|
|
9
|
+
// top: the `element` field .elementRef(true) attaches, and the
|
|
10
|
+
// { topFrame, frames } shape of .frames(true). The names below are the ones
|
|
11
|
+
// this file has always exported.
|
|
12
|
+
import type * as Core from '@surea11y/core';
|
|
13
|
+
import type { PackLike, WcagVersion, WcagLevel } from '@surea11y/binding-base';
|
|
14
|
+
export type { PackLike, WcagVersion, WcagLevel } from '@surea11y/binding-base';
|
|
15
|
+
|
|
16
|
+
export type Outcome = Core.Outcome;
|
|
17
|
+
export type OutcomeNormalized = Core.OutcomeNormalized;
|
|
18
|
+
export type Severity = Core.Severity;
|
|
19
|
+
export type Confidence = Core.Confidence;
|
|
20
|
+
export type RuleType = Core.RuleType;
|
|
21
|
+
export type Category = Core.RuleMeta['category'];
|
|
22
|
+
/** An open set: core can add a value in a minor release. */
|
|
23
|
+
export type LocaleResolutionReason = Core.LocaleResolution['reason'];
|
|
24
|
+
export type LocaleResolution = Core.LocaleResolution;
|
|
25
|
+
/** `engine.version` is the @surea11y/core release that produced the result (1.10.0 and later). */
|
|
26
|
+
export type EngineInfo = Core.EngineInfo;
|
|
27
|
+
export type RenderingEnvironment = Core.RenderingEnvironment;
|
|
28
|
+
export type NormativeMapping = Core.NormativeMapping;
|
|
29
|
+
export type CheckResultMeta = Core.RuleMeta;
|
|
30
|
+
export type VisibilityFilter = Core.VisibilityFilter;
|
|
31
|
+
/** How close a rule's closest passing measurement came to its threshold (`CheckResult.margin`). */
|
|
32
|
+
export type Margin = Core.Margin;
|
|
33
|
+
/** How the include() scope resolved; `elementCount: 0` means nothing was scanned. */
|
|
34
|
+
export type ContextMatch = Core.ContextMatch;
|
|
35
|
+
export type CompositeResult = Core.CompositeResult;
|
|
36
|
+
export type CompositeResultDetails = Core.CompositeResult['data']['details'];
|
|
37
|
+
export type EngineOptions = Core.EngineOptions;
|
|
10
38
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
// The reason set is open: core can add a value in a minor release, so the
|
|
19
|
-
// union stays assignable from any string rather than going stale.
|
|
20
|
-
export type LocaleResolutionReason =
|
|
21
|
-
| 'ok'
|
|
22
|
-
| 'primary-subtag'
|
|
23
|
-
| 'dictionary-not-loaded'
|
|
24
|
-
| 'unknown-locale'
|
|
25
|
-
| 'partial-dictionary'
|
|
26
|
-
| (string & {});
|
|
27
|
-
|
|
28
|
-
export interface LocaleResolution {
|
|
29
|
-
requested: string;
|
|
30
|
-
resolved: string;
|
|
31
|
-
reason: LocaleResolutionReason;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface EngineInfo {
|
|
35
|
-
tag: string;
|
|
36
|
-
schemaVersion: string;
|
|
37
|
-
locale: LocaleResolution;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface NormativeMapping {
|
|
41
|
-
standard: string;
|
|
42
|
-
version: string;
|
|
43
|
-
requirement: string;
|
|
44
|
-
title: string;
|
|
45
|
-
conformanceLevel: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface CheckResultMeta {
|
|
49
|
-
ruleId: string;
|
|
50
|
-
ruleInterfaceVersion: string;
|
|
51
|
-
ruleVersion: string;
|
|
52
|
-
normative: boolean;
|
|
53
|
-
atomic: boolean;
|
|
54
|
-
category: Category;
|
|
55
|
-
normativeMappings: NormativeMapping[];
|
|
56
|
-
standard: string | null;
|
|
57
|
-
applicability: string;
|
|
58
|
-
expectation: string;
|
|
59
|
-
references: string[];
|
|
60
|
-
requirements: Record<string, unknown> | null;
|
|
61
|
-
mappings: Record<string, unknown> | null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface VisibilityFilter {
|
|
65
|
-
targetSet: string;
|
|
66
|
-
accEligible: boolean | null;
|
|
67
|
-
reasons: string[];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface Occurrence {
|
|
71
|
-
selector: string;
|
|
72
|
-
html: string;
|
|
73
|
-
structuralPath: number[] | null;
|
|
74
|
-
summary: string;
|
|
75
|
-
hint: string;
|
|
76
|
-
i18n: { summaryKey: string; hintKey: string; params: Record<string, unknown> } | null;
|
|
77
|
-
data: {
|
|
78
|
-
visibilityFilter?: VisibilityFilter;
|
|
79
|
-
details?: Record<string, unknown>;
|
|
80
|
-
};
|
|
39
|
+
/**
|
|
40
|
+
* An occurrence as @surea11y/core reports it. One inside a shadow tree
|
|
41
|
+
* carries `shadowHostSelectors` and a `structuralPath` of null; its
|
|
42
|
+
* `selector` holds only inside the last host's shadow root.
|
|
43
|
+
*/
|
|
44
|
+
export interface Occurrence extends Core.Occurrence {
|
|
81
45
|
/**
|
|
82
46
|
* Only present when `.elementRef(true)` was used. `null` when this
|
|
83
|
-
* occurrence has no single
|
|
84
|
-
*
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
47
|
+
* occurrence has no single target element (`selector` is `""`) or no
|
|
48
|
+
* element matches it any more -- see A11yCoreBuilder#elementRef. Found
|
|
49
|
+
* through `shadowHostSelectors` for an element in a shadow tree. Named
|
|
50
|
+
* `element` (a `WebdriverIO.Element`), not `elementHandle` as in the
|
|
51
|
+
* Puppeteer/Playwright bindings -- WebdriverIO has no "handle" concept. A
|
|
52
|
+
* sub-frame occurrence's element is only usable while the browser is
|
|
53
|
+
* switched into that frame.
|
|
89
54
|
*/
|
|
90
55
|
element?: WebdriverIO.Element | null;
|
|
91
56
|
}
|
|
92
57
|
|
|
93
|
-
export interface CheckResult {
|
|
94
|
-
ruleId: string;
|
|
95
|
-
outcome: Outcome;
|
|
96
|
-
outcomeNormalized: OutcomeNormalized;
|
|
97
|
-
severity: Severity;
|
|
98
|
-
confidence: Confidence;
|
|
99
|
-
type: RuleType;
|
|
58
|
+
export interface CheckResult extends Omit<Core.CheckResult, 'occurrences'> {
|
|
100
59
|
occurrences: Occurrence[];
|
|
101
|
-
title: string;
|
|
102
|
-
description: string;
|
|
103
|
-
i18n: { titleKey: string; descriptionKey: string } | null;
|
|
104
|
-
meta: CheckResultMeta;
|
|
105
|
-
engineOptions: Record<string, unknown>;
|
|
106
|
-
schemaVersion: string;
|
|
107
|
-
/** Present only if the rule threw, or the manual-fail-to-cantTell coercion fired. */
|
|
108
|
-
error?: string;
|
|
109
60
|
}
|
|
110
61
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
notApplicableCount: number;
|
|
119
|
-
passCount: number;
|
|
120
|
-
missingCount: number;
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface CompositeResult {
|
|
125
|
-
ruleId: string;
|
|
126
|
-
outcome: Outcome;
|
|
127
|
-
severity: Severity;
|
|
128
|
-
confidence: Confidence;
|
|
129
|
-
type: RuleType;
|
|
130
|
-
title: string;
|
|
131
|
-
description: string;
|
|
132
|
-
meta: CheckResultMeta;
|
|
133
|
-
engineOptions: Record<string, unknown>;
|
|
134
|
-
schemaVersion: string;
|
|
135
|
-
/** Always empty -- composites are rollups, not element-level findings. */
|
|
136
|
-
occurrences: [];
|
|
137
|
-
data: { details: CompositeResultDetails };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** surea11y's native top-level result shape -- see docs/OUTPUT_SCHEMA.md. */
|
|
141
|
-
export interface A11yCoreResult {
|
|
142
|
-
engine: EngineInfo;
|
|
143
|
-
url: string | null;
|
|
144
|
-
title: string | null;
|
|
145
|
-
timestamp: string | null;
|
|
146
|
-
perfStats: Record<string, unknown> | null;
|
|
147
|
-
contextSelector: string | string[] | null;
|
|
62
|
+
/**
|
|
63
|
+
* @surea11y/core's native top-level result shape -- see its
|
|
64
|
+
* docs/OUTPUT_SCHEMA.md. Since 1.10.0 it also says how the include() scope
|
|
65
|
+
* resolved (`contextMatch`) and which custom rules did not run
|
|
66
|
+
* (`skippedCustomRules`).
|
|
67
|
+
*/
|
|
68
|
+
export interface A11yCoreResult extends Omit<Core.ScanResult, 'checksResults'> {
|
|
148
69
|
checksResults: CheckResult[];
|
|
149
|
-
rulesResults: CompositeResult[];
|
|
150
70
|
}
|
|
151
71
|
|
|
152
72
|
/** A sub-frame that couldn't be scanned (detached, navigated away, or sandboxed). */
|
|
@@ -193,7 +113,12 @@ export class A11yCoreBuilder {
|
|
|
193
113
|
*/
|
|
194
114
|
constructor(opts: { browser: WebdriverIO.Browser; url?: string });
|
|
195
115
|
|
|
196
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Scope the scan to one region. Call multiple times for a multi-region
|
|
118
|
+
* union. A selector that matches nothing scans nothing (see
|
|
119
|
+
* `contextMatch`); with .frames(true) the scope applies to the top frame
|
|
120
|
+
* only and each sub-frame is scanned whole.
|
|
121
|
+
*/
|
|
197
122
|
include(selector: string): this;
|
|
198
123
|
/**
|
|
199
124
|
* Skip elements matching this selector anywhere in the scanned scope.
|
|
@@ -202,7 +127,11 @@ export class A11yCoreBuilder {
|
|
|
202
127
|
* from other `.exclude(selector)` calls.
|
|
203
128
|
*/
|
|
204
129
|
exclude(selector: string, opts?: { rules?: string | string[] }): this;
|
|
205
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Only run rules carrying at least one of these tags. Like the three
|
|
132
|
+
* methods below, throws a TypeError with `code: 'INVALID_RUN_ONLY'` for
|
|
133
|
+
* anything but a string or an array of strings.
|
|
134
|
+
*/
|
|
206
135
|
withTags(tags: string | string[]): this;
|
|
207
136
|
/** Never run rules carrying any of these tags (applied after withTags). */
|
|
208
137
|
disableTags(tags: string | string[]): this;
|
|
@@ -210,10 +139,23 @@ export class A11yCoreBuilder {
|
|
|
210
139
|
withRules(ruleIds: string | string[]): this;
|
|
211
140
|
/** Never run these specific rule IDs (applied after withRules). */
|
|
212
141
|
disableRules(ruleIds: string | string[]): this;
|
|
142
|
+
/**
|
|
143
|
+
* Run the rules for a WCAG conformance target: `withWcag('2.2', 'AA')`
|
|
144
|
+
* runs those of WCAG 2.2's A and AA criteria. withTags()/withRules() add
|
|
145
|
+
* to them. A later call replaces the target.
|
|
146
|
+
*/
|
|
147
|
+
withWcag(version: WcagVersion | 2.0 | 2.1 | 2.2, level?: WcagLevel): this;
|
|
148
|
+
/** Also run the best-practice rules, those that name no WCAG criterion. */
|
|
149
|
+
withBestPractices(enabled?: boolean): this;
|
|
213
150
|
/** Merge arbitrary engineOptions (locale, contrast.mode, policyContract, ...). */
|
|
214
151
|
options(partialEngineOptions: Record<string, unknown>): this;
|
|
215
152
|
/** Register one or more custom rules for just this scan. Call multiple times to accumulate. */
|
|
216
153
|
withCustomRules(rules: CustomRuleDescriptor | CustomRuleDescriptor[]): this;
|
|
154
|
+
/**
|
|
155
|
+
* Packs from `@surea11y/core/pack` (core 1.11 or later): registered in each
|
|
156
|
+
* frame before the scan, and named in `engineOptions.packs`.
|
|
157
|
+
*/
|
|
158
|
+
withPacks(packs: PackLike | PackLike[]): this;
|
|
217
159
|
/** Post-filter checksResults down to only the given outcomes. */
|
|
218
160
|
reportOnly(outcomes: Outcome | Outcome[]): this;
|
|
219
161
|
/** Opt in to also scanning every sub-frame on the page (including cross-origin and nested iframes). */
|
|
@@ -221,16 +163,31 @@ export class A11yCoreBuilder {
|
|
|
221
163
|
/** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
|
|
222
164
|
elementRef(enabled?: boolean): this;
|
|
223
165
|
|
|
224
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Runs the scan. Returns { topFrame, frames } instead of a single result
|
|
168
|
+
* when .frames(true) was used. Rejects with an `EngineError` (from
|
|
169
|
+
* `@surea11y/binding-base`, re-exported here) whose `code` is
|
|
170
|
+
* `INVALID_RUN_ONLY` when a rule or tag list names nothing @surea11y/core
|
|
171
|
+
* knows, `INVALID_CONTEXT_SELECTOR` (with `selector`) when an include()
|
|
172
|
+
* selector does not parse, `INVALID_ENGINE_OPTIONS` for an option
|
|
173
|
+
* `strictOptions: true` rejects, or `PAGE_BUILTINS_BROKEN` when the page's
|
|
174
|
+
* scripts broke a JavaScript built-in the engine relies on.
|
|
175
|
+
*/
|
|
225
176
|
analyze(): Promise<A11yCoreResult | A11yCoreMultiFrameResult>;
|
|
226
177
|
}
|
|
227
178
|
|
|
228
179
|
/**
|
|
229
|
-
* Formats a checksResults array into a short, human-readable
|
|
230
|
-
* entry per occurrence, not per rule.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
180
|
+
* Formats a result, or its checksResults array, into a short, human-readable
|
|
181
|
+
* block -- one entry per fail/cantTell occurrence, not per rule. Given the
|
|
182
|
+
* whole result, it also lists what the scan left out (an include() scope
|
|
183
|
+
* that matched nothing, a custom rule or a pack that did not run) and the
|
|
184
|
+
* @surea11y/core release that produced it. Meant for an assertion
|
|
185
|
+
* library's failure-message parameter, e.g.
|
|
186
|
+
* `assert.strictEqual(fails.length, 0, formatFailures(results))`.
|
|
187
|
+
* Throws a TypeError for anything else, such as a .frames(true) result:
|
|
188
|
+
* format `topFrame` and each entry of `frames` on its own.
|
|
235
189
|
*/
|
|
236
|
-
export function formatFailures(
|
|
190
|
+
export function formatFailures(
|
|
191
|
+
input: A11yCoreResult | ReadonlyArray<CheckResult>,
|
|
192
|
+
opts?: { outcomes?: Outcome[] }
|
|
193
|
+
): string;
|
package/src/A11yCoreBuilder.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { runa11yCoreInPage } = require('@surea11y/core');
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
A11yCoreBuilderBase,
|
|
6
|
+
createInPageScan,
|
|
7
|
+
rethrowEngineError,
|
|
8
|
+
queryOccurrenceElement,
|
|
9
|
+
getScanGaps
|
|
10
|
+
} = require('@surea11y/binding-base');
|
|
11
|
+
|
|
12
|
+
// core's runa11yCoreInPage, wrapped so an engine error (INVALID_RUN_ONLY,
|
|
13
|
+
// INVALID_CONTEXT_SELECTOR) keeps its `code` across browser.execute(),
|
|
14
|
+
// which carries only an error's message. Self-contained, so WebdriverIO
|
|
15
|
+
// serializes it exactly as it serialized runa11yCoreInPage.
|
|
16
|
+
const inPageScan = createInPageScan(runa11yCoreInPage);
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
19
|
* WebdriverIO binding for surea11y -- scans a real, already-rendered page.
|
|
@@ -66,6 +78,12 @@ const { A11yCoreBuilderBase } = require('@surea11y/binding-base');
|
|
|
66
78
|
* Default off, so plain .analyze() keeps returning the single native result
|
|
67
79
|
* object it always has.
|
|
68
80
|
*
|
|
81
|
+
* An include() scope applies to the top frame only: each sub-frame is
|
|
82
|
+
* scanned whole (contextSelector null), as @surea11y/core's own
|
|
83
|
+
* runa11yCoreAcrossFrames does. Since core 1.10.0 a selector that matches
|
|
84
|
+
* nothing scans nothing, so passing the top frame's scope down would leave
|
|
85
|
+
* every frame that lacks it unscanned.
|
|
86
|
+
*
|
|
69
87
|
* By default `analyze()` returns every rule's outcome, including
|
|
70
88
|
* `pass`/`notApplicable` -- @surea11y/core's own deliberate "not a
|
|
71
89
|
* violations-only list" design (see ../core/docs/OUTPUT_SCHEMA.md).
|
|
@@ -118,14 +136,14 @@ const { A11yCoreBuilderBase } = require('@surea11y/binding-base');
|
|
|
118
136
|
*
|
|
119
137
|
* Create one builder per scan. This is a mutable object with no reset
|
|
120
138
|
* between analyze() calls: include()/exclude()/withRules()/disableRules()/
|
|
121
|
-
* withTags()/disableTags()/options()/withCustomRules() all push onto or
|
|
139
|
+
* withTags()/disableTags()/options()/withCustomRules()/withPacks() all push onto or
|
|
122
140
|
* merge into internal state that persists for the instance's lifetime, so
|
|
123
141
|
* calling one of them again before a second analyze() call accumulates on
|
|
124
142
|
* top of the first scan's scope rather than replacing it (intentional for
|
|
125
143
|
* "call include() several times for one scan" -- see above -- but a footgun
|
|
126
144
|
* if you hold one instance across multiple assertions).
|
|
127
|
-
* reportOnly()/frames()/elementRef() are the
|
|
128
|
-
* the previous value rather than merging with it.
|
|
145
|
+
* reportOnly()/frames()/elementRef()/withWcag()/withBestPractices() are the
|
|
146
|
+
* exception: each call replaces the previous value rather than merging with it.
|
|
129
147
|
*/
|
|
130
148
|
class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
131
149
|
/**
|
|
@@ -147,6 +165,11 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
147
165
|
*/
|
|
148
166
|
async analyze() {
|
|
149
167
|
const { contextSelector, engineOptions, runOnly } = this._buildEngineArgs();
|
|
168
|
+
if (this._scanFrames) {
|
|
169
|
+
// Make sure we start from the top-level frame, whatever context the
|
|
170
|
+
// caller happened to leave the browser in.
|
|
171
|
+
await this._browser.switchFrame(null);
|
|
172
|
+
}
|
|
150
173
|
|
|
151
174
|
// Unlike Playwright's page.evaluate(fn, arg), which only accepts a
|
|
152
175
|
// SINGLE argument (forcing a hand-built single-arg wrapper there),
|
|
@@ -154,10 +177,10 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
154
177
|
// execute<ReturnValue, InnerArguments extends unknown[]>(script, ...args)
|
|
155
178
|
// (confirmed against a real webdriverio 9.x install's own
|
|
156
179
|
// build/commands/browser/execute.d.ts). Like Puppeteer, that means
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
180
|
+
// the scan's 4 positional args can be passed straight through with no
|
|
181
|
+
// single-arg wrapper -- WebdriverIO serializes the function itself.
|
|
182
|
+
// Verified empirically against a real headless Chrome session before
|
|
183
|
+
// trusting it (this project's whole ethos, inherited
|
|
161
184
|
// from surea11y and its sibling bindings, is "verified against a real
|
|
162
185
|
// run," not "reasoned about").
|
|
163
186
|
//
|
|
@@ -165,23 +188,36 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
165
188
|
// WebdriverIO's execute()/$ target the current context, a stateful model
|
|
166
189
|
// unlike Puppeteer/Playwright's per-frame-object one. `.frames(true)`
|
|
167
190
|
// orchestrates the switching (see _scanChildFrames).
|
|
168
|
-
|
|
191
|
+
//
|
|
192
|
+
// inPageScan takes the same four arguments as runa11yCoreInPage; an
|
|
193
|
+
// engine error comes back as a plain object, which rethrowEngineError()
|
|
194
|
+
// throws again here as an EngineError with its `code`.
|
|
195
|
+
// The script that registers withPacks()'s packs in a frame, which the
|
|
196
|
+
// scan names in engineOptions.packs; null without packs. execute() runs
|
|
197
|
+
// a string as a function body, so the script runs as written.
|
|
198
|
+
const packScript = this._packScript();
|
|
199
|
+
const scanCurrent = async (scope) => {
|
|
169
200
|
const frameUrl = this._url || (await this._browser.execute(() => document.location.href));
|
|
170
|
-
|
|
201
|
+
if (packScript) await this._browser.execute(packScript);
|
|
202
|
+
const result = rethrowEngineError(
|
|
203
|
+
await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
|
|
204
|
+
);
|
|
205
|
+
this._warnScanGaps(result);
|
|
171
206
|
return this._elementRef ? this._attachElementRefs(result) : result;
|
|
172
207
|
};
|
|
173
208
|
|
|
174
209
|
if (!this._scanFrames) {
|
|
175
|
-
return this._applyReportOnly(await scanCurrent());
|
|
210
|
+
return this._applyReportOnly(await scanCurrent(contextSelector));
|
|
176
211
|
}
|
|
177
212
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
const topFrame = this._applyReportOnly(await scanCurrent());
|
|
213
|
+
// The top frame is scanned first, so an INVALID_RUN_ONLY or
|
|
214
|
+
// INVALID_CONTEXT_SELECTOR error is thrown from it, before any sub-frame.
|
|
215
|
+
const topFrame = this._applyReportOnly(await scanCurrent(contextSelector));
|
|
182
216
|
|
|
217
|
+
// Sub-frames are scanned whole: the include() scope belongs to the top
|
|
218
|
+
// frame's document (see the class comment).
|
|
183
219
|
const frames = [];
|
|
184
|
-
await this._scanChildFrames([], scanCurrent, frames);
|
|
220
|
+
await this._scanChildFrames([], () => scanCurrent(null), frames);
|
|
185
221
|
|
|
186
222
|
// Leave the browser back at the top-level frame regardless of how the
|
|
187
223
|
// traversal ended, so a caller's next command isn't silently running in
|
|
@@ -264,24 +300,55 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
264
300
|
}
|
|
265
301
|
|
|
266
302
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
+
/**
|
|
323
|
+
* Resolves each occurrence to a live WebdriverIO.Element, scoped to
|
|
324
|
+
* whatever frame the browser is currently switched into (execute() is
|
|
325
|
+
* stateful on the current context). Mutates and returns the same result
|
|
326
|
+
* object -- it's a fresh object from this scan, not shared external state.
|
|
327
|
+
*
|
|
328
|
+
* The lookup runs in the page through binding-base's
|
|
329
|
+
* queryOccurrenceElement(), which follows `shadowHostSelectors` (core
|
|
330
|
+
* 1.10.0 and later): an occurrence inside a shadow tree has a `selector`
|
|
331
|
+
* that holds only inside its shadow root, so browser.$(selector) found
|
|
332
|
+
* another element or none. The element reference execute() returns is
|
|
333
|
+
* wrapped with browser.$(), which makes no further WebDriver call.
|
|
334
|
+
* `element` is null when the occurrence has no selector (a page-wide
|
|
335
|
+
* finding can carry "") or nothing matches it any more.
|
|
272
336
|
*/
|
|
273
337
|
async _attachElementRefs(result) {
|
|
274
338
|
if (!Array.isArray(result.checksResults)) return result;
|
|
275
339
|
for (const check of result.checksResults) {
|
|
276
340
|
if (!Array.isArray(check.occurrences) || !check.occurrences.length) continue;
|
|
277
341
|
for (const occurrence of check.occurrences) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
342
|
+
if (!occurrence.selector) {
|
|
343
|
+
occurrence.element = null;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const ref = await this._browser.execute(
|
|
347
|
+
queryOccurrenceElement,
|
|
348
|
+
occurrence.selector,
|
|
349
|
+
occurrence.shadowHostSelectors || null
|
|
350
|
+
);
|
|
351
|
+
occurrence.element = ref ? await this._browser.$(ref) : null;
|
|
285
352
|
}
|
|
286
353
|
}
|
|
287
354
|
return result;
|
package/src/index.d.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
1
|
export * from './A11yCoreBuilder';
|
|
2
|
+
export {
|
|
3
|
+
EngineError,
|
|
4
|
+
ENGINE_ERROR_CODES,
|
|
5
|
+
getScanGaps,
|
|
6
|
+
formatOccurrenceLocation
|
|
7
|
+
} from '@surea11y/binding-base';
|
|
8
|
+
export type { EngineErrorCode, ScanGap, ScanResultLike, OccurrenceLocation } from '@surea11y/binding-base';
|
package/src/index.js
CHANGED
|
@@ -2,5 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
const { A11yCoreBuilder } = require('./A11yCoreBuilder');
|
|
4
4
|
const { formatFailures } = require('./formatFailures');
|
|
5
|
+
const {
|
|
6
|
+
EngineError,
|
|
7
|
+
ENGINE_ERROR_CODES,
|
|
8
|
+
getScanGaps,
|
|
9
|
+
formatOccurrenceLocation
|
|
10
|
+
} = require('@surea11y/binding-base');
|
|
5
11
|
|
|
6
|
-
module.exports = {
|
|
12
|
+
module.exports = {
|
|
13
|
+
A11yCoreBuilder,
|
|
14
|
+
formatFailures,
|
|
15
|
+
EngineError,
|
|
16
|
+
ENGINE_ERROR_CODES,
|
|
17
|
+
getScanGaps,
|
|
18
|
+
formatOccurrenceLocation
|
|
19
|
+
};
|