@surea11y/webdriverio 1.1.0 → 1.2.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 +54 -11
- package/package.json +5 -4
- package/src/A11yCoreBuilder.d.ts +83 -143
- package/src/A11yCoreBuilder.js +87 -25
- package/src/index.d.ts +7 -0
- package/src/index.js +14 -1
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ const results = await new A11yCoreBuilder({ browser })
|
|
|
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.10.0"
|
|
37
38
|
await browser.deleteSession();
|
|
38
39
|
```
|
|
39
40
|
|
|
@@ -51,6 +52,10 @@ WebdriverIO v9 defaults to the WebDriver **BiDi** protocol. A long-lived session
|
|
|
51
52
|
|
|
52
53
|
`withTags()`/`disableRules()` above 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.
|
|
53
54
|
|
|
55
|
+
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
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
54
59
|
`.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
60
|
|
|
56
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.
|
|
@@ -63,7 +68,7 @@ The pattern above works unchanged inside a real WebdriverIO testrunner spec, whi
|
|
|
63
68
|
|
|
64
69
|
```js
|
|
65
70
|
const assert = require('node:assert');
|
|
66
|
-
const { A11yCoreBuilder, formatFailures } = require('@surea11y/webdriverio');
|
|
71
|
+
const { A11yCoreBuilder, formatFailures, getScanGaps } = require('@surea11y/webdriverio');
|
|
67
72
|
|
|
68
73
|
describe('accessibility gate', () => {
|
|
69
74
|
it('has no accessibility violations', async () => {
|
|
@@ -71,7 +76,10 @@ describe('accessibility gate', () => {
|
|
|
71
76
|
|
|
72
77
|
const results = await new A11yCoreBuilder({ browser }).reportOnly(['fail']).analyze();
|
|
73
78
|
|
|
74
|
-
assert.strictEqual(results.checksResults.length, 0, formatFailures(results
|
|
79
|
+
assert.strictEqual(results.checksResults.length, 0, formatFailures(results));
|
|
80
|
+
// An include() scope that matched nothing, or a custom rule that did not
|
|
81
|
+
// run, leaves no failure behind: check for them too.
|
|
82
|
+
assert.deepStrictEqual(getScanGaps(results), [], formatFailures(results));
|
|
75
83
|
});
|
|
76
84
|
});
|
|
77
85
|
```
|
|
@@ -82,18 +90,22 @@ Note the gate above uses `node:assert`, not WebdriverIO's bundled `expect`: Webd
|
|
|
82
90
|
|
|
83
91
|
### Readable console/CI output on failure
|
|
84
92
|
|
|
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(
|
|
93
|
+
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
94
|
|
|
87
95
|
```
|
|
88
96
|
1) button-name-present (serious): This button has no accessible name.
|
|
89
97
|
at html > body > button
|
|
90
98
|
Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.
|
|
91
99
|
2) img-alt-present (serious): Missing alt attribute on <img>.
|
|
92
|
-
at
|
|
100
|
+
at #host >>> p > img
|
|
93
101
|
Add an alt attribute (use alt="" only for decorative images).
|
|
102
|
+
|
|
103
|
+
Scanned with @surea11y/core 1.10.0.
|
|
94
104
|
```
|
|
95
105
|
|
|
96
|
-
|
|
106
|
+
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
|
+
|
|
108
|
+
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
109
|
|
|
98
110
|
### Scanning every frame, including cross-origin and nested iframes
|
|
99
111
|
|
|
@@ -110,6 +122,8 @@ for (const frame of results.frames) {
|
|
|
110
122
|
|
|
111
123
|
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
124
|
|
|
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 `INVALID_RUN_ONLY` or `INVALID_CONTEXT_SELECTOR` error 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 }`.
|
|
126
|
+
|
|
113
127
|
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
128
|
|
|
115
129
|
### Trimming the result to just violations
|
|
@@ -138,14 +152,14 @@ await failing.occurrences[0].element.saveScreenshot('./flagged.png');
|
|
|
138
152
|
await failing.occurrences[0].element.click();
|
|
139
153
|
```
|
|
140
154
|
|
|
141
|
-
This
|
|
155
|
+
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
156
|
|
|
143
157
|
Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(true)`, both real and worth knowing:
|
|
144
158
|
|
|
145
159
|
- **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
160
|
- **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
161
|
|
|
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.
|
|
162
|
+
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
163
|
|
|
150
164
|
### Registering a custom rule at runtime
|
|
151
165
|
|
|
@@ -179,15 +193,44 @@ const results = await new A11yCoreBuilder({ browser })
|
|
|
179
193
|
|
|
180
194
|
**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
195
|
|
|
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
|
|
196
|
+
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
|
+
|
|
198
|
+
### When a scan can't run as asked
|
|
199
|
+
|
|
200
|
+
`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:
|
|
201
|
+
|
|
202
|
+
- `INVALID_RUN_ONLY`: none of the rule IDs, or none of the tags, given to `.withRules()`/`.withTags()` is one the engine knows.
|
|
203
|
+
- `INVALID_CONTEXT_SELECTOR`: an `.include()` selector is not valid CSS; `error.selector` names it.
|
|
204
|
+
|
|
205
|
+
```js
|
|
206
|
+
const { A11yCoreBuilder, EngineError } = require('@surea11y/webdriverio');
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await new A11yCoreBuilder({ browser }).withTags(['wcag2.2aa']).analyze();
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (e instanceof EngineError && e.code === 'INVALID_RUN_ONLY') {
|
|
212
|
+
// a typo in the tag list, not a broken page
|
|
213
|
+
}
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
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
|
+
|
|
220
|
+
- **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`.
|
|
222
|
+
|
|
223
|
+
`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` or `custom-rule-skipped`), so a gate can fail on them, as the E2E example above does. `formatFailures(results)` lists them too.
|
|
183
224
|
|
|
184
225
|
### Element addressing beyond a CSS selector
|
|
185
226
|
|
|
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.
|
|
227
|
+
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.
|
|
228
|
+
|
|
229
|
+
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
230
|
|
|
188
231
|
## TypeScript
|
|
189
232
|
|
|
190
|
-
`src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships
|
|
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 `ScanGap` come from `@surea11y/binding-base`. `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.
|
|
191
234
|
|
|
192
235
|
## Relationship to `@surea11y/playwright` and `@surea11y/puppeteer`
|
|
193
236
|
|
|
@@ -195,7 +238,7 @@ This binding's builder API is deliberately the same shape as [`@surea11y/playwri
|
|
|
195
238
|
|
|
196
239
|
Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
|
|
197
240
|
|
|
198
|
-
- **`analyze()`'s injection call** is variadic like Puppeteer's (`browser.execute(
|
|
241
|
+
- **`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
242
|
- **`.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
243
|
- **`.elementRef(true)`** attaches `occurrence.element` (a `WebdriverIO.Element`), not `occurrence.elementHandle`, with the sub-frame-context caveat described above.
|
|
201
244
|
- **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.2.0",
|
|
4
4
|
"description": "WebdriverIO binding for surea11y -- scan real, live-rendered pages for accessibility issues.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@surea11y/binding-base": "^1.
|
|
44
|
-
"@surea11y/core": "^1.
|
|
43
|
+
"@surea11y/binding-base": "^1.2.0",
|
|
44
|
+
"@surea11y/core": "^1.10.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"webdriverio": ">=8.0.0"
|
|
@@ -49,8 +49,9 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@wdio/cli": "^9.30.0",
|
|
51
51
|
"@wdio/local-runner": "^9.30.0",
|
|
52
|
-
"@wdio/mocha-framework": "^
|
|
52
|
+
"@wdio/mocha-framework": "^9.32.0",
|
|
53
53
|
"@wdio/spec-reporter": "^9.29.1",
|
|
54
|
+
"typescript": "^5.9.3",
|
|
54
55
|
"webdriverio": "^9.0.0"
|
|
55
56
|
},
|
|
56
57
|
"scripts": {
|
package/src/A11yCoreBuilder.d.ts
CHANGED
|
@@ -3,150 +3,68 @@
|
|
|
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
|
+
|
|
14
|
+
export type Outcome = Core.Outcome;
|
|
15
|
+
export type OutcomeNormalized = Core.OutcomeNormalized;
|
|
16
|
+
export type Severity = Core.Severity;
|
|
17
|
+
export type Confidence = Core.Confidence;
|
|
18
|
+
export type RuleType = Core.RuleType;
|
|
19
|
+
export type Category = Core.RuleMeta['category'];
|
|
20
|
+
/** An open set: core can add a value in a minor release. */
|
|
21
|
+
export type LocaleResolutionReason = Core.LocaleResolution['reason'];
|
|
22
|
+
export type LocaleResolution = Core.LocaleResolution;
|
|
23
|
+
/** `engine.version` is the @surea11y/core release that produced the result (1.10.0 and later). */
|
|
24
|
+
export type EngineInfo = Core.EngineInfo;
|
|
25
|
+
export type RenderingEnvironment = Core.RenderingEnvironment;
|
|
26
|
+
export type NormativeMapping = Core.NormativeMapping;
|
|
27
|
+
export type CheckResultMeta = Core.RuleMeta;
|
|
28
|
+
export type VisibilityFilter = Core.VisibilityFilter;
|
|
29
|
+
/** How close a rule's closest passing measurement came to its threshold (`CheckResult.margin`). */
|
|
30
|
+
export type Margin = Core.Margin;
|
|
31
|
+
/** How the include() scope resolved; `elementCount: 0` means nothing was scanned. */
|
|
32
|
+
export type ContextMatch = Core.ContextMatch;
|
|
33
|
+
export type CompositeResult = Core.CompositeResult;
|
|
34
|
+
export type CompositeResultDetails = Core.CompositeResult['data']['details'];
|
|
35
|
+
export type EngineOptions = Core.EngineOptions;
|
|
10
36
|
|
|
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
|
-
};
|
|
37
|
+
/**
|
|
38
|
+
* An occurrence as @surea11y/core reports it. One inside a shadow tree
|
|
39
|
+
* carries `shadowHostSelectors` and a `structuralPath` of null; its
|
|
40
|
+
* `selector` holds only inside the last host's shadow root.
|
|
41
|
+
*/
|
|
42
|
+
export interface Occurrence extends Core.Occurrence {
|
|
81
43
|
/**
|
|
82
44
|
* Only present when `.elementRef(true)` was used. `null` when this
|
|
83
|
-
* occurrence has no single
|
|
84
|
-
*
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
45
|
+
* occurrence has no single target element (`selector` is `""`) or no
|
|
46
|
+
* element matches it any more -- see A11yCoreBuilder#elementRef. Found
|
|
47
|
+
* through `shadowHostSelectors` for an element in a shadow tree. Named
|
|
48
|
+
* `element` (a `WebdriverIO.Element`), not `elementHandle` as in the
|
|
49
|
+
* Puppeteer/Playwright bindings -- WebdriverIO has no "handle" concept. A
|
|
50
|
+
* sub-frame occurrence's element is only usable while the browser is
|
|
51
|
+
* switched into that frame.
|
|
89
52
|
*/
|
|
90
53
|
element?: WebdriverIO.Element | null;
|
|
91
54
|
}
|
|
92
55
|
|
|
93
|
-
export interface CheckResult {
|
|
94
|
-
ruleId: string;
|
|
95
|
-
outcome: Outcome;
|
|
96
|
-
outcomeNormalized: OutcomeNormalized;
|
|
97
|
-
severity: Severity;
|
|
98
|
-
confidence: Confidence;
|
|
99
|
-
type: RuleType;
|
|
56
|
+
export interface CheckResult extends Omit<Core.CheckResult, 'occurrences'> {
|
|
100
57
|
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
58
|
}
|
|
110
59
|
|
|
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;
|
|
60
|
+
/**
|
|
61
|
+
* @surea11y/core's native top-level result shape -- see its
|
|
62
|
+
* docs/OUTPUT_SCHEMA.md. Since 1.10.0 it also says how the include() scope
|
|
63
|
+
* resolved (`contextMatch`) and which custom rules did not run
|
|
64
|
+
* (`skippedCustomRules`).
|
|
65
|
+
*/
|
|
66
|
+
export interface A11yCoreResult extends Omit<Core.ScanResult, 'checksResults'> {
|
|
148
67
|
checksResults: CheckResult[];
|
|
149
|
-
rulesResults: CompositeResult[];
|
|
150
68
|
}
|
|
151
69
|
|
|
152
70
|
/** A sub-frame that couldn't be scanned (detached, navigated away, or sandboxed). */
|
|
@@ -193,7 +111,12 @@ export class A11yCoreBuilder {
|
|
|
193
111
|
*/
|
|
194
112
|
constructor(opts: { browser: WebdriverIO.Browser; url?: string });
|
|
195
113
|
|
|
196
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Scope the scan to one region. Call multiple times for a multi-region
|
|
116
|
+
* union. A selector that matches nothing scans nothing (see
|
|
117
|
+
* `contextMatch`); with .frames(true) the scope applies to the top frame
|
|
118
|
+
* only and each sub-frame is scanned whole.
|
|
119
|
+
*/
|
|
197
120
|
include(selector: string): this;
|
|
198
121
|
/**
|
|
199
122
|
* Skip elements matching this selector anywhere in the scanned scope.
|
|
@@ -202,7 +125,11 @@ export class A11yCoreBuilder {
|
|
|
202
125
|
* from other `.exclude(selector)` calls.
|
|
203
126
|
*/
|
|
204
127
|
exclude(selector: string, opts?: { rules?: string | string[] }): this;
|
|
205
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Only run rules carrying at least one of these tags. Like the three
|
|
130
|
+
* methods below, throws a TypeError with `code: 'INVALID_RUN_ONLY'` for
|
|
131
|
+
* anything but a string or an array of strings.
|
|
132
|
+
*/
|
|
206
133
|
withTags(tags: string | string[]): this;
|
|
207
134
|
/** Never run rules carrying any of these tags (applied after withTags). */
|
|
208
135
|
disableTags(tags: string | string[]): this;
|
|
@@ -221,16 +148,29 @@ export class A11yCoreBuilder {
|
|
|
221
148
|
/** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
|
|
222
149
|
elementRef(enabled?: boolean): this;
|
|
223
150
|
|
|
224
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Runs the scan. Returns { topFrame, frames } instead of a single result
|
|
153
|
+
* when .frames(true) was used. Rejects with an `EngineError` (from
|
|
154
|
+
* `@surea11y/binding-base`, re-exported here) whose `code` is
|
|
155
|
+
* `INVALID_RUN_ONLY` when a rule or tag list names nothing @surea11y/core
|
|
156
|
+
* knows, or `INVALID_CONTEXT_SELECTOR` (with `selector`) when an include()
|
|
157
|
+
* selector does not parse.
|
|
158
|
+
*/
|
|
225
159
|
analyze(): Promise<A11yCoreResult | A11yCoreMultiFrameResult>;
|
|
226
160
|
}
|
|
227
161
|
|
|
228
162
|
/**
|
|
229
|
-
* Formats a checksResults array into a short, human-readable
|
|
230
|
-
* entry per occurrence, not per rule.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
163
|
+
* Formats a result, or its checksResults array, into a short, human-readable
|
|
164
|
+
* block -- one entry per fail/cantTell occurrence, not per rule. Given the
|
|
165
|
+
* 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
|
|
167
|
+
* @surea11y/core release that produced it. Meant for an assertion
|
|
168
|
+
* library's failure-message parameter, e.g.
|
|
169
|
+
* `assert.strictEqual(fails.length, 0, formatFailures(results))`.
|
|
170
|
+
* Throws a TypeError for anything else, such as a .frames(true) result:
|
|
171
|
+
* format `topFrame` and each entry of `frames` on its own.
|
|
235
172
|
*/
|
|
236
|
-
export function formatFailures(
|
|
173
|
+
export function formatFailures(
|
|
174
|
+
input: A11yCoreResult | ReadonlyArray<CheckResult>,
|
|
175
|
+
opts?: { outcomes?: Outcome[] }
|
|
176
|
+
): 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).
|
|
@@ -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,31 @@ 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
|
+
const scanCurrent = async (scope) => {
|
|
169
196
|
const frameUrl = this._url || (await this._browser.execute(() => document.location.href));
|
|
170
|
-
const result =
|
|
197
|
+
const result = rethrowEngineError(
|
|
198
|
+
await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
|
|
199
|
+
);
|
|
200
|
+
this._warnScanGaps(result);
|
|
171
201
|
return this._elementRef ? this._attachElementRefs(result) : result;
|
|
172
202
|
};
|
|
173
203
|
|
|
174
204
|
if (!this._scanFrames) {
|
|
175
|
-
return this._applyReportOnly(await scanCurrent());
|
|
205
|
+
return this._applyReportOnly(await scanCurrent(contextSelector));
|
|
176
206
|
}
|
|
177
207
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
const topFrame = this._applyReportOnly(await scanCurrent());
|
|
208
|
+
// The top frame is scanned first, so an INVALID_RUN_ONLY or
|
|
209
|
+
// INVALID_CONTEXT_SELECTOR error is thrown from it, before any sub-frame.
|
|
210
|
+
const topFrame = this._applyReportOnly(await scanCurrent(contextSelector));
|
|
182
211
|
|
|
212
|
+
// Sub-frames are scanned whole: the include() scope belongs to the top
|
|
213
|
+
// frame's document (see the class comment).
|
|
183
214
|
const frames = [];
|
|
184
|
-
await this._scanChildFrames([], scanCurrent, frames);
|
|
215
|
+
await this._scanChildFrames([], () => scanCurrent(null), frames);
|
|
185
216
|
|
|
186
217
|
// Leave the browser back at the top-level frame regardless of how the
|
|
187
218
|
// traversal ended, so a caller's next command isn't silently running in
|
|
@@ -264,24 +295,55 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
|
264
295
|
}
|
|
265
296
|
|
|
266
297
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
+
/**
|
|
318
|
+
* Resolves each occurrence to a live WebdriverIO.Element, scoped to
|
|
319
|
+
* whatever frame the browser is currently switched into (execute() is
|
|
320
|
+
* stateful on the current context). Mutates and returns the same result
|
|
321
|
+
* object -- it's a fresh object from this scan, not shared external state.
|
|
322
|
+
*
|
|
323
|
+
* The lookup runs in the page through binding-base's
|
|
324
|
+
* queryOccurrenceElement(), which follows `shadowHostSelectors` (core
|
|
325
|
+
* 1.10.0 and later): an occurrence inside a shadow tree has a `selector`
|
|
326
|
+
* that holds only inside its shadow root, so browser.$(selector) found
|
|
327
|
+
* another element or none. The element reference execute() returns is
|
|
328
|
+
* wrapped with browser.$(), which makes no further WebDriver call.
|
|
329
|
+
* `element` is null when the occurrence has no selector (a page-wide
|
|
330
|
+
* finding can carry "") or nothing matches it any more.
|
|
272
331
|
*/
|
|
273
332
|
async _attachElementRefs(result) {
|
|
274
333
|
if (!Array.isArray(result.checksResults)) return result;
|
|
275
334
|
for (const check of result.checksResults) {
|
|
276
335
|
if (!Array.isArray(check.occurrences) || !check.occurrences.length) continue;
|
|
277
336
|
for (const occurrence of check.occurrences) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
337
|
+
if (!occurrence.selector) {
|
|
338
|
+
occurrence.element = null;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const ref = await this._browser.execute(
|
|
342
|
+
queryOccurrenceElement,
|
|
343
|
+
occurrence.selector,
|
|
344
|
+
occurrence.shadowHostSelectors || null
|
|
345
|
+
);
|
|
346
|
+
occurrence.element = ref ? await this._browser.$(ref) : null;
|
|
285
347
|
}
|
|
286
348
|
}
|
|
287
349
|
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
|
+
};
|