@percy/nightwatch 2.2.0 → 2.2.1-beta.1
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 +209 -139
- package/commands/percySnapshot.js +62 -24
- package/lib/regions.js +59 -0
- package/lib/snapshot.js +733 -0
- package/package.json +18 -12
- package/types/percySnapshot.d.ts +64 -1
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
// Canonical iframe-capture helpers, single source of truth (percy/cli #2319).
|
|
2
|
+
// Aliased to the local _FRAME_ names this module already uses.
|
|
3
|
+
const {
|
|
4
|
+
DEFAULT_MAX_IFRAME_DEPTH: DEFAULT_MAX_FRAME_DEPTH,
|
|
5
|
+
isUnsupportedIframeSrc,
|
|
6
|
+
clampIframeDepth: clampFrameDepth,
|
|
7
|
+
normalizeIgnoreSelectors
|
|
8
|
+
} = require('@percy/sdk-utils');
|
|
9
|
+
|
|
10
|
+
const CS_MAX_SCREENSHOT_LIMIT = 25000;
|
|
11
|
+
const SCROLL_DEFAULT_SLEEP_TIME = 0.45; // 450ms
|
|
12
|
+
|
|
13
|
+
// Kept for backward compatibility with callers that previously pinned a
|
|
14
|
+
// module-scope domScript + log. The new code threads both through ctx /
|
|
15
|
+
// function arguments so two concurrent percySnapshot calls cannot race on
|
|
16
|
+
// shared module state. This function is a no-op.
|
|
17
|
+
function setSnapshotContext(/* script, logger */) {
|
|
18
|
+
// Intentionally empty — context now flows through ctx.
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getOrigin(url) {
|
|
22
|
+
try {
|
|
23
|
+
return new URL(url).origin;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ignoreCanvasSerializationErrors(options = {}, utils) {
|
|
30
|
+
return options?.ignoreCanvasSerializationErrors ??
|
|
31
|
+
utils?.percy?.config?.snapshot?.ignoreCanvasSerializationErrors ??
|
|
32
|
+
false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ignoreStyleSheetSerializationErrors(options = {}, utils) {
|
|
36
|
+
return options?.ignoreStyleSheetSerializationErrors ??
|
|
37
|
+
utils?.percy?.config?.snapshot?.ignoreStyleSheetSerializationErrors ??
|
|
38
|
+
false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unwrapResult(result) {
|
|
42
|
+
if (!result) return result;
|
|
43
|
+
if (typeof result === 'object' && 'value' in result && result.value !== undefined) {
|
|
44
|
+
return result.value;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sleep(ms) {
|
|
50
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function executeScript(browser, fn, args = []) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
try {
|
|
56
|
+
browser.execute(fn, args, result => {
|
|
57
|
+
let value = unwrapResult(result);
|
|
58
|
+
if (result && result.status && result.status !== 0 && result.error) {
|
|
59
|
+
return reject(result.error);
|
|
60
|
+
}
|
|
61
|
+
resolve(value);
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
reject(error);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function executeAsyncScript(browser, fn, args = []) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
try {
|
|
72
|
+
const cb = result => {
|
|
73
|
+
let value = unwrapResult(result);
|
|
74
|
+
if (result && result.status && result.status !== 0 && result.error) {
|
|
75
|
+
return reject(result.error);
|
|
76
|
+
}
|
|
77
|
+
resolve(value);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (typeof browser.executeAsync === 'function') {
|
|
81
|
+
browser.executeAsync(fn, args, cb);
|
|
82
|
+
} else if (typeof browser.executeAsyncScript === 'function') {
|
|
83
|
+
browser.executeAsyncScript(fn, args, cb);
|
|
84
|
+
} else {
|
|
85
|
+
resolve(undefined);
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
reject(error);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Readiness gate. Runs *before* serialize when CLI exposes
|
|
94
|
+
// PercyDOM.waitForReady. Falls back silently on older CLI builds and never
|
|
95
|
+
// blocks snapshot capture. Uses sdk-utils.waitForReadyScript({ callback: true })
|
|
96
|
+
// as the shared in-browser invoker — uses `arguments[arguments.length - 1]`
|
|
97
|
+
// for the executeAsync done callback.
|
|
98
|
+
async function waitForReady(browser, options = {}, utils, log) {
|
|
99
|
+
// All readiness orchestration (disabled check + shallow-merge config +
|
|
100
|
+
// callback-mode script generation + try/catch) lives in @percy/sdk-utils
|
|
101
|
+
// 1.31.15+ as runReadinessGate. Degrade to no-op when the helper is
|
|
102
|
+
// absent — same behaviour as an old CLI without PercyDOM.waitForReady.
|
|
103
|
+
if (typeof utils?.runReadinessGate !== 'function') return undefined;
|
|
104
|
+
const result = await utils.runReadinessGate(
|
|
105
|
+
(script) => executeAsyncScript(browser, script),
|
|
106
|
+
options,
|
|
107
|
+
{ callback: true, log }
|
|
108
|
+
);
|
|
109
|
+
// sdk-utils returns null on no-op / failure; nightwatch's contract is
|
|
110
|
+
// `undefined`, so normalise.
|
|
111
|
+
return result == null ? undefined : result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getCookies(browser) {
|
|
115
|
+
if (typeof browser.getCookies !== 'function') return Promise.resolve([]);
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
try {
|
|
118
|
+
browser.getCookies(result => {
|
|
119
|
+
if (result && result.status && result.status !== 0 && result.error) {
|
|
120
|
+
return reject(result.error);
|
|
121
|
+
}
|
|
122
|
+
resolve(Array.isArray(result?.value) ? result.value : (result?.value || []));
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
reject(error);
|
|
126
|
+
}
|
|
127
|
+
}).catch(() => []);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveMaxFrameDepth(options = {}, utils) {
|
|
131
|
+
return clampFrameDepth(
|
|
132
|
+
options.maxIframeDepth ??
|
|
133
|
+
utils?.percy?.config?.snapshot?.maxIframeDepth ??
|
|
134
|
+
DEFAULT_MAX_FRAME_DEPTH
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveIgnoreSelectors(options = {}, utils) {
|
|
139
|
+
return normalizeIgnoreSelectors(
|
|
140
|
+
options.ignoreIframeSelectors ??
|
|
141
|
+
utils?.percy?.config?.snapshot?.ignoreIframeSelectors ??
|
|
142
|
+
[]
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// In-browser script that walks document.querySelectorAll('iframe') and
|
|
147
|
+
// returns metadata for each. Defined as a function reference so the two
|
|
148
|
+
// call sites (top-level enumeration in captureCorsIframes and per-frame
|
|
149
|
+
// recursion in processFrameTree) share one body.
|
|
150
|
+
function enumerateIframesScript(selectors) {
|
|
151
|
+
let iframes = document.querySelectorAll('iframe');
|
|
152
|
+
let result = [];
|
|
153
|
+
for (let i = 0; i < iframes.length; i++) {
|
|
154
|
+
let frame = iframes[i];
|
|
155
|
+
let matchesIgnore = false;
|
|
156
|
+
if (selectors && selectors.length) {
|
|
157
|
+
for (let j = 0; j < selectors.length; j++) {
|
|
158
|
+
try { if (frame.matches(selectors[j])) { matchesIgnore = true; break; } } catch (e) { /* invalid selector — ignore */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
result.push({
|
|
162
|
+
src: frame.src || '',
|
|
163
|
+
srcdoc: frame.getAttribute('srcdoc'),
|
|
164
|
+
percyElementId: frame.getAttribute('data-percy-element-id'),
|
|
165
|
+
dataPercyIgnore: frame.hasAttribute('data-percy-ignore'),
|
|
166
|
+
matchesIgnoreSelector: matchesIgnore,
|
|
167
|
+
index: i
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function shouldSkipIframe(iframe, currentOrigin, log) {
|
|
174
|
+
if (iframe.dataPercyIgnore) {
|
|
175
|
+
log?.debug?.(`Skipping iframe marked with data-percy-ignore: ${iframe.src || '(no src)'}`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (iframe.matchesIgnoreSelector) {
|
|
179
|
+
log?.debug?.(`Skipping iframe matching ignoreIframeSelectors: ${iframe.src || '(no src)'}`);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if (!iframe.src || isUnsupportedIframeSrc(iframe.src)) {
|
|
183
|
+
if (iframe.src) log?.debug?.(`Skipping unsupported iframe src: ${iframe.src}`);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (iframe.srcdoc) {
|
|
187
|
+
log?.debug?.(`Skipping srcdoc iframe at index ${iframe.index}`);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
let frameOrigin = getOrigin(iframe.src);
|
|
191
|
+
if (!frameOrigin) {
|
|
192
|
+
log?.debug?.(`Skipping iframe with invalid URL: ${iframe.src}`);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (frameOrigin === currentOrigin) {
|
|
196
|
+
log?.debug?.(`Skipping same-origin iframe: ${iframe.src}`);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (!iframe.percyElementId) {
|
|
200
|
+
log?.debug?.(`Skipping cross-origin iframe without data-percy-element-id: ${iframe.src}`);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Switch into the given iframe element from the current frame context, capture
|
|
207
|
+
// its DOM, then recurse into any cross-origin iframes nested inside it. Restores
|
|
208
|
+
// the parent context on exit. Bounded by maxFrameDepth to prevent runaway
|
|
209
|
+
// recursion when pages link to each other in cycles. `ancestorUrls` tracks the
|
|
210
|
+
// chain of frame URLs above this one — if the current frame's URL appears in
|
|
211
|
+
// the chain, we treat it as a cycle and stop descending (still capturing the
|
|
212
|
+
// current frame once).
|
|
213
|
+
async function processFrameTree(browser, iframe, depth, ancestorUrls, ctx) {
|
|
214
|
+
const { maxFrameDepth, ignoreSelectors, serializeOptions, domScript, log } = ctx;
|
|
215
|
+
if (depth > maxFrameDepth) {
|
|
216
|
+
log?.debug?.(`Reached max iframe nesting depth (${maxFrameDepth}); stopping at ${iframe.src}`);
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
if (ancestorUrls && ancestorUrls.has(iframe.src)) {
|
|
220
|
+
log?.debug?.(`Skipping cyclic iframe (${iframe.src} appears in ancestor chain)`);
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const collected = [];
|
|
225
|
+
let switchedIn = false;
|
|
226
|
+
let capturedError = null;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
log?.debug?.(`Processing cross-origin iframe (depth ${depth}): ${iframe.src}`);
|
|
230
|
+
|
|
231
|
+
// Switch to the iframe by its data-percy-element-id attribute instead of numeric
|
|
232
|
+
// index, which avoids drift if the DOM changes between enumeration and switch.
|
|
233
|
+
// CSS.escape() neutralises any quote/backslash characters that could otherwise
|
|
234
|
+
// break out of the attribute selector — defence in depth, since the id is
|
|
235
|
+
// SDK-generated.
|
|
236
|
+
let iframeElement = await executeScript(browser, function(percyId) {
|
|
237
|
+
/* eslint-disable-next-line no-undef */
|
|
238
|
+
const cssApi = (typeof CSS !== 'undefined') ? CSS : null;
|
|
239
|
+
const safeId = (cssApi && cssApi.escape) ? cssApi.escape(percyId) : String(percyId).replace(/["\\]/g, '\\$&');
|
|
240
|
+
return document.querySelector('iframe[data-percy-element-id="' + safeId + '"]');
|
|
241
|
+
}, [iframe.percyElementId]);
|
|
242
|
+
|
|
243
|
+
if (!iframeElement) {
|
|
244
|
+
log?.warn?.(`Could not find iframe element with data-percy-element-id: ${iframe.percyElementId}`);
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await promisifyBrowserCommand(browser, 'frame', iframeElement);
|
|
249
|
+
switchedIn = true;
|
|
250
|
+
|
|
251
|
+
// Inject PercyDOM into the frame
|
|
252
|
+
if (domScript) {
|
|
253
|
+
await executeScript(browser, domScript);
|
|
254
|
+
log?.debug?.(`Injected PercyDOM into frame: ${iframe.src}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Serialize the frame's DOM; enableJavaScript is intentionally forced to true
|
|
258
|
+
// to prevent the standard iframe serialization logic from running, since we
|
|
259
|
+
// handle cross-origin iframe serialization manually here
|
|
260
|
+
let frameResult = await executeScript(browser, function(opts) {
|
|
261
|
+
return {
|
|
262
|
+
/* eslint-disable-next-line no-undef */
|
|
263
|
+
snapshot: PercyDOM.serialize(opts),
|
|
264
|
+
frameUrl: document.URL
|
|
265
|
+
};
|
|
266
|
+
}, [{ ...serializeOptions, enableJavaScript: true }]);
|
|
267
|
+
|
|
268
|
+
if (!frameResult || !frameResult.snapshot) {
|
|
269
|
+
log?.warn?.(`Serialization returned empty result for frame: ${iframe.src}`);
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
log?.debug?.(`Captured cross-origin iframe (depth ${depth}): ${frameResult.frameUrl}`);
|
|
274
|
+
|
|
275
|
+
collected.push({
|
|
276
|
+
frameUrl: frameResult.frameUrl,
|
|
277
|
+
iframeData: { percyElementId: iframe.percyElementId },
|
|
278
|
+
iframeSnapshot: frameResult.snapshot
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Look for cross-origin iframes nested inside this frame and recurse into them.
|
|
282
|
+
// Same-origin descendants are already inlined as srcdoc by PercyDOM.serialize above.
|
|
283
|
+
if (depth < maxFrameDepth) {
|
|
284
|
+
let currentOrigin = getOrigin(frameResult.frameUrl);
|
|
285
|
+
let childIframesRaw = await executeScript(browser, enumerateIframesScript, [ignoreSelectors]);
|
|
286
|
+
let childIframes = Array.isArray(childIframesRaw) ? childIframesRaw : [];
|
|
287
|
+
let nextAncestors = new Set(ancestorUrls || []);
|
|
288
|
+
nextAncestors.add(frameResult.frameUrl);
|
|
289
|
+
nextAncestors.add(iframe.src);
|
|
290
|
+
for (let child of childIframes) {
|
|
291
|
+
if (shouldSkipIframe(child, currentOrigin, log)) continue;
|
|
292
|
+
let nested = await processFrameTree(browser, child, depth + 1, nextAncestors, ctx);
|
|
293
|
+
if (nested.length) collected.push(...nested);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return collected;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error && error.percyContextLost) {
|
|
300
|
+
// Merge any partial capture from the inner level into ours before propagating
|
|
301
|
+
if (Array.isArray(error.partialCapture) && error.partialCapture.length) {
|
|
302
|
+
collected.push(...error.partialCapture);
|
|
303
|
+
error.partialCapture = collected;
|
|
304
|
+
} else {
|
|
305
|
+
error.partialCapture = collected;
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
log?.warn?.(`Failed to process cross-origin iframe ${iframe.src}: ${error.message}`);
|
|
310
|
+
capturedError = error;
|
|
311
|
+
return collected;
|
|
312
|
+
} finally {
|
|
313
|
+
if (switchedIn) {
|
|
314
|
+
// Step up exactly one level so an outer recursion can continue from its
|
|
315
|
+
// own context. If parentFrame fails we have no reliable way to land in
|
|
316
|
+
// the correct parent — fall back to top and signal the caller to stop
|
|
317
|
+
// iterating siblings (whose enumeration was performed in a now-lost
|
|
318
|
+
// context). Without this signal, sibling lookups would silently match
|
|
319
|
+
// against the top document and produce wrong percyElementId resolutions.
|
|
320
|
+
try {
|
|
321
|
+
await promisifyBrowserCommand(browser, 'frameParent');
|
|
322
|
+
} catch (e) {
|
|
323
|
+
log?.warn?.(`Failed to switch back to parent frame: ${e.message}`);
|
|
324
|
+
try { await promisifyBrowserCommand(browser, 'frame', null); } catch (_) {}
|
|
325
|
+
// Always raise percyContextLost regardless of depth: even at depth 1
|
|
326
|
+
// the fallback `frame(null)` leaves us at the top document, so the
|
|
327
|
+
// caller's pending sibling enumeration was performed in a now-stale
|
|
328
|
+
// context and must be aborted. Carry partial capture so the outer
|
|
329
|
+
// caller can keep what was already serialized successfully.
|
|
330
|
+
const err = new Error(`Lost parent frame context: ${e.message}`);
|
|
331
|
+
err.percyContextLost = true;
|
|
332
|
+
err.partialCapture = collected;
|
|
333
|
+
// Preserve the original error so it isn't swallowed by this throw
|
|
334
|
+
if (capturedError) err.cause = capturedError;
|
|
335
|
+
// eslint-disable-next-line no-unsafe-finally
|
|
336
|
+
throw err;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function captureCorsIframes(browser, pageUrl, ctx) {
|
|
343
|
+
const { ignoreSelectors, log } = ctx;
|
|
344
|
+
try {
|
|
345
|
+
let iframeInfoRaw = await executeScript(browser, enumerateIframesScript, [ignoreSelectors]);
|
|
346
|
+
let iframeInfo = Array.isArray(iframeInfoRaw) ? iframeInfoRaw : [];
|
|
347
|
+
if (!iframeInfo.length) return [];
|
|
348
|
+
|
|
349
|
+
log?.debug?.(`Found ${iframeInfo.length} top-level iframe(s)`);
|
|
350
|
+
|
|
351
|
+
let pageOrigin = getOrigin(pageUrl);
|
|
352
|
+
let corsIframes = [];
|
|
353
|
+
let skippedCount = 0;
|
|
354
|
+
|
|
355
|
+
for (let iframe of iframeInfo) {
|
|
356
|
+
if (shouldSkipIframe(iframe, pageOrigin, log)) {
|
|
357
|
+
skippedCount++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
let entries;
|
|
361
|
+
try {
|
|
362
|
+
entries = await processFrameTree(browser, iframe, 1, new Set([pageUrl]), ctx);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error && error.percyContextLost) {
|
|
365
|
+
log?.warn?.('Aborting further nested CORS capture due to lost frame context');
|
|
366
|
+
if (Array.isArray(error.partialCapture) && error.partialCapture.length) {
|
|
367
|
+
corsIframes.push(...error.partialCapture);
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
if (entries && entries.length) corsIframes.push(...entries);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
log?.debug?.(`Captured ${corsIframes.length} cross-origin iframe(s) (top-level skipped: ${skippedCount})`);
|
|
377
|
+
|
|
378
|
+
return corsIframes;
|
|
379
|
+
} catch (error) {
|
|
380
|
+
log?.warn?.(`Error capturing CORS iframes: ${error.message}`);
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function captureSerializedDOM(browser, options = {}, utils, domScript = null, log = null) {
|
|
386
|
+
const serializeOptions = {
|
|
387
|
+
...options,
|
|
388
|
+
ignoreCanvasSerializationErrors: ignoreCanvasSerializationErrors(options, utils),
|
|
389
|
+
ignoreStyleSheetSerializationErrors: ignoreStyleSheetSerializationErrors(options, utils)
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
let { domSnapshot, url } = await executeScript(browser, function(opts) {
|
|
393
|
+
return {
|
|
394
|
+
/* eslint-disable-next-line no-undef */
|
|
395
|
+
domSnapshot: PercyDOM.serialize(opts),
|
|
396
|
+
url: document.URL
|
|
397
|
+
};
|
|
398
|
+
}, [serializeOptions]);
|
|
399
|
+
|
|
400
|
+
if (!domSnapshot) domSnapshot = {};
|
|
401
|
+
domSnapshot.cookies = await getCookies(browser);
|
|
402
|
+
|
|
403
|
+
// Capture cross-origin iframes. ctx carries the per-call domScript + log so
|
|
404
|
+
// concurrent percySnapshot invocations don't race on shared module state.
|
|
405
|
+
const ctx = {
|
|
406
|
+
maxFrameDepth: resolveMaxFrameDepth(options, utils),
|
|
407
|
+
ignoreSelectors: resolveIgnoreSelectors(options, utils),
|
|
408
|
+
serializeOptions,
|
|
409
|
+
domScript,
|
|
410
|
+
log
|
|
411
|
+
};
|
|
412
|
+
let corsIframes = await captureCorsIframes(browser, url, ctx);
|
|
413
|
+
if (corsIframes.length > 0) {
|
|
414
|
+
domSnapshot.corsIframes = corsIframes;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { domSnapshot, url };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isResponsiveOptionEnabled(options = {}, utils) {
|
|
421
|
+
if (utils?.percy?.config?.percy?.deferUploads) return false;
|
|
422
|
+
|
|
423
|
+
if (Object.prototype.hasOwnProperty.call(options, 'responsiveSnapshotCapture')) {
|
|
424
|
+
return Boolean(options.responsiveSnapshotCapture);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (Object.prototype.hasOwnProperty.call(options, 'responsive_snapshot_capture')) {
|
|
428
|
+
return Boolean(options.responsive_snapshot_capture);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return Boolean(utils?.percy?.config?.snapshot?.responsiveSnapshotCapture);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function getWidthsForMultiDOM(userPassedWidths = [], eligibleWidths = {}) {
|
|
435
|
+
let widths = [];
|
|
436
|
+
|
|
437
|
+
if (eligibleWidths?.mobile?.length) widths = widths.concat(eligibleWidths.mobile);
|
|
438
|
+
|
|
439
|
+
if (userPassedWidths.length) {
|
|
440
|
+
widths = widths.concat(userPassedWidths);
|
|
441
|
+
} else if (eligibleWidths?.config?.length) {
|
|
442
|
+
widths = widths.concat(eligibleWidths.config);
|
|
443
|
+
} else if (Array.isArray(eligibleWidths)) {
|
|
444
|
+
widths = widths.concat(eligibleWidths);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return [...new Set(widths)].filter(width => !!width);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function promisifyBrowserCommand(browser, command, ...args) {
|
|
451
|
+
if (typeof browser[command] !== 'function') return Promise.resolve();
|
|
452
|
+
|
|
453
|
+
return new Promise((resolve, reject) => {
|
|
454
|
+
try {
|
|
455
|
+
browser[command](...args, result => {
|
|
456
|
+
if (result && result.status && result.status !== 0 && result.error) {
|
|
457
|
+
return reject(result.error);
|
|
458
|
+
}
|
|
459
|
+
resolve(unwrapResult(result));
|
|
460
|
+
});
|
|
461
|
+
} catch (error) {
|
|
462
|
+
reject(error);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function getWindowRect(browser) {
|
|
468
|
+
let rect = await executeScript(browser, function() {
|
|
469
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
470
|
+
}) || {};
|
|
471
|
+
|
|
472
|
+
if (typeof rect.width !== 'number' || Number.isNaN(rect.width)) rect.width = 1280;
|
|
473
|
+
if (typeof rect.height !== 'number' || Number.isNaN(rect.height)) rect.height = 720;
|
|
474
|
+
return rect;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function setWindowRect(browser, { width, height }) {
|
|
478
|
+
const rect = { width, height };
|
|
479
|
+
if (typeof browser.setWindowRect === 'function') {
|
|
480
|
+
return await promisifyBrowserCommand(browser, 'setWindowRect', rect);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (typeof browser.setWindowSize === 'function') {
|
|
484
|
+
return await promisifyBrowserCommand(browser, 'setWindowSize', width, height);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (typeof browser.resizeWindow === 'function') {
|
|
488
|
+
return await promisifyBrowserCommand(browser, 'resizeWindow', width, height);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return await executeScript(browser, function(dimensions) {
|
|
492
|
+
window.resizeTo(dimensions.width, dimensions.height);
|
|
493
|
+
}, [rect]);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function waitForCondition(condition, { timeout = 1000, interval = 100 } = {}) {
|
|
497
|
+
const end = Date.now() + timeout;
|
|
498
|
+
|
|
499
|
+
while (Date.now() < end) {
|
|
500
|
+
if (await condition()) return true;
|
|
501
|
+
await sleep(interval);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return await condition();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function waitForResizeEvent(browser, resizeCount) {
|
|
508
|
+
await waitForCondition(async () => {
|
|
509
|
+
/* istanbul ignore next */
|
|
510
|
+
const current = await executeScript(browser, function() {
|
|
511
|
+
return window.resizeCount;
|
|
512
|
+
});
|
|
513
|
+
return current === resizeCount;
|
|
514
|
+
}).catch(() => {});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function sendCDPResize(browser, width, height) {
|
|
518
|
+
const driver = browser?.driver || browser?.transport?.driver;
|
|
519
|
+
if (!driver || typeof driver.sendDevToolsCommand !== 'function') return false;
|
|
520
|
+
|
|
521
|
+
await driver.sendDevToolsCommand('Emulation.setDeviceMetricsOverride', {
|
|
522
|
+
height,
|
|
523
|
+
width,
|
|
524
|
+
deviceScaleFactor: 1,
|
|
525
|
+
mobile: false
|
|
526
|
+
});
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function changeWindowDimensionAndWait(browser, width, height, resizeCount, log) {
|
|
531
|
+
if (typeof width !== 'number' || Number.isNaN(width)) return;
|
|
532
|
+
if (typeof height !== 'number' || Number.isNaN(height)) {
|
|
533
|
+
({ height } = await getWindowRect(browser));
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const caps = browser?.options?.capabilities || browser?.options?.desiredCapabilities;
|
|
537
|
+
const browserName = caps?.browserName || caps?.browser || '';
|
|
538
|
+
let usedCDP = false;
|
|
539
|
+
|
|
540
|
+
if (
|
|
541
|
+
typeof process !== 'undefined' &&
|
|
542
|
+
process.env.PERCY_DISABLE_CDP_RESIZE !== 'true' &&
|
|
543
|
+
browserName.toLowerCase() === 'chrome'
|
|
544
|
+
) {
|
|
545
|
+
try {
|
|
546
|
+
usedCDP = await sendCDPResize(browser, width, height);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
log?.debug?.(`Resizing using CDP failed for width ${width}`, error);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!usedCDP) {
|
|
553
|
+
await setWindowRect(browser, { width, height });
|
|
554
|
+
}
|
|
555
|
+
} catch (error) {
|
|
556
|
+
log?.debug?.(`Resizing window failed for width ${width}`, error);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
await waitForResizeEvent(browser, resizeCount);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
log?.debug?.(`Timed out waiting for window resize event for width ${width}`, error);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function maybeReloadPage(browser, domScript) {
|
|
567
|
+
if (!process.env.PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) return false;
|
|
568
|
+
await promisifyBrowserCommand(browser, 'refresh');
|
|
569
|
+
// Guard: if a consumer imported captureDOM directly (bypassing percySnapshot)
|
|
570
|
+
// domScript may be null. Skipping the re-injection is safe — the page reload
|
|
571
|
+
// will only matter when PercyDOM is also pinned.
|
|
572
|
+
if (domScript) {
|
|
573
|
+
await executeScript(browser, domScript);
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function maybeSleepForResponsiveCapture() {
|
|
579
|
+
if (!process.env.RESPONSIVE_CAPTURE_SLEEP_TIME) return;
|
|
580
|
+
const sleepTime = parseFloat(process.env.RESPONSIVE_CAPTURE_SLEEP_TIME);
|
|
581
|
+
if (!Number.isFinite(sleepTime)) return;
|
|
582
|
+
await sleep(sleepTime * 1000);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function maybeScrollForLazyLoad(browser) {
|
|
586
|
+
if (!process.env.PERCY_ENABLE_LAZY_LOADING_SCROLL) return;
|
|
587
|
+
await slowScrollToBottom(browser);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function getResponsiveHeight(browser, currentHeight, utils) {
|
|
591
|
+
if (!process.env.PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) return currentHeight;
|
|
592
|
+
const minHeight = utils?.percy?.config?.snapshot?.minHeight || currentHeight;
|
|
593
|
+
return await executeScript(browser, function(height) {
|
|
594
|
+
return window.outerHeight - window.innerHeight + height;
|
|
595
|
+
}, [minHeight]);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function captureResponsiveDOM(browser, options, utils, domScript = null, log = null) {
|
|
599
|
+
const widths = getWidthsForMultiDOM(options.widths || [], utils?.percy?.widths || {});
|
|
600
|
+
const domSnapshots = [];
|
|
601
|
+
const { width: originalWidth, height: originalHeight } = await getWindowRect(browser);
|
|
602
|
+
let lastWindowWidth = originalWidth;
|
|
603
|
+
let resizeCount = 0;
|
|
604
|
+
|
|
605
|
+
log?.info?.('Responsive snapshot capture enabled');
|
|
606
|
+
await executeScript(browser, function() {
|
|
607
|
+
/* eslint-disable-next-line no-undef */
|
|
608
|
+
PercyDOM.waitForResize();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const height = await getResponsiveHeight(browser, originalHeight, utils);
|
|
612
|
+
const responsiveWidths = widths.length ? widths : [originalWidth];
|
|
613
|
+
let lastURL;
|
|
614
|
+
|
|
615
|
+
for (let width of responsiveWidths) {
|
|
616
|
+
if (lastWindowWidth !== width) {
|
|
617
|
+
resizeCount += 1;
|
|
618
|
+
await changeWindowDimensionAndWait(browser, width, height, resizeCount, log);
|
|
619
|
+
lastWindowWidth = width;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
log?.info?.(`Capturing responsive snapshot at width ${width}px`);
|
|
623
|
+
const reloaded = await maybeReloadPage(browser, domScript);
|
|
624
|
+
if (reloaded) {
|
|
625
|
+
await executeScript(browser, function() {
|
|
626
|
+
/* eslint-disable-next-line no-undef */
|
|
627
|
+
PercyDOM.waitForResize();
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
await maybeSleepForResponsiveCapture();
|
|
631
|
+
await maybeScrollForLazyLoad(browser);
|
|
632
|
+
|
|
633
|
+
let { domSnapshot, url } = await captureSerializedDOM(browser, options, utils, domScript, log);
|
|
634
|
+
domSnapshot.width = width;
|
|
635
|
+
domSnapshots.push(domSnapshot);
|
|
636
|
+
lastURL = url;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
await changeWindowDimensionAndWait(browser, originalWidth, originalHeight, resizeCount + 1, log);
|
|
640
|
+
return { domSnapshot: domSnapshots, url: lastURL };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Backward-compatibility shim for the two trailing positional arguments.
|
|
644
|
+
//
|
|
645
|
+
// captureDOM is exported and was published (v2.2.1-beta.0) with the signature
|
|
646
|
+
// captureDOM(browser, options, utils, log, domScript)
|
|
647
|
+
// This branch reordered the trailing pair to (..., domScript, log) so the
|
|
648
|
+
// per-call domScript + logger could be threaded through ctx. Silently swapping
|
|
649
|
+
// two published positional args would break any external caller that imports
|
|
650
|
+
// captureDOM directly and still passes them in the old order.
|
|
651
|
+
//
|
|
652
|
+
// domScript is always the PercyDOM source (a string); log is always a logger
|
|
653
|
+
// object (or null/undefined). They are never the same type, so we can accept
|
|
654
|
+
// either order and route each value to its correct slot. This keeps both the
|
|
655
|
+
// old (log, domScript) and new (domScript, log) call shapes working.
|
|
656
|
+
function normalizeScriptAndLog(a, b) {
|
|
657
|
+
const isScript = v => typeof v === 'string';
|
|
658
|
+
if (isScript(a) && !isScript(b)) return { domScript: a, log: b ?? null };
|
|
659
|
+
if (isScript(b) && !isScript(a)) return { domScript: b, log: a ?? null };
|
|
660
|
+
// Neither is a string (e.g. both null, or domScript passed as non-string):
|
|
661
|
+
// fall back to the new positional order without reinterpreting.
|
|
662
|
+
return { domScript: a ?? null, log: b ?? null };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function captureDOM(browser, options = {}, utils, arg4 = null, arg5 = null) {
|
|
666
|
+
const { domScript, log } = normalizeScriptAndLog(arg4, arg5);
|
|
667
|
+
const responsiveEnabled = isResponsiveOptionEnabled(options, utils);
|
|
668
|
+
if (responsiveEnabled) {
|
|
669
|
+
return await captureResponsiveDOM(browser, options, utils, domScript, log);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return await captureSerializedDOM(browser, options, utils, domScript, log);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function slowScrollToBottom(browser, scrollSleep = SCROLL_DEFAULT_SLEEP_TIME) {
|
|
676
|
+
if (process.env.PERCY_LAZY_LOAD_SCROLL_TIME) {
|
|
677
|
+
scrollSleep = parseFloat(process.env.PERCY_LAZY_LOAD_SCROLL_TIME);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const scrollHeightCommand = function() {
|
|
681
|
+
return Math.max(
|
|
682
|
+
document.body.scrollHeight,
|
|
683
|
+
document.body.clientHeight,
|
|
684
|
+
document.body.offsetHeight,
|
|
685
|
+
document.documentElement.scrollHeight,
|
|
686
|
+
document.documentElement.clientHeight,
|
|
687
|
+
document.documentElement.offsetHeight
|
|
688
|
+
);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
let scrollHeight = Math.min(await executeScript(browser, scrollHeightCommand), CS_MAX_SCREENSHOT_LIMIT);
|
|
692
|
+
const clientHeight = await executeScript(browser, function() {
|
|
693
|
+
return document.documentElement.clientHeight;
|
|
694
|
+
});
|
|
695
|
+
let current = 0;
|
|
696
|
+
let page = 1;
|
|
697
|
+
|
|
698
|
+
while (scrollHeight > current && current < CS_MAX_SCREENSHOT_LIMIT) {
|
|
699
|
+
current = clientHeight * page;
|
|
700
|
+
page += 1;
|
|
701
|
+
await executeScript(browser, function(position) {
|
|
702
|
+
window.scrollTo(0, position);
|
|
703
|
+
}, [current]);
|
|
704
|
+
await sleep(scrollSleep * 1000);
|
|
705
|
+
scrollHeight = await executeScript(browser, scrollHeightCommand);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (process.env.BYPASS_SCROLL_TO_TOP !== 'true') {
|
|
709
|
+
await executeScript(browser, function() {
|
|
710
|
+
window.scrollTo(0, 0);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let sleepAfterScroll = 1;
|
|
715
|
+
if (process.env.PERCY_SLEEP_AFTER_LAZY_LOAD_COMPLETE) {
|
|
716
|
+
sleepAfterScroll = parseFloat(process.env.PERCY_SLEEP_AFTER_LAZY_LOAD_COMPLETE);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
await sleep(sleepAfterScroll * 1000);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
module.exports = {
|
|
723
|
+
captureSerializedDOM,
|
|
724
|
+
captureDOM,
|
|
725
|
+
setSnapshotContext,
|
|
726
|
+
slowScrollToBottom,
|
|
727
|
+
ignoreCanvasSerializationErrors,
|
|
728
|
+
ignoreStyleSheetSerializationErrors,
|
|
729
|
+
isUnsupportedIframeSrc,
|
|
730
|
+
getOrigin,
|
|
731
|
+
captureCorsIframes,
|
|
732
|
+
waitForReady
|
|
733
|
+
};
|