@surea11y/webdriverio 1.2.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
@@ -28,13 +32,13 @@ await browser.url('https://example.com/');
28
32
  const results = await new A11yCoreBuilder({ browser })
29
33
  .include('#main') // optional -- call multiple times for multi-region scans
30
34
  .exclude('.cookie-banner') // optional
31
- .withTags(['wcag2a', 'wcag2aa'])
35
+ .withWcag('2.2', 'AA') // the rules for WCAG 2.2's A and AA criteria
32
36
  .disableRules(['meta-refresh-no-exceptions'])
33
37
  .options({ contrast: { mode: 'auditorAssist' } })
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.10.0"
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,7 +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
- `withTags()`/`disableRules()` above have counterparts: `.withRules([...])` (only run these specific rule IDs) and `.disableTags([...])` (never run rules carrying any of these tags). All four compose the same way similar allow/deny-list options do in other accessibility testing tools, with one non-obvious rule worth knowing: a "disable" always wins over a "with" on the same ID/tag (e.g. `.withRules(['a']).disableRules(['a'])` drops `'a'` entirely), and combining `.withRules()` **and** `.withTags()` together requires a rule to satisfy *both* (`@surea11y/core`'s default `includeMode: 'and'`), not either one.
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.
85
+
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
+ ```
54
95
 
55
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.
56
97
 
@@ -58,7 +99,7 @@ Rule IDs change rarely, but core 1.10.0 changed two: `label-title-only` is depre
58
99
 
59
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.
60
101
 
61
- **Create one builder per scan.** `A11yCoreBuilder` is a mutable object with no reset between `.analyze()` calls — `include()`/`exclude()`/`withRules()`/`disableRules()`/`withTags()`/`disableTags()`/`options()`/`withCustomRules()` all push onto or merge into internal state that persists for the instance's lifetime. Calling one of them again before a second `.analyze()` call *accumulates* on top of the first scan's scope rather than replacing it (this is exactly what makes "call `.include()` several times for one scan," above, work — the same accumulation just also applies across separate scans if you reuse an instance). `.reportOnly()`/`.frames()`/`.elementRef()` are the exception: each call replaces the previous value instead of merging with it.
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.
62
103
 
63
104
  This binding works against any browser WebdriverIO can drive; the tests and examples use headless Chrome via `chromedriver`, which WebdriverIO manages automatically.
64
105
 
@@ -77,14 +118,14 @@ describe('accessibility gate', () => {
77
118
  const results = await new A11yCoreBuilder({ browser }).reportOnly(['fail']).analyze();
78
119
 
79
120
  assert.strictEqual(results.checksResults.length, 0, formatFailures(results));
80
- // An include() scope that matched nothing, or a custom rule that did not
81
- // run, leaves no failure behind: check for them too.
121
+ // An include() scope that matched nothing, or a custom rule or a pack
122
+ // that did not run, leaves no failure behind: check for them too.
82
123
  assert.deepStrictEqual(getScanGaps(results), [], formatFailures(results));
83
124
  });
84
125
  });
85
126
  ```
86
127
 
87
- See `examples/e2e-test-example.test.js` for a fuller, runnable version (`npm run example:e2e`), driven by `examples/wdio.conf.js` — one test proving real violations get caught (unlabeled button, missing `alt`), one proving a well-formed page passes cleanly. It uses the **WebdriverIO testrunner** (`@wdio/cli` + mocha), which is how real WebdriverIO suites are written. This project's own *unit* suite (`tests/*.test.js`) uses `node:test` + `remote()`, the right tool for exercising the builder API in isolation.
128
+ See `examples/e2e-test-example.test.js` for a fuller, runnable version (`npm run example:e2e`), driven by `examples/wdio.conf.js` — one test proving real violations get caught (unlabeled button, missing `alt`), one proving a well-formed page passes cleanly, and one gating a page on a pack's checklist (see [Scanning with packs](#scanning-with-packs-standards-checklists-and-your-own-rules)). It uses the **WebdriverIO testrunner** (`@wdio/cli` + mocha), which is how real WebdriverIO suites are written. This project's own *unit* suite (`tests/*.test.js`) uses `node:test` + `remote()`, the right tool for exercising the builder API in isolation.
88
129
 
89
130
  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.
90
131
 
@@ -95,14 +136,16 @@ A bare length/equality assertion alone gets you a *working* gate, but the failur
95
136
  ```
96
137
  1) button-name-present (serious): This button has no accessible name.
97
138
  at html > body > button
98
- Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.
99
- 2) img-alt-present (serious): Missing alt attribute on <img>.
139
+ Give the button visible text, or name it with aria-label, so screen reader users know what it does.
140
+ 2) img-alt-present (serious): This <img> has no alt attribute.
100
141
  at #host >>> p > img
101
- Add an alt attribute (use alt="" only for decorative images).
142
+ Add an alt attribute that says what the image shows, or alt="" if the image is purely decorative.
102
143
 
103
- Scanned with @surea11y/core 1.10.0.
144
+ Scanned with @surea11y/core 1.12.0.
104
145
  ```
105
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
+
106
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.
107
150
 
108
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.
@@ -122,7 +165,17 @@ for (const frame of results.frames) {
122
165
 
123
166
  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.
124
167
 
125
- **`.include()` scopes the top frame only.** Each sub-frame is scanned whole, as `@surea11y/core`'s own `runa11yCoreAcrossFrames` does: the selectors belong to the top document, and since core 1.10.0 a selector that matches nothing scans nothing, so passing them down would leave every frame without them unscanned. `.exclude()` and the rule filters apply in every frame. An `INVALID_RUN_ONLY` or `INVALID_CONTEXT_SELECTOR` error rejects `analyze()` from the top frame, before any sub-frame is scanned; a sub-frame that fails for another reason is listed in `results.frames` as `{ url, error }`.
168
+ **`.include()` scopes the top frame only.** Each sub-frame is scanned whole, as `@surea11y/core`'s own `runa11yCoreAcrossFrames` does: the selectors belong to the top document, and since core 1.10.0 a selector that matches nothing scans nothing, so passing them down would leave every frame without them unscanned. `.exclude()` and the rule filters apply in every frame. An `EngineError` (see [When a scan can't run as asked](#when-a-scan-cant-run-as-asked)) rejects `analyze()` from the top frame, before any sub-frame is scanned; a sub-frame that fails for another reason is listed in `results.frames` as `{ url, error }`.
169
+
170
+ `@surea11y/core`'s reporters take this result as it is (core 1.11.1 and later): `renderSarifReport(results)`, `renderJunitReport(results)`, `renderHtmlReport(results)` and the baseline functions report every frame's findings, and a frame that couldn't be scanned as not scanned, so it never reads as a pass.
171
+
172
+ ```js
173
+ const fs = require('node:fs');
174
+ const { renderSarifReport } = require('@surea11y/core/sarif');
175
+
176
+ const results = await new A11yCoreBuilder({ browser }).frames(true).analyze();
177
+ fs.writeFileSync('a11y.sarif', renderSarifReport(results));
178
+ ```
126
179
 
127
180
  Verified against a real cross-origin page (`https://example.org/` embedded in an unrelated origin) and against a real nested-iframe page (top → child → grandchild) — see `tests/builder.test.js`. Default off, so plain `.analyze()` is unaffected unless you opt in. Unlike script-injection-based accessibility tools (which need a `postMessage`-based protocol to reach cross-origin iframes, since they're injected as a plain `<script>` subject to the same-origin policy), this needs no extra `@surea11y/core` engine support at all.
128
181
 
@@ -140,6 +193,8 @@ console.log(results.checksResults); // only fail/cantTell entries, pass/notAppli
140
193
 
141
194
  Valid outcome values are `'pass'`, `'fail'`, `'cantTell'`, `'notApplicable'`. This is pure binding-layer filtering — the engine itself still computes every rule; nothing about the scan itself changes. Combines with `.frames(true)`: the filter is applied to `results.topFrame` and each entry of `results.frames` independently.
142
195
 
196
+ A result trimmed this way is no longer the whole scan to `@surea11y/core`'s reporters: JUnit counts one test per rule it finds, and EARL makes one assertion per rule. To keep a result small and still hand it to them, ask the engine for a compact one instead, with `.options({ output: { detail: 'findings' } })`: a rule that passed or did not apply keeps only its outcome, and every reporter reads it as the full result. An empty page's result goes from about 238 KB to 82 KB.
197
+
143
198
  ### Getting a live element, not just a selector string
144
199
 
145
200
  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)`:
@@ -161,6 +216,60 @@ Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(tru
161
216
 
162
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.
163
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
+
164
273
  ### Registering a custom rule at runtime
165
274
 
166
275
  `@surea11y/core` supports registering additional rules per-scan via `engineOptions.customRules`. Use `.withCustomRules()` to register one:
@@ -193,6 +302,8 @@ const results = await new A11yCoreBuilder({ browser })
193
302
 
194
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.
195
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
+
196
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.)
197
308
 
198
309
  ### When a scan can't run as asked
@@ -201,6 +312,8 @@ Invalid input (a missing/empty `id`, or a `runInPage`/`applicability` that's nei
201
312
 
202
313
  - `INVALID_RUN_ONLY`: none of the rule IDs, or none of the tags, given to `.withRules()`/`.withTags()` is one the engine knows.
203
314
  - `INVALID_CONTEXT_SELECTOR`: an `.include()` selector is not valid CSS; `error.selector` names it.
315
+ - `INVALID_ENGINE_OPTIONS`: with `.options({ strictOptions: true })`, an option the engine doesn't read or a value it can't use. The message names each, with the closest known option (`lcoale`: did you mean `locale`?). Without `strictOptions`, a likely typo is only warned about in the page's console.
316
+ - `PAGE_BUILTINS_BROKEN`: the page's own scripts broke a JavaScript built-in the engine relies on (`Array.prototype.filter`, `Map`, `JSON.stringify`, ...), so no result it gave could be trusted. The message names each one.
204
317
 
205
318
  ```js
206
319
  const { A11yCoreBuilder, EngineError } = require('@surea11y/webdriverio');
@@ -218,9 +331,65 @@ try {
218
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:
219
332
 
220
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.
221
- - **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.
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)).
336
+
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.
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:
222
340
 
223
- `analyze()` prints each of these with `console.warn`, and `getScanGaps(results)` returns them as `[{ kind, message, ... }]` (`kind` is `context-not-found`, `context-partly-not-found` or `custom-rule-skipped`), so a gate can fail on them, as the E2E example above does. `formatFailures(results)` lists them too.
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
+
367
+ ### Scanning with packs: standards, checklists and your own rules
368
+
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.
370
+
371
+ `.withPacks()` registers packs in the page with `browser.execute()`, and in every frame with `.frames(true)`, before the scan; a pack's profile is chosen with `.options({ profile })`:
372
+
373
+ ```js
374
+ const acmePolicy = require('@acme/a11y-policy'); // a pack
375
+
376
+ const results = await new A11yCoreBuilder({ browser })
377
+ .withPacks(acmePolicy)
378
+ .options({ profile: 'acme-web-2026' })
379
+ .analyze();
380
+
381
+ results.engine.packs; // ['@acme/a11y-policy@1.0.0']
382
+ results.engine.profile; // 'acme-web-2026'
383
+
384
+ // A checklist's items, each one result, beside core's WCAG rollups:
385
+ results.rulesResults.filter((r) => r.meta && r.meta.standard === 'Acme web policy');
386
+ ```
387
+
388
+ A pack's own rules appear in `checksResults` like core's, with ids that start with its namespace (`acme-no-autoplay`). Without its profile, a pack's rules run only when selected, with `.withRules()` or by a tag. `@surea11y/core`'s HTML, SARIF and JUnit reports show a standard's or a checklist's items in a section of their own. Pass an array, or call `.withPacks()` again, to scan with several packs.
389
+
390
+ **Writing a pack.** A pack is an object made with `definePack` from `@surea11y/core/pack`. [`examples/acme-policy-pack.js`](./examples/acme-policy-pack.js) is a small one: a house rule, a checklist profile (WCAG 2.2 A and AA, `region`, and the house rule) and two checklist items. `npm run example:pack` scans a page with it, and [`examples/e2e-test-example.test.js`](./examples/e2e-test-example.test.js) uses it as a test gate. For a pack of your own, `npx surea11y-pack new <folder>` (from `@surea11y/core`) writes a package that works as generated, with its tests, docs and lint; core's [`PACKS.md`](https://github.com/SureA11y/core/blob/main/docs/PACKS.md) is the guide.
391
+
392
+ **When a pack doesn't run.** `.withPacks()` builds the page's script with the `@surea11y/core` this binding loads, and checks each pack as it does: `.analyze()` throws before scanning for a pack that core can't run, such as one whose `core` range leaves out the installed core, or whose profile names a rule that doesn't exist. A pack the page then doesn't run (one named with a raw `.options({ packs })` and never registered, or a page running another core version, when two copies of core are installed) is listed in `results.skippedPacks` with the reason. `getScanGaps()` reports that as `'pack-skipped'`, and `.analyze()` warns about it, so a test gate that asserts on `getScanGaps(results)` fails rather than passing with the pack's checks never run. Install the pack beside the binding, so npm resolves one core for both.
224
393
 
225
394
  ### Element addressing beyond a CSS selector
226
395
 
@@ -230,11 +399,37 @@ An element inside a shadow tree is different: its `selector` holds only inside i
230
399
 
231
400
  ## TypeScript
232
401
 
233
- `src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships types for the whole builder API plus `@surea11y/core`'s native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.). The result shapes are built on the types `@surea11y/core` ships itself, so fields such as `engine.version`, `engine.environment`, `contextMatch`, `skippedCustomRules`, `margin` and `shadowHostSelectors` follow the engine; this package adds `occurrence.element` and the `.frames(true)` shape. `EngineError`, `EngineErrorCode` and `ScanGap` come from `@surea11y/binding-base`. `analyze()` is typed `Promise<A11yCoreResult | A11yCoreMultiFrameResult>` — narrow on `'topFrame' in results` (or cast, if you already know which mode you called) to get the specific shape back, since a fluent builder can't statically track that `.frames(true)` was called earlier in the chain. `occurrence.element` is typed `WebdriverIO.Element | null`. `webdriverio` is a `peerDependencies` entry (not just `devDependencies`) since the class's `browser` argument and `Occurrence#element` both come from its global `WebdriverIO` type namespace — consumers need their own `webdriverio` install for the types to resolve, same as they already do to construct a `browser` in the first place.
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`.
419
+
420
+ ## What changes with `@surea11y/core` 1.11
421
+
422
+ The binding passes rule IDs, tags and options through to the engine, so these show in results without a change here (see core's [`CHANGELOG.md`](https://github.com/SureA11y/core/blob/main/CHANGELOG.md) for the full list):
423
+
424
+ - **Messages are reworded** in all five languages to say plainly what is wrong and what to do (`This <img> has no alt attribute.`). Message keys, rule IDs, reason codes and baselines are unchanged; a test that matches message text needs the new wording.
425
+ - **Contrast and target size measure what is on screen.** In a browser, the contrast rules check only text a reader can see, measure it against what is painted under it, and measure form field values and placeholders. `target-size-minimum` measures the area a pointer can hit, and exempts only links in a sentence. Expect new findings, and some old ones gone.
426
+ - **`aria-hidden-focus` maps to 4.1.2 only**, no longer to 2.4.7 Focus Visible, so `.withTags('wcag2aa')` alone no longer selects it.
427
+ - **A custom rule counts toward its WCAG criterion.** A `.withCustomRules()` rule with `meta.wcagSc` (or a WCAG `normativeMappings` entry) fails that criterion's rollup when it fails, and custom rules run after core's.
428
+ - **On/off options read as meant**: `.options({ perfStats: 'false' })` is off, where it was on. `.options({ strictOptions: true })` rejects any option the engine doesn't read (see [When a scan can't run as asked](#when-a-scan-cant-run-as-asked)).
234
429
 
235
430
  ## Relationship to `@surea11y/playwright` and `@surea11y/puppeteer`
236
431
 
237
- This binding's builder API is deliberately the same shape as [`@surea11y/playwright`](https://github.com/SureA11y/playwright)'s and `@surea11y/puppeteer`'s — same method names, same mutability contract, same result shapes — so the accessibility-gate logic reads almost identically across all three. The state-accumulation half of the builder (`include`/`exclude`/`withTags`/`disableTags`/`withRules`/`disableRules`/`options`/`withCustomRules`/`reportOnly`) is not copied across bindings — `A11yCoreBuilder` here extends `A11yCoreBuilderBase` from [`@surea11y/binding-base`](https://github.com/SureA11y/binding-base), the single shared implementation every sibling binding depends on.
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.
238
433
 
239
434
  Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
240
435
 
package/package.json CHANGED
@@ -1,19 +1,26 @@
1
1
  {
2
2
  "name": "@surea11y/webdriverio",
3
- "version": "1.2.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,23 +47,25 @@
40
47
  "LICENSE"
41
48
  ],
42
49
  "dependencies": {
43
- "@surea11y/binding-base": "^1.2.0",
44
- "@surea11y/core": "^1.10.0"
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": {
50
- "@wdio/cli": "^9.30.0",
51
- "@wdio/local-runner": "^9.30.0",
57
+ "@surea11y/core": "^1.12.0",
58
+ "@wdio/cli": "^9.32.0",
59
+ "@wdio/local-runner": "^9.32.0",
52
60
  "@wdio/mocha-framework": "^9.32.0",
53
- "@wdio/spec-reporter": "^9.29.1",
54
- "typescript": "^5.9.3",
55
- "webdriverio": "^9.0.0"
61
+ "@wdio/spec-reporter": "^9.32.0",
62
+ "typescript": "^7.0.2",
63
+ "webdriverio": "^9.32.0"
56
64
  },
57
65
  "scripts": {
58
66
  "test": "node --test --test-concurrency=1 tests/*.test.js",
59
67
  "example": "node examples/basic-scan.js",
60
- "example:e2e": "wdio run examples/wdio.conf.js"
68
+ "example:e2e": "wdio run examples/wdio.conf.js",
69
+ "example:pack": "node examples/scan-with-pack.js"
61
70
  }
62
71
  }
@@ -10,6 +10,8 @@ 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, WcagLevelInput, ScanGap, ScanNote, PlannedCheck, PlannedCriterion } from '@surea11y/binding-base';
14
+ export type { PackLike, WcagVersion, WcagLevel } from '@surea11y/binding-base';
13
15
 
14
16
  export type Outcome = Core.Outcome;
15
17
  export type OutcomeNormalized = Core.OutcomeNormalized;
@@ -111,6 +113,15 @@ export class A11yCoreBuilder {
111
113
  */
112
114
  constructor(opts: { browser: WebdriverIO.Browser; url?: string });
113
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
+
114
125
  /**
115
126
  * Scope the scan to one region. Call multiple times for a multi-region
116
127
  * union. A selector that matches nothing scans nothing (see
@@ -137,24 +148,62 @@ export class A11yCoreBuilder {
137
148
  withRules(ruleIds: string | string[]): this;
138
149
  /** Never run these specific rule IDs (applied after withRules). */
139
150
  disableRules(ruleIds: string | string[]): this;
151
+ /**
152
+ * Run the rules for a WCAG conformance target: `withWcag('2.2', 'AA')`
153
+ * runs those of WCAG 2.2's A and AA criteria. withTags()/withRules() add
154
+ * to them. A later call replaces the target.
155
+ */
156
+ withWcag(version: WcagVersion | 2.0 | 2.1 | 2.2, level?: WcagLevelInput): this;
157
+ /** Also run the best-practice rules, those that name no WCAG criterion. */
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;
140
178
  /** Merge arbitrary engineOptions (locale, contrast.mode, policyContract, ...). */
141
179
  options(partialEngineOptions: Record<string, unknown>): this;
142
180
  /** Register one or more custom rules for just this scan. Call multiple times to accumulate. */
143
181
  withCustomRules(rules: CustomRuleDescriptor | CustomRuleDescriptor[]): this;
182
+ /**
183
+ * Packs from `@surea11y/core/pack` (core 1.11 or later): registered in each
184
+ * frame before the scan, and named in `engineOptions.packs`.
185
+ */
186
+ withPacks(packs: PackLike | PackLike[]): this;
144
187
  /** Post-filter checksResults down to only the given outcomes. */
145
188
  reportOnly(outcomes: Outcome | Outcome[]): this;
146
189
  /** Opt in to also scanning every sub-frame on the page (including cross-origin and nested iframes). */
147
190
  frames(enabled?: boolean): this;
148
191
  /** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
149
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[];
150
197
 
151
198
  /**
152
199
  * Runs the scan. Returns { topFrame, frames } instead of a single result
153
200
  * when .frames(true) was used. Rejects with an `EngineError` (from
154
201
  * `@surea11y/binding-base`, re-exported here) whose `code` is
155
202
  * `INVALID_RUN_ONLY` when a rule or tag list names nothing @surea11y/core
156
- * knows, or `INVALID_CONTEXT_SELECTOR` (with `selector`) when an include()
157
- * selector does not parse.
203
+ * knows, `INVALID_CONTEXT_SELECTOR` (with `selector`) when an include()
204
+ * selector does not parse, `INVALID_ENGINE_OPTIONS` for an option
205
+ * `strictOptions: true` rejects, or `PAGE_BUILTINS_BROKEN` when the page's
206
+ * scripts broke a JavaScript built-in the engine relies on.
158
207
  */
159
208
  analyze(): Promise<A11yCoreResult | A11yCoreMultiFrameResult>;
160
209
  }
@@ -163,7 +212,7 @@ export class A11yCoreBuilder {
163
212
  * Formats a result, or its checksResults array, into a short, human-readable
164
213
  * block -- one entry per fail/cantTell occurrence, not per rule. Given the
165
214
  * whole result, it also lists what the scan left out (an include() scope
166
- * that matched nothing, a custom rule that did not run) and the
215
+ * that matched nothing, a custom rule or a pack that did not run) and the
167
216
  * @surea11y/core release that produced it. Meant for an assertion
168
217
  * library's failure-message parameter, e.g.
169
218
  * `assert.strictEqual(fails.length, 0, formatFailures(results))`.
@@ -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,
@@ -136,16 +135,57 @@ const inPageScan = createInPageScan(runa11yCoreInPage);
136
135
  *
137
136
  * Create one builder per scan. This is a mutable object with no reset
138
137
  * between analyze() calls: include()/exclude()/withRules()/disableRules()/
139
- * withTags()/disableTags()/options()/withCustomRules() all push onto or
138
+ * withTags()/disableTags()/options()/withCustomRules()/withPacks() all push onto or
140
139
  * merge into internal state that persists for the instance's lifetime, so
141
140
  * calling one of them again before a second analyze() call accumulates on
142
141
  * top of the first scan's scope rather than replacing it (intentional for
143
142
  * "call include() several times for one scan" -- see above -- but a footgun
144
143
  * if you hold one instance across multiple assertions).
145
- * reportOnly()/frames()/elementRef() are the exception: each call replaces
146
- * the previous value rather than merging with it.
144
+ * reportOnly()/frames()/elementRef()/withWcag()/withBestPractices() are the
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 --
@@ -192,12 +232,17 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
192
232
  // inPageScan takes the same four arguments as runa11yCoreInPage; an
193
233
  // engine error comes back as a plain object, which rethrowEngineError()
194
234
  // throws again here as an EngineError with its `code`.
235
+ // The script that registers withPacks()'s packs in a frame, which the
236
+ // scan names in engineOptions.packs; null without packs. execute() runs
237
+ // a string as a function body, so the script runs as written.
238
+ const packScript = this._packScript();
195
239
  const scanCurrent = async (scope) => {
196
240
  const frameUrl = this._url || (await this._browser.execute(() => document.location.href));
241
+ if (packScript) await this._browser.execute(packScript);
197
242
  const result = rethrowEngineError(
198
243
  await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
199
244
  );
200
- this._warnScanGaps(result);
245
+ this._warnScanGaps(result, '@surea11y/webdriverio');
201
246
  return this._elementRef ? this._attachElementRefs(result) : result;
202
247
  };
203
248
 
@@ -294,26 +339,6 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
294
339
  }
295
340
  }
296
341
 
297
- /**
298
- * Prints what the scan left out (getScanGaps(): an include() scope that
299
- * matched nothing, a custom rule that did not run) with console.warn.
300
- * @surea11y/core warns about these too, but in the page's console, which a
301
- * WebdriverIO run does not show; a result whose checksResults alone look
302
- * clean would otherwise pass without a word.
303
- */
304
- _warnScanGaps(result) {
305
- let gaps;
306
- try {
307
- gaps = getScanGaps(result);
308
- } catch (e) {
309
- return; // not a scan result; the caller sees it as it is
310
- }
311
- const where = result.url ? ` (${result.url})` : '';
312
- for (const gap of gaps) {
313
- console.warn(`@surea11y/webdriverio${where}: ${gap.message}`);
314
- }
315
- }
316
-
317
342
  /**
318
343
  * Resolves each occurrence to a live WebdriverIO.Element, scoped to
319
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
  };