@surea11y/webdriverio 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,7 @@ const results = await new A11yCoreBuilder({ browser })
34
34
  .analyze();
35
35
 
36
36
  console.log(results.checksResults.filter(r => r.outcome === 'fail'));
37
+ console.log(results.engine.version); // the @surea11y/core release that scanned, e.g. "1.10.0"
37
38
  await browser.deleteSession();
38
39
  ```
39
40
 
@@ -51,6 +52,10 @@ WebdriverIO v9 defaults to the WebDriver **BiDi** protocol. A long-lived session
51
52
 
52
53
  `withTags()`/`disableRules()` above have counterparts: `.withRules([...])` (only run these specific rule IDs) and `.disableTags([...])` (never run rules carrying any of these tags). All four compose the same way similar allow/deny-list options do in other accessibility testing tools, with one non-obvious rule worth knowing: a "disable" always wins over a "with" on the same ID/tag (e.g. `.withRules(['a']).disableRules(['a'])` drops `'a'` entirely), and combining `.withRules()` **and** `.withTags()` together requires a rule to satisfy *both* (`@surea11y/core`'s default `includeMode: 'and'`), not either one.
53
54
 
55
+ A rule or tag list that names nothing `@surea11y/core` knows makes `analyze()` reject with `code: 'INVALID_RUN_ONLY'` (see [When a scan can't run as asked](#when-a-scan-cant-run-as-asked)), so a typo such as `.withTags(['wcag2.2aa'])` can't run no rule at all and pass. An unknown name beside known ones is ignored, with a warning in the page's console. The four methods also throw a `TypeError` with the same `code` straight away for anything but a string or an array of strings, such as `undefined` from a missing config value.
56
+
57
+ Rule IDs change rarely, but core 1.10.0 changed two: `label-title-only` is deprecated (it reports `notApplicable`; every field it flagged, `form-control-programmatic-label-quality` flags too) and keeps resolving until 2.0.0, so a `.disableRules(['label-title-only'])` still works but no longer does anything; and `landmark-role-name-present` is new, a `best-practice` rule that asks about an element with `role="region"` or `role="form"` and no accessible name.
58
+
54
59
  `.exclude(selector)` above excludes globally. Pass a second argument to scope it to specific rule IDs instead: `.exclude('.mat-select', { rules: ['aria-required-children'] })` skips `.mat-select` for that rule only — every other rule still sees it. Global and rule-scoped `.exclude()` calls compose freely.
55
60
 
56
61
  **Create one builder per scan.** `A11yCoreBuilder` is a mutable object with no reset between `.analyze()` calls — `include()`/`exclude()`/`withRules()`/`disableRules()`/`withTags()`/`disableTags()`/`options()`/`withCustomRules()` all push onto or merge into internal state that persists for the instance's lifetime. Calling one of them again before a second `.analyze()` call *accumulates* on top of the first scan's scope rather than replacing it (this is exactly what makes "call `.include()` several times for one scan," above, work — the same accumulation just also applies across separate scans if you reuse an instance). `.reportOnly()`/`.frames()`/`.elementRef()` are the exception: each call replaces the previous value instead of merging with it.
@@ -63,7 +68,7 @@ The pattern above works unchanged inside a real WebdriverIO testrunner spec, whi
63
68
 
64
69
  ```js
65
70
  const assert = require('node:assert');
66
- const { A11yCoreBuilder, formatFailures } = require('@surea11y/webdriverio');
71
+ const { A11yCoreBuilder, formatFailures, getScanGaps } = require('@surea11y/webdriverio');
67
72
 
68
73
  describe('accessibility gate', () => {
69
74
  it('has no accessibility violations', async () => {
@@ -71,7 +76,10 @@ describe('accessibility gate', () => {
71
76
 
72
77
  const results = await new A11yCoreBuilder({ browser }).reportOnly(['fail']).analyze();
73
78
 
74
- assert.strictEqual(results.checksResults.length, 0, formatFailures(results.checksResults));
79
+ assert.strictEqual(results.checksResults.length, 0, formatFailures(results));
80
+ // An include() scope that matched nothing, or a custom rule that did not
81
+ // run, leaves no failure behind: check for them too.
82
+ assert.deepStrictEqual(getScanGaps(results), [], formatFailures(results));
75
83
  });
76
84
  });
77
85
  ```
@@ -82,18 +90,22 @@ Note the gate above uses `node:assert`, not WebdriverIO's bundled `expect`: Webd
82
90
 
83
91
  ### Readable console/CI output on failure
84
92
 
85
- A bare length/equality assertion alone gets you a *working* gate, but the failure message is a raw, deeply-nested object diff — hundreds of lines for a handful of violations. `formatFailures(checksResults)` turns that into a short, scannable block (one entry per occurrence, numbered, with rule ID/severity/selector/hint) that you hand to your assertion library's own failure-message parameter, as above. A real failure then prints:
93
+ A bare length/equality assertion alone gets you a *working* gate, but the failure message is a raw, deeply-nested object diff — hundreds of lines for a handful of violations. `formatFailures(results)` turns that into a short, scannable block (one entry per occurrence, numbered, with rule ID/severity/location/hint) that you hand to your assertion library's own failure-message parameter, as above. Given the whole result, it also says what the scan left out and which `@surea11y/core` release ran it. A real failure then prints:
86
94
 
87
95
  ```
88
96
  1) button-name-present (serious): This button has no accessible name.
89
97
  at html > body > button
90
98
  Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.
91
99
  2) img-alt-present (serious): Missing alt attribute on <img>.
92
- at html > body > img
100
+ at #host >>> p > img
93
101
  Add an alt attribute (use alt="" only for decorative images).
102
+
103
+ Scanned with @surea11y/core 1.10.0.
94
104
  ```
95
105
 
96
- Deliberately a plain function, not a custom matcher — no dependency on any particular assertion library, so it works the same with `node:assert`, WebdriverIO's `expect`, Jest, Vitest, or a hand-rolled `if`/`throw`. Defaults to `fail`/`cantTell` outcomes (the only two that ever carry occurrences); pass `{ outcomes: [...] }` to narrow further. A thrown rule (`occurrences: []`, `error` set) is still surfaced using its `error` message rather than silently dropped.
106
+ An element inside a shadow tree is located as `host >>> selector` (the second entry above); `formatOccurrenceLocation(occurrence)` gives the same text if you print occurrences yourself. `formatFailures(results.checksResults)` still works and prints only the entries. A `.frames(true)` result is not one result, so `formatFailures()` throws a `TypeError` for it: format `results.topFrame` and each entry of `results.frames` on its own.
107
+
108
+ Deliberately a plain function, not a custom matcher — no dependency on any particular assertion library, so it works the same with `node:assert`, WebdriverIO's `expect`, Jest, Vitest, or a hand-rolled `if`/`throw`. Defaults to `fail`/`cantTell` outcomes (the only two whose occurrences are findings: a `notApplicable` rule may carry one occurrence saying why it had nothing to judge); pass `{ outcomes: [...] }` to narrow further. A thrown rule (`occurrences: []`, `error` set) is still surfaced using its `error` message rather than silently dropped.
97
109
 
98
110
  ### Scanning every frame, including cross-origin and nested iframes
99
111
 
@@ -110,6 +122,8 @@ for (const frame of results.frames) {
110
122
 
111
123
  The mechanics here are genuinely different from those bindings, though, because WebdriverIO's frame model is different. Puppeteer/Playwright expose `page.frames()` — an array of independent `Frame` objects, each with its own `.evaluate()`/`.$()` — so a binding can iterate them freely. WebdriverIO instead has a single **stateful current context**: `browser.switchFrame(iframeElement)` changes which document `browser.execute()`/`browser.$()` run against, and `browser.switchFrame(null)` returns to the top-level frame. There is no array of frame objects. So `.frames(true)` enumerates each context's direct-child `<iframe>` elements with `browser.$$('iframe')`, switches into each, scans the now-current document, and recurses into that frame's own children (re-navigating from the top each time, since element references and BiDi context ids don't reliably survive a context switch). Cross-origin iframes are reached with no extra work — WebdriverIO's automation layer switches into them the same as same-origin ones, exactly like Puppeteer/Playwright's CDP does.
112
124
 
125
+ **`.include()` scopes the top frame only.** Each sub-frame is scanned whole, as `@surea11y/core`'s own `runa11yCoreAcrossFrames` does: the selectors belong to the top document, and since core 1.10.0 a selector that matches nothing scans nothing, so passing them down would leave every frame without them unscanned. `.exclude()` and the rule filters apply in every frame. An `INVALID_RUN_ONLY` or `INVALID_CONTEXT_SELECTOR` error rejects `analyze()` from the top frame, before any sub-frame is scanned; a sub-frame that fails for another reason is listed in `results.frames` as `{ url, error }`.
126
+
113
127
  Verified against a real cross-origin page (`https://example.org/` embedded in an unrelated origin) and against a real nested-iframe page (top → child → grandchild) — see `tests/builder.test.js`. Default off, so plain `.analyze()` is unaffected unless you opt in. Unlike script-injection-based accessibility tools (which need a `postMessage`-based protocol to reach cross-origin iframes, since they're injected as a plain `<script>` subject to the same-origin policy), this needs no extra `@surea11y/core` engine support at all.
114
128
 
115
129
  ### Trimming the result to just violations
@@ -138,14 +152,14 @@ await failing.occurrences[0].element.saveScreenshot('./flagged.png');
138
152
  await failing.occurrences[0].element.click();
139
153
  ```
140
154
 
141
- This resolves `occurrence.selector` to a `WebdriverIO.Element` (via `browser.$()`) and attaches it as **`occurrence.element`** — instead of leaving you to re-resolve a possibly-stale selector string yourself. Default off — resolving an element per occurrence is a real page query per occurrence, so it costs more than a plain `.analyze()`.
155
+ This finds each occurrence's element in the page and attaches it as **`occurrence.element`** — instead of leaving you to re-resolve a possibly-stale selector string yourself. An element inside a shadow tree is found through its `shadowHostSelectors` (see below), where `browser.$(occurrence.selector)` would find another element or none. `element` is `null` when nothing matches any more. Default off — resolving an element per occurrence is a real page query per occurrence, so it costs more than a plain `.analyze()`.
142
156
 
143
157
  Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(true)`, both real and worth knowing:
144
158
 
145
159
  - **The field is `occurrence.element`, not `occurrence.elementHandle`.** WebdriverIO has no "handle" concept — `browser.$()` returns a `WebdriverIO.Element`. Its API differs too: read a property with `.getProperty('id')` (not Puppeteer's `.evaluate(el => el.id)`), screenshot with `.saveScreenshot(path)` (not `.screenshot({ path })`). See the [WebdriverIO element API](https://webdriver.io/docs/api/element).
146
160
  - **A sub-frame element is only usable while switched into its frame.** WebdriverIO element references are bound to whichever frame was the current context when they were resolved. When you combine `.frames(true).elementRef(true)`, a *sub-frame* occurrence's `element` is resolved against that frame's own document (correct), but after `analyze()` returns the browser is back at the top-level frame — so to act on a sub-frame element you must `browser.switchFrame()` back into its frame first (`browser.switchFrame((await browser.$$('iframe'))[i])`). Top-frame occurrences have no such caveat. This is a direct consequence of WebdriverIO's stateful context model and has no analogue in Puppeteer/Playwright, whose handles are bound to an execution context, not a mutable "current frame."
147
161
 
148
- Not every occurrence has one target element — a page-wide finding (some `manual`/`cantTell` rules) can carry `selector: ""`, in which case `occurrence.element` is `null` rather than an element. (WebdriverIO's `browser.$('')` actually *throws* `invalid selector: No selector specified`, unlike Puppeteer/Playwright's `.$('')` which resolves to `null` — so the empty-selector guard that produces this `null` matters even more here; verified with a real run.)
162
+ Not every occurrence has one target element — a page-wide finding (some `manual`/`cantTell` rules) can carry `selector: ""`, in which case `occurrence.element` is `null` rather than an element.
149
163
 
150
164
  ### Registering a custom rule at runtime
151
165
 
@@ -179,15 +193,44 @@ const results = await new A11yCoreBuilder({ browser })
179
193
 
180
194
  **Why `.withCustomRules()` instead of the raw `.options({ customRules })` passthrough** (still supported, and composes with this method if you use both): `runInPage`/`applicability` must reach the page as a function-source *string*, not a live `Function` — a WebdriverIO `browser.execute()` argument crosses a serialization boundary that can't carry a live function reference, only a string `@surea11y/core` can reconstruct with `new Function` on the page side. Passing a raw live function via `.options()` directly would silently fail to serialize; `.withCustomRules()` calls `.toString()` on a live function for you (patching the ES6 method-shorthand `.toString()` quirk automatically), so you can write a normal function and not have to remember that constraint yourself. A string is still accepted as-is if you already have one.
181
195
 
182
- Invalid input (a missing/empty `id`, or a `runInPage`/`applicability` that's neither a function nor a non-empty string) throws immediately from `.withCustomRules()` itself, rather than surfacing later as a silently-skipped rule deep inside the page — easier to catch during development. (Note: a *raw* `.options({ customRules })` call bypasses this check entirely and defers to `@surea11y/core`'s own engine-side behavior, which silently skips an invalid descriptor rather than throwing.)
196
+ Invalid input (a missing/empty `id`, or a `runInPage`/`applicability` that's neither a function nor a non-empty string) throws immediately from `.withCustomRules()` itself, rather than surfacing later as a skipped rule deep inside the page — easier to catch during development. (Note: a *raw* `.options({ customRules })` call bypasses this check entirely and defers to `@surea11y/core`'s own engine-side behavior, which skips an invalid descriptor rather than throwing. The result lists each skipped rule in `skippedCustomRules`, and `analyze()` warns about it — see below.)
197
+
198
+ ### When a scan can't run as asked
199
+
200
+ `analyze()` rejects with an `EngineError` (exported, along with `ENGINE_ERROR_CODES`) when `@surea11y/core` can't use what it was given. Its `code` says why:
201
+
202
+ - `INVALID_RUN_ONLY`: none of the rule IDs, or none of the tags, given to `.withRules()`/`.withTags()` is one the engine knows.
203
+ - `INVALID_CONTEXT_SELECTOR`: an `.include()` selector is not valid CSS; `error.selector` names it.
204
+
205
+ ```js
206
+ const { A11yCoreBuilder, EngineError } = require('@surea11y/webdriverio');
207
+
208
+ try {
209
+ await new A11yCoreBuilder({ browser }).withTags(['wcag2.2aa']).analyze();
210
+ } catch (e) {
211
+ if (e instanceof EngineError && e.code === 'INVALID_RUN_ONLY') {
212
+ // a typo in the tag list, not a broken page
213
+ }
214
+ throw e;
215
+ }
216
+ ```
217
+
218
+ Two other cases don't throw but leave part of the page unchecked, so a result that reports nothing failing is not a clean page:
219
+
220
+ - **An `.include()` selector that matches nothing.** Since core 1.10.0 nothing is then scanned (it used to fall back to the whole page): every rule reports `notApplicable`, and `results.contextMatch` is `{ elementCount: 0, unmatchedSelectors: [...] }`. With several selectors, one that matches nothing is named there while the others are scanned.
221
+ - **A custom rule that did not run**, listed with its reason in `results.skippedCustomRules`.
222
+
223
+ `analyze()` prints each of these with `console.warn`, and `getScanGaps(results)` returns them as `[{ kind, message, ... }]` (`kind` is `context-not-found`, `context-partly-not-found` or `custom-rule-skipped`), so a gate can fail on them, as the E2E example above does. `formatFailures(results)` lists them too.
183
224
 
184
225
  ### Element addressing beyond a CSS selector
185
226
 
186
- Every occurrence already carries `selector` and (with `.elementRef(true)`, above) a live `element`. It also carries `structuralPath` — a sibling-index path from the document root down to the flagged element (e.g. `[1, 0, 2]`) — a more robust identity than a selector string alone, since it survives some DOM changes a selector wouldn't (an id/class rename, for instance). No opt-in needed; it's already on every `fail`/`cantTell` occurrence today. See [`OUTPUT_SCHEMA.md`](https://github.com/SureA11y/core/blob/main/docs/OUTPUT_SCHEMA.md) for the full field description.
227
+ Every occurrence already carries `selector` and (with `.elementRef(true)`, above) a live `element`. It also carries `structuralPath` — a sibling-index path from the document root down to the flagged element (e.g. `[1, 0, 2]`) — a more robust identity than a selector string alone, since it survives some DOM changes a selector wouldn't (an id/class rename, for instance). No opt-in needed; it's already on every `fail`/`cantTell` occurrence today.
228
+
229
+ An element inside a shadow tree is different: its `selector` holds only inside its shadow root, its `shadowHostSelectors` lists the shadow hosts that lead to it (outermost first), and its `structuralPath` is `null`. `formatOccurrenceLocation(occurrence)` joins them as `host >>> selector` for display. See [`OUTPUT_SCHEMA.md`](https://github.com/SureA11y/core/blob/main/docs/OUTPUT_SCHEMA.md) for the full field description.
187
230
 
188
231
  ## TypeScript
189
232
 
190
- `src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships hand-written types for the whole builder API plus `@surea11y/core`'s native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.). `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.
233
+ `src/A11yCoreBuilder.d.ts` (re-exported from `src/index.d.ts`, wired up via `package.json`'s `types` field) ships types for the whole builder API plus `@surea11y/core`'s native result shapes (`A11yCoreResult`, `CheckResult`, `Occurrence`, `CompositeResult`, etc.). The result shapes are built on the types `@surea11y/core` ships itself, so fields such as `engine.version`, `engine.environment`, `contextMatch`, `skippedCustomRules`, `margin` and `shadowHostSelectors` follow the engine; this package adds `occurrence.element` and the `.frames(true)` shape. `EngineError`, `EngineErrorCode` and `ScanGap` come from `@surea11y/binding-base`. `analyze()` is typed `Promise<A11yCoreResult | A11yCoreMultiFrameResult>` — narrow on `'topFrame' in results` (or cast, if you already know which mode you called) to get the specific shape back, since a fluent builder can't statically track that `.frames(true)` was called earlier in the chain. `occurrence.element` is typed `WebdriverIO.Element | null`. `webdriverio` is a `peerDependencies` entry (not just `devDependencies`) since the class's `browser` argument and `Occurrence#element` both come from its global `WebdriverIO` type namespace — consumers need their own `webdriverio` install for the types to resolve, same as they already do to construct a `browser` in the first place.
191
234
 
192
235
  ## Relationship to `@surea11y/playwright` and `@surea11y/puppeteer`
193
236
 
@@ -195,7 +238,7 @@ This binding's builder API is deliberately the same shape as [`@surea11y/playwri
195
238
 
196
239
  Where it genuinely diverges is WebdriverIO's driver model, and those differences are real, not cosmetic:
197
240
 
198
- - **`analyze()`'s injection call** is variadic like Puppeteer's (`browser.execute(runa11yCoreInPage, url, ctx, opts, runOnly)`) — no single-arg wrapper/`eval()` trick the Playwright binding needs.
241
+ - **`analyze()`'s injection call** is variadic like Puppeteer's (`browser.execute(inPageScan, url, ctx, opts, runOnly)`, where `inPageScan` is `@surea11y/core`'s `runa11yCoreInPage` wrapped by `@surea11y/binding-base` so an engine error keeps its `code`) — no single-arg wrapper the Playwright binding needs.
199
242
  - **`.frames(true)`** can't iterate a `page.frames()` array (there isn't one) — it drives WebdriverIO's stateful `switchFrame()` context model instead, recursing to reach nested frames. See above.
200
243
  - **`.elementRef(true)`** attaches `occurrence.element` (a `WebdriverIO.Element`), not `occurrence.elementHandle`, with the sub-frame-context caveat described above.
201
244
  - **Protocol:** classic WebDriver is strongly preferred over WebdriverIO v9's default BiDi for this execute()-heavy workload (see above).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/webdriverio",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "WebdriverIO binding for surea11y -- scan real, live-rendered pages for accessibility issues.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -40,8 +40,8 @@
40
40
  "LICENSE"
41
41
  ],
42
42
  "dependencies": {
43
- "@surea11y/binding-base": "^1.1.0",
44
- "@surea11y/core": "^1.5.0"
43
+ "@surea11y/binding-base": "^1.2.0",
44
+ "@surea11y/core": "^1.10.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "webdriverio": ">=8.0.0"
@@ -49,8 +49,9 @@
49
49
  "devDependencies": {
50
50
  "@wdio/cli": "^9.30.0",
51
51
  "@wdio/local-runner": "^9.30.0",
52
- "@wdio/mocha-framework": "^7.7.3",
52
+ "@wdio/mocha-framework": "^9.32.0",
53
53
  "@wdio/spec-reporter": "^9.29.1",
54
+ "typescript": "^5.9.3",
54
55
  "webdriverio": "^9.0.0"
55
56
  },
56
57
  "scripts": {
@@ -3,150 +3,68 @@
3
3
  // not as named module exports) -- see webdriverio's own build/types.d.ts.
4
4
  import type {} from 'webdriverio';
5
5
 
6
- // See surea11y's docs/OUTPUT_SCHEMA.md -- this file mirrors that document's
7
- // shapes exactly (plus the `element` field this binding adds on top when
8
- // .elementRef(true) is used). Keep in sync with that doc, not the other way
9
- // around -- it's the source of truth for what the engine actually returns.
6
+ // The result shapes come from @surea11y/core's own types (shipped since
7
+ // 1.9.0 and checked there against real scan results), so they follow the
8
+ // engine instead of a copy here. This file adds what the binding puts on
9
+ // top: the `element` field .elementRef(true) attaches, and the
10
+ // { topFrame, frames } shape of .frames(true). The names below are the ones
11
+ // this file has always exported.
12
+ import type * as Core from '@surea11y/core';
13
+
14
+ export type Outcome = Core.Outcome;
15
+ export type OutcomeNormalized = Core.OutcomeNormalized;
16
+ export type Severity = Core.Severity;
17
+ export type Confidence = Core.Confidence;
18
+ export type RuleType = Core.RuleType;
19
+ export type Category = Core.RuleMeta['category'];
20
+ /** An open set: core can add a value in a minor release. */
21
+ export type LocaleResolutionReason = Core.LocaleResolution['reason'];
22
+ export type LocaleResolution = Core.LocaleResolution;
23
+ /** `engine.version` is the @surea11y/core release that produced the result (1.10.0 and later). */
24
+ export type EngineInfo = Core.EngineInfo;
25
+ export type RenderingEnvironment = Core.RenderingEnvironment;
26
+ export type NormativeMapping = Core.NormativeMapping;
27
+ export type CheckResultMeta = Core.RuleMeta;
28
+ export type VisibilityFilter = Core.VisibilityFilter;
29
+ /** How close a rule's closest passing measurement came to its threshold (`CheckResult.margin`). */
30
+ export type Margin = Core.Margin;
31
+ /** How the include() scope resolved; `elementCount: 0` means nothing was scanned. */
32
+ export type ContextMatch = Core.ContextMatch;
33
+ export type CompositeResult = Core.CompositeResult;
34
+ export type CompositeResultDetails = Core.CompositeResult['data']['details'];
35
+ export type EngineOptions = Core.EngineOptions;
10
36
 
11
- export type Outcome = 'pass' | 'fail' | 'cantTell' | 'notApplicable';
12
- export type OutcomeNormalized = 'pass' | 'fail' | 'cantTell' | 'inapplicable';
13
- export type Severity = 'minor' | 'moderate' | 'serious' | 'critical';
14
- export type Confidence = 'high' | 'medium' | 'low';
15
- export type RuleType = 'automatic' | 'manual';
16
- export type Category = 'perceivable' | 'operable' | 'understandable' | 'robust' | null;
17
-
18
- // The reason set is open: core can add a value in a minor release, so the
19
- // union stays assignable from any string rather than going stale.
20
- export type LocaleResolutionReason =
21
- | 'ok'
22
- | 'primary-subtag'
23
- | 'dictionary-not-loaded'
24
- | 'unknown-locale'
25
- | 'partial-dictionary'
26
- | (string & {});
27
-
28
- export interface LocaleResolution {
29
- requested: string;
30
- resolved: string;
31
- reason: LocaleResolutionReason;
32
- }
33
-
34
- export interface EngineInfo {
35
- tag: string;
36
- schemaVersion: string;
37
- locale: LocaleResolution;
38
- }
39
-
40
- export interface NormativeMapping {
41
- standard: string;
42
- version: string;
43
- requirement: string;
44
- title: string;
45
- conformanceLevel: string;
46
- }
47
-
48
- export interface CheckResultMeta {
49
- ruleId: string;
50
- ruleInterfaceVersion: string;
51
- ruleVersion: string;
52
- normative: boolean;
53
- atomic: boolean;
54
- category: Category;
55
- normativeMappings: NormativeMapping[];
56
- standard: string | null;
57
- applicability: string;
58
- expectation: string;
59
- references: string[];
60
- requirements: Record<string, unknown> | null;
61
- mappings: Record<string, unknown> | null;
62
- }
63
-
64
- export interface VisibilityFilter {
65
- targetSet: string;
66
- accEligible: boolean | null;
67
- reasons: string[];
68
- }
69
-
70
- export interface Occurrence {
71
- selector: string;
72
- html: string;
73
- structuralPath: number[] | null;
74
- summary: string;
75
- hint: string;
76
- i18n: { summaryKey: string; hintKey: string; params: Record<string, unknown> } | null;
77
- data: {
78
- visibilityFilter?: VisibilityFilter;
79
- details?: Record<string, unknown>;
80
- };
37
+ /**
38
+ * An occurrence as @surea11y/core reports it. One inside a shadow tree
39
+ * carries `shadowHostSelectors` and a `structuralPath` of null; its
40
+ * `selector` holds only inside the last host's shadow root.
41
+ */
42
+ export interface Occurrence extends Core.Occurrence {
81
43
  /**
82
44
  * Only present when `.elementRef(true)` was used. `null` when this
83
- * occurrence has no single resolvable target element (e.g. `selector` was
84
- * `""`) -- see A11yCoreBuilder#elementRef. Named `element` (a
85
- * `WebdriverIO.Element`), not `elementHandle` as in the Puppeteer/Playwright
86
- * bindings -- WebdriverIO has no "handle" concept. A sub-frame occurrence's
87
- * element is only usable while the browser is switched into that frame; see
88
- * A11yCoreBuilder#elementRef.
45
+ * occurrence has no single target element (`selector` is `""`) or no
46
+ * element matches it any more -- see A11yCoreBuilder#elementRef. Found
47
+ * through `shadowHostSelectors` for an element in a shadow tree. Named
48
+ * `element` (a `WebdriverIO.Element`), not `elementHandle` as in the
49
+ * Puppeteer/Playwright bindings -- WebdriverIO has no "handle" concept. A
50
+ * sub-frame occurrence's element is only usable while the browser is
51
+ * switched into that frame.
89
52
  */
90
53
  element?: WebdriverIO.Element | null;
91
54
  }
92
55
 
93
- export interface CheckResult {
94
- ruleId: string;
95
- outcome: Outcome;
96
- outcomeNormalized: OutcomeNormalized;
97
- severity: Severity;
98
- confidence: Confidence;
99
- type: RuleType;
56
+ export interface CheckResult extends Omit<Core.CheckResult, 'occurrences'> {
100
57
  occurrences: Occurrence[];
101
- title: string;
102
- description: string;
103
- i18n: { titleKey: string; descriptionKey: string } | null;
104
- meta: CheckResultMeta;
105
- engineOptions: Record<string, unknown>;
106
- schemaVersion: string;
107
- /** Present only if the rule threw, or the manual-fail-to-cantTell coercion fired. */
108
- error?: string;
109
58
  }
110
59
 
111
- export interface CompositeResultDetails {
112
- reasonCode: string;
113
- checksIds: string[];
114
- contributors: Array<{ testId: string; outcome: string; severity: string | null }>;
115
- metrics: {
116
- failCount: number;
117
- cantTellCount: number;
118
- notApplicableCount: number;
119
- passCount: number;
120
- missingCount: number;
121
- };
122
- }
123
-
124
- export interface CompositeResult {
125
- ruleId: string;
126
- outcome: Outcome;
127
- severity: Severity;
128
- confidence: Confidence;
129
- type: RuleType;
130
- title: string;
131
- description: string;
132
- meta: CheckResultMeta;
133
- engineOptions: Record<string, unknown>;
134
- schemaVersion: string;
135
- /** Always empty -- composites are rollups, not element-level findings. */
136
- occurrences: [];
137
- data: { details: CompositeResultDetails };
138
- }
139
-
140
- /** surea11y's native top-level result shape -- see docs/OUTPUT_SCHEMA.md. */
141
- export interface A11yCoreResult {
142
- engine: EngineInfo;
143
- url: string | null;
144
- title: string | null;
145
- timestamp: string | null;
146
- perfStats: Record<string, unknown> | null;
147
- contextSelector: string | string[] | null;
60
+ /**
61
+ * @surea11y/core's native top-level result shape -- see its
62
+ * docs/OUTPUT_SCHEMA.md. Since 1.10.0 it also says how the include() scope
63
+ * resolved (`contextMatch`) and which custom rules did not run
64
+ * (`skippedCustomRules`).
65
+ */
66
+ export interface A11yCoreResult extends Omit<Core.ScanResult, 'checksResults'> {
148
67
  checksResults: CheckResult[];
149
- rulesResults: CompositeResult[];
150
68
  }
151
69
 
152
70
  /** A sub-frame that couldn't be scanned (detached, navigated away, or sandboxed). */
@@ -193,7 +111,12 @@ export class A11yCoreBuilder {
193
111
  */
194
112
  constructor(opts: { browser: WebdriverIO.Browser; url?: string });
195
113
 
196
- /** Scope the scan to one region. Call multiple times for a multi-region union. */
114
+ /**
115
+ * Scope the scan to one region. Call multiple times for a multi-region
116
+ * union. A selector that matches nothing scans nothing (see
117
+ * `contextMatch`); with .frames(true) the scope applies to the top frame
118
+ * only and each sub-frame is scanned whole.
119
+ */
197
120
  include(selector: string): this;
198
121
  /**
199
122
  * Skip elements matching this selector anywhere in the scanned scope.
@@ -202,7 +125,11 @@ export class A11yCoreBuilder {
202
125
  * from other `.exclude(selector)` calls.
203
126
  */
204
127
  exclude(selector: string, opts?: { rules?: string | string[] }): this;
205
- /** Only run rules carrying at least one of these tags. */
128
+ /**
129
+ * Only run rules carrying at least one of these tags. Like the three
130
+ * methods below, throws a TypeError with `code: 'INVALID_RUN_ONLY'` for
131
+ * anything but a string or an array of strings.
132
+ */
206
133
  withTags(tags: string | string[]): this;
207
134
  /** Never run rules carrying any of these tags (applied after withTags). */
208
135
  disableTags(tags: string | string[]): this;
@@ -221,16 +148,29 @@ export class A11yCoreBuilder {
221
148
  /** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
222
149
  elementRef(enabled?: boolean): this;
223
150
 
224
- /** Runs the scan. Returns { topFrame, frames } instead of a single result when .frames(true) was used. */
151
+ /**
152
+ * Runs the scan. Returns { topFrame, frames } instead of a single result
153
+ * when .frames(true) was used. Rejects with an `EngineError` (from
154
+ * `@surea11y/binding-base`, re-exported here) whose `code` is
155
+ * `INVALID_RUN_ONLY` when a rule or tag list names nothing @surea11y/core
156
+ * knows, or `INVALID_CONTEXT_SELECTOR` (with `selector`) when an include()
157
+ * selector does not parse.
158
+ */
225
159
  analyze(): Promise<A11yCoreResult | A11yCoreMultiFrameResult>;
226
160
  }
227
161
 
228
162
  /**
229
- * Formats a checksResults array into a short, human-readable block -- one
230
- * entry per occurrence, not per rule. Meant for an assertion library's
231
- * failure-message parameter, e.g.
232
- * `assert.strictEqual(results.checksResults.length, 0, formatFailures(results.checksResults))`.
233
- * Deliberately framework-agnostic -- no dependency on any particular
234
- * `expect` implementation.
163
+ * Formats a result, or its checksResults array, into a short, human-readable
164
+ * block -- one entry per fail/cantTell occurrence, not per rule. Given the
165
+ * whole result, it also lists what the scan left out (an include() scope
166
+ * that matched nothing, a custom rule that did not run) and the
167
+ * @surea11y/core release that produced it. Meant for an assertion
168
+ * library's failure-message parameter, e.g.
169
+ * `assert.strictEqual(fails.length, 0, formatFailures(results))`.
170
+ * Throws a TypeError for anything else, such as a .frames(true) result:
171
+ * format `topFrame` and each entry of `frames` on its own.
235
172
  */
236
- export function formatFailures(checksResults: CheckResult[], opts?: { outcomes?: Outcome[] }): string;
173
+ export function formatFailures(
174
+ input: A11yCoreResult | ReadonlyArray<CheckResult>,
175
+ opts?: { outcomes?: Outcome[] }
176
+ ): string;
@@ -1,7 +1,19 @@
1
1
  'use strict';
2
2
 
3
3
  const { runa11yCoreInPage } = require('@surea11y/core');
4
- const { A11yCoreBuilderBase } = require('@surea11y/binding-base');
4
+ const {
5
+ A11yCoreBuilderBase,
6
+ createInPageScan,
7
+ rethrowEngineError,
8
+ queryOccurrenceElement,
9
+ getScanGaps
10
+ } = require('@surea11y/binding-base');
11
+
12
+ // core's runa11yCoreInPage, wrapped so an engine error (INVALID_RUN_ONLY,
13
+ // INVALID_CONTEXT_SELECTOR) keeps its `code` across browser.execute(),
14
+ // which carries only an error's message. Self-contained, so WebdriverIO
15
+ // serializes it exactly as it serialized runa11yCoreInPage.
16
+ const inPageScan = createInPageScan(runa11yCoreInPage);
5
17
 
6
18
  /**
7
19
  * WebdriverIO binding for surea11y -- scans a real, already-rendered page.
@@ -66,6 +78,12 @@ const { A11yCoreBuilderBase } = require('@surea11y/binding-base');
66
78
  * Default off, so plain .analyze() keeps returning the single native result
67
79
  * object it always has.
68
80
  *
81
+ * An include() scope applies to the top frame only: each sub-frame is
82
+ * scanned whole (contextSelector null), as @surea11y/core's own
83
+ * runa11yCoreAcrossFrames does. Since core 1.10.0 a selector that matches
84
+ * nothing scans nothing, so passing the top frame's scope down would leave
85
+ * every frame that lacks it unscanned.
86
+ *
69
87
  * By default `analyze()` returns every rule's outcome, including
70
88
  * `pass`/`notApplicable` -- @surea11y/core's own deliberate "not a
71
89
  * violations-only list" design (see ../core/docs/OUTPUT_SCHEMA.md).
@@ -147,6 +165,11 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
147
165
  */
148
166
  async analyze() {
149
167
  const { contextSelector, engineOptions, runOnly } = this._buildEngineArgs();
168
+ if (this._scanFrames) {
169
+ // Make sure we start from the top-level frame, whatever context the
170
+ // caller happened to leave the browser in.
171
+ await this._browser.switchFrame(null);
172
+ }
150
173
 
151
174
  // Unlike Playwright's page.evaluate(fn, arg), which only accepts a
152
175
  // SINGLE argument (forcing a hand-built single-arg wrapper there),
@@ -154,10 +177,10 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
154
177
  // execute<ReturnValue, InnerArguments extends unknown[]>(script, ...args)
155
178
  // (confirmed against a real webdriverio 9.x install's own
156
179
  // build/commands/browser/execute.d.ts). Like Puppeteer, that means
157
- // runa11yCoreInPage's own 4 positional args can be passed straight
158
- // through with no wrapper/eval() trick -- WebdriverIO serializes the
159
- // function itself. Verified empirically against a real headless Chrome
160
- // session before trusting it (this project's whole ethos, inherited
180
+ // the scan's 4 positional args can be passed straight through with no
181
+ // single-arg wrapper -- WebdriverIO serializes the function itself.
182
+ // Verified empirically against a real headless Chrome session before
183
+ // trusting it (this project's whole ethos, inherited
161
184
  // from surea11y and its sibling bindings, is "verified against a real
162
185
  // run," not "reasoned about").
163
186
  //
@@ -165,23 +188,31 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
165
188
  // WebdriverIO's execute()/$ target the current context, a stateful model
166
189
  // unlike Puppeteer/Playwright's per-frame-object one. `.frames(true)`
167
190
  // orchestrates the switching (see _scanChildFrames).
168
- const scanCurrent = async () => {
191
+ //
192
+ // inPageScan takes the same four arguments as runa11yCoreInPage; an
193
+ // engine error comes back as a plain object, which rethrowEngineError()
194
+ // throws again here as an EngineError with its `code`.
195
+ const scanCurrent = async (scope) => {
169
196
  const frameUrl = this._url || (await this._browser.execute(() => document.location.href));
170
- const result = await this._browser.execute(runa11yCoreInPage, frameUrl, contextSelector, engineOptions, runOnly);
197
+ const result = rethrowEngineError(
198
+ await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
199
+ );
200
+ this._warnScanGaps(result);
171
201
  return this._elementRef ? this._attachElementRefs(result) : result;
172
202
  };
173
203
 
174
204
  if (!this._scanFrames) {
175
- return this._applyReportOnly(await scanCurrent());
205
+ return this._applyReportOnly(await scanCurrent(contextSelector));
176
206
  }
177
207
 
178
- // Make sure we start from the top-level frame, whatever context the
179
- // caller happened to leave the browser in.
180
- await this._browser.switchFrame(null);
181
- const topFrame = this._applyReportOnly(await scanCurrent());
208
+ // The top frame is scanned first, so an INVALID_RUN_ONLY or
209
+ // INVALID_CONTEXT_SELECTOR error is thrown from it, before any sub-frame.
210
+ const topFrame = this._applyReportOnly(await scanCurrent(contextSelector));
182
211
 
212
+ // Sub-frames are scanned whole: the include() scope belongs to the top
213
+ // frame's document (see the class comment).
183
214
  const frames = [];
184
- await this._scanChildFrames([], scanCurrent, frames);
215
+ await this._scanChildFrames([], () => scanCurrent(null), frames);
185
216
 
186
217
  // Leave the browser back at the top-level frame regardless of how the
187
218
  // traversal ended, so a caller's next command isn't silently running in
@@ -264,24 +295,55 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
264
295
  }
265
296
 
266
297
  /**
267
- * Resolves occurrence.selector to a live WebdriverIO.Element for every
268
- * fail/cantTell occurrence, scoped to whatever frame the browser is
269
- * currently switched into (browser.$ is stateful on the current context).
270
- * Mutates and returns the same result object -- it's a fresh object from
271
- * this scan, not shared external state.
298
+ * Prints what the scan left out (getScanGaps(): an include() scope that
299
+ * matched nothing, a custom rule that did not run) with console.warn.
300
+ * @surea11y/core warns about these too, but in the page's console, which a
301
+ * WebdriverIO run does not show; a result whose checksResults alone look
302
+ * clean would otherwise pass without a word.
303
+ */
304
+ _warnScanGaps(result) {
305
+ let gaps;
306
+ try {
307
+ gaps = getScanGaps(result);
308
+ } catch (e) {
309
+ return; // not a scan result; the caller sees it as it is
310
+ }
311
+ const where = result.url ? ` (${result.url})` : '';
312
+ for (const gap of gaps) {
313
+ console.warn(`@surea11y/webdriverio${where}: ${gap.message}`);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Resolves each occurrence to a live WebdriverIO.Element, scoped to
319
+ * whatever frame the browser is currently switched into (execute() is
320
+ * stateful on the current context). Mutates and returns the same result
321
+ * object -- it's a fresh object from this scan, not shared external state.
322
+ *
323
+ * The lookup runs in the page through binding-base's
324
+ * queryOccurrenceElement(), which follows `shadowHostSelectors` (core
325
+ * 1.10.0 and later): an occurrence inside a shadow tree has a `selector`
326
+ * that holds only inside its shadow root, so browser.$(selector) found
327
+ * another element or none. The element reference execute() returns is
328
+ * wrapped with browser.$(), which makes no further WebDriver call.
329
+ * `element` is null when the occurrence has no selector (a page-wide
330
+ * finding can carry "") or nothing matches it any more.
272
331
  */
273
332
  async _attachElementRefs(result) {
274
333
  if (!Array.isArray(result.checksResults)) return result;
275
334
  for (const check of result.checksResults) {
276
335
  if (!Array.isArray(check.occurrences) || !check.occurrences.length) continue;
277
336
  for (const occurrence of check.occurrences) {
278
- // Most occurrences carry a concrete element selector, but a page-wide
279
- // finding with no single target element (e.g. some `manual`/cantTell
280
- // rules) can carry "" -- leave `element` null rather than passing ""
281
- // to browser.$(), which throws ("invalid selector: No selector
282
- // specified") on WebdriverIO, unlike Puppeteer/Playwright's .$("")
283
- // which resolves to null. Verified against a real run.
284
- occurrence.element = occurrence.selector ? await this._browser.$(occurrence.selector) : null;
337
+ if (!occurrence.selector) {
338
+ occurrence.element = null;
339
+ continue;
340
+ }
341
+ const ref = await this._browser.execute(
342
+ queryOccurrenceElement,
343
+ occurrence.selector,
344
+ occurrence.shadowHostSelectors || null
345
+ );
346
+ occurrence.element = ref ? await this._browser.$(ref) : null;
285
347
  }
286
348
  }
287
349
  return result;
package/src/index.d.ts CHANGED
@@ -1 +1,8 @@
1
1
  export * from './A11yCoreBuilder';
2
+ export {
3
+ EngineError,
4
+ ENGINE_ERROR_CODES,
5
+ getScanGaps,
6
+ formatOccurrenceLocation
7
+ } from '@surea11y/binding-base';
8
+ export type { EngineErrorCode, ScanGap, ScanResultLike, OccurrenceLocation } from '@surea11y/binding-base';
package/src/index.js CHANGED
@@ -2,5 +2,18 @@
2
2
 
3
3
  const { A11yCoreBuilder } = require('./A11yCoreBuilder');
4
4
  const { formatFailures } = require('./formatFailures');
5
+ const {
6
+ EngineError,
7
+ ENGINE_ERROR_CODES,
8
+ getScanGaps,
9
+ formatOccurrenceLocation
10
+ } = require('@surea11y/binding-base');
5
11
 
6
- module.exports = { A11yCoreBuilder, formatFailures };
12
+ module.exports = {
13
+ A11yCoreBuilder,
14
+ formatFailures,
15
+ EngineError,
16
+ ENGINE_ERROR_CODES,
17
+ getScanGaps,
18
+ formatOccurrenceLocation
19
+ };