@testivai/witness-webdriverio 0.4.6 → 0.5.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
@@ -131,6 +131,11 @@ Captures a viewport screenshot, the DOM and the element map, and writes them as
131
131
  | `options.maxElements` | number | Cap on elements collected for the element map. Default: 3000. |
132
132
  | `options.ignoreSelectors` | string[] | Elements hidden (`visibility: hidden`) for this capture; merged with the global `ignoreSelectors` from `.testivai/config.json`. |
133
133
  | `options.stabilize` | boolean | Override capture stabilization (animations frozen, caret hidden, fonts awaited). Default: `true` (or the global `stabilize` setting). |
134
+ | `options.native` | boolean | Native app mode (experimental): detected by default in an Appium session on a native Android or iOS screen. `true` forces it, `false` keeps the web path. |
135
+ | `options.maskStatusBar` | boolean | Native mode only: mask the status bar, measured at capture time (Android: the window manager's bar, `mobile: getSystemBars`; iOS: `mobile: viewportRect`). Default: `true`. |
136
+ | `options.context` | string | Capture in another Appium context for this call, then switch back: `'webview'` (the app's web view) or an exact context name. For a hybrid app driven in the native context, the web view is captured as a page, with every layer. |
137
+
138
+ In an Appium session on a native app screen, `witness()` runs no page script (a native screen has no DOM): it takes the whole screen, masks the status bar, records the device, and keys the snapshot `<name>__<platform>-<device>-<osMajor>`. Pixel diff only. See [Native mobile apps](https://github.com/testivai/testivai-oss/blob/main/docs/guides/native-mobile.md).
134
139
 
135
140
  ### `TestivaiService`
136
141
 
@@ -147,6 +152,7 @@ Class registered as a WDIO service. Implements `onComplete`.
147
152
  ## Status
148
153
 
149
154
  - **Local mode**: stable
155
+ - **Native app mode**: experimental (verified on an Android 14 emulator; iOS not verified)
150
156
 
151
157
  ## Links
152
158
 
@@ -14,7 +14,7 @@ export interface PageElementMap {
14
14
  * run (so all lanes produce identical maps), plus the viewport it was taken
15
15
  * in. WebdriverIO string scripts need an explicit `return`.
16
16
  */
17
- export declare function elementMapScript(maxElements: number, ignoreSelectors: string[]): string;
17
+ export declare function elementMapScript(maxElements: number, ignoreSelectors: string[], idAttributes?: string[]): string;
18
18
  export declare function isPageElementMap(value: unknown): value is PageElementMap;
19
19
  /**
20
20
  * WebDriver's Take Screenshot captures the viewport, while the collector
@@ -9,8 +9,8 @@ const witness_1 = require("@testivai/witness");
9
9
  * run (so all lanes produce identical maps), plus the viewport it was taken
10
10
  * in. WebdriverIO string scripts need an explicit `return`.
11
11
  */
12
- function elementMapScript(maxElements, ignoreSelectors) {
13
- return (`return { elements: ${(0, witness_1.buildElementMapExpression)(maxElements, ignoreSelectors)}, ` +
12
+ function elementMapScript(maxElements, ignoreSelectors, idAttributes = []) {
13
+ return (`return { elements: ${(0, witness_1.buildElementMapExpression)(maxElements, ignoreSelectors, idAttributes)}, ` +
14
14
  'scrollX: window.scrollX || 0, scrollY: window.scrollY || 0, ' +
15
15
  'viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, ' +
16
16
  'dpr: window.devicePixelRatio || 1 };');
package/dist/types.d.ts CHANGED
@@ -24,6 +24,16 @@ export interface WitnessBrowser {
24
24
  * recorded as capture provenance in the capture's metadata.json.
25
25
  */
26
26
  capabilities?: Record<string, unknown>;
27
+ /**
28
+ * Appium's current context (`NATIVE_APP`, `WEBVIEW_…`): WebdriverIO's
29
+ * `browser.getContext()`. Asked only in a mobile session that names no
30
+ * browser, to tell a native screen from a hybrid app's web view.
31
+ */
32
+ getContext?(): Promise<unknown>;
33
+ /** Appium's contexts (WebdriverIO's `browser.getContexts()`), for `options.context`. */
34
+ getContexts?(): Promise<unknown[]>;
35
+ /** Switch Appium's context (WebdriverIO's `browser.switchContext()`), for `options.context`. */
36
+ switchContext?(context: string): Promise<unknown>;
27
37
  }
28
38
  /**
29
39
  * Options for a single witness() call. All optional.
@@ -64,6 +74,32 @@ export interface WitnessOptions {
64
74
  * via `stabilize` in `.testivai/config.json`; this per-call value wins).
65
75
  */
66
76
  stabilize?: boolean;
77
+ /**
78
+ * Native app mode (experimental). By default it is detected: an Appium
79
+ * session on Android or iOS that names no browser, in the `NATIVE_APP`
80
+ * context (or, when the context cannot be read, started with `app`,
81
+ * `appPackage` or `bundleId`). A native screen has no page, so no page
82
+ * script runs: the capture is the screenshot alone (pixel diff only), the
83
+ * status bar is masked, the environment comes from the capabilities, and
84
+ * the snapshot is keyed by device. `true` forces it, `false` turns it off.
85
+ */
86
+ native?: boolean;
87
+ /**
88
+ * Native mode only: mask the status bar, measured at capture time: on
89
+ * Android the bar's frame in the window manager (`mobile: getSystemBars`),
90
+ * on iOS `mobile: viewportRect`. Default: true.
91
+ */
92
+ maskStatusBar?: boolean;
93
+ /**
94
+ * Capture in another Appium context for this call, then switch back (also
95
+ * when the capture fails). `'webview'` is the app's web view: the one
96
+ * named after the app's package, else the first `WEBVIEW_…` context. Any
97
+ * other value is an exact context name (`'WEBVIEW_com.example.app'`,
98
+ * `'NATIVE_APP'`). For a hybrid app driven in the native context, this
99
+ * captures its web view as a page, with every layer. Needs WebView
100
+ * debugging enabled in the app's build.
101
+ */
102
+ context?: 'webview' | (string & {});
67
103
  }
68
104
  /**
69
105
  * Configuration passed to the WDIO service.
package/dist/witness.js CHANGED
@@ -118,6 +118,99 @@ async function waitForFonts(browser) {
118
118
  await new Promise((resolve) => setTimeout(resolve, 100));
119
119
  }
120
120
  }
121
+ /** A variant as it goes into a snapshot name. */
122
+ const variantSegment = (variant) => variant.replace(/[^a-z0-9_-]+/gi, '_').toLowerCase();
123
+ /**
124
+ * Whether this capture is of a native app screen (experimental native mode).
125
+ * Only a mobile session that names no browser is asked for its context, so a
126
+ * browser session makes no extra call. NATIVE_APP is native; a web view
127
+ * (WEBVIEW_…, CHROMIUM) is a page. When the context cannot be read, the
128
+ * capabilities decide: started on an app (app, appPackage, bundleId).
129
+ */
130
+ async function isNativeCapture(browser, forced) {
131
+ if (forced !== undefined)
132
+ return forced;
133
+ const caps = browser.capabilities;
134
+ if (!(0, witness_1.mobilePlatform)(caps) || (0, witness_1.namesBrowser)(caps))
135
+ return false;
136
+ if (typeof browser.getContext === 'function') {
137
+ try {
138
+ const context = await browser.getContext();
139
+ const id = typeof context === 'string' ? context : context?.id;
140
+ if (typeof id === 'string' && id.length > 0)
141
+ return id === 'NATIVE_APP';
142
+ }
143
+ catch {
144
+ // the capabilities decide
145
+ }
146
+ }
147
+ return (0, witness_1.isNativeAppCapabilities)(caps);
148
+ }
149
+ /**
150
+ * Native mode: a native screen has no page, so no page script runs (Appium
151
+ * rejects them all there, and WebdriverIO resends each failed one three
152
+ * times: ~1.5 s each, measured on an Android 14 emulator). The capture is the
153
+ * screenshot alone, with the status bar masked (`statusBarHeight`), the
154
+ * environment from the capabilities, and the name keyed by device unless the
155
+ * call gives a variant.
156
+ */
157
+ async function witnessNative(browser, name, options) {
158
+ const localConfig = (0, witness_1.loadLocalConfig)(process.cwd());
159
+ const variant = options.variant ? variantSegment(options.variant) : (0, witness_1.nativeAppVariant)((0, witness_1.nativeAppDevice)(browser.capabilities));
160
+ if (variant)
161
+ name = `${name}__${variant}`;
162
+ const environment = (0, witness_1.appiumEnvironment)({
163
+ capabilities: browser.capabilities,
164
+ adapter: { name: '@testivai/witness-webdriverio', version: ADAPTER_VERSION, captureMethod: witness_1.CAPTURE_METHODS.APPIUM_SCREEN },
165
+ framework: { name: 'webdriverio', version: (0, witness_1.installedPackageVersion)('webdriverio') },
166
+ });
167
+ name = (0, witness_1.applyEnvironmentPolicyToName)(name, { browser: { name: environment.browser.name }, os: { platform: process.platform } }, localConfig.environmentPolicy);
168
+ const statusBar = options.maskStatusBar === false ? null : await statusBarHeight(browser);
169
+ const base64 = await browser.takeScreenshot();
170
+ if (typeof base64 !== 'string' || base64.length === 0) {
171
+ throw new Error(`testivai.witness("${name}"): browser.takeScreenshot() returned an empty value`);
172
+ }
173
+ const store = new witness_1.BaselineStore(process.cwd());
174
+ // Nothing from an earlier capture of this name: no DOM, element map or metadata.
175
+ for (const leftover of [store.getTempDomPath(name), store.getTempMetadataPath(name), path.join(store.getTempDir(), name, 'elements.json')]) {
176
+ fs.rmSync(leftover, { force: true });
177
+ }
178
+ store.writeTemp(name, Buffer.from(base64, 'base64'));
179
+ store.writeTempMetadata(name, { name, environment, ...(statusBar ? { masks: [{ top: statusBar }] } : {}) });
180
+ }
181
+ /** Sessions whose `mobile: getSystemBars` failed: not asked again, as WebdriverIO resends a failed command. */
182
+ const withoutSystemBars = new WeakSet();
183
+ /**
184
+ * The status bar's height in screenshot pixels for the mask, or null.
185
+ *
186
+ * On Android, the bar's window frame from the window manager at capture time
187
+ * (`mobile: getSystemBars`). UiAutomator2's own figure (`mobile: viewportRect`,
188
+ * then the capabilities) is read once, when the session starts, from a
189
+ * resource of its server app: on an Android 14 emulator it came back 63 px for
190
+ * a 136 px bar in one CI run, and it does not follow a rotation. It is the
191
+ * fallback, and the iOS source.
192
+ */
193
+ async function statusBarHeight(browser) {
194
+ if ((0, witness_1.mobilePlatform)(browser.capabilities) === 'android' && !withoutSystemBars.has(browser)) {
195
+ try {
196
+ const fromWindowManager = (0, witness_1.statusBarFromSystemBars)(await browser.execute('mobile: getSystemBars'));
197
+ if (fromWindowManager !== null)
198
+ return fromWindowManager;
199
+ }
200
+ catch {
201
+ withoutSystemBars.add(browser);
202
+ }
203
+ }
204
+ try {
205
+ const fromViewport = (0, witness_1.statusBarFromViewportRect)(await browser.execute('mobile: viewportRect'));
206
+ if (fromViewport !== null)
207
+ return fromViewport;
208
+ }
209
+ catch {
210
+ // older drivers: the capabilities' reading from the session start
211
+ }
212
+ return (0, witness_1.statusBarFromCapabilities)(browser.capabilities);
213
+ }
121
214
  /**
122
215
  * Capture a screenshot + DOM and write them as a temp snapshot.
123
216
  *
@@ -134,11 +227,68 @@ async function witness(browser, name, options = {}) {
134
227
  if (!browser || typeof browser.takeScreenshot !== 'function') {
135
228
  throw new Error('testivai.witness: browser argument must expose takeScreenshot()');
136
229
  }
230
+ if (options.context !== undefined) {
231
+ return inContext(browser, options.context, (native) => capture(browser, name, { ...options, native: options.native ?? native }));
232
+ }
233
+ return capture(browser, name, options);
234
+ }
235
+ /** A context as Appium names it: a string, or WebdriverIO's detailed `{ id }`. */
236
+ const contextName = (c) => typeof c === 'string' ? c : typeof c?.id === 'string' ? c.id : '';
237
+ /**
238
+ * Run a capture in another Appium context and switch back afterwards, also
239
+ * when it fails. `'webview'` is the web view named after the app (Android
240
+ * names it WEBVIEW_<package>), else the first one (iOS numbers them).
241
+ * Native mode follows the context: NATIVE_APP is native, any other is a page.
242
+ */
243
+ async function inContext(browser, wanted, run) {
244
+ if (typeof browser.getContext !== 'function' || typeof browser.getContexts !== 'function' || typeof browser.switchContext !== 'function') {
245
+ throw new Error('testivai.witness: options.context needs an Appium session: browser.getContext(), getContexts() and switchContext()');
246
+ }
247
+ const contexts = (await browser.getContexts()).map(contextName).filter((c) => c.length > 0);
248
+ const current = contextName(await browser.getContext());
249
+ let target;
250
+ if (wanted === 'webview') {
251
+ const views = contexts.filter((c) => c.startsWith('WEBVIEW'));
252
+ const caps = (browser.capabilities ?? {});
253
+ const app = caps.appPackage ?? caps['appium:appPackage'] ?? caps.bundleId ?? caps['appium:bundleId'];
254
+ target = views.find((c) => typeof app === 'string' && c === `WEBVIEW_${app}`) ?? views[0];
255
+ if (!target) {
256
+ throw new Error(`testivai.witness: options.context 'webview': no web view context in this session (contexts: ${contexts.join(', ') || 'none'}). ` +
257
+ "The app's build needs WebView debugging enabled (Android WebView.setWebContentsDebuggingEnabled, iOS isInspectable), and the web view must be on screen.");
258
+ }
259
+ }
260
+ else {
261
+ target = wanted;
262
+ if (!contexts.includes(target)) {
263
+ throw new Error(`testivai.witness: options.context: no context "${wanted}" in this session (contexts: ${contexts.join(', ') || 'none'})`);
264
+ }
265
+ }
266
+ const switched = target !== current && current.length > 0;
267
+ if (target !== current)
268
+ await browser.switchContext(target);
269
+ try {
270
+ await run(target === 'NATIVE_APP');
271
+ }
272
+ catch (err) {
273
+ // Back where the test was; the capture error is the one that matters.
274
+ if (switched)
275
+ await browser.switchContext(current).catch(() => undefined);
276
+ throw err;
277
+ }
278
+ // A failed switch back leaves the session elsewhere: let it surface.
279
+ if (switched)
280
+ await browser.switchContext(current);
281
+ }
282
+ /** One capture in the session's current context: native mode or a page. */
283
+ async function capture(browser, name, options) {
284
+ if (await isNativeCapture(browser, options.native)) {
285
+ return witnessNative(browser, name, options);
286
+ }
137
287
  // Multi-capability runs: fold the variant into the name so capabilities
138
288
  // don't overwrite each other's baselines (same mechanism as the Playwright
139
289
  // adapter's multi-project handling).
140
290
  if (options.variant) {
141
- name = `${name}__${options.variant.replace(/[^a-z0-9_-]+/gi, '_').toLowerCase()}`;
291
+ name = `${name}__${variantSegment(options.variant)}`;
142
292
  }
143
293
  const localConfig = (0, witness_1.loadLocalConfig)(process.cwd());
144
294
  // Capture provenance: what the page is rendered with, measured before
@@ -217,10 +367,13 @@ async function witness(browser, name, options = {}) {
217
367
  // It powers the style check, region attribution and page-shift
218
368
  // detection on the compare side. The screenshot is the viewport, so the
219
369
  // map keeps only the elements inside it, in screenshot coordinates.
370
+ // Opt-in: paths anchored on attributes such as data-testid (config
371
+ // `elementIdAttributes`), recorded with the capture only when set.
372
+ const idAttributes = (0, witness_1.normalizeElementIdAttributes)(localConfig.elementIdAttributes);
220
373
  let elementMap;
221
374
  if (!options.skipElementMap && typeof browser.execute === 'function') {
222
375
  try {
223
- const result = await browser.execute((0, element_map_1.elementMapScript)(options.maxElements ?? witness_1.DEFAULT_MAX_ELEMENTS, ignoreSelectors));
376
+ const result = await browser.execute((0, element_map_1.elementMapScript)(options.maxElements ?? witness_1.DEFAULT_MAX_ELEMENTS, ignoreSelectors, idAttributes));
224
377
  if ((0, element_map_1.isPageElementMap)(result)) {
225
378
  const inView = (0, element_map_1.toViewportElementMap)(result);
226
379
  if (inView.length > 0)
@@ -248,6 +401,8 @@ async function witness(browser, name, options = {}) {
248
401
  const tempDir = path.join(process.cwd(), '.testivai', 'temp', name);
249
402
  fs.mkdirSync(tempDir, { recursive: true });
250
403
  fs.writeFileSync(path.join(tempDir, 'elements.json'), JSON.stringify(elementMap));
404
+ if (idAttributes.length > 0)
405
+ store.writeTempMetadata(name, { elementIdAttributes: idAttributes });
251
406
  }
252
407
  catch {
253
408
  // A map we cannot persist is not worth failing a capture over.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testivai/witness-webdriverio",
3
- "version": "0.4.6",
3
+ "version": "0.5.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,7 +54,7 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@testivai/witness": "2.5.0"
57
+ "@testivai/witness": "2.6.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/jest": "^30.0.0",