@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 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';
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ 'use strict';
2
+
3
+ const { A11yCoreBuilder } = require('./A11yCoreBuilder');
4
+ const { formatFailures } = require('./formatFailures');
5
+
6
+ module.exports = { A11yCoreBuilder, formatFailures };