@surea11y/webdriverio 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +47 -0
- package/src/A11yCoreBuilder.d.ts +214 -0
- package/src/A11yCoreBuilder.js +291 -0
- package/src/formatFailures.js +12 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jorge Rumoroso
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# surea11y-webdriverio
|
|
2
|
+
|
|
3
|
+
A WebdriverIO binding for [`surea11y`](../surea11y) — scans a real, already-rendered page for accessibility issues using surea11y's DOM-rules engine.
|
|
4
|
+
|
|
5
|
+
This is a **separate project/package** from `surea11y` itself and from its siblings [`surea11y-playwright`](../surea11y-playwright) and [`surea11y-puppeteer`](../surea11y-puppeteer), kept as its own sibling directory rather than a monorepo subfolder — see `ROADMAP.md` §1 for the reasoning (the same reasoning the two sibling bindings already used).
|
|
6
|
+
|
|
7
|
+
## Install (local development)
|
|
8
|
+
|
|
9
|
+
`surea11y` isn't published to npm yet, so this package depends on it via a relative `file:` path (see `package.json`):
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
"dependencies": { "surea11y": "file:../core" }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That means this project must stay a sibling of `surea11y` (or you update the path) for `npm install` to resolve it.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
npm test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`webdriverio` is a `devDependency` here. WebdriverIO v9 auto-manages a local `chromedriver` for you the first time it launches Chrome (no separate Selenium server, and no `npx playwright install`-style step needed for `npm test` to work) — so a fresh clone's `npm install && npm test` launches a real headless Chrome on its own.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const { remote } = require('webdriverio');
|
|
28
|
+
const { A11yCoreBuilder } = require('surea11y-webdriverio');
|
|
29
|
+
|
|
30
|
+
const browser = await remote({
|
|
31
|
+
capabilities: {
|
|
32
|
+
browserName: 'chrome',
|
|
33
|
+
'wdio:enforceWebDriverClassic': true, // recommended -- see below
|
|
34
|
+
'goog:chromeOptions': { args: ['--headless=new'] }
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
await browser.url('https://example.com/');
|
|
38
|
+
|
|
39
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
40
|
+
.include('#main') // optional -- call multiple times for multi-region scans
|
|
41
|
+
.exclude('.cookie-banner') // optional
|
|
42
|
+
.withTags(['wcag2a', 'wcag2aa'])
|
|
43
|
+
.disableRules(['meta-refresh-no-exceptions'])
|
|
44
|
+
.options({ contrast: { mode: 'auditorAssist' } })
|
|
45
|
+
.analyze();
|
|
46
|
+
|
|
47
|
+
console.log(results.checksResults.filter(r => r.outcome === 'fail'));
|
|
48
|
+
await browser.deleteSession();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The builder takes `{ browser }` — the object WebdriverIO's `remote()` returns (standalone mode), or the global `browser` the WDIO testrunner injects (see the E2E section). It must already be navigated to and settled at the URL to scan; the class does not navigate for you.
|
|
52
|
+
|
|
53
|
+
`results` is surea11y's own native result shape — see [`../surea11y/docs/OUTPUT_SCHEMA.md`](../surea11y/docs/OUTPUT_SCHEMA.md) — not the `violations`/`passes`/`incomplete`/`inapplicable` shape used by other popular accessibility testing tools. The builder's *method names* are modeled on common conventions in this space for migration familiarity; the richer result schema is kept as-is.
|
|
54
|
+
|
|
55
|
+
Also see `examples/basic-scan.js` for a runnable script (`npm run example -- <url>`).
|
|
56
|
+
|
|
57
|
+
### A note on the protocol: prefer classic WebDriver for this workload
|
|
58
|
+
|
|
59
|
+
WebdriverIO v9 defaults to the WebDriver **BiDi** protocol. A long-lived session that issues many `browser.execute()` calls — exactly what an accessibility scan does — degraded badly over BiDi in testing here: per-scan latency climbed from ~2s into the *minutes* and eventually the session started dropping commands (`Couldn't resolve command with id N`). Forcing the **classic** WebDriver protocol with `'wdio:enforceWebDriverClassic': true` in the capabilities gave a steady ~150ms per scan with no degradation, and every operation this binding needs (`execute`, `$`, `$$`, `switchFrame`) is fully supported on it. This project's own tests and examples all set that flag, and it's recommended for yours. The binding itself works under either protocol (it never depends on a BiDi-only feature) — see `ROADMAP.md` §2c/§4 for the full write-up.
|
|
60
|
+
|
|
61
|
+
### Composing the rule filters
|
|
62
|
+
|
|
63
|
+
`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's default `includeMode: 'and'` — see `../surea11y/docs/ENGINE_OPTIONS.md`), not either one.
|
|
64
|
+
|
|
65
|
+
**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.
|
|
66
|
+
|
|
67
|
+
This binding works against any browser WebdriverIO can drive; the tests and examples use headless Chrome via `chromedriver`, which WebdriverIO manages automatically.
|
|
68
|
+
|
|
69
|
+
### Using it as an E2E accessibility gate
|
|
70
|
+
|
|
71
|
+
The pattern above works unchanged inside a real WebdriverIO testrunner spec, which is the pattern that actually matters for a CI/E2E suite (not just an ad hoc script). Inside the testrunner, `browser` is a global you don't construct yourself:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
const assert = require('node:assert');
|
|
75
|
+
const { A11yCoreBuilder, formatFailures } = require('surea11y-webdriverio');
|
|
76
|
+
|
|
77
|
+
describe('accessibility gate', () => {
|
|
78
|
+
it('has no accessibility violations', async () => {
|
|
79
|
+
await browser.url('https://example.com/');
|
|
80
|
+
|
|
81
|
+
const results = await new A11yCoreBuilder({ browser }).reportOnly(['fail']).analyze();
|
|
82
|
+
|
|
83
|
+
assert.strictEqual(results.checksResults.length, 0, formatFailures(results.checksResults));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
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 — unlike the sibling Puppeteer project, which used `node:test` because Puppeteer ships no first-party runner (`ROADMAP.md` §6 has the full reasoning). This project's own *unit* suite (`tests/*.test.js`) still uses `node:test` + `remote()`, the right tool for exercising the builder API in isolation.
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
### Readable console/CI output on failure
|
|
93
|
+
|
|
94
|
+
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(checksResults)` turns that into a short, scannable block (one entry per occurrence, numbered, with rule ID/severity/selector/hint) that you hand to your assertion library's own failure-message parameter, as above. A real failure then prints:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
1) button-name-present (serious): This button has no accessible name.
|
|
98
|
+
at html > body > button
|
|
99
|
+
Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.
|
|
100
|
+
2) img-alt-present (serious): Missing alt attribute on <img>.
|
|
101
|
+
at html > body > img
|
|
102
|
+
Add an alt attribute (use alt="" only for decorative images).
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
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 that ever carry occurrences); pass `{ outcomes: [...] }` to narrow further. A thrown rule (`occurrences: []`, `error` set — see `../surea11y/docs/OUTPUT_SCHEMA.md`) is still surfaced using its `error` message rather than silently dropped.
|
|
106
|
+
|
|
107
|
+
### Scanning every frame, including cross-origin and nested iframes
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
const results = await new A11yCoreBuilder({ browser }).frames(true).analyze();
|
|
111
|
+
|
|
112
|
+
console.log(results.topFrame.checksResults.filter(r => r.outcome === 'fail')); // the top-level page
|
|
113
|
+
for (const frame of results.frames) {
|
|
114
|
+
console.log(frame.checksResults.filter(r => r.outcome === 'fail')); // each sub-frame, same result shape
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`results.frames` is a **flat array**, one entry per sub-frame at any depth (nested iframes-within-iframes included), matching the shape the Puppeteer/Playwright bindings return.
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
|
|
122
|
+
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 `ROADMAP.md` §2c and `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 `surea11y` engine support at all.
|
|
123
|
+
|
|
124
|
+
### Trimming the result to just violations
|
|
125
|
+
|
|
126
|
+
By default `analyze()` returns every rule's outcome, including `pass`/`notApplicable` — surea11y's own deliberate "not a violations-only list" design (see `../surea11y/docs/OUTPUT_SCHEMA.md`). Use `.reportOnly()` to post-filter down to only the outcomes you care about:
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
130
|
+
.reportOnly(['fail', 'cantTell'])
|
|
131
|
+
.analyze();
|
|
132
|
+
|
|
133
|
+
console.log(results.checksResults); // only fail/cantTell entries, pass/notApplicable dropped
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Valid outcome values are `'pass'`, `'fail'`, `'cantTell'`, `'notApplicable'`. This is pure binding-layer filtering — surea11y 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.
|
|
137
|
+
|
|
138
|
+
### Getting a live element, not just a selector string
|
|
139
|
+
|
|
140
|
+
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)`:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
const results = await new A11yCoreBuilder({ browser }).elementRef(true).analyze();
|
|
144
|
+
|
|
145
|
+
const [failing] = results.checksResults.filter(r => r.outcome === 'fail');
|
|
146
|
+
await failing.occurrences[0].element.saveScreenshot('./flagged.png');
|
|
147
|
+
await failing.occurrences[0].element.click();
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This resolves `occurrence.selector` to a `WebdriverIO.Element` (via `browser.$()`) and attaches it as **`occurrence.element`** — instead of leaving you to re-resolve a possibly-stale selector string yourself. Default off — resolving an element per occurrence is a real page query per occurrence, so it costs more than a plain `.analyze()`.
|
|
151
|
+
|
|
152
|
+
Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(true)`, both real and worth knowing:
|
|
153
|
+
|
|
154
|
+
- **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).
|
|
155
|
+
- **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." See `ROADMAP.md` §2d.
|
|
156
|
+
|
|
157
|
+
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. (WebdriverIO's `browser.$('')` actually *throws* `invalid selector: No selector specified`, unlike Puppeteer/Playwright's `.$('')` which resolves to `null` — so the empty-selector guard that produces this `null` matters even more here; verified with a real run.)
|
|
158
|
+
|
|
159
|
+
### Registering a custom rule at runtime
|
|
160
|
+
|
|
161
|
+
`surea11y` supports registering additional rules per-scan via `engineOptions.customRules`. Use `.withCustomRules()` to register one:
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
165
|
+
.withCustomRules({
|
|
166
|
+
id: 'my-org-custom-rule',
|
|
167
|
+
meta: { title: 'My custom rule', tags: ['custom'], defaultSeverity: 'serious' },
|
|
168
|
+
// A real, live function is fine here -- .withCustomRules() converts it
|
|
169
|
+
// to a function-source string for you (see below for why that matters).
|
|
170
|
+
runInPage(ctx) {
|
|
171
|
+
const el = ctx.document.querySelector('.my-widget');
|
|
172
|
+
return el ? { outcome: 'fail', occurrences: [{ __node: el }] } : { outcome: 'notApplicable', occurrences: [] };
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
.analyze();
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A custom rule descriptor is the same shape as one of surea11y's own internal rule modules (`{ id, meta, runInPage, applicability?, data? }`) — see `../surea11y/docs/ENGINE_OPTIONS.md` for the full contract. Results appear in `checksResults` exactly like a built-in rule's, including automatic `selector`/`html`/`structuralPath` fill-in. Registered per-scan only (nothing persists between calls or shows up in any catalog listing), and a custom rule whose `id` collides with a built-in one overrides it for that scan.
|
|
179
|
+
|
|
180
|
+
Pass an array to register several at once, or call `.withCustomRules()` again to add more — like `.withRules()`/`.withTags()`, it accumulates rather than replacing what was already registered:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
const results = await new A11yCoreBuilder({ browser })
|
|
184
|
+
.withCustomRules([firstRule, secondRule])
|
|
185
|
+
.withCustomRules(thirdRule) // adds a third, doesn't replace the first two
|
|
186
|
+
.analyze();
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**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 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 — see `ROADMAP.md` §2a), 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.
|
|
190
|
+
|
|
191
|
+
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 silently-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's own engine-side behavior, which silently skips an invalid descriptor rather than throwing — see `../surea11y/docs/ENGINE_OPTIONS.md`.)
|
|
192
|
+
|
|
193
|
+
### Element addressing beyond a CSS selector
|
|
194
|
+
|
|
195
|
+
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. See `../surea11y/docs/OUTPUT_SCHEMA.md` for the full field description.
|
|
196
|
+
|
|
197
|
+
## TypeScript
|
|
198
|
+
|
|
199
|
+
`src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships hand-written types for the whole builder API plus surea11y's native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.), mirrored from `../surea11y/docs/OUTPUT_SCHEMA.md`. `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. Verified with a real `tsc --strict` compile against a throwaway consumer script exercising every builder method and both `analyze()` return shapes (see `ROADMAP.md` §4).
|
|
200
|
+
|
|
201
|
+
## Relationship to `surea11y-playwright` and `surea11y-puppeteer`
|
|
202
|
+
|
|
203
|
+
This binding's builder API is deliberately the same shape as [`surea11y-playwright`](../surea11y-playwright)'s and [`surea11y-puppeteer`](../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`) used to be copied near-verbatim across every binding; as of `ROADMAP.md` §8 it's no longer copied at all — `A11yCoreBuilder` here extends `A11yCoreBuilderBase` from [`../surea11y-binding-base`](../surea11y-binding-base), the single shared implementation every sibling binding now depends on.
|
|
204
|
+
|
|
205
|
+
Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
|
|
206
|
+
|
|
207
|
+
- **`analyze()`'s injection call** is variadic like Puppeteer's (`browser.execute(runa11yCoreInPage, url, ctx, opts, runOnly)`) — no single-arg wrapper/`eval()` trick the Playwright binding needs.
|
|
208
|
+
- **`.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 and `ROADMAP.md` §2c.
|
|
209
|
+
- **`.elementRef(true)`** attaches `occurrence.element` (a `WebdriverIO.Element`), not `occurrence.elementHandle`, with the sub-frame-context caveat described above.
|
|
210
|
+
- **Protocol:** classic WebDriver is strongly preferred over WebdriverIO v9's default BiDi for this execute()-heavy workload (see above and `ROADMAP.md` §4).
|
|
211
|
+
|
|
212
|
+
Also see [`../surea11y/docs/BINDING_AUTHORS_GUIDE.md`](../surea11y/docs/BINDING_AUTHORS_GUIDE.md) — `surea11y`'s own reference for building a binding like this one (it names WebdriverIO explicitly as a candidate next binding), distinguishing what's already engine-level (a generic `.options()`/`runOnly` passthrough, WCAG-version tag filtering, `structuralPath`) from what every binding has to build itself (element refs, `reportOnly`-style verbosity filtering, the serialization-boundary caveat `.withCustomRules()` exists to paper over).
|
|
213
|
+
|
|
214
|
+
## Status and what's next
|
|
215
|
+
|
|
216
|
+
See `ROADMAP.md` — it documents what's built, what's verified with a real passing run, and what's still open.
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@surea11y/webdriverio",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "WebdriverIO binding for surea11y -- scan real, live-rendered pages for accessibility issues.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
7
|
+
"author": "Jorge Rumoroso",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/rumoroso/surea11y-core-webdriverio.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/rumoroso/surea11y-core-webdriverio#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/rumoroso/surea11y-core-webdriverio/issues"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.20.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@surea11y/core": "^1.1.0",
|
|
30
|
+
"@surea11y/binding-base": "^0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"webdriverio": ">=8.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@wdio/cli": "^9.30.0",
|
|
37
|
+
"@wdio/local-runner": "^9.30.0",
|
|
38
|
+
"@wdio/mocha-framework": "^9.30.0",
|
|
39
|
+
"@wdio/spec-reporter": "^9.29.1",
|
|
40
|
+
"webdriverio": "^9.0.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test \"tests/**/*.test.js\"",
|
|
44
|
+
"example": "node examples/basic-scan.js",
|
|
45
|
+
"example:e2e": "wdio run examples/wdio.conf.js"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Pull in WebdriverIO's global type augmentation (WebdriverIO.Browser /
|
|
2
|
+
// WebdriverIO.Element live on a `declare global { namespace WebdriverIO }`,
|
|
3
|
+
// not as named module exports) -- see webdriverio's own build/types.d.ts.
|
|
4
|
+
import type {} from 'webdriverio';
|
|
5
|
+
|
|
6
|
+
// See surea11y's docs/OUTPUT_SCHEMA.md -- this file mirrors that document's
|
|
7
|
+
// shapes exactly (plus the `element` field this binding adds on top when
|
|
8
|
+
// .elementRef(true) is used). Keep in sync with that doc, not the other way
|
|
9
|
+
// around -- it's the source of truth for what the engine actually returns.
|
|
10
|
+
|
|
11
|
+
export type Outcome = 'pass' | 'fail' | 'cantTell' | 'notApplicable';
|
|
12
|
+
export type OutcomeNormalized = 'pass' | 'fail' | 'cantTell' | 'inapplicable';
|
|
13
|
+
export type Severity = 'minor' | 'moderate' | 'serious' | 'critical';
|
|
14
|
+
export type Confidence = 'high' | 'medium' | 'low';
|
|
15
|
+
export type RuleType = 'automatic' | 'manual';
|
|
16
|
+
export type Category = 'perceivable' | 'operable' | 'understandable' | 'robust' | null;
|
|
17
|
+
|
|
18
|
+
export interface EngineInfo {
|
|
19
|
+
tag: string;
|
|
20
|
+
schemaVersion: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NormativeMapping {
|
|
24
|
+
standard: string;
|
|
25
|
+
version: string;
|
|
26
|
+
requirement: string;
|
|
27
|
+
title: string;
|
|
28
|
+
conformanceLevel: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CheckResultMeta {
|
|
32
|
+
ruleId: string;
|
|
33
|
+
ruleInterfaceVersion: string;
|
|
34
|
+
ruleVersion: string;
|
|
35
|
+
normative: boolean;
|
|
36
|
+
atomic: boolean;
|
|
37
|
+
category: Category;
|
|
38
|
+
normativeMappings: NormativeMapping[];
|
|
39
|
+
standard: string | null;
|
|
40
|
+
applicability: string;
|
|
41
|
+
expectation: string;
|
|
42
|
+
references: string[];
|
|
43
|
+
requirements: Record<string, unknown> | null;
|
|
44
|
+
mappings: Record<string, unknown> | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface VisibilityFilter {
|
|
48
|
+
targetSet: string;
|
|
49
|
+
accEligible: boolean | null;
|
|
50
|
+
reasons: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Occurrence {
|
|
54
|
+
selector: string;
|
|
55
|
+
html: string;
|
|
56
|
+
structuralPath: number[] | null;
|
|
57
|
+
summary: string;
|
|
58
|
+
hint: string;
|
|
59
|
+
i18n: { summaryKey: string; hintKey: string; params: Record<string, unknown> } | null;
|
|
60
|
+
data: {
|
|
61
|
+
visibilityFilter?: VisibilityFilter;
|
|
62
|
+
details?: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Only present when `.elementRef(true)` was used. `null` when this
|
|
66
|
+
* occurrence has no single resolvable target element (e.g. `selector` was
|
|
67
|
+
* `""`) -- see A11yCoreBuilder#elementRef. Named `element` (a
|
|
68
|
+
* `WebdriverIO.Element`), not `elementHandle` as in the Puppeteer/Playwright
|
|
69
|
+
* bindings -- WebdriverIO has no "handle" concept. A sub-frame occurrence's
|
|
70
|
+
* element is only usable while the browser is switched into that frame; see
|
|
71
|
+
* A11yCoreBuilder#elementRef and ../ROADMAP.md §2d.
|
|
72
|
+
*/
|
|
73
|
+
element?: WebdriverIO.Element | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CheckResult {
|
|
77
|
+
ruleId: string;
|
|
78
|
+
outcome: Outcome;
|
|
79
|
+
outcomeNormalized: OutcomeNormalized;
|
|
80
|
+
severity: Severity;
|
|
81
|
+
confidence: Confidence;
|
|
82
|
+
type: RuleType;
|
|
83
|
+
occurrences: Occurrence[];
|
|
84
|
+
title: string;
|
|
85
|
+
description: string;
|
|
86
|
+
i18n: { titleKey: string; descriptionKey: string } | null;
|
|
87
|
+
meta: CheckResultMeta;
|
|
88
|
+
engineOptions: Record<string, unknown>;
|
|
89
|
+
schemaVersion: string;
|
|
90
|
+
/** Present only if the rule threw, or the manual-fail-to-cantTell coercion fired. */
|
|
91
|
+
error?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CompositeResultDetails {
|
|
95
|
+
reasonCode: string;
|
|
96
|
+
checksIds: string[];
|
|
97
|
+
contributors: Array<{ testId: string; outcome: string; severity: string | null }>;
|
|
98
|
+
metrics: {
|
|
99
|
+
failCount: number;
|
|
100
|
+
cantTellCount: number;
|
|
101
|
+
notApplicableCount: number;
|
|
102
|
+
passCount: number;
|
|
103
|
+
missingCount: number;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CompositeResult {
|
|
108
|
+
ruleId: string;
|
|
109
|
+
outcome: Outcome;
|
|
110
|
+
severity: Severity;
|
|
111
|
+
confidence: Confidence;
|
|
112
|
+
type: RuleType;
|
|
113
|
+
title: string;
|
|
114
|
+
description: string;
|
|
115
|
+
meta: CheckResultMeta;
|
|
116
|
+
engineOptions: Record<string, unknown>;
|
|
117
|
+
schemaVersion: string;
|
|
118
|
+
/** Always empty -- composites are rollups, not element-level findings. */
|
|
119
|
+
occurrences: [];
|
|
120
|
+
data: { details: CompositeResultDetails };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** surea11y's native top-level result shape -- see docs/OUTPUT_SCHEMA.md. */
|
|
124
|
+
export interface A11yCoreResult {
|
|
125
|
+
engine: EngineInfo;
|
|
126
|
+
url: string | null;
|
|
127
|
+
title: string | null;
|
|
128
|
+
timestamp: string | null;
|
|
129
|
+
perfStats: Record<string, unknown> | null;
|
|
130
|
+
contextSelector: string | string[] | null;
|
|
131
|
+
checksResults: CheckResult[];
|
|
132
|
+
rulesResults: CompositeResult[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A sub-frame that couldn't be scanned (detached, navigated away, or sandboxed). */
|
|
136
|
+
export interface A11yCoreFrameError {
|
|
137
|
+
url: string | null;
|
|
138
|
+
error: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Returned by analyze() when .frames(true) is enabled, instead of a single A11yCoreResult. */
|
|
142
|
+
export interface A11yCoreMultiFrameResult {
|
|
143
|
+
topFrame: A11yCoreResult;
|
|
144
|
+
frames: Array<A11yCoreResult | A11yCoreFrameError>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A runtime-registered rule descriptor for `.withCustomRules()` -- the same
|
|
149
|
+
* shape as an internal surea11y rule module's own export (see surea11y's
|
|
150
|
+
* docs/ENGINE_OPTIONS.md). `runInPage`/`applicability` may be passed as
|
|
151
|
+
* either a real function or a function-source string -- `.withCustomRules()`
|
|
152
|
+
* converts a live function to its source string for you, since it must
|
|
153
|
+
* cross a browser.execute() serialization boundary that cannot carry a live
|
|
154
|
+
* Function reference.
|
|
155
|
+
*/
|
|
156
|
+
export interface CustomRuleDescriptor {
|
|
157
|
+
id: string;
|
|
158
|
+
meta?: {
|
|
159
|
+
title?: string;
|
|
160
|
+
description?: string;
|
|
161
|
+
tags?: string[];
|
|
162
|
+
defaultSeverity?: Severity;
|
|
163
|
+
defaultConfidence?: Confidence;
|
|
164
|
+
[key: string]: unknown;
|
|
165
|
+
};
|
|
166
|
+
runInPage: ((ctx: unknown) => unknown) | string;
|
|
167
|
+
applicability?: ((ctx: unknown) => boolean) | string;
|
|
168
|
+
data?: Record<string, unknown>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export class A11yCoreBuilder {
|
|
172
|
+
/**
|
|
173
|
+
* @param opts.browser A WebdriverIO Browser (from `remote()` or the WDIO
|
|
174
|
+
* testrunner's global `browser`), already navigated to and settled at the
|
|
175
|
+
* URL to scan -- this class does not navigate for you.
|
|
176
|
+
*/
|
|
177
|
+
constructor(opts: { browser: WebdriverIO.Browser; url?: string });
|
|
178
|
+
|
|
179
|
+
/** Scope the scan to one region. Call multiple times for a multi-region union. */
|
|
180
|
+
include(selector: string): this;
|
|
181
|
+
/** Skip elements matching this selector anywhere in the scanned scope. */
|
|
182
|
+
exclude(selector: string): this;
|
|
183
|
+
/** Only run rules carrying at least one of these tags. */
|
|
184
|
+
withTags(tags: string | string[]): this;
|
|
185
|
+
/** Never run rules carrying any of these tags (applied after withTags). */
|
|
186
|
+
disableTags(tags: string | string[]): this;
|
|
187
|
+
/** Only run these specific rule IDs (accepts with or without the prefix). */
|
|
188
|
+
withRules(ruleIds: string | string[]): this;
|
|
189
|
+
/** Never run these specific rule IDs (applied after withRules). */
|
|
190
|
+
disableRules(ruleIds: string | string[]): this;
|
|
191
|
+
/** Merge arbitrary engineOptions (locale, contrast.mode, policyContract, ...). */
|
|
192
|
+
options(partialEngineOptions: Record<string, unknown>): this;
|
|
193
|
+
/** Register one or more custom rules for just this scan. Call multiple times to accumulate. */
|
|
194
|
+
withCustomRules(rules: CustomRuleDescriptor | CustomRuleDescriptor[]): this;
|
|
195
|
+
/** Post-filter checksResults down to only the given outcomes. */
|
|
196
|
+
reportOnly(outcomes: Outcome | Outcome[]): this;
|
|
197
|
+
/** Opt in to also scanning every sub-frame on the page (including cross-origin and nested iframes). */
|
|
198
|
+
frames(enabled?: boolean): this;
|
|
199
|
+
/** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
|
|
200
|
+
elementRef(enabled?: boolean): this;
|
|
201
|
+
|
|
202
|
+
/** Runs the scan. Returns { topFrame, frames } instead of a single result when .frames(true) was used. */
|
|
203
|
+
analyze(): Promise<A11yCoreResult | A11yCoreMultiFrameResult>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Formats a checksResults array into a short, human-readable block -- one
|
|
208
|
+
* entry per occurrence, not per rule. Meant for an assertion library's
|
|
209
|
+
* failure-message parameter, e.g.
|
|
210
|
+
* `assert.strictEqual(results.checksResults.length, 0, formatFailures(results.checksResults))`.
|
|
211
|
+
* Deliberately framework-agnostic -- no dependency on any particular
|
|
212
|
+
* `expect` implementation.
|
|
213
|
+
*/
|
|
214
|
+
export function formatFailures(checksResults: CheckResult[], opts?: { outcomes?: Outcome[] }): string;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { runa11yCoreInPage } = require('@surea11y/core');
|
|
4
|
+
const { A11yCoreBuilderBase } = require('@surea11y/binding-base');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WebdriverIO binding for surea11y -- scans a real, already-rendered page.
|
|
8
|
+
*
|
|
9
|
+
* const results = await new A11yCoreBuilder({ browser })
|
|
10
|
+
* .include('#main')
|
|
11
|
+
* .exclude('.cookie-banner')
|
|
12
|
+
* .withTags(['wcag2a', 'wcag2aa'])
|
|
13
|
+
* .disableRules(['meta-refresh-no-exceptions'])
|
|
14
|
+
* .options({ contrast: { mode: 'auditorAssist' } })
|
|
15
|
+
* .analyze();
|
|
16
|
+
*
|
|
17
|
+
* `results` is surea11y's own native result shape (checksResults /
|
|
18
|
+
* rulesResults -- see surea11y's docs/OUTPUT_SCHEMA.md), not the
|
|
19
|
+
* violations/passes/incomplete/inapplicable shape used by other tools in
|
|
20
|
+
* this space. Method names are modeled on common conventions in this space
|
|
21
|
+
* for migration ease, but the richer native schema (severity, confidence,
|
|
22
|
+
* occurrences, policy contract, WCAG SC mappings) is kept as-is rather than
|
|
23
|
+
* reshaped to match.
|
|
24
|
+
*
|
|
25
|
+
* `browser` is the object returned by WebdriverIO's `remote()` (standalone
|
|
26
|
+
* mode) or the global `browser` inside the WDIO testrunner -- it must already
|
|
27
|
+
* be navigated to and settled at the URL to scan; this class does not
|
|
28
|
+
* navigate for you.
|
|
29
|
+
*
|
|
30
|
+
* Extends `A11yCoreBuilderBase` (from `surea11y-binding-base`), which owns
|
|
31
|
+
* every method with no driver-specific work at all -- `include()`/
|
|
32
|
+
* `exclude()`/`withTags()`/`disableTags()`/`withRules()`/`disableRules()`/
|
|
33
|
+
* `options()`/`reportOnly()`/`elementRef()`/`frames()`/`withCustomRules()`'s
|
|
34
|
+
* validation (including the default customRules stringification, correct
|
|
35
|
+
* here since WebdriverIO's `browser.execute()` crosses a real serialization
|
|
36
|
+
* boundary), and `_buildEngineArgs()`. This class adds exactly the parts
|
|
37
|
+
* that are genuinely WebdriverIO-specific: `analyze()`'s injection
|
|
38
|
+
* mechanics, the stateful index-path frame-traversal design below, and
|
|
39
|
+
* `_attachElementRefs()`. See `../surea11y-binding-base/README.md` for
|
|
40
|
+
* what's shared and why.
|
|
41
|
+
*
|
|
42
|
+
* Opt in to scanning every frame on the page (including cross-origin
|
|
43
|
+
* iframes -- and nested iframes-within-iframes, recursively) via
|
|
44
|
+
* .frames(true):
|
|
45
|
+
*
|
|
46
|
+
* const results = await new A11yCoreBuilder({ browser }).frames(true).analyze();
|
|
47
|
+
* // results.topFrame -- same shape as the single-frame case above
|
|
48
|
+
* // results.frames -- flat array of the same native result shape, one per sub-frame
|
|
49
|
+
*
|
|
50
|
+
* WebdriverIO's frame model is fundamentally different from Puppeteer's and
|
|
51
|
+
* Playwright's. Those expose `page.frames()` -- an array of independent Frame
|
|
52
|
+
* objects, each with its own `.evaluate()`/`.$()` -- so a binding can iterate
|
|
53
|
+
* them freely. WebdriverIO instead has a single *stateful current context*:
|
|
54
|
+
* `browser.switchFrame(iframeElement)` changes which document
|
|
55
|
+
* `browser.execute()`/`browser.$()` run against, and `browser.switchFrame(null)`
|
|
56
|
+
* returns to the top-level frame. There is no array of frame objects. So
|
|
57
|
+
* `.frames(true)` here works by enumerating each context's direct-child
|
|
58
|
+
* `<iframe>` elements with `browser.$$('iframe')`, switching into each with
|
|
59
|
+
* `browser.switchFrame(el)`, scanning the now-current document, then
|
|
60
|
+
* recursing into that frame's own children before returning to the parent
|
|
61
|
+
* context (re-entered by the context id `switchFrame()` handed back on the
|
|
62
|
+
* way in). Cross-origin iframes are reached with no extra work -- the
|
|
63
|
+
* WebDriver/BiDi automation layer switches into them the same as same-origin
|
|
64
|
+
* ones, exactly like Puppeteer/Playwright's CDP does; verified against a
|
|
65
|
+
* real cross-origin frame (`https://example.org/`). See ../ROADMAP.md §2c.
|
|
66
|
+
* Default off, so plain .analyze() keeps returning the single native result
|
|
67
|
+
* object it always has.
|
|
68
|
+
*
|
|
69
|
+
* By default `analyze()` returns every rule's outcome, including
|
|
70
|
+
* `pass`/`notApplicable` -- surea11y's own deliberate "not a
|
|
71
|
+
* violations-only list" design (see surea11y's docs/OUTPUT_SCHEMA.md).
|
|
72
|
+
* Opt in to a lighter payload with `.reportOnly(['fail', 'cantTell'])`,
|
|
73
|
+
* which post-filters `checksResults` by `outcome` (applied per-frame when
|
|
74
|
+
* combined with `.frames(true)`, since `checksResults` lives at
|
|
75
|
+
* `results.topFrame` / each `results.frames[i]` in that shape, not at the
|
|
76
|
+
* top level):
|
|
77
|
+
*
|
|
78
|
+
* const results = await new A11yCoreBuilder({ browser })
|
|
79
|
+
* .reportOnly(['fail', 'cantTell'])
|
|
80
|
+
* .analyze();
|
|
81
|
+
*
|
|
82
|
+
* Opt in to a live `WebdriverIO.Element` per occurrence (instead of just a
|
|
83
|
+
* CSS selector string) with `.elementRef(true)`, attached as
|
|
84
|
+
* `occurrence.element`, so you can act on the flagged element directly
|
|
85
|
+
* (`.click()`, `.saveScreenshot()`, `.getProperty()`, ...) rather than
|
|
86
|
+
* re-resolving its selector yourself:
|
|
87
|
+
*
|
|
88
|
+
* const results = await new A11yCoreBuilder({ browser }).elementRef(true).analyze();
|
|
89
|
+
* const [firstFail] = results.checksResults.filter(r => r.outcome === 'fail');
|
|
90
|
+
* await firstFail.occurrences[0].element.saveScreenshot('./flagged.png');
|
|
91
|
+
*
|
|
92
|
+
* NOTE the field is `occurrence.element` (a `WebdriverIO.Element`), not
|
|
93
|
+
* Puppeteer/Playwright's `occurrence.elementHandle` -- WebdriverIO has no
|
|
94
|
+
* "handle" concept, and its element API differs (`.getProperty('id')` rather
|
|
95
|
+
* than `.evaluate(el => el.id)`, `.saveScreenshot(path)` rather than
|
|
96
|
+
* `.screenshot({ path })`). See ../ROADMAP.md §2d and the README.
|
|
97
|
+
*
|
|
98
|
+
* Register your own rule(s) for just this scan with
|
|
99
|
+
* `.withCustomRules([...])` (surea11y's `engineOptions.customRules`
|
|
100
|
+
* escape hatch -- see surea11y's docs/ENGINE_OPTIONS.md). Pass a real,
|
|
101
|
+
* live `runInPage`/
|
|
102
|
+
* `applicability` function -- unlike the raw `.options({ customRules })`
|
|
103
|
+
* passthrough, this method converts them to the function-source string
|
|
104
|
+
* surea11y needs on this side of the browser.execute() serialization
|
|
105
|
+
* boundary for you, so you don't have to remember to call .toString()
|
|
106
|
+
* yourself:
|
|
107
|
+
*
|
|
108
|
+
* const results = await new A11yCoreBuilder({ browser })
|
|
109
|
+
* .withCustomRules({
|
|
110
|
+
* id: 'my-org-custom-rule',
|
|
111
|
+
* meta: { title: 'My custom rule', tags: ['custom'] },
|
|
112
|
+
* runInPage(ctx) {
|
|
113
|
+
* const el = ctx.document.querySelector('.my-widget');
|
|
114
|
+
* return el ? { outcome: 'fail', occurrences: [{ __node: el }] } : { outcome: 'notApplicable', occurrences: [] };
|
|
115
|
+
* }
|
|
116
|
+
* })
|
|
117
|
+
* .analyze();
|
|
118
|
+
*
|
|
119
|
+
* Create one builder per scan. This is a mutable object with no reset
|
|
120
|
+
* between analyze() calls: include()/exclude()/withRules()/disableRules()/
|
|
121
|
+
* withTags()/disableTags()/options()/withCustomRules() all push onto or
|
|
122
|
+
* merge into internal state that persists for the instance's lifetime, so
|
|
123
|
+
* calling one of them again before a second analyze() call accumulates on
|
|
124
|
+
* top of the first scan's scope rather than replacing it (intentional for
|
|
125
|
+
* "call include() several times for one scan" -- see above -- but a footgun
|
|
126
|
+
* if you hold one instance across multiple assertions).
|
|
127
|
+
* reportOnly()/frames()/elementRef() are the exception: each call replaces
|
|
128
|
+
* the previous value rather than merging with it.
|
|
129
|
+
*/
|
|
130
|
+
class A11yCoreBuilder extends A11yCoreBuilderBase {
|
|
131
|
+
/**
|
|
132
|
+
* @param {{ browser: import('webdriverio').Browser, url?: string }} opts
|
|
133
|
+
* `browser` must already be navigated to and settled at the URL to scan --
|
|
134
|
+
* this class does not navigate for you.
|
|
135
|
+
*/
|
|
136
|
+
constructor({ browser, url } = {}) {
|
|
137
|
+
super({ url });
|
|
138
|
+
if (!browser || typeof browser.execute !== 'function') {
|
|
139
|
+
throw new Error('A11yCoreBuilder requires { browser } (a WebdriverIO Browser, with an .execute() method).');
|
|
140
|
+
}
|
|
141
|
+
this._browser = browser;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Runs the scan and returns surea11y's native result object.
|
|
146
|
+
* @returns {Promise<object>} see surea11y's docs/OUTPUT_SCHEMA.md
|
|
147
|
+
*/
|
|
148
|
+
async analyze() {
|
|
149
|
+
const { contextSelector, engineOptions, runOnly } = this._buildEngineArgs();
|
|
150
|
+
|
|
151
|
+
// Unlike Playwright's page.evaluate(fn, arg), which only accepts a
|
|
152
|
+
// SINGLE argument (forcing a hand-built single-arg wrapper there),
|
|
153
|
+
// WebdriverIO's browser.execute() is genuinely variadic:
|
|
154
|
+
// execute<ReturnValue, InnerArguments extends unknown[]>(script, ...args)
|
|
155
|
+
// (confirmed against a real webdriverio 9.x install's own
|
|
156
|
+
// build/commands/browser/execute.d.ts). Like Puppeteer, that means
|
|
157
|
+
// runa11yCoreInPage's own 4 positional args can be passed straight
|
|
158
|
+
// through with no wrapper/eval() trick -- WebdriverIO serializes the
|
|
159
|
+
// function itself. Verified empirically against a real headless Chrome
|
|
160
|
+
// session before trusting it (this project's whole ethos, inherited
|
|
161
|
+
// from surea11y and its sibling bindings, is "verified against a real
|
|
162
|
+
// run," not "reasoned about").
|
|
163
|
+
//
|
|
164
|
+
// Runs against whatever frame the browser is CURRENTLY switched into --
|
|
165
|
+
// WebdriverIO's execute()/$ target the current context, a stateful model
|
|
166
|
+
// unlike Puppeteer/Playwright's per-frame-object one. `.frames(true)`
|
|
167
|
+
// orchestrates the switching (see _scanChildFrames).
|
|
168
|
+
const scanCurrent = async () => {
|
|
169
|
+
const frameUrl = this._url || (await this._browser.execute(() => document.location.href));
|
|
170
|
+
const result = await this._browser.execute(runa11yCoreInPage, frameUrl, contextSelector, engineOptions, runOnly);
|
|
171
|
+
return this._elementRef ? this._attachElementRefs(result) : result;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (!this._scanFrames) {
|
|
175
|
+
return this._applyReportOnly(await scanCurrent());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Make sure we start from the top-level frame, whatever context the
|
|
179
|
+
// caller happened to leave the browser in.
|
|
180
|
+
await this._browser.switchFrame(null);
|
|
181
|
+
const topFrame = this._applyReportOnly(await scanCurrent());
|
|
182
|
+
|
|
183
|
+
const frames = [];
|
|
184
|
+
await this._scanChildFrames([], scanCurrent, frames);
|
|
185
|
+
|
|
186
|
+
// Leave the browser back at the top-level frame regardless of how the
|
|
187
|
+
// traversal ended, so a caller's next command isn't silently running in
|
|
188
|
+
// some leftover sub-frame context.
|
|
189
|
+
await this._browser.switchFrame(null).catch(() => {});
|
|
190
|
+
|
|
191
|
+
return { topFrame, frames };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Depth-first traversal of the frame tree rooted at the context addressed
|
|
196
|
+
* by `path` -- an array of child <iframe> indices from the top frame (so
|
|
197
|
+
* `[]` is the top frame, `[0]` its first child, `[0, 2]` that child's third
|
|
198
|
+
* child, and so on). Each direct child is navigated into, scanned, and
|
|
199
|
+
* recursed through; every scanned frame at any depth is pushed onto the same
|
|
200
|
+
* flat `out` array, matching the flat `frames` list Puppeteer/Playwright's
|
|
201
|
+
* page.frames()-based bindings return.
|
|
202
|
+
*
|
|
203
|
+
* The traversal deliberately re-navigates from the top frame down for each
|
|
204
|
+
* child (via _switchToPath) rather than caching a "handle" to return to.
|
|
205
|
+
* That's what makes it protocol-agnostic: WebdriverIO's switchFrame()
|
|
206
|
+
* returns a reusable BiDi context id under the default BiDi protocol but
|
|
207
|
+
* `null` under the classic WebDriver protocol (`wdio:enforceWebDriverClassic`,
|
|
208
|
+
* which this project's own tests/examples use -- see ../ROADMAP.md §2c/§4),
|
|
209
|
+
* and `$$('iframe')` element references don't survive a context switch away
|
|
210
|
+
* and back either. The one thing that reliably re-addresses a nested context
|
|
211
|
+
* on both protocols is replaying `switchFrame(null)` then the chain of
|
|
212
|
+
* `switchFrame(childElement)` down the index path, re-querying the iframes
|
|
213
|
+
* fresh at each hop.
|
|
214
|
+
*/
|
|
215
|
+
async _scanChildFrames(path, scanCurrent, out) {
|
|
216
|
+
let childCount;
|
|
217
|
+
try {
|
|
218
|
+
const iframes = await this._browser.$$('iframe');
|
|
219
|
+
childCount = iframes.length;
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return; // can't enumerate this context's children -- nothing to recurse into
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i < childCount; i++) {
|
|
225
|
+
let frameUrl = null;
|
|
226
|
+
try {
|
|
227
|
+
// Re-establish this context from the top, then step into child i with
|
|
228
|
+
// a freshly re-queried element reference.
|
|
229
|
+
await this._switchToPath(path);
|
|
230
|
+
const iframes = await this._browser.$$('iframe');
|
|
231
|
+
await this._browser.switchFrame(iframes[i]);
|
|
232
|
+
frameUrl = await this._browser.execute(() => document.location.href);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
// A frame can detach/navigate away mid-scan, or be a sandboxed frame
|
|
235
|
+
// the browser blocks switching into -- record it and move on rather
|
|
236
|
+
// than aborting the whole multi-frame scan.
|
|
237
|
+
out.push({ url: frameUrl, error: (e && e.message) || String(e) });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
out.push(this._applyReportOnly(await scanCurrent()));
|
|
243
|
+
} catch (e) {
|
|
244
|
+
out.push({ url: frameUrl, error: (e && e.message) || String(e) });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// We're currently switched into child i -- recurse into ITS children.
|
|
248
|
+
await this._scanChildFrames(path.concat(i), scanCurrent, out);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Switches the browser into the context addressed by `path` (array of child
|
|
254
|
+
* <iframe> indices from the top frame) by replaying switchFrame(null) then
|
|
255
|
+
* one switchFrame(childElement) per hop, re-querying $$('iframe') fresh at
|
|
256
|
+
* each level. Protocol-agnostic -- see _scanChildFrames.
|
|
257
|
+
*/
|
|
258
|
+
async _switchToPath(path) {
|
|
259
|
+
await this._browser.switchFrame(null);
|
|
260
|
+
for (const index of path) {
|
|
261
|
+
const iframes = await this._browser.$$('iframe');
|
|
262
|
+
await this._browser.switchFrame(iframes[index]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolves occurrence.selector to a live WebdriverIO.Element for every
|
|
268
|
+
* fail/cantTell occurrence, scoped to whatever frame the browser is
|
|
269
|
+
* currently switched into (browser.$ is stateful on the current context).
|
|
270
|
+
* Mutates and returns the same result object -- it's a fresh object from
|
|
271
|
+
* this scan, not shared external state.
|
|
272
|
+
*/
|
|
273
|
+
async _attachElementRefs(result) {
|
|
274
|
+
if (!Array.isArray(result.checksResults)) return result;
|
|
275
|
+
for (const check of result.checksResults) {
|
|
276
|
+
if (!Array.isArray(check.occurrences) || !check.occurrences.length) continue;
|
|
277
|
+
for (const occurrence of check.occurrences) {
|
|
278
|
+
// Most occurrences carry a concrete element selector, but a page-wide
|
|
279
|
+
// finding with no single target element (e.g. some `manual`/cantTell
|
|
280
|
+
// rules) can carry "" -- leave `element` null rather than passing ""
|
|
281
|
+
// to browser.$(), which throws ("invalid selector: No selector
|
|
282
|
+
// specified") on WebdriverIO, unlike Puppeteer/Playwright's .$("")
|
|
283
|
+
// which resolves to null. Verified against a real run.
|
|
284
|
+
occurrence.element = occurrence.selector ? await this._browser.$(occurrence.selector) : null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { A11yCoreBuilder };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Re-exported from surea11y-binding-base, which now holds the single
|
|
4
|
+
// canonical copy shared across every surea11y binding -- see
|
|
5
|
+
// ../ROADMAP.md's note on the shared-package extraction, and
|
|
6
|
+
// ../surea11y-binding-base/README.md for the full rationale. Kept as a
|
|
7
|
+
// real file here (not just re-exporting straight from src/index.js) so
|
|
8
|
+
// `require('surea11y-webdriverio/src/formatFailures')` keeps working for
|
|
9
|
+
// anyone importing the submodule path directly.
|
|
10
|
+
const { formatFailures } = require('@surea11y/binding-base');
|
|
11
|
+
|
|
12
|
+
module.exports = { formatFailures };
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './A11yCoreBuilder';
|