@testivai/witness-webdriverio 0.3.1 → 0.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
@@ -95,27 +95,37 @@ npx testivai approve --all
95
95
 
96
96
  Approve writes the temp capture over the baseline and backs the previous baseline up to `.testivai/baselines/<name>/.previous/`. `npx testivai approve --undo "homepage"` reverts.
97
97
 
98
- ## DOM noise hint
98
+ ## DOM noise hint and style check
99
99
 
100
- When pixels differ but the page DOM is structurally identical, the report flags the change as "likely render noise" (anti-aliasing, font hinting, sub-pixel layout). When the DOM is also different, the report shows added / removed / attribute-change counts so you can decide whether the change is intentional. Same signal as the Playwright adapter — both write `dom.html` alongside the screenshot.
100
+ Alongside the screenshot the adapter writes `dom.html` and `elements.json`, the element map: each element's box and a digest of its computed styles, collected by the same function the Playwright and Selenium adapters use.
101
101
 
102
- DOM capture happens automatically. To skip it for a single snapshot:
102
+ - When pixels differ but the DOM is structurally identical and no computed style changed, the report flags the change as "likely render noise" (anti-aliasing, font hinting, sub-pixel layout).
103
+ - When the DOM is identical but computed styles differ, it reports a style-only change, which is real, and names the restyled elements.
104
+ - When the DOM also differs, it shows added / removed / attribute-change counts so you can decide whether the change is intentional.
105
+
106
+ The element map also powers region attribution (which selectors a diff region covers) and page-shift detection.
107
+
108
+ WebDriver's screenshot is the **viewport**, not the full page, so the element map keeps only the elements inside the captured viewport, in screenshot coordinates. Size the browser window to what you want compared, and capture at the same scroll position each run.
109
+
110
+ Both captures happen automatically and are best-effort: if either fails, the pixel diff still runs. To skip them for a single snapshot:
103
111
 
104
112
  ```ts
105
- await testivai.witness(browser, 'no-dom', { skipDom: true });
113
+ await testivai.witness(browser, 'no-dom', { skipDom: true, skipElementMap: true });
106
114
  ```
107
115
 
108
116
  ## API
109
117
 
110
118
  ### `testivai.witness(browser, name, options?)`
111
119
 
112
- Captures a screenshot + DOM and writes them as a temp snapshot.
120
+ Captures a viewport screenshot, the DOM and the element map, and writes them as a temp snapshot.
113
121
 
114
122
  | Param | Type | Description |
115
123
  |---|---|---|
116
124
  | `browser` | WebdriverIO browser | Must expose `takeScreenshot()` and (for DOM) `execute()`. |
117
125
  | `name` | string | Snapshot name. Becomes `.testivai/temp/<name>/` and the key in the report. |
118
126
  | `options.skipDom` | boolean | Skip DOM capture for this snapshot. |
127
+ | `options.skipElementMap` | boolean | Skip the element map for this snapshot (no style check, region attribution or page-shift detection). |
128
+ | `options.maxElements` | number | Cap on elements collected for the element map. Default: 3000. |
119
129
  | `options.ignoreSelectors` | string[] | Elements hidden (`visibility: hidden`) for this capture; merged with the global `ignoreSelectors` from `.testivai/config.json`. |
120
130
  | `options.stabilize` | boolean | Override capture stabilization (animations frozen, caret hidden, fonts awaited). Default: `true` (or the global `stabilize` setting). |
121
131
 
@@ -0,0 +1,25 @@
1
+ import { type CollectedElement } from '@testivai/witness';
2
+ /** What the page reports: the shared collector's map plus where the viewport was. */
3
+ export interface PageElementMap {
4
+ /** From the shared collector: document coordinates in device pixels. */
5
+ elements: CollectedElement[];
6
+ scrollX: number;
7
+ scrollY: number;
8
+ viewportWidth: number;
9
+ viewportHeight: number;
10
+ dpr: number;
11
+ }
12
+ /**
13
+ * Page-side script: the same collector the Playwright and Selenium adapters
14
+ * run (so all lanes produce identical maps), plus the viewport it was taken
15
+ * in. WebdriverIO string scripts need an explicit `return`.
16
+ */
17
+ export declare function elementMapScript(maxElements: number, ignoreSelectors: string[]): string;
18
+ export declare function isPageElementMap(value: unknown): value is PageElementMap;
19
+ /**
20
+ * WebDriver's Take Screenshot captures the viewport, while the collector
21
+ * reports document coordinates. Keep only the elements inside the captured
22
+ * viewport, moved into screenshot coordinates, so the map describes the
23
+ * pixels that were actually compared.
24
+ */
25
+ export declare function toViewportElementMap(page: PageElementMap): CollectedElement[];
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.elementMapScript = elementMapScript;
4
+ exports.isPageElementMap = isPageElementMap;
5
+ exports.toViewportElementMap = toViewportElementMap;
6
+ const witness_1 = require("@testivai/witness");
7
+ /**
8
+ * Page-side script: the same collector the Playwright and Selenium adapters
9
+ * run (so all lanes produce identical maps), plus the viewport it was taken
10
+ * in. WebdriverIO string scripts need an explicit `return`.
11
+ */
12
+ function elementMapScript(maxElements, ignoreSelectors) {
13
+ return (`return { elements: ${(0, witness_1.buildElementMapExpression)(maxElements, ignoreSelectors)}, ` +
14
+ 'scrollX: window.scrollX || 0, scrollY: window.scrollY || 0, ' +
15
+ 'viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, ' +
16
+ 'dpr: window.devicePixelRatio || 1 };');
17
+ }
18
+ function isPageElementMap(value) {
19
+ if (!value || typeof value !== 'object')
20
+ return false;
21
+ const v = value;
22
+ return (Array.isArray(v.elements) &&
23
+ ['scrollX', 'scrollY', 'viewportWidth', 'viewportHeight', 'dpr'].every((k) => typeof v[k] === 'number'));
24
+ }
25
+ /**
26
+ * WebDriver's Take Screenshot captures the viewport, while the collector
27
+ * reports document coordinates. Keep only the elements inside the captured
28
+ * viewport, moved into screenshot coordinates, so the map describes the
29
+ * pixels that were actually compared.
30
+ */
31
+ function toViewportElementMap(page) {
32
+ const offsetX = Math.round(page.scrollX * page.dpr);
33
+ const offsetY = Math.round(page.scrollY * page.dpr);
34
+ const width = Math.round(page.viewportWidth * page.dpr);
35
+ const height = Math.round(page.viewportHeight * page.dpr);
36
+ return page.elements
37
+ .map((e) => ({ ...e, x: e.x - offsetX, y: e.y - offsetY }))
38
+ .filter((e) => e.x < width && e.x + e.width > 0 && e.y < height && e.y + e.height > 0);
39
+ }
package/dist/types.d.ts CHANGED
@@ -29,6 +29,14 @@ export interface WitnessOptions {
29
29
  * known to be expensive or unstable.
30
30
  */
31
31
  skipDom?: boolean;
32
+ /**
33
+ * Skip the element map (each element's box and a digest of its computed
34
+ * styles). Without it the report falls back to the pixel and DOM layers:
35
+ * no style check, region attribution or page-shift detection.
36
+ */
37
+ skipElementMap?: boolean;
38
+ /** Cap on elements collected for the element map. Default: 3000. */
39
+ maxElements?: number;
32
40
  /**
33
41
  * CSS selectors for elements to hide (`visibility: hidden`) for the
34
42
  * duration of the capture, so dynamic content (timestamps, ads, live
package/dist/witness.d.ts CHANGED
@@ -6,9 +6,9 @@
6
6
  * import { testivai } from '@testivai/witness-webdriverio';
7
7
  * await testivai.witness(browser, 'homepage');
8
8
  *
9
- * Captures a full-page screenshot via `browser.takeScreenshot()` and the
10
- * page DOM via `browser.execute(() => document.documentElement.outerHTML)`,
11
- * then writes both into `.testivai/temp/<name>/` using the same
9
+ * Captures the viewport screenshot via `browser.takeScreenshot()` (WebDriver's
10
+ * Take Screenshot is viewport-sized), the page DOM, and the element map via
11
+ * `browser.execute(...)`, then writes them into `.testivai/temp/<name>/` using the same
12
12
  * BaselineStore layout as @testivai/witness-playwright. The TestivaiService
13
13
  * (registered in wdio.conf.ts) runs `compareAll` + `generateReport` after
14
14
  * all tests finish.
package/dist/witness.js CHANGED
@@ -7,9 +7,9 @@
7
7
  * import { testivai } from '@testivai/witness-webdriverio';
8
8
  * await testivai.witness(browser, 'homepage');
9
9
  *
10
- * Captures a full-page screenshot via `browser.takeScreenshot()` and the
11
- * page DOM via `browser.execute(() => document.documentElement.outerHTML)`,
12
- * then writes both into `.testivai/temp/<name>/` using the same
10
+ * Captures the viewport screenshot via `browser.takeScreenshot()` (WebDriver's
11
+ * Take Screenshot is viewport-sized), the page DOM, and the element map via
12
+ * `browser.execute(...)`, then writes them into `.testivai/temp/<name>/` using the same
13
13
  * BaselineStore layout as @testivai/witness-playwright. The TestivaiService
14
14
  * (registered in wdio.conf.ts) runs `compareAll` + `generateReport` after
15
15
  * all tests finish.
@@ -19,9 +19,45 @@
19
19
  * point of the per-framework adapter — using the framework's stable public
20
20
  * API instead of fighting Chrome-launch coordination.
21
21
  */
22
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ var desc = Object.getOwnPropertyDescriptor(m, k);
25
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
26
+ desc = { enumerable: true, get: function() { return m[k]; } };
27
+ }
28
+ Object.defineProperty(o, k2, desc);
29
+ }) : (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ o[k2] = m[k];
32
+ }));
33
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
34
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
35
+ }) : function(o, v) {
36
+ o["default"] = v;
37
+ });
38
+ var __importStar = (this && this.__importStar) || (function () {
39
+ var ownKeys = function(o) {
40
+ ownKeys = Object.getOwnPropertyNames || function (o) {
41
+ var ar = [];
42
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
43
+ return ar;
44
+ };
45
+ return ownKeys(o);
46
+ };
47
+ return function (mod) {
48
+ if (mod && mod.__esModule) return mod;
49
+ var result = {};
50
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
51
+ __setModuleDefault(result, mod);
52
+ return result;
53
+ };
54
+ })();
22
55
  Object.defineProperty(exports, "__esModule", { value: true });
23
56
  exports.witness = witness;
57
+ const fs = __importStar(require("fs"));
58
+ const path = __importStar(require("path"));
24
59
  const witness_1 = require("@testivai/witness");
60
+ const element_map_1 = require("./element-map");
25
61
  /** id of the style element injected for the duration of a capture */
26
62
  const STYLE_ID = '__testivai_capture_css__';
27
63
  /**
@@ -157,7 +193,36 @@ async function witness(browser, name, options = {}) {
157
193
  // is unavailable for this snapshot; pixel diff still works.
158
194
  }
159
195
  }
160
- // 3. Write to .testivai/temp/<name>/
196
+ // 3. Capture the element map (best-effort — same contract as the DOM).
197
+ // It powers the style check, region attribution and page-shift
198
+ // detection on the compare side. The screenshot is the viewport, so the
199
+ // map keeps only the elements inside it, in screenshot coordinates.
200
+ let elementMap;
201
+ if (!options.skipElementMap && typeof browser.execute === 'function') {
202
+ try {
203
+ const result = await browser.execute((0, element_map_1.elementMapScript)(options.maxElements ?? witness_1.DEFAULT_MAX_ELEMENTS, ignoreSelectors));
204
+ if ((0, element_map_1.isPageElementMap)(result)) {
205
+ const inView = (0, element_map_1.toViewportElementMap)(result);
206
+ if (inView.length > 0)
207
+ elementMap = inView;
208
+ }
209
+ }
210
+ catch {
211
+ // Suppressed by design, exactly like DOM capture: without a map the
212
+ // report falls back to the pixel and DOM layers instead of failing.
213
+ }
214
+ }
215
+ // 4. Write to .testivai/temp/<name>/
161
216
  const store = new witness_1.BaselineStore(process.cwd());
162
217
  store.writeTemp(name, screenshot, dom);
218
+ if (elementMap) {
219
+ try {
220
+ const tempDir = path.join(process.cwd(), '.testivai', 'temp', name);
221
+ fs.mkdirSync(tempDir, { recursive: true });
222
+ fs.writeFileSync(path.join(tempDir, 'elements.json'), JSON.stringify(elementMap));
223
+ }
224
+ catch {
225
+ // A map we cannot persist is not worth failing a capture over.
226
+ }
227
+ }
163
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testivai/witness-webdriverio",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "WebdriverIO adapter for TestivAI Witness — local-first visual regression with pixel + DOM comparison",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -54,15 +54,15 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@testivai/witness": "2.0.1"
57
+ "@testivai/witness": "2.1.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/jest": "^30.0.0",
61
61
  "@types/node": "^20.0.0",
62
- "jest": "^30.4.2",
62
+ "jest": "^30.5.1",
63
63
  "ts-jest": "^29.4.12",
64
64
  "typescript": "^6.0.3",
65
- "webdriverio": "^9.30.1"
65
+ "webdriverio": "^9.31.7"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "tsc",