@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 +6 -0
- package/dist/element-map.d.ts +1 -1
- package/dist/element-map.js +2 -2
- package/dist/types.d.ts +36 -0
- package/dist/witness.js +157 -2
- package/package.json +2 -2
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
|
|
package/dist/element-map.d.ts
CHANGED
|
@@ -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
|
package/dist/element-map.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
57
|
+
"@testivai/witness": "2.6.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/jest": "^30.0.0",
|