@surea11y/webdriverio 1.4.1 → 1.4.2

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
@@ -5,12 +5,12 @@ A WebdriverIO binding for [`@surea11y/core`](https://github.com/SureA11y/core)
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install @surea11y/webdriverio webdriverio
8
+ npm install @surea11y/core @surea11y/webdriverio webdriverio
9
9
  ```
10
10
 
11
- `@surea11y/core` 1.12.0 or later, the engine, comes with it as a regular dependency, so the line above is all you need, whatever your package manager.
11
+ `@surea11y/core` 1.12.0 or later, the engine, comes with the binding as a regular dependency, so a project that doesn't list it still works. List it in your own `package.json` too, as the line above does: your project and the binding then share one copy, so whatever you do with core directly, writing a SARIF log or an HTML report (`@surea11y/core/sarif`, `/report`), reading the rule catalogue or keeping a baseline, reads results with the same core that produced them. When your core can't match the binding's range, as with a linked checkout (`file:../core`) or a fork, add `"overrides": { "@surea11y/core": "$@surea11y/core" }` beside it.
12
12
 
13
- If your project uses core directly too, to write a SARIF log or an HTML report (`@surea11y/core/sarif`, `/report`), read the rule catalogue, or keep a baseline, keep one copy of it, so those read results with the same core that produced them. A compatible range in your own `package.json` (`"@surea11y/core": "^1.12.0"`) is enough: npm installs core once. When yours can't match the binding's range, as with a linked checkout (`file:../core`) or a fork, add `"overrides": { "@surea11y/core": "$@surea11y/core" }` beside it, and the binding uses your copy, which needs to be 1.12.0 or later. 1.4.0 made core a peer dependency instead; 1.4.1 undoes that, because Yarn, npm 6 and npm with `legacy-peer-deps` don't install peers, and a project with an older core pinned couldn't install the binding at all.
13
+ **Coming in 2.0:** core becomes a peer dependency, so the binding will no longer install it for you, and the `overrides` entry is no longer needed. Add `@surea11y/core` to your `package.json` now and 2.0 needs no change.
14
14
 
15
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.
16
16
 
@@ -99,7 +99,7 @@ Rule IDs change rarely, but core 1.10.0 changed two: `label-title-only` is depre
99
99
 
100
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.
101
101
 
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.
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()`/`.screenshots()`/`.withWcag()`/`.withBestPractices()`/`.includeMode()`/`.wcagLevel()`/`.logger()` are the exception: each call replaces the previous value instead of merging with it.
103
103
 
104
104
  This binding works against any browser WebdriverIO can drive; the tests and examples use headless Chrome via `chromedriver`, which WebdriverIO manages automatically.
105
105
 
@@ -216,6 +216,37 @@ Two WebdriverIO-specific differences from the sibling bindings' `.elementRef(tru
216
216
 
217
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.
218
218
 
219
+ ### A picture of each failing element
220
+
221
+ Opt in with `.screenshots()`, and each occurrence the scan flags gets a `screenshot` field: a picture of the element, with a box drawn around it, or the reason there is none.
222
+
223
+ ```js
224
+ const results = await new A11yCoreBuilder({ browser })
225
+ .screenshots({ dir: 'a11y-shots', padding: 16 })
226
+ .analyze();
227
+
228
+ const [first] = results.checksResults.filter((r) => r.outcome === 'fail');
229
+ first.occurrences[0].screenshot;
230
+ // { path: 'a11y-shots/element-3f9a1c07.png', box: { x: 20, y: 20, width: 120, height: 40 } }
231
+ ```
232
+
233
+ | Option | Default | |
234
+ |---|---|---|
235
+ | `dir` | none | Write each picture there. Without it, each comes back as `{ buffer, box }`, a PNG for you to store your own way. |
236
+ | `outcomes` | `['fail']` | Which occurrences to picture. For contrast, `['fail', 'cantTell']` shows the background the engine couldn't judge. |
237
+ | `padding` | `0` | CSS pixels of context around the element. |
238
+ | `highlight` | `true` | Draw a box around the element, over the page; the element itself is never restyled. |
239
+ | `max` | `50` | At most this many pictures per scan, frames included. |
240
+
241
+ - **One picture per element.** Several rules can flag the same element; it's pictured once, and each of their occurrences carries the same `screenshot`. A file is named after where the element is, so it keeps its name from run to run, and `formatFailures()` names it under each finding (` screenshot: a11y-shots/element-3f9a1c07.png`).
242
+ - **`box`** is where the element shows in the picture, in CSS pixels; the PNG is larger by the device pixel ratio. `cropped: true` says only part of it shows, as for an element taller than the viewport, which is pictured from its top-left corner.
243
+ - **No picture, and why**: `{ skipped }` with `'no-element'` (no selector, or nothing matches it any more), `'page-level'` (a finding about the whole page, on `<html>`, `<head>` or `<body>`), `'not-rendered'` (such as `display: none`), `'zero-size'`, `'off-screen'` (such as `left: -9999px`), `'max-reached'`, or `'capture-failed'` with the reason. A failed picture never fails the scan.
244
+ - **The page is put back.** Each element is scrolled into view for its picture, and afterwards every scroll position is restored and the box removed, also when a picture fails. Something drawn over the element, such as a sticky header, shows as it does on screen.
245
+ - **Frames.** With `.frames(true)`, an element in an `<iframe>`, cross-origin ones included, is pictured on the page as it shows there, and the browser ends at the top-level page as before. Without it, the scan runs in whatever frame the browser is switched into; pictures are placed from the top-level page, so a scan started inside a frame gives its occurrences `capture-failed` and says why. Scan from the top-level page with `.frames(true)` to picture a frame's elements.
246
+ - **Off by default.** Pictures of real pages can hold personal data, and they end up as CI artifacts.
247
+
248
+ The pictures are cut out of WebDriver's screenshot of the viewport (`browser.takeScreenshot()`), right after each frame's scan, before `.reportOnly()` trims the result, and combine with `.elementRef(true)`. Focus, hover, animations or content that loads late can still differ from what the scan saw. An option it can't use throws a `TypeError` with `code: 'INVALID_ENGINE_OPTIONS'` at the call, and so does `.analyze()` with `options({ output: { includeSelector: false } })`, since each element is found by its selector. Works over WebDriver classic and BiDi.
249
+
219
250
  ### Web components: the internals hook
220
251
 
221
252
  Many web components keep their `ElementInternals` in a private field and name themselves, set their states or give themselves a role through it:
@@ -368,6 +399,14 @@ results.engine.profile; // 'en301549-v4.1.1'
368
399
 
369
400
  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
401
 
402
+ Install a published pack in your project, beside core and this package:
403
+
404
+ ```bash
405
+ npm install @surea11y/core @surea11y/webdriverio webdriverio @acme/a11y-policy
406
+ ```
407
+
408
+ Your project, this package and the pack then share one copy of core, so the core that checks the pack is the one that runs it. With two copies, a scan can skip the pack (see "When a pack doesn't run" below).
409
+
371
410
  `.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
411
 
373
412
  ```js
@@ -389,7 +428,7 @@ A pack's own rules appear in `checksResults` like core's, with ids that start wi
389
428
 
390
429
  **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
430
 
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.
431
+ **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.
393
432
 
394
433
  ### Element addressing beyond a CSS selector
395
434
 
@@ -399,7 +438,7 @@ An element inside a shadow tree is different: its `selector` holds only inside i
399
438
 
400
439
  ## TypeScript
401
440
 
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.
441
+ `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`), `withPacks()`, which takes a pack made with `definePack` (`PackLike`), and `.screenshots()` (`ScreenshotOptions`, and `OccurrenceScreenshot` for an occurrence's `screenshot`). 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
442
 
404
443
  ## What changes with `@surea11y/core` 1.12
405
444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/webdriverio",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
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",
@@ -47,7 +47,7 @@
47
47
  "LICENSE"
48
48
  ],
49
49
  "dependencies": {
50
- "@surea11y/binding-base": "^1.4.0",
50
+ "@surea11y/binding-base": "^1.5.0",
51
51
  "@surea11y/core": "^1.12.0"
52
52
  },
53
53
  "peerDependencies": {
@@ -10,8 +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
+ import type { PackLike, WcagVersion, WcagLevelInput, ScanGap, ScanNote, PlannedCheck, PlannedCriterion, ScreenshotOptions, OccurrenceScreenshot } from '@surea11y/binding-base';
14
+ export type { PackLike, WcagVersion, WcagLevel, ScreenshotOptions, ScreenshotSkip, ScreenshotBox, OccurrenceScreenshot } from '@surea11y/binding-base';
15
15
 
16
16
  export type Outcome = Core.Outcome;
17
17
  export type OutcomeNormalized = Core.OutcomeNormalized;
@@ -53,6 +53,12 @@ export interface Occurrence extends Core.Occurrence {
53
53
  * switched into that frame.
54
54
  */
55
55
  element?: WebdriverIO.Element | null;
56
+ /**
57
+ * Only present when `.screenshots()` was used and this occurrence's own
58
+ * outcome is one it pictures: the picture, or why there is none. The
59
+ * occurrences about the same element share one object.
60
+ */
61
+ screenshot?: OccurrenceScreenshot;
56
62
  }
57
63
 
58
64
  export interface CheckResult extends Omit<Core.CheckResult, 'occurrences'> {
@@ -190,6 +196,13 @@ export class A11yCoreBuilder {
190
196
  frames(enabled?: boolean): this;
191
197
  /** Opt in to resolving each fail/cantTell occurrence's selector to a live WebdriverIO.Element. */
192
198
  elementRef(enabled?: boolean): this;
199
+ /**
200
+ * Opt in to a picture of each element the scan flags, on its occurrences'
201
+ * `screenshot` field: a Buffer, or a file in `dir`. `false` turns them
202
+ * off. Throws a TypeError with code 'INVALID_ENGINE_OPTIONS' for an option
203
+ * it can't use.
204
+ */
205
+ screenshots(options?: ScreenshotOptions | boolean): this;
193
206
  /** The rules this scan will run, worked out in Node with no page (@surea11y/core 1.12.0 and later). */
194
207
  plannedChecks(): PlannedCheck[];
195
208
  /** The criteria this scan will report in rulesResults, worked out in Node with no page. */
@@ -5,7 +5,9 @@ const {
5
5
  A11yCoreBuilderBase,
6
6
  createInPageScan,
7
7
  rethrowEngineError,
8
- queryOccurrenceElement
8
+ queryOccurrenceElement,
9
+ planScreenshots,
10
+ cropViewportScreenshot
9
11
  } = require('@surea11y/binding-base');
10
12
 
11
13
  // core's runa11yCoreInPage, wrapped so an engine error (INVALID_RUN_ONLY,
@@ -112,6 +114,19 @@ const inPageScan = createInPageScan(runa11yCoreInPage);
112
114
  * than `.evaluate(el => el.id)`, `.saveScreenshot(path)` rather than
113
115
  * `.screenshot({ path })`). See the README.
114
116
  *
117
+ * Or let the scan take a picture of each element it flags, with
118
+ * `.screenshots()` (@surea11y/binding-base's, see its README): each
119
+ * occurrence gets a `screenshot` field, the picture (a Buffer, or a file in
120
+ * `dir`) or why there is none. The element is scrolled into view and marked
121
+ * with a box drawn over the page, and the page is put back afterwards; in
122
+ * every frame with `.frames(true)`, including cross-origin ones:
123
+ *
124
+ * const results = await new A11yCoreBuilder({ browser })
125
+ * .screenshots({ dir: 'a11y-shots', padding: 16 })
126
+ * .analyze();
127
+ * // results.checksResults[i].occurrences[j].screenshot
128
+ * // -> { path: 'a11y-shots/element-3f9a1c07.png', box: { x, y, width, height } }
129
+ *
115
130
  * Register your own rule(s) for just this scan with
116
131
  * `.withCustomRules([...])` (@surea11y/core's `engineOptions.customRules`
117
132
  * escape hatch -- see ../core/docs/ENGINE_OPTIONS.md). Pass a real,
@@ -141,8 +156,9 @@ const inPageScan = createInPageScan(runa11yCoreInPage);
141
156
  * top of the first scan's scope rather than replacing it (intentional for
142
157
  * "call include() several times for one scan" -- see above -- but a footgun
143
158
  * if you hold one instance across multiple assertions).
144
- * reportOnly()/frames()/elementRef()/withWcag()/withBestPractices() are the
145
- * exception: each call replaces the previous value rather than merging with it.
159
+ * reportOnly()/frames()/elementRef()/screenshots()/withWcag()/withBestPractices()
160
+ * are the exception: each call replaces the previous value rather than
161
+ * merging with it.
146
162
  */
147
163
  class A11yCoreBuilder extends A11yCoreBuilderBase {
148
164
  /**
@@ -236,28 +252,38 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
236
252
  // scan names in engineOptions.packs; null without packs. execute() runs
237
253
  // a string as a function body, so the script runs as written.
238
254
  const packScript = this._packScript();
239
- const scanCurrent = async (scope) => {
255
+ // `path` is the frame being scanned, as _scanChildFrames addresses it
256
+ // ([] for the top-level page); the screenshots step needs it to reach
257
+ // the frame again, and its parents.
258
+ const scanCurrent = async (scope, path) => {
240
259
  const frameUrl = this._url || (await this._browser.execute(() => document.location.href));
241
260
  if (packScript) await this._browser.execute(packScript);
242
261
  const result = rethrowEngineError(
243
262
  await this._browser.execute(inPageScan, frameUrl, scope, engineOptions, runOnly)
244
263
  );
245
264
  this._warnScanGaps(result, '@surea11y/webdriverio');
246
- return this._elementRef ? this._attachElementRefs(result) : result;
265
+ // Element references first: they are resolved in the current frame,
266
+ // which the screenshots step switches away from.
267
+ if (this._elementRef) await this._attachElementRefs(result);
268
+ if (this._screenshots) await this._screenshotFrame(result, path);
269
+ return result;
247
270
  };
248
271
 
249
272
  if (!this._scanFrames) {
250
- return this._applyReportOnly(await scanCurrent(contextSelector));
273
+ // Whatever frame the caller left the browser in; the screenshots step
274
+ // can place pictures only from the top-level page (see
275
+ // _screenshotFrame), and null says so.
276
+ return this._applyReportOnly(await scanCurrent(contextSelector, null));
251
277
  }
252
278
 
253
279
  // The top frame is scanned first, so an INVALID_RUN_ONLY or
254
280
  // INVALID_CONTEXT_SELECTOR error is thrown from it, before any sub-frame.
255
- const topFrame = this._applyReportOnly(await scanCurrent(contextSelector));
281
+ const topFrame = this._applyReportOnly(await scanCurrent(contextSelector, []));
256
282
 
257
283
  // Sub-frames are scanned whole: the include() scope belongs to the top
258
284
  // frame's document (see the class comment).
259
285
  const frames = [];
260
- await this._scanChildFrames([], () => scanCurrent(null), frames);
286
+ await this._scanChildFrames([], (childPath) => scanCurrent(null, childPath), frames);
261
287
 
262
288
  // Leave the browser back at the top-level frame regardless of how the
263
289
  // traversal ended, so a caller's next command isn't silently running in
@@ -315,7 +341,7 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
315
341
  }
316
342
 
317
343
  try {
318
- out.push(this._applyReportOnly(await scanCurrent()));
344
+ out.push(this._applyReportOnly(await scanCurrent(path.concat(i))));
319
345
  } catch (e) {
320
346
  out.push({ url: frameUrl, error: (e && e.message) || String(e) });
321
347
  }
@@ -339,6 +365,58 @@ class A11yCoreBuilder extends A11yCoreBuilderBase {
339
365
  }
340
366
  }
341
367
 
368
+ /**
369
+ * binding-base's screenshots step for the frame at `path` (child <iframe>
370
+ * indices from the top, as _scanChildFrames walks them), with the
371
+ * <iframe>s leading to it given by index among `$$('iframe')`, the way
372
+ * this binding counts frames: element references don't survive switching
373
+ * away and back under BiDi. The step switches between frames as it goes,
374
+ * so the browser is switched back into the scanned frame afterwards, where
375
+ * the traversal goes on.
376
+ *
377
+ * `path` is null for a scan without .frames(true), which runs in whatever
378
+ * frame the caller left the browser in. From the top-level page that is
379
+ * `[]`; from inside a frame, nothing says how it sits on the page, so its
380
+ * occurrences get `capture-failed` rather than misplaced pictures.
381
+ */
382
+ async _screenshotFrame(result, path) {
383
+ if (path === null) {
384
+ const atTop = await this._browser.execute(() => window === window.top).catch(() => false);
385
+ if (!atTop) {
386
+ const error = 'the browser was switched into a frame when analyze() ran; pictures are placed from the top-level page, so scan from there, with .frames(true) to include the frame';
387
+ const { elements, unlocated } = planScreenshots(result, { outcomes: this._screenshots.outcomes });
388
+ for (const element of elements) {
389
+ const shot = { skipped: 'capture-failed', error };
390
+ for (const occurrence of element.occurrences) occurrence.screenshot = shot;
391
+ }
392
+ for (const occurrence of unlocated) occurrence.screenshot = { skipped: 'no-element' };
393
+ return;
394
+ }
395
+ path = [];
396
+ }
397
+ const framePath = path.map((index, depth) => ({ frame: path.slice(0, depth), iframe: { selector: 'iframe', index } }));
398
+ try {
399
+ await this._captureScreenshots(result, path, framePath);
400
+ } finally {
401
+ await this._switchToPath(path).catch(() => {});
402
+ }
403
+ }
404
+
405
+ /** For binding-base's screenshots step: runs `fn(...args)` in the frame at `path`. */
406
+ async _evaluateInFrame(path, fn, args) {
407
+ await this._switchToPath(path);
408
+ return this._browser.execute(fn, ...args);
409
+ }
410
+
411
+ /**
412
+ * For binding-base's screenshots step: a PNG of `box`, CSS pixels in the
413
+ * top-level viewport, cut out of WebDriver's screenshot of that viewport,
414
+ * which it takes whatever frame the browser is switched into.
415
+ */
416
+ async _captureBox(box, view) {
417
+ return cropViewportScreenshot(await this._browser.takeScreenshot(), box, view);
418
+ }
419
+
342
420
  /**
343
421
  * Resolves each occurrence to a live WebdriverIO.Element, scoped to
344
422
  * whatever frame the browser is currently switched into (execute() is