@surea11y/webdriverio 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,10 @@ A WebdriverIO binding for [`@surea11y/core`](https://github.com/SureA11y/core)
8
8
  npm install @surea11y/webdriverio webdriverio
9
9
  ```
10
10
 
11
+ `@surea11y/core` 1.12.0 or later comes with it as a peer dependency, as WebdriverIO does: npm 7 and later, and pnpm 8 and later, install it for you, so the line above is all you need. With Yarn 1 or npm 6, which don't install peers, add `@surea11y/core` to that line.
12
+
13
+ Being a peer means your project and this binding share one copy of core. That matters as soon as your project uses core directly, to write a SARIF log or an HTML report (`@surea11y/core/sarif`, `/report`), read the rule catalogue, or keep a baseline: those read results with the same core that produced them, so a report never labels a finding with another release's rules. It holds when you bring your own core too: a linked checkout (`file:../core`), a monorepo, a fork, or a pinned release. Before 1.4.0, core was a regular dependency, and a project whose core differed from the binding's got a second copy without a word; such a project needed `"overrides": { "@surea11y/core": "$@surea11y/core" }` in its `package.json`, which it can now drop.
14
+
11
15
  `webdriverio` v9 auto-manages a local `chromedriver` for you the first time it launches Chrome (no separate Selenium server, and no extra browser-download step needed) — so a fresh `npm install` is enough for `npm test` to launch a real headless Chrome on its own.
12
16
 
13
17
  ## Usage
@@ -34,7 +38,7 @@ const results = await new A11yCoreBuilder({ browser })
34
38
  .analyze();
35
39
 
36
40
  console.log(results.checksResults.filter(r => r.outcome === 'fail'));
37
- console.log(results.engine.version); // the @surea11y/core release that scanned, e.g. "1.11.1"
41
+ console.log(results.engine.version); // the @surea11y/core release that scanned, e.g. "1.12.0"
38
42
  await browser.deleteSession();
39
43
  ```
40
44
 
@@ -42,6 +46,8 @@ The builder takes `{ browser }` — the object WebdriverIO's `remote()` returns
42
46
 
43
47
  `results` is `@surea11y/core`'s own native result shape — see its [`OUTPUT_SCHEMA.md`](https://github.com/SureA11y/core/blob/main/docs/OUTPUT_SCHEMA.md) — not the `violations`/`passes`/`incomplete`/`inapplicable` shape used by other popular accessibility testing tools. The builder's *method names* are modeled on common conventions in this space for migration familiarity; the richer result schema is kept as-is.
44
48
 
49
+ From 1.4.0 this package needs `@surea11y/core` 1.12.0 or later; see [What changes with `@surea11y/core` 1.12](#what-changes-with-surea11ycore-112).
50
+
45
51
  Also see `examples/basic-scan.js` for a runnable script (`npm run example -- <url>`).
46
52
 
47
53
  ### A note on the protocol: prefer classic WebDriver for this workload
@@ -50,9 +56,42 @@ WebdriverIO v9 defaults to the WebDriver **BiDi** protocol. A long-lived session
50
56
 
51
57
  ### Composing the rule filters
52
58
 
53
- `.withWcag(version, level)` selects by conformance target: `.withWcag('2.2', 'AA')` runs the rules for WCAG 2.2's Level A and AA criteria, by the level each criterion has in that version (4.1.1 Parsing, which 2.2 removed, is part of a `'2.1'` target and not of a `'2.2'` one). `.withBestPractices()` adds the rules that name no WCAG criterion, such as `region` and `heading-order`; on its own it runs only those. `.withRules()` and `.withTags()` add to a target's rules, and the disable methods apply after. A version other than `'2.0'`, `'2.1'` or `'2.2'`, or a level other than `'A'`, `'AA'` or `'AAA'`, throws a `TypeError` with `code: 'INVALID_RUN_ONLY'` at the call. Both need `@surea11y/core` 1.11 or later.
59
+ `.withWcag(version, level)` selects by conformance target: `.withWcag('2.2', 'AA')` runs the rules for WCAG 2.2's Level A and AA criteria, by the level each criterion has in that version (4.1.1 Parsing, which 2.2 removed, is part of a `'2.1'` target and not of a `'2.2'` one). `.withBestPractices()` adds the rules that name no WCAG criterion, such as `region` and `heading-order`; on its own it runs only those. `.withRules()` and `.withTags()` add to a target's rules, and the disable methods apply after. The level is read in any case (`'aa'` is `'AA'`). A version other than `'2.0'`, `'2.1'` or `'2.2'`, or a level other than A, AA or AAA, throws a `TypeError` with `code: 'INVALID_RUN_ONLY'` at the call. Both need `@surea11y/core` 1.11 or later.
60
+
61
+ `withTags()`/`disableRules()` have counterparts: `.withRules([...])` (only run these specific rule IDs) and `.disableTags([...])` (never run rules carrying any of these tags). All four compose the same way similar allow/deny-list options do in other accessibility testing tools, with one non-obvious rule worth knowing: a "disable" always wins over a "with" on the same ID/tag (e.g. `.withRules(['a']).disableRules(['a'])` drops `'a'` entirely), and combining `.withRules()` **and** `.withTags()` together requires a rule to satisfy *both* (`@surea11y/core`'s default `includeMode: 'and'`), not either one, unless you ask for that with `.includeMode('or')` (below).
62
+
63
+ `.includeMode(mode)` decides how `.withRules()` and `.withTags()` combine when both are given: `'and'`, the default, runs only the rules that match both, and `'or'` runs those that match either. That's what lets a scan say "this area, plus these checks". A team that owns a design system's form components, say, can run every rule about forms and the contrast check too, in one scan:
64
+
65
+ ```js
66
+ const results = await new A11yCoreBuilder({ browser })
67
+ .withTags('forms')
68
+ .withRules('contrast-computable')
69
+ .includeMode('or') // every forms rule, plus the contrast check
70
+ .analyze();
71
+ ```
72
+
73
+ Without `.includeMode('or')`, the same lines run no rule at all, since `contrast-computable` isn't tagged `forms`, and a scan of no rules looks clean. The same goes for a WCAG level tag beside a best practice: `.withTags('wcag2a').withRules('region')` runs nothing, and with `'or'` runs the `wcag2a` rules plus `region`. On its own, with no other selection, it changes nothing. A value other than `'and'` or `'or'` throws a `TypeError` with `code: 'INVALID_RUN_ONLY'` at the call. A `.withWcag()` target doesn't need it: its rules are always added to what tags and rules select.
74
+
75
+ `.wcagLevel(level)` sets the WCAG level of the criteria a scan reports, without changing which rules run: criteria above it are left out of `results.rulesResults`, while every selected rule still runs and reports in `results.checksResults`. It's what lets a scan that names a few checks say it is meant as WCAG AA. A team that gates each pull request on colour contrast alone, say, runs one check, which covers both 1.4.3 (AA) and 1.4.6 (AAA):
76
+
77
+ ```js
78
+ const results = await new A11yCoreBuilder({ browser })
79
+ .withRules('contrast-computable')
80
+ .wcagLevel('AA') // report 1.4.3, not the AAA 1.4.6
81
+ .analyze();
82
+ ```
83
+
84
+ Without `.wcagLevel('AA')`, that scan reports 1.4.6 as well, so a site that meets AA can show a failing AAA criterion it never aimed for. With it, the scan reports 1.4.3 alone, and `results.engine.wcagLevel` says `'AA'`. A `.withWcag()` target already implies its level; `.wcagLevel()` is for selections that don't, and wins when both are given, as it does over a level given through `.options()`. `'A'`, `'AA'` or `'AAA'` in any case is accepted. Anything else throws a `TypeError` with `code: 'INVALID_ENGINE_OPTIONS'` at the call, and `null` clears it. Needs `@surea11y/core` 1.12.0 or later.
54
85
 
55
- `withTags()`/`disableRules()` have counterparts: `.withRules([...])` (only run these specific rule IDs) and `.disableTags([...])` (never run rules carrying any of these tags). All four compose the same way similar allow/deny-list options do in other accessibility testing tools, with one non-obvious rule worth knowing: a "disable" always wins over a "with" on the same ID/tag (e.g. `.withRules(['a']).disableRules(['a'])` drops `'a'` entirely), and combining `.withRules()` **and** `.withTags()` together requires a rule to satisfy *both* (`@surea11y/core`'s default `includeMode: 'and'`), not either one.
86
+ `.plannedChecks()` and `.plannedCriteria()` say what a scan *will* check before you run it: the rules it runs (entries with `ruleId`) and the criteria it reports in `rulesResults` (entries with `id`), worked out in Node from the same selection and options, with no browser. Use them to record what a run covered next to its result, or to catch a selection that runs nothing, which would otherwise look like a clean scan:
87
+
88
+ ```js
89
+ const builder = new A11yCoreBuilder({ browser }).withTags('forms').withRules('contrast-computable');
90
+ if (builder.plannedChecks().length === 0) {
91
+ throw new Error('This selection runs no rule. Did you mean .includeMode("or")?');
92
+ }
93
+ const results = await builder.analyze();
94
+ ```
56
95
 
57
96
  A rule or tag list that names nothing `@surea11y/core` knows makes `analyze()` reject with `code: 'INVALID_RUN_ONLY'` (see [When a scan can't run as asked](#when-a-scan-cant-run-as-asked)), so a typo such as `.withTags(['wcag2.2aa'])` can't run no rule at all and pass. An unknown name beside known ones is ignored, with a warning in the page's console. The four methods also throw a `TypeError` with the same `code` straight away for anything but a string or an array of strings, such as `undefined` from a missing config value.
58
97
 
@@ -60,7 +99,7 @@ Rule IDs change rarely, but core 1.10.0 changed two: `label-title-only` is depre
60
99
 
61
100
  `.exclude(selector)` above excludes globally. Pass a second argument to scope it to specific rule IDs instead: `.exclude('.mat-select', { rules: ['aria-required-children'] })` skips `.mat-select` for that rule only — every other rule still sees it. Global and rule-scoped `.exclude()` calls compose freely.
62
101
 
63
- **Create one builder per scan.** `A11yCoreBuilder` is a mutable object with no reset between `.analyze()` calls — `include()`/`exclude()`/`withRules()`/`disableRules()`/`withTags()`/`disableTags()`/`options()`/`withCustomRules()`/`withPacks()` all push onto or merge into internal state that persists for the instance's lifetime. Calling one of them again before a second `.analyze()` call *accumulates* on top of the first scan's scope rather than replacing it (this is exactly what makes "call `.include()` several times for one scan," above, work — the same accumulation just also applies across separate scans if you reuse an instance). `.reportOnly()`/`.frames()`/`.elementRef()`/`.withWcag()`/`.withBestPractices()` are the exception: each call replaces the previous value instead of merging with it.
102
+ **Create one builder per scan.** `A11yCoreBuilder` is a mutable object with no reset between `.analyze()` calls — `include()`/`exclude()`/`withRules()`/`disableRules()`/`withTags()`/`disableTags()`/`options()`/`withCustomRules()`/`withPacks()` all push onto or merge into internal state that persists for the instance's lifetime. Calling one of them again before a second `.analyze()` call *accumulates* on top of the first scan's scope rather than replacing it (this is exactly what makes "call `.include()` several times for one scan," above, work — the same accumulation just also applies across separate scans if you reuse an instance). `.reportOnly()`/`.frames()`/`.elementRef()`/`.withWcag()`/`.withBestPractices()`/`.includeMode()`/`.wcagLevel()`/`.logger()` are the exception: each call replaces the previous value instead of merging with it.
64
103
 
65
104
  This binding works against any browser WebdriverIO can drive; the tests and examples use headless Chrome via `chromedriver`, which WebdriverIO manages automatically.
66
105
 
@@ -102,9 +141,11 @@ A bare length/equality assertion alone gets you a *working* gate, but the failur
102
141
  at #host >>> p > img
103
142
  Add an alt attribute that says what the image shows, or alt="" if the image is purely decorative.
104
143
 
105
- Scanned with @surea11y/core 1.11.1.
144
+ Scanned with @surea11y/core 1.12.0.
106
145
  ```
107
146
 
147
+ Before the version line, it also prints the result's notes (`getScanNotes()`, below), such as `The page was shown in forced colors (high contrast); contrast findings may differ with forced colors off.`
148
+
108
149
  An element inside a shadow tree is located as `host >>> selector` (the second entry above); `formatOccurrenceLocation(occurrence)` gives the same text if you print occurrences yourself. `formatFailures(results.checksResults)` still works and prints only the entries. A `.frames(true)` result is not one result, so `formatFailures()` throws a `TypeError` for it: format `results.topFrame` and each entry of `results.frames` on its own.
109
150
 
110
151
  Deliberately a plain function, not a custom matcher — no dependency on any particular assertion library, so it works the same with `node:assert`, WebdriverIO's `expect`, Jest, Vitest, or a hand-rolled `if`/`throw`. Defaults to `fail`/`cantTell` outcomes (the only two whose occurrences are findings: a `notApplicable` rule may carry one occurrence saying why it had nothing to judge); pass `{ outcomes: [...] }` to narrow further. A thrown rule (`occurrences: []`, `error` set) is still surfaced using its `error` message rather than silently dropped.
@@ -175,6 +216,60 @@ Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(tru
175
216
 
176
217
  Not every occurrence has one target element — a page-wide finding (some `manual`/`cantTell` rules) can carry `selector: ""`, in which case `occurrence.element` is `null` rather than an element.
177
218
 
219
+ ### Web components: the internals hook
220
+
221
+ Many web components keep their `ElementInternals` in a private field and name themselves, set their states or give themselves a role through it:
222
+
223
+ ```js
224
+ class SubscribeCheckbox extends HTMLElement {
225
+ #internals = this.attachInternals();
226
+ constructor() {
227
+ super();
228
+ this.#internals.ariaLabel = 'Subscribe';
229
+ this.#internals.ariaChecked = 'false';
230
+ }
231
+ }
232
+ ```
233
+
234
+ The browser exposes that name, but no page script can read it back, so by default the scan asks about such a component (`cantTell`) instead of judging it. A component that's a `switch` only through `this.#internals.role` isn't even asked about: the scan doesn't see its role, so it isn't checked at all.
235
+
236
+ `A11yCoreBuilder.installInternalsHook(browser)` installs `@surea11y/core`'s opt-in hook, which records each component's internals for the scan to read. It has to run before the page's own scripts, so it uses:
237
+
238
+ - **`browser.addInitScript()`** when the session speaks WebDriver BiDi (WebdriverIO 9's default): Chrome, Edge and Firefox;
239
+ - otherwise, on **Chrome and Edge**, the DevTools protocol through chromedriver (`Page.addScriptToEvaluateOnNewDocument`). That covers the classic sessions this README [recommends for scanning](#a-note-on-the-protocol-prefer-classic-webdriver-for-this-workload), and WebdriverIO 8.
240
+
241
+ Either way it runs in every frame and after each navigation. Call it **before** `browser.url()`:
242
+
243
+ ```js
244
+ await A11yCoreBuilder.installInternalsHook(browser); // 'bidi' or 'cdp'
245
+ await browser.url('https://example.com/');
246
+
247
+ const results = await new A11yCoreBuilder({ browser }).analyze();
248
+ results.engine.environment.internalsHook; // 'early': the hook ran before the page's scripts
249
+ ```
250
+
251
+ In the WDIO testrunner, call it in a `before` hook. With it, the checkbox above passes, an unnamed component that attaches internals fails like any unnamed control, and the switch is checked as a switch, so it fails without a name. It resolves to `'bidi'` or `'cdp'`, the way it was installed.
252
+
253
+ - **A classic session on Firefox or Safari** has no way to run a script before the page loads: the call rejects. There, such components stay questions, and roles set through internals aren't checked. A BiDi session reaches Firefox.
254
+ - **Before the page loads.** On a page that's already loaded, the call has no effect until the next navigation (a refresh will do), and the result has no `internalsHook`.
255
+ - **For test and audit runs.** The hook wraps `HTMLElement.prototype.attachInternals` (components get the same internals back), adds a stack frame to errors thrown through it, and keeps its record under a global symbol, where any script on the page can read every component's internals. That's why it's opt-in.
256
+ - **Without it**, `analyze()` says so once per scan when it asked about such components (`1 custom element was asked about because the scan could not read...`), and `getScanNotes()` gives that note as `kind: 'internals-hook'`.
257
+
258
+ ### Forced colors
259
+
260
+ A page shown in forced colors (Windows high contrast) draws in the user's colors, not its own. `results.engine.environment.forcedColors` says which it was: `'active'` or `'none'`. To check a page under forced colors on purpose in Chrome or Edge, emulate it through chromedriver's DevTools commands before scanning, in a classic or a BiDi session:
261
+
262
+ ```js
263
+ await browser.sendCommand('Emulation.setEmulatedMedia', {
264
+ features: [{ name: 'forced-colors', value: 'active' }]
265
+ });
266
+ const results = await new A11yCoreBuilder({ browser }).analyze();
267
+ results.engine.environment.forcedColors; // 'active'
268
+ await browser.sendCommand('Emulation.setEmulatedMedia', { features: [] }); // back to normal
269
+ ```
270
+
271
+ `browser.sendCommand()` is one of WebdriverIO's Chromium commands, so there's no equivalent for Firefox or Safari. The contrast rules still measure the page's own colors under forced colors, since WCAG judges contrast in those, so results match a scan with forced colors off; a contrast finding the engine couldn't measure that way carries `details.forcedColors: true`. When forced colors were active, `formatFailures()` adds a line saying so, since it's easy to miss on a CI agent with high contrast turned on.
272
+
178
273
  ### Registering a custom rule at runtime
179
274
 
180
275
  `@surea11y/core` supports registering additional rules per-scan via `engineOptions.customRules`. Use `.withCustomRules()` to register one:
@@ -207,6 +302,8 @@ const results = await new A11yCoreBuilder({ browser })
207
302
 
208
303
  **Why `.withCustomRules()` instead of the raw `.options({ customRules })` passthrough** (still supported, and composes with this method if you use both): `runInPage`/`applicability` must reach the page as a function-source *string*, not a live `Function` — a WebdriverIO `browser.execute()` argument crosses a serialization boundary that can't carry a live function reference, only a string `@surea11y/core` can reconstruct with `new Function` on the page side. Passing a raw live function via `.options()` directly would silently fail to serialize; `.withCustomRules()` calls `.toString()` on a live function for you (patching the ES6 method-shorthand `.toString()` quirk automatically), so you can write a normal function and not have to remember that constraint yourself. A string is still accepted as-is if you already have one.
209
304
 
305
+ A custom rule you turn off for one scan (`.withCustomRules(rules).disableRules('org-banner-text')`) is listed in `skippedCustomRules` with `excluded: true` and isn't a scan gap, so it isn't warned about (core 1.12.0 and later).
306
+
210
307
  Invalid input (a missing/empty `id`, or a `runInPage`/`applicability` that's neither a function nor a non-empty string) throws immediately from `.withCustomRules()` itself, rather than surfacing later as a skipped rule deep inside the page — easier to catch during development. (Note: a *raw* `.options({ customRules })` call bypasses this check entirely and defers to `@surea11y/core`'s own engine-side behavior, which skips an invalid descriptor rather than throwing. The result lists each skipped rule in `skippedCustomRules`, and `analyze()` warns about it — see below.)
211
308
 
212
309
  ### When a scan can't run as asked
@@ -234,11 +331,39 @@ try {
234
331
  Two other cases don't throw but leave part of the page unchecked, so a result that reports nothing failing is not a clean page:
235
332
 
236
333
  - **An `.include()` selector that matches nothing.** Since core 1.10.0 nothing is then scanned (it used to fall back to the whole page): every rule reports `notApplicable`, and `results.contextMatch` is `{ elementCount: 0, unmatchedSelectors: [...] }`. With several selectors, one that matches nothing is named there while the others are scanned.
237
- - **A custom rule that did not run**, listed with its reason in `results.skippedCustomRules`.
334
+ - **A custom rule that did not run**, listed with its reason in `results.skippedCustomRules`. One you excluded yourself with `.disableRules()` is listed too, with `excluded: true`, and isn't a gap.
238
335
  - **A pack that did not run**, listed with its reason in `results.skippedPacks` (see [Scanning with packs](#scanning-with-packs-standards-checklists-and-your-own-rules)).
239
336
 
240
337
  `analyze()` prints each of these with `console.warn`, and `getScanGaps(results)` returns them as `[{ kind, message, ... }]` (`kind` is `context-not-found`, `context-partly-not-found`, `custom-rule-skipped` or `pack-skipped`), so a gate can fail on them, as the E2E example above does. `formatFailures(results)` lists them too.
241
338
 
339
+ That warning is the right default for a test run. A project that already handles gaps itself can take them over with `.logger(fn)`: `fn(gap, result)` is called once per gap, with the gap as `getScanGaps()` gives it and the result of the frame it came from, instead of `console.warn`. A scanning service that shows gaps to its users, say, can send them once to its own structured log, rather than also printing a loose line on the server's console for every scan:
340
+
341
+ ```js
342
+ const results = await new A11yCoreBuilder({ browser })
343
+ .include('#checkout')
344
+ .logger((gap, frame) => log.warn({ url: frame.url, kind: gap.kind }, gap.message))
345
+ .analyze();
346
+ ```
347
+
348
+ A batch job that writes `getScanGaps(results)` into its own report can pass `.logger(() => {})` to keep its output to the report, and `.logger(null)` brings `console.warn` back. Anything other than a function or `null` throws a `TypeError` at the call, and a later call replaces the logger. Either way, `getScanGaps(results)` still lists every gap.
349
+
350
+ `getScanNotes(results)` says how the page was scanned, which a gap doesn't cover, and nothing was left out: `'forced-colors'` when the page was shown in [forced colors](#forced-colors), and `'internals-hook'` when the scan asked about web components it could have read with the [internals hook](#web-components-the-internals-hook). `analyze()` also prints the internals note, once per scan, and a `.logger()` receives it (check `kind` to tell a note from a gap).
351
+
352
+ ### Checking against EN 301 549
353
+
354
+ EN 301 549, the standard the European Accessibility Act refers to, restates WCAG's Level A and AA criteria in its chapter 9. `.options({ profile })` runs the rules for its version, and each finding carries its EN 301 549 clause beside its WCAG criterion, in `meta.normativeMappings` (WCAG 1.1.1 is clause 9.1.1.1):
355
+
356
+ ```js
357
+ // EN 301 549 V4.1.1 (WCAG 2.2 A and AA); 'en301549-v3.2.1' for V3.2.1 (WCAG 2.1 A and AA)
358
+ const results = await new A11yCoreBuilder({ browser })
359
+ .options({ profile: 'en301549-v4.1.1' })
360
+ .analyze();
361
+
362
+ results.engine.profile; // 'en301549-v4.1.1'
363
+ ```
364
+
365
+ `.withWcag()`, `.withRules()` and `.withTags()` select rules instead of a profile, so leave them out when scanning with one. As with any target, a scan with no failures means no rule found a violation, not that the page conforms: most criteria need a person to check them. A profile name core doesn't know is not an error: the scan runs without it, core warns in the page's console, and `results.engine.profile` is left out.
366
+
242
367
  ### Scanning with packs: standards, checklists and your own rules
243
368
 
244
369
  A pack brings rules, variants of core's rules, a standard or a checklist, and their profiles and messages, from a package of its own: a national standard such as RGAA, or your organisation's accessibility policy. `@surea11y/core` runs a pack as it runs its own rules and standards, so its results, rollups and report entries look like core's. Packs need `@surea11y/core` 1.11.1 or later.
@@ -274,7 +399,23 @@ An element inside a shadow tree is different: its `selector` holds only inside i
274
399
 
275
400
  ## TypeScript
276
401
 
277
- `src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships types for the whole builder API plus `@surea11y/core`'s native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.). The result shapes are built on the types `@surea11y/core` ships itself, so fields such as `engine.version`, `engine.environment`, `contextMatch`, `skippedCustomRules`, `margin` and `shadowHostSelectors` follow the engine; this package adds `occurrence.element` and the `.frames(true)` shape. `EngineError`, `EngineErrorCode` and `ScanGap` come from `@surea11y/binding-base`, and so do the types of `withWcag()` (`WcagVersion`, `WcagLevel`) and `withPacks()`, which takes a pack made with `definePack` (`PackLike`). Code that handles every `ScanGap` kind it knows and treats the rest in an `else` branch needs a branch for `'pack-skipped'`, which carries `pack` rather than `selectors`. `analyze()` is typed `Promise<A11yCoreResult | A11yCoreMultiFrameResult>` — narrow on `'topFrame' in results` (or cast, if you already know which mode you called) to get the specific shape back, since a fluent builder can't statically track that `.frames(true)` was called earlier in the chain. `occurrence.element` is typed `WebdriverIO.Element | null`. `webdriverio` is a `peerDependencies` entry (not just `devDependencies`) since the class's `browser` argument and `Occurrence#element` both come from its global `WebdriverIO` type namespace — consumers need their own `webdriverio` install for the types to resolve, same as they already do to construct a `browser` in the first place.
402
+ `src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships types for the whole builder API plus `@surea11y/core`'s native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.). The result shapes are built on the types `@surea11y/core` ships itself, so fields such as `engine.version`, `engine.environment`, `contextMatch`, `skippedCustomRules`, `margin` and `shadowHostSelectors` follow the engine; this package adds `occurrence.element` and the `.frames(true)` shape. `EngineError`, `EngineErrorCode`, `ScanGap` and `ScanNote` come from `@surea11y/binding-base`, and so do the types of `withWcag()` and `wcagLevel()` (`WcagVersion`, `WcagLevelInput`), `plannedChecks()`/`plannedCriteria()` (`PlannedCheck`, `PlannedCriterion`), and `withPacks()`, which takes a pack made with `definePack` (`PackLike`). The static `installInternalsHook()` and `getScanNotes()` are typed too. Code that handles every `ScanGap` kind it knows and treats the rest in an `else` branch needs a branch for `'pack-skipped'`, which carries `pack` rather than `selectors`. `analyze()` is typed `Promise<A11yCoreResult | A11yCoreMultiFrameResult>` — narrow on `'topFrame' in results` (or cast, if you already know which mode you called) to get the specific shape back, since a fluent builder can't statically track that `.frames(true)` was called earlier in the chain. `occurrence.element` is typed `WebdriverIO.Element | null`. `webdriverio` is a `peerDependencies` entry (not just `devDependencies`) since the class's `browser` argument and `Occurrence#element` both come from its global `WebdriverIO` type namespace — consumers need their own `webdriverio` install for the types to resolve, same as they already do to construct a `browser` in the first place.
403
+
404
+ ## What changes with `@surea11y/core` 1.12
405
+
406
+ This release needs `@surea11y/core` 1.12.0. What a scan through this binding shows differently (core's [`CHANGELOG.md`](https://github.com/SureA11y/core/blob/main/CHANGELOG.md) has the full list):
407
+
408
+ - **An empty heading now fails an unscoped scan.** `empty-heading` used to ask about a heading with no accessible name; it now fails it. It's a best practice, so it runs in a scan that selects nothing and with `.withBestPractices()`, not under `.withWcag()`, tags or a profile. A gate on a page with `<h2></h2>` that asserted no failures now fails: fix the heading, or scan a WCAG target.
409
+ - **Scans that pick rules report their criteria.** `.withRules('img-alt-present')`, a non-WCAG tag or `.withBestPractices()` now fill `rulesResults` with the WCAG criteria those rules belong to, where it was empty. A criterion whose other rules weren't selected is `cantTell` (`reasonCode` `composite.rollup.cantTell.notSelectedChild`), and each check's `rollupIds` names the criteria it was added for. `.wcagLevel()` keeps those criteria to a level.
410
+ - **Four rules count towards more criteria**, as W3C's ACT rules map them: `input-image-alt-present` adds 4.1.2, `aria-roles-valid` 1.3.1, `link-name-present` 2.4.9 (AAA), and `meta-refresh-timing-absent` 2.2.4 and 3.2.5 (AAA). The findings don't change; the 1.3.1 and 4.1.2 rollups can now fail where they passed.
411
+ - **Unnamed custom elements are questions.** A defined custom element with no visible name may name itself through `ElementInternals`, so it's `cantTell` (`name_internals`) where it used to fail. [`installInternalsHook()`](#web-components-the-internals-hook) lets the engine decide, where the session allows it.
412
+ - **`engine.wcagLevel`** records the level a scan was gated by, and [`.wcagLevel()`](#composing-the-rule-filters) sets it.
413
+ - **`engine.environment.forcedColors`** records whether the page was in [forced colors](#forced-colors).
414
+ - **A custom rule you exclude** isn't a scan gap any more, so `.disableRules()` of one prints no warning.
415
+ - **Rule IDs in another case select their rule**: `.withRules('IMG-ALT-PRESENT')` used to throw `INVALID_RUN_ONLY` or be ignored.
416
+ - **`runOnly.optInTags` on its own throws** `INVALID_RUN_ONLY`; it was accepted and did nothing. Opt-in rules are unlocked with `.options({ optInRules })`.
417
+ - **A finding in a shadow tree is located through its hosts**, as `#card >>> img`, in core's HTML report, SARIF, JUnit and baselines, as `formatFailures()` already did.
418
+ - **An inline `policyContract`** with an unknown value in `allowedOutcomes` or `allowedConfidence` drops that value with a warning, where it turned every check into `cantTell`.
278
419
 
279
420
  ## What changes with `@surea11y/core` 1.11
280
421
 
@@ -288,7 +429,7 @@ The binding passes rule IDs, tags and options through to the engine, so these sh
288
429
 
289
430
  ## Relationship to `@surea11y/playwright` and `@surea11y/puppeteer`
290
431
 
291
- This binding's builder API is deliberately the same shape as [`@surea11y/playwright`](https://github.com/SureA11y/playwright)'s and `@surea11y/puppeteer`'s — same method names, same mutability contract, same result shapes — so the accessibility-gate logic reads almost identically across all three. The state-accumulation half of the builder (`include`/`exclude`/`withTags`/`disableTags`/`withRules`/`disableRules`/`withWcag`/`withBestPractices`/`options`/`withCustomRules`/`withPacks`/`reportOnly`) is not copied across bindings — `A11yCoreBuilder` here extends `A11yCoreBuilderBase` from [`@surea11y/binding-base`](https://github.com/SureA11y/binding-base), the single shared implementation every sibling binding depends on.
432
+ This binding's builder API is deliberately the same shape as [`@surea11y/playwright`](https://github.com/SureA11y/playwright)'s and `@surea11y/puppeteer`'s — same method names, same mutability contract, same result shapes — so the accessibility-gate logic reads almost identically across all three. The state-accumulation half of the builder (`include`/`exclude`/`withTags`/`disableTags`/`withRules`/`disableRules`/`withWcag`/`withBestPractices`/`includeMode`/`wcagLevel`/`logger`/`options`/`withCustomRules`/`withPacks`/`reportOnly`) is not copied across bindings — `A11yCoreBuilder` here extends `A11yCoreBuilderBase` from [`@surea11y/binding-base`](https://github.com/SureA11y/binding-base), the single shared implementation every sibling binding depends on.
292
433
 
293
434
  Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
294
435
 
package/package.json CHANGED
@@ -1,19 +1,26 @@
1
1
  {
2
2
  "name": "@surea11y/webdriverio",
3
- "version": "1.3.0",
4
- "description": "WebdriverIO binding for surea11y -- scan real, live-rendered pages for accessibility issues.",
3
+ "version": "1.4.0",
4
+ "description": "Accessibility (a11y, WCAG) checks for WebdriverIO tests: scan the live page with new A11yCoreBuilder({ browser }).analyze() and get only proven violations, with findings that need human review listed separately.",
5
5
  "keywords": [
6
6
  "accessibility",
7
7
  "a11y",
8
+ "a11y-testing",
8
9
  "wcag",
10
+ "wcag21",
9
11
  "wcag22",
12
+ "en301549",
13
+ "en-301-549",
14
+ "european-accessibility-act",
10
15
  "accessibility-testing",
11
16
  "webdriverio",
12
17
  "wdio",
13
18
  "webdriver",
14
19
  "e2e",
15
20
  "e2e-testing",
21
+ "end-to-end",
16
22
  "audit",
23
+ "aria",
17
24
  "surea11y"
18
25
  ],
19
26
  "main": "src/index.js",
@@ -40,13 +47,14 @@
40
47
  "LICENSE"
41
48
  ],
42
49
  "dependencies": {
43
- "@surea11y/binding-base": "^1.3.1",
44
- "@surea11y/core": "^1.11.1"
50
+ "@surea11y/binding-base": "^1.4.0"
45
51
  },
46
52
  "peerDependencies": {
53
+ "@surea11y/core": ">=1.12.0",
47
54
  "webdriverio": ">=8.0.0"
48
55
  },
49
56
  "devDependencies": {
57
+ "@surea11y/core": "^1.12.0",
50
58
  "@wdio/cli": "^9.32.0",
51
59
  "@wdio/local-runner": "^9.32.0",
52
60
  "@wdio/mocha-framework": "^9.32.0",
@@ -10,7 +10,7 @@ import type {} from 'webdriverio';
10
10
  // { topFrame, frames } shape of .frames(true). The names below are the ones
11
11
  // this file has always exported.
12
12
  import type * as Core from '@surea11y/core';
13
- import type { PackLike, WcagVersion, WcagLevel } from '@surea11y/binding-base';
13
+ import type { PackLike, WcagVersion, WcagLevelInput, ScanGap, ScanNote, PlannedCheck, PlannedCriterion } from '@surea11y/binding-base';
14
14
  export type { PackLike, WcagVersion, WcagLevel } from '@surea11y/binding-base';
15
15
 
16
16
  export type Outcome = Core.Outcome;
@@ -113,6 +113,15 @@ export class A11yCoreBuilder {
113
113
  */
114
114
  constructor(opts: { browser: WebdriverIO.Browser; url?: string });
115
115
 
116
+ /**
117
+ * Installs @surea11y/core's opt-in internals hook, so a scan can read
118
+ * names, states and roles web components set through a private
119
+ * ElementInternals. Call it before browser.url(). Uses addInitScript()
120
+ * in a BiDi session, else the DevTools protocol on Chrome and Edge, else
121
+ * rejects. Resolves to the way it was installed.
122
+ */
123
+ static installInternalsHook(browser: WebdriverIO.Browser): Promise<'bidi' | 'cdp'>;
124
+
116
125
  /**
117
126
  * Scope the scan to one region. Call multiple times for a multi-region
118
127
  * union. A selector that matches nothing scans nothing (see
@@ -144,9 +153,28 @@ export class A11yCoreBuilder {
144
153
  * runs those of WCAG 2.2's A and AA criteria. withTags()/withRules() add
145
154
  * to them. A later call replaces the target.
146
155
  */
147
- withWcag(version: WcagVersion | 2.0 | 2.1 | 2.2, level?: WcagLevel): this;
156
+ withWcag(version: WcagVersion | 2.0 | 2.1 | 2.2, level?: WcagLevelInput): this;
148
157
  /** Also run the best-practice rules, those that name no WCAG criterion. */
149
158
  withBestPractices(enabled?: boolean): this;
159
+ /**
160
+ * How withRules() and withTags() combine when both are given: 'and' (the
161
+ * default) runs the rules matching both, 'or' those matching either. A
162
+ * later call replaces the mode.
163
+ */
164
+ includeMode(mode: 'and' | 'or'): this;
165
+ /**
166
+ * The WCAG level of the criteria the scan reports, without changing which
167
+ * rules run: `.withRules('contrast-computable').wcagLevel('AA')` reports
168
+ * 1.4.3 and not 1.4.6. Any case; null clears it. A later call replaces it.
169
+ */
170
+ wcagLevel(level: WcagLevelInput | null): this;
171
+ /**
172
+ * Where analyze() sends each scan gap instead of console.warn: called once
173
+ * per gap, with the result of the frame it came from, and once per scan
174
+ * for the internals-hook note (tell them apart by `kind`). `() => {}`
175
+ * silences them, `null` goes back to console.warn. A later call replaces it.
176
+ */
177
+ logger(fn: ((entry: ScanGap | ScanNote, result: A11yCoreResult) => void) | null): this;
150
178
  /** Merge arbitrary engineOptions (locale, contrast.mode, policyContract, ...). */
151
179
  options(partialEngineOptions: Record<string, unknown>): this;
152
180
  /** Register one or more custom rules for just this scan. Call multiple times to accumulate. */
@@ -162,6 +190,10 @@ export class A11yCoreBuilder {
162
190
  frames(enabled?: boolean): this;
163
191
  /** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
164
192
  elementRef(enabled?: boolean): this;
193
+ /** The rules this scan will run, worked out in Node with no page (@surea11y/core 1.12.0 and later). */
194
+ plannedChecks(): PlannedCheck[];
195
+ /** The criteria this scan will report in rulesResults, worked out in Node with no page. */
196
+ plannedCriteria(): PlannedCriterion[];
165
197
 
166
198
  /**
167
199
  * Runs the scan. Returns { topFrame, frames } instead of a single result
@@ -1,12 +1,11 @@
1
1
  'use strict';
2
2
 
3
- const { runa11yCoreInPage } = require('@surea11y/core');
3
+ const { runa11yCoreInPage, internalsHookScript } = require('@surea11y/core');
4
4
  const {
5
5
  A11yCoreBuilderBase,
6
6
  createInPageScan,
7
7
  rethrowEngineError,
8
- queryOccurrenceElement,
9
- getScanGaps
8
+ queryOccurrenceElement
10
9
  } = require('@surea11y/binding-base');
11
10
 
12
11
  // core's runa11yCoreInPage, wrapped so an engine error (INVALID_RUN_ONLY,
@@ -146,6 +145,47 @@ const inPageScan = createInPageScan(runa11yCoreInPage);
146
145
  * exception: each call replaces the previous value rather than merging with it.
147
146
  */
148
147
  class A11yCoreBuilder extends A11yCoreBuilderBase {
148
+ /**
149
+ * Installs @surea11y/core's opt-in internals hook, so a scan can read the
150
+ * names, states and roles web components set through a private
151
+ * ElementInternals. Call it before browser.url(). It uses:
152
+ *
153
+ * - browser.addInitScript() when the session speaks WebDriver BiDi
154
+ * (WebdriverIO 9's default);
155
+ * - otherwise, on Chrome and Edge, the DevTools protocol through
156
+ * chromedriver (Page.addScriptToEvaluateOnNewDocument, sent with
157
+ * browser.sendCommandAndGetResult()), as in a WebdriverIO 8 or
158
+ * classic session.
159
+ *
160
+ * Both run the script in every frame of the page, before its scripts, and
161
+ * after each navigation. Any other session gets an error saying it has no
162
+ * way to run a script before the page loads. Resolves to the way it was
163
+ * installed, 'bidi' or 'cdp'.
164
+ * @param {WebdriverIO.Browser} browser
165
+ * @returns {Promise<'bidi' | 'cdp'>}
166
+ */
167
+ static async installInternalsHook(browser) {
168
+ if (!browser || typeof browser.execute !== 'function') {
169
+ throw new TypeError('A11yCoreBuilder.installInternalsHook() requires a WebdriverIO browser.');
170
+ }
171
+ const source = internalsHookScript();
172
+ if (browser.isBidi && typeof browser.addInitScript === 'function') {
173
+ // addInitScript() takes a function, and sends its source as a BiDi
174
+ // preload script.
175
+ await browser.addInitScript(new Function(source));
176
+ return 'bidi';
177
+ }
178
+ if (typeof browser.sendCommandAndGetResult === 'function') {
179
+ await browser.sendCommandAndGetResult('Page.addScriptToEvaluateOnNewDocument', { source });
180
+ return 'cdp';
181
+ }
182
+ throw new Error(
183
+ 'A11yCoreBuilder.installInternalsHook(): this session has no way to run a script before the page loads. ' +
184
+ 'Use a WebDriver BiDi session (WebdriverIO 9\'s default, in Chrome, Edge or Firefox), or Chrome or Edge, ' +
185
+ 'whose DevTools protocol can.'
186
+ );
187
+ }
188
+
149
189
  /**
150
190
  * @param {{ browser: import('webdriverio').Browser, url?: string }} opts
151
191
  * `browser` must already be navigated to and settled at the URL to scan --
@@ -202,7 +242,7 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
202
242
  const result = rethrowEngineError(
203
243
  await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
204
244
  );
205
- this._warnScanGaps(result);
245
+ this._warnScanGaps(result, '@surea11y/webdriverio');
206
246
  return this._elementRef ? this._attachElementRefs(result) : result;
207
247
  };
208
248
 
@@ -299,26 +339,6 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
299
339
  }
300
340
  }
301
341
 
302
- /**
303
- * Prints what the scan left out (getScanGaps(): an include() scope that
304
- * matched nothing, a custom rule or a pack that did not run) with console.warn.
305
- * @surea11y/core warns about these too, but in the page's console, which a
306
- * WebdriverIO run does not show; a result whose checksResults alone look
307
- * clean would otherwise pass without a word.
308
- */
309
- _warnScanGaps(result) {
310
- let gaps;
311
- try {
312
- gaps = getScanGaps(result);
313
- } catch (e) {
314
- return; // not a scan result; the caller sees it as it is
315
- }
316
- const where = result.url ? ` (${result.url})` : '';
317
- for (const gap of gaps) {
318
- console.warn(`@surea11y/webdriverio${where}: ${gap.message}`);
319
- }
320
- }
321
-
322
342
  /**
323
343
  * Resolves each occurrence to a live WebdriverIO.Element, scoped to
324
344
  * whatever frame the browser is currently switched into (execute() is
package/src/index.d.ts CHANGED
@@ -3,6 +3,16 @@ export {
3
3
  EngineError,
4
4
  ENGINE_ERROR_CODES,
5
5
  getScanGaps,
6
+ getScanNotes,
6
7
  formatOccurrenceLocation
7
8
  } from '@surea11y/binding-base';
8
- export type { EngineErrorCode, ScanGap, ScanResultLike, OccurrenceLocation } from '@surea11y/binding-base';
9
+ export type {
10
+ EngineErrorCode,
11
+ ScanGap,
12
+ ScanNote,
13
+ ScanResultLike,
14
+ OccurrenceLocation,
15
+ PlannedCheck,
16
+ PlannedCriterion,
17
+ WcagLevelInput
18
+ } from '@surea11y/binding-base';
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ const {
6
6
  EngineError,
7
7
  ENGINE_ERROR_CODES,
8
8
  getScanGaps,
9
+ getScanNotes,
9
10
  formatOccurrenceLocation
10
11
  } = require('@surea11y/binding-base');
11
12
 
@@ -15,5 +16,6 @@ module.exports = {
15
16
  EngineError,
16
17
  ENGINE_ERROR_CODES,
17
18
  getScanGaps,
19
+ getScanNotes,
18
20
  formatOccurrenceLocation
19
21
  };