-
Notifications
You must be signed in to change notification settings - Fork 40
/
utils.js
373 lines (322 loc) · 12.5 KB
/
utils.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
import EventEmitter from 'events';
import { sha256hash } from '@percy/client/utils';
import { camelcase, merge } from '@percy/config/utils';
export {
request,
getPackageJSON,
hostnameMatches
} from '@percy/client/utils';
export {
Server,
createServer
} from './server.js';
// Returns the hostname portion of a URL.
export function hostname(url) {
return new URL(url).hostname;
}
// Normalizes a URL by stripping hashes to ensure unique resources.
export function normalizeURL(url) {
let { protocol, host, pathname, search } = new URL(url);
return `${protocol}//${host}${pathname}${search}`;
}
/* istanbul ignore next: tested, but coverage is stripped */
// Returns the body for automateScreenshot in structure
export function percyAutomateRequestHandler(req, percy) {
if (req.body.client_info) {
req.body.clientInfo = req.body.client_info;
}
if (req.body.environment_info) {
req.body.environmentInfo = req.body.environment_info;
}
// combines array and overrides global config with per-screenshot config
let camelCasedOptions = {};
Object.entries(req.body.options || {}).forEach(([key, value]) => {
camelCasedOptions[camelcase(key)] = value;
});
req.body.options = merge([{
fullPage: percy.config.snapshot.fullPage,
percyCSS: percy.config.snapshot.percyCSS,
freezeAnimatedImage: percy.config.snapshot.freezeAnimatedImage || percy.config.snapshot.freezeAnimation,
freezeImageBySelectors: percy.config.snapshot.freezeAnimatedImageOptions?.freezeImageBySelectors,
freezeImageByXpaths: percy.config.snapshot.freezeAnimatedImageOptions?.freezeImageByXpaths,
ignoreRegionSelectors: percy.config.snapshot.ignoreRegions?.ignoreRegionSelectors,
ignoreRegionXpaths: percy.config.snapshot.ignoreRegions?.ignoreRegionXpaths,
considerRegionSelectors: percy.config.snapshot.considerRegions?.considerRegionSelectors,
considerRegionXpaths: percy.config.snapshot.considerRegions?.considerRegionXpaths,
sync: percy.config.snapshot.sync,
version: 'v2'
},
camelCasedOptions
], (path, prev, next) => {
switch (path.map(k => k.toString()).join('.')) {
case 'percyCSS': // concatenate percy css
return [path, [prev, next].filter(Boolean).join('\n')];
}
});
req.body.buildInfo = percy.build;
}
// Returns the body for sendEvent structure
export function percyBuildEventHandler(req, cliVersion) {
if (Array.isArray(req.body)) {
return req.body.map(item => processSendEventData(item, cliVersion));
} else {
// Treat the input as an object and perform instructions
return processSendEventData(req.body, cliVersion);
}
}
// Process sendEvent object
function processSendEventData(input, cliVersion) {
// Add Properties here to send to eventData
const allowedEventProperties = ['message', 'cliVersion', 'clientInfo', 'errorKind', 'extra'];
const extractedData = {};
for (const property of allowedEventProperties) {
if (Object.prototype.hasOwnProperty.call(input, property)) {
extractedData[property] = input[property];
}
}
if (extractedData.clientInfo) {
const [client, clientVersion] = extractedData.clientInfo.split('/');
// Add the client and clientVersion fields to the object
extractedData.client = client;
extractedData.clientVersion = clientVersion;
delete extractedData.clientInfo;
}
if (!input.cliVersion) {
extractedData.cliVersion = cliVersion;
}
return extractedData;
}
// Creates a local resource object containing the resource URL, mimetype, content, sha, and any
// other additional resources attributes.
export function createResource(url, content, mimetype, attrs) {
return { ...attrs, sha: sha256hash(content), mimetype, content, url };
}
// Creates a root resource object with an additional `root: true` property. The URL is normalized
// here as a convenience since root resources are usually created outside of asset discovery.
export function createRootResource(url, content) {
return createResource(normalizeURL(url), content, 'text/html', { root: true });
}
// Creates a Percy CSS resource object.
export function createPercyCSSResource(url, css) {
let { href, pathname } = new URL(`/percy-specific.${Date.now()}.css`, url);
return createResource(href, css, 'text/css', { pathname });
}
// Creates a log resource object.
export function createLogResource(logs) {
let [url, content] = [`/percy.${Date.now()}.log`, JSON.stringify(logs)];
return createResource(url, content, 'text/plain', { log: true });
}
// Returns true or false if the provided object is a generator or not
export function isGenerator(subject) {
return typeof subject?.next === 'function' && (
typeof subject[Symbol.iterator] === 'function' ||
typeof subject[Symbol.asyncIterator] === 'function'
);
}
// Iterates over the provided generator and resolves to the final value when done. With an
// AbortSignal, the generator will throw with the abort reason when aborted. Also accepts an
// optional node-style callback, called before the returned promise resolves.
export async function generatePromise(gen, signal, cb) {
try {
if (typeof signal === 'function') [cb, signal] = [signal];
if (typeof gen === 'function') gen = await gen();
let { done, value } = !isGenerator(gen)
? { done: true, value: await gen }
: await gen.next();
while (!done) {
({ done, value } = signal?.aborted
? await gen.throw(signal.reason)
: await gen.next(value));
}
if (!cb) return value;
return cb(null, value);
} catch (error) {
if (!cb) throw error;
return cb(error);
}
}
// Bare minimum AbortController polyfill for Node < 16.14
export class AbortController {
signal = new EventEmitter();
abort(reason = new AbortError()) {
if (this.signal.aborted) return;
Object.assign(this.signal, { reason, aborted: true });
this.signal.emit('abort', reason);
}
}
// Similar to DOMException[AbortError] but accepts additional properties
export class AbortError extends Error {
constructor(msg = 'This operation was aborted', props) {
Object.assign(super(msg), { name: 'AbortError', ...props });
}
}
// An async generator that yields after every event loop until the promise settles
export async function* yieldTo(subject) {
// yield to any provided generator or return non-promise values
if (isGenerator(subject)) return yield* subject;
if (typeof subject?.then !== 'function') return subject;
// update local variables with the provided promise
let result, error, pending = !!subject
.then(r => (result = r), e => (error = e))
.finally(() => (pending = false));
/* eslint-disable-next-line no-unmodified-loop-condition */
while (pending) yield new Promise(r => setImmediate(r));
if (error) throw error;
return result;
}
// An async generator that runs provided generators concurrently
export async function* yieldAll(all) {
let res = new Array(all.length).fill();
all = all.map(yieldTo);
while (true) {
res = await Promise.all(all.map((g, i) => (
res[i]?.done ? res[i] : g.next(res[i]?.value)
)));
let vals = res.map(r => r?.value);
if (res.some(r => !r?.done)) yield vals;
else return vals;
}
}
// An async generator that infinitely yields to the predicate function until a truthy value is
// returned. When a timeout is provided, an error will be thrown during the next iteration after the
// timeout has been exceeded. If an idle option is provided, the predicate will be yielded to a
// second time, after the idle period, to ensure the yielded value is still truthy. The poll option
// determines how long to wait before yielding to the predicate function during each iteration.
export async function* yieldFor(predicate, options = {}) {
if (Number.isInteger(options)) options = { timeout: options };
let { timeout, idle, poll = 10 } = options;
let start = Date.now();
let done, value;
while (true) {
if (timeout && Date.now() - start >= timeout) {
throw new Error(`Timeout of ${timeout}ms exceeded.`);
} else if (!(value = yield predicate())) {
done = await waitForTimeout(poll, false);
} else if (idle && !done) {
done = await waitForTimeout(idle, true);
} else {
return value;
}
}
}
// Promisified version of `yieldFor` above.
export function waitFor() {
return generatePromise(yieldFor(...arguments));
}
// Promisified version of `setTimeout` (no callback argument).
export function waitForTimeout() {
return new Promise(resolve => setTimeout(resolve, ...arguments));
}
// Browser-specific util to wait for a query selector to exist within an optional timeout.
/* istanbul ignore next: tested, but coverage is stripped */
async function waitForSelector(selector, timeout) {
try {
return await waitFor(() => document.querySelector(selector), timeout);
} catch {
throw new Error(`Unable to find: ${selector}`);
}
}
// Browser-specific util to wait for an xpath selector to exist within an optional timeout.
/* istanbul ignore next: tested, but coverage is stripped */
async function waitForXPath(selector, timeout) {
try {
let xpath = () => document.evaluate(selector, document, null, 9, null);
return await waitFor(() => xpath().singleNodeValue, timeout);
} catch {
throw new Error(`Unable to find: ${selector}`);
}
}
// Browser-specific util to scroll to the bottom of a page, optionally calling the provided function
// after each window segment has been scrolled.
/* istanbul ignore next: tested, but coverage is stripped */
async function scrollToBottom(options, onScroll) {
if (typeof options === 'function') [onScroll, options] = [options];
let size = () => Math.ceil(document.body.scrollHeight / window.innerHeight);
for (let s, i = 1; i < (s = size()); i++) {
window.scrollTo({ ...options, top: window.innerHeight * i });
await onScroll?.(i, s);
}
}
// Used to test if a string looks like a function
const FUNC_REG = /^(async\s+)?(function\s*)?(\w+\s*)?\(.*?\)\s*(\{|=>)/is;
// Serializes the provided function with percy helpers for use in evaluating browser scripts
export function serializeFunction(fn) {
// stringify or convert a function body into a complete function
let fnbody = (typeof fn === 'string' && !FUNC_REG.test(fn))
? `async function eval() {\n${fn}\n}` : fn.toString();
// we might have a function shorthand if this fails
/* eslint-disable-next-line no-new, no-new-func */
try { new Function(`(${fnbody})`); } catch (error) {
fnbody = fnbody.startsWith('async ')
? fnbody.replace(/^async/, 'async function')
: `function ${fnbody}`;
/* eslint-disable-next-line no-new, no-new-func */
try { new Function(`(${fnbody})`); } catch (error) {
throw new Error('The provided function is not serializable');
}
}
// wrap the function body with percy helpers
fnbody = 'function withPercyHelpers() {\n' + [
'const { config, snapshot } = window.__PERCY__ ?? {};',
`return (${fnbody})({`,
' config, snapshot, generatePromise, yieldFor,',
' waitFor, waitForTimeout, waitForSelector, waitForXPath,',
' scrollToBottom',
'}, ...arguments);',
`${isGenerator}`,
`${generatePromise}`,
`${yieldFor}`,
`${waitFor}`,
`${waitForTimeout}`,
`${waitForSelector}`,
`${waitForXPath}`,
`${scrollToBottom}`
].join('\n') + '\n}';
/* istanbul ignore else: ironic. */
if (fnbody.includes('cov_')) {
// remove coverage statements during testing
fnbody = fnbody.replace(/cov_.*?(;\n?|,)\s*/g, '');
}
return fnbody;
}
export async function withRetries(fn, { count, onRetry, signal, throwOn }) {
count ||= 1; // default a single try
let run = 0;
while (true) {
run += 1;
try {
return await generatePromise(fn, signal);
} catch (e) {
// if this error should not be retried on, we want to skip errors
let throwError = throwOn?.includes(e.name);
if (!throwError && run < count) {
await onRetry?.();
continue;
}
throw e;
}
}
}
export function snapshotLogName(name, meta) {
if (meta?.snapshot?.testCase) {
return `testCase: ${meta.snapshot.testCase}, ${name}`;
}
return name;
}
// DefaultMap, which returns a default value for an uninitialized key
// Similar to defaultDict in python
export class DefaultMap extends Map {
constructor(getDefaultValue, ...mapConstructorArgs) {
super(...mapConstructorArgs);
if (typeof getDefaultValue !== 'function') {
throw new Error('getDefaultValue must be a function');
}
this.getDefaultValue = getDefaultValue;
}
get = (key) => {
if (!this.has(key)) {
this.set(key, this.getDefaultValue(key));
}
return super.get(key);
};
};