@percy/core 1.0.0-beta.8 → 1.0.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 +226 -67
- package/dist/api.js +94 -0
- package/dist/browser.js +292 -0
- package/dist/config.js +512 -30
- package/dist/discovery.js +118 -0
- package/dist/index.js +5 -29
- package/dist/install.js +156 -0
- package/dist/network.js +298 -0
- package/dist/page.js +264 -0
- package/dist/percy.js +373 -306
- package/dist/queue.js +122 -73
- package/dist/server.js +424 -76
- package/dist/session.js +103 -0
- package/dist/snapshot.js +433 -0
- package/dist/utils.js +127 -0
- package/package.json +42 -28
- package/post-install.js +20 -0
- package/test/helpers/server.js +33 -0
- package/types/index.d.ts +69 -39
- package/dist/discoverer.js +0 -367
- package/dist/percy-css.js +0 -33
- package/dist/utils/assert.js +0 -50
- package/dist/utils/bytes.js +0 -24
- package/dist/utils/idle.js +0 -15
- package/dist/utils/install-browser.js +0 -76
- package/dist/utils/resources.js +0 -75
- package/dist/utils/url.js +0 -64
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import logger from '@percy/logger';
|
|
2
|
+
import PercyConfig from '@percy/config';
|
|
3
|
+
import micromatch from 'micromatch';
|
|
4
|
+
import { configSchema } from './config.js';
|
|
5
|
+
import { request, hostnameMatches, createRootResource, createPercyCSSResource, createLogResource } from './utils.js'; // Throw a better error message for missing or invalid urls
|
|
6
|
+
|
|
7
|
+
export function validURL(url, base) {
|
|
8
|
+
if (!url) {
|
|
9
|
+
throw new Error('Missing required URL for snapshot');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return new URL(url, base);
|
|
14
|
+
} catch (e) {
|
|
15
|
+
throw new Error(`Invalid snapshot URL: ${e.input}`);
|
|
16
|
+
}
|
|
17
|
+
} // used to deserialize regular expression strings
|
|
18
|
+
|
|
19
|
+
const RE_REGEXP = /^\/(.+)\/(\w+)?$/; // Returns true or false if a snapshot matches the provided include and exclude predicates. A
|
|
20
|
+
// predicate can be an array of predicates, a regular expression, a glob pattern, or a function.
|
|
21
|
+
|
|
22
|
+
export function snapshotMatches(snapshot, include, exclude) {
|
|
23
|
+
var _include, _include2;
|
|
24
|
+
|
|
25
|
+
// support an options object as the second argument
|
|
26
|
+
if ((_include = include) !== null && _include !== void 0 && _include.include || (_include2 = include) !== null && _include2 !== void 0 && _include2.exclude) ({
|
|
27
|
+
include,
|
|
28
|
+
exclude
|
|
29
|
+
} = include); // recursive predicate test function
|
|
30
|
+
|
|
31
|
+
let test = (predicate, fallback) => {
|
|
32
|
+
if (predicate && typeof predicate === 'string') {
|
|
33
|
+
// snapshot name matches exactly or matches a glob
|
|
34
|
+
let result = snapshot.name === predicate || micromatch.isMatch(snapshot.name, predicate, {
|
|
35
|
+
basename: !predicate.startsWith('/')
|
|
36
|
+
}); // snapshot might match a string-based regexp pattern
|
|
37
|
+
|
|
38
|
+
if (!result) {
|
|
39
|
+
try {
|
|
40
|
+
let [, parsed = predicate, flags] = RE_REGEXP.exec(predicate) || [];
|
|
41
|
+
result = new RegExp(parsed, flags).test(snapshot.name);
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
} else if (predicate instanceof RegExp) {
|
|
47
|
+
// snapshot matches a regular expression
|
|
48
|
+
return predicate.test(snapshot.name);
|
|
49
|
+
} else if (typeof predicate === 'function') {
|
|
50
|
+
// advanced matching
|
|
51
|
+
return predicate(snapshot);
|
|
52
|
+
} else if (Array.isArray(predicate) && predicate.length) {
|
|
53
|
+
// array of predicates
|
|
54
|
+
return predicate.some(p => test(p));
|
|
55
|
+
} else {
|
|
56
|
+
// default fallback
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
}; // nothing to match, return true
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if (!include && !exclude) return true; // not excluded or explicitly included
|
|
63
|
+
|
|
64
|
+
return !test(exclude, false) && test(include, true);
|
|
65
|
+
} // Accepts an array of snapshots to filter and map with matching options.
|
|
66
|
+
|
|
67
|
+
export function mapSnapshotOptions(percy, snapshots, config) {
|
|
68
|
+
if (!(snapshots !== null && snapshots !== void 0 && snapshots.length)) return []; // reduce options into a single function
|
|
69
|
+
|
|
70
|
+
let applyOptions = [].concat((config === null || config === void 0 ? void 0 : config.options) || []).reduceRight((next, {
|
|
71
|
+
include,
|
|
72
|
+
exclude,
|
|
73
|
+
...opts
|
|
74
|
+
}) => snap => next( // assign additional options to included snaphots
|
|
75
|
+
snapshotMatches(snap, include, exclude) ? Object.assign(snap, opts) : snap), s => getSnapshotConfig(percy, s)); // reduce snapshots with overrides
|
|
76
|
+
|
|
77
|
+
return snapshots.reduce((acc, snapshot) => {
|
|
78
|
+
var _snapshot;
|
|
79
|
+
|
|
80
|
+
// transform snapshot URL shorthand into an object
|
|
81
|
+
if (typeof snapshot === 'string') snapshot = {
|
|
82
|
+
url: snapshot
|
|
83
|
+
}; // normalize the snapshot url and use it for the default name
|
|
84
|
+
|
|
85
|
+
let url = validURL(snapshot.url, config === null || config === void 0 ? void 0 : config.baseUrl);
|
|
86
|
+
(_snapshot = snapshot).name || (_snapshot.name = `${url.pathname}${url.search}${url.hash}`);
|
|
87
|
+
snapshot.url = url.href; // use the snapshot when matching include/exclude
|
|
88
|
+
|
|
89
|
+
if (snapshotMatches(snapshot, config)) {
|
|
90
|
+
acc.push(applyOptions(snapshot));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return acc;
|
|
94
|
+
}, []);
|
|
95
|
+
} // Returns an array of derived snapshot options
|
|
96
|
+
|
|
97
|
+
export async function gatherSnapshots(percy, options) {
|
|
98
|
+
let {
|
|
99
|
+
baseUrl,
|
|
100
|
+
snapshots
|
|
101
|
+
} = options;
|
|
102
|
+
if ('url' in options) snapshots = [options];
|
|
103
|
+
if ('sitemap' in options) snapshots = await getSitemapSnapshots(options); // validate evaluated snapshots
|
|
104
|
+
|
|
105
|
+
if (typeof snapshots === 'function') {
|
|
106
|
+
({
|
|
107
|
+
snapshots
|
|
108
|
+
} = validateSnapshotOptions({
|
|
109
|
+
baseUrl,
|
|
110
|
+
snapshots: await snapshots(baseUrl)
|
|
111
|
+
}));
|
|
112
|
+
} // map snapshots with snapshot options
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
snapshots = mapSnapshotOptions(percy, snapshots, options);
|
|
116
|
+
if (!snapshots.length) throw new Error('No snapshots found');
|
|
117
|
+
return snapshots;
|
|
118
|
+
} // Validates and migrates snapshot options against the correct schema based on provided
|
|
119
|
+
// properties. Eagerly throws an error when missing a URL for any snapshot, and warns about all
|
|
120
|
+
// other invalid options which are also scrubbed from the returned migrated options.
|
|
121
|
+
|
|
122
|
+
export function validateSnapshotOptions(options) {
|
|
123
|
+
let schema; // decide which schema to validate against
|
|
124
|
+
|
|
125
|
+
if ('domSnapshot' in options) {
|
|
126
|
+
schema = '/snapshot/dom';
|
|
127
|
+
} else if ('url' in options) {
|
|
128
|
+
schema = '/snapshot';
|
|
129
|
+
} else if ('sitemap' in options) {
|
|
130
|
+
schema = '/snapshot/sitemap';
|
|
131
|
+
} else if ('serve' in options) {
|
|
132
|
+
schema = '/snapshot/server';
|
|
133
|
+
} else if ('snapshots' in options) {
|
|
134
|
+
schema = '/snapshot/list';
|
|
135
|
+
} else {
|
|
136
|
+
schema = '/snapshot';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let {
|
|
140
|
+
// migrate and remove certain properties from validating
|
|
141
|
+
clientInfo,
|
|
142
|
+
environmentInfo,
|
|
143
|
+
snapshots,
|
|
144
|
+
...migrated
|
|
145
|
+
} = PercyConfig.migrate(options, schema); // gather info for validating individual snapshot URLs
|
|
146
|
+
|
|
147
|
+
let isSnapshot = schema === '/snapshot/dom' || schema === '/snapshot';
|
|
148
|
+
let baseUrl = schema === '/snapshot/server' ? 'http://localhost' : options.baseUrl;
|
|
149
|
+
let snaps = isSnapshot ? [migrated] : Array.isArray(snapshots) ? snapshots : [];
|
|
150
|
+
|
|
151
|
+
for (let snap of snaps) validURL(typeof snap === 'string' ? snap : snap.url, baseUrl); // add back snapshots before validating and scrubbing; function snapshots are validated later
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if (snapshots) migrated.snapshots = typeof snapshots === 'function' ? [] : snapshots;
|
|
155
|
+
let errors = PercyConfig.validate(migrated, schema);
|
|
156
|
+
|
|
157
|
+
if (errors) {
|
|
158
|
+
// warn on validation errors
|
|
159
|
+
let log = logger('core:snapshot');
|
|
160
|
+
log.warn('Invalid snapshot options:');
|
|
161
|
+
|
|
162
|
+
for (let e of errors) log.warn(`- ${e.path}: ${e.message}`);
|
|
163
|
+
} // add back the snapshots function if there was one
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if (typeof snapshots === 'function') migrated.snapshots = snapshots; // add back an empty array if all server snapshots were scrubbed
|
|
167
|
+
|
|
168
|
+
if ('serve' in options && 'snapshots' in options) migrated.snapshots ?? (migrated.snapshots = []);
|
|
169
|
+
return {
|
|
170
|
+
clientInfo,
|
|
171
|
+
environmentInfo,
|
|
172
|
+
...migrated
|
|
173
|
+
};
|
|
174
|
+
} // Fetches a sitemap and parses it into a list of URLs for taking snapshots. Duplicate URLs,
|
|
175
|
+
// including a trailing slash, are removed from the resulting list.
|
|
176
|
+
|
|
177
|
+
export async function getSitemapSnapshots(options) {
|
|
178
|
+
return request(options.sitemap, (body, res) => {
|
|
179
|
+
// validate sitemap content-type
|
|
180
|
+
let [contentType] = res.headers['content-type'].split(';');
|
|
181
|
+
|
|
182
|
+
if (!/^(application|text)\/xml$/.test(contentType)) {
|
|
183
|
+
throw new Error('The sitemap must be an XML document, ' + `but the content-type was "${contentType}"`);
|
|
184
|
+
} // parse XML content into a list of URLs
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
let urls = body.match(/(?<=<loc>)(.*)(?=<\/loc>)/ig) ?? []; // filter out duplicate URLs that differ by a trailing slash
|
|
188
|
+
|
|
189
|
+
return urls.filter((url, i) => {
|
|
190
|
+
let match = urls.indexOf(url.replace(/\/$/, ''));
|
|
191
|
+
return match === -1 || match === i;
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
} // Return snapshot options merged with defaults and global options.
|
|
195
|
+
|
|
196
|
+
export function getSnapshotConfig(percy, options) {
|
|
197
|
+
return PercyConfig.merge([{
|
|
198
|
+
widths: configSchema.snapshot.properties.widths.default,
|
|
199
|
+
discovery: {
|
|
200
|
+
allowedHostnames: [validURL(options.url).hostname]
|
|
201
|
+
},
|
|
202
|
+
meta: {
|
|
203
|
+
snapshot: {
|
|
204
|
+
name: options.name
|
|
205
|
+
},
|
|
206
|
+
build: percy.build
|
|
207
|
+
}
|
|
208
|
+
}, percy.config.snapshot, {
|
|
209
|
+
// only specific discovery options are used per-snapshot
|
|
210
|
+
discovery: {
|
|
211
|
+
allowedHostnames: percy.config.discovery.allowedHostnames,
|
|
212
|
+
disallowedHostnames: percy.config.discovery.disallowedHostnames,
|
|
213
|
+
networkIdleTimeout: percy.config.discovery.networkIdleTimeout,
|
|
214
|
+
requestHeaders: percy.config.discovery.requestHeaders,
|
|
215
|
+
authorization: percy.config.discovery.authorization,
|
|
216
|
+
disableCache: percy.config.discovery.disableCache,
|
|
217
|
+
userAgent: percy.config.discovery.userAgent
|
|
218
|
+
}
|
|
219
|
+
}, options], (path, prev, next) => {
|
|
220
|
+
var _next;
|
|
221
|
+
|
|
222
|
+
switch (path.map(k => k.toString()).join('.')) {
|
|
223
|
+
case 'widths':
|
|
224
|
+
// dedup, sort, and override widths when not empty
|
|
225
|
+
return [path, (_next = next) !== null && _next !== void 0 && _next.length ? Array.from(new Set(next)).sort((a, b) => a - b) : prev];
|
|
226
|
+
|
|
227
|
+
case 'percyCSS':
|
|
228
|
+
// concatenate percy css
|
|
229
|
+
return [path, [prev, next].filter(Boolean).join('\n')];
|
|
230
|
+
|
|
231
|
+
case 'execute':
|
|
232
|
+
// shorthand for execute.beforeSnapshot
|
|
233
|
+
return Array.isArray(next) || typeof next !== 'object' ? [path.concat('beforeSnapshot'), next] : [path];
|
|
234
|
+
|
|
235
|
+
case 'discovery.disallowedHostnames':
|
|
236
|
+
// prevent disallowing the root hostname
|
|
237
|
+
return [path, (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
|
|
238
|
+
} // ensure additional snapshots have complete names
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if (path[0] === 'additionalSnapshots' && path.length === 2) {
|
|
242
|
+
let {
|
|
243
|
+
prefix = '',
|
|
244
|
+
suffix = '',
|
|
245
|
+
...n
|
|
246
|
+
} = next;
|
|
247
|
+
next = {
|
|
248
|
+
name: `${prefix}${options.name}${suffix}`,
|
|
249
|
+
...n
|
|
250
|
+
};
|
|
251
|
+
return [path, next];
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
} // Returns a complete and valid snapshot config object and logs verbose debug logs detailing various
|
|
255
|
+
// snapshot options. When `showInfo` is true, specific messages will be logged as info logs rather
|
|
256
|
+
// than debug logs.
|
|
257
|
+
|
|
258
|
+
function debugSnapshotConfig(snapshot, showInfo) {
|
|
259
|
+
let log = logger('core:snapshot'); // log snapshot info
|
|
260
|
+
|
|
261
|
+
log.debug('---------', snapshot.meta);
|
|
262
|
+
if (showInfo) log.info(`Snapshot found: ${snapshot.name}`, snapshot.meta);else log.debug(`Handling snapshot: ${snapshot.name}`, snapshot.meta); // will log debug info for an object property if its value is defined
|
|
263
|
+
|
|
264
|
+
let debugProp = (obj, prop, format = String) => {
|
|
265
|
+
let val = prop.split('.').reduce((o, k) => o === null || o === void 0 ? void 0 : o[k], obj);
|
|
266
|
+
|
|
267
|
+
if (val != null) {
|
|
268
|
+
// join formatted array values with a space
|
|
269
|
+
val = [].concat(val).map(format).join(', ');
|
|
270
|
+
log.debug(`- ${prop}: ${val}`, snapshot.meta);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
debugProp(snapshot, 'url');
|
|
275
|
+
debugProp(snapshot, 'widths', v => `${v}px`);
|
|
276
|
+
debugProp(snapshot, 'minHeight', v => `${v}px`);
|
|
277
|
+
debugProp(snapshot, 'enableJavaScript');
|
|
278
|
+
debugProp(snapshot, 'waitForTimeout');
|
|
279
|
+
debugProp(snapshot, 'waitForSelector');
|
|
280
|
+
debugProp(snapshot, 'execute.afterNavigation');
|
|
281
|
+
debugProp(snapshot, 'execute.beforeResize');
|
|
282
|
+
debugProp(snapshot, 'execute.afterResize');
|
|
283
|
+
debugProp(snapshot, 'execute.beforeSnapshot');
|
|
284
|
+
debugProp(snapshot, 'discovery.allowedHostnames');
|
|
285
|
+
debugProp(snapshot, 'discovery.disallowedHostnames');
|
|
286
|
+
debugProp(snapshot, 'discovery.requestHeaders', JSON.stringify);
|
|
287
|
+
debugProp(snapshot, 'discovery.authorization', JSON.stringify);
|
|
288
|
+
debugProp(snapshot, 'discovery.disableCache');
|
|
289
|
+
debugProp(snapshot, 'discovery.userAgent');
|
|
290
|
+
debugProp(snapshot, 'clientInfo');
|
|
291
|
+
debugProp(snapshot, 'environmentInfo');
|
|
292
|
+
debugProp(snapshot, 'domSnapshot', Boolean);
|
|
293
|
+
|
|
294
|
+
for (let added of snapshot.additionalSnapshots || []) {
|
|
295
|
+
if (showInfo) log.info(`Snapshot found: ${added.name}`, snapshot.meta);else log.debug(`Additional snapshot: ${added.name}`, snapshot.meta);
|
|
296
|
+
debugProp(added, 'waitForTimeout');
|
|
297
|
+
debugProp(added, 'waitForSelector');
|
|
298
|
+
debugProp(added, 'execute');
|
|
299
|
+
}
|
|
300
|
+
} // Calls the provided callback with additional resources
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
function handleSnapshotResources(snapshot, map, callback) {
|
|
304
|
+
let resources = [...map.values()]; // sort the root resource first
|
|
305
|
+
|
|
306
|
+
let [root] = resources.splice(resources.findIndex(r => r.root), 1);
|
|
307
|
+
resources.unshift(root); // inject Percy CSS
|
|
308
|
+
|
|
309
|
+
if (snapshot.percyCSS) {
|
|
310
|
+
let css = createPercyCSSResource(root.url, snapshot.percyCSS);
|
|
311
|
+
resources.push(css); // replace root contents and associated properties
|
|
312
|
+
|
|
313
|
+
Object.assign(root, createRootResource(root.url, root.content.replace(/(<\/body>)(?!.*\1)/is, `<link data-percy-specific-css rel="stylesheet" href="${css.pathname}"/>` + '$&')));
|
|
314
|
+
} // include associated snapshot logs matched by meta information
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
resources.push(createLogResource(logger.query(log => {
|
|
318
|
+
var _log$meta$snapshot;
|
|
319
|
+
|
|
320
|
+
return ((_log$meta$snapshot = log.meta.snapshot) === null || _log$meta$snapshot === void 0 ? void 0 : _log$meta$snapshot.name) === snapshot.meta.snapshot.name;
|
|
321
|
+
})));
|
|
322
|
+
return callback(snapshot, resources);
|
|
323
|
+
} // Wait for a page's asset discovery network to idle
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
function waitForDiscoveryNetworkIdle(page, options) {
|
|
327
|
+
let {
|
|
328
|
+
allowedHostnames,
|
|
329
|
+
networkIdleTimeout
|
|
330
|
+
} = options;
|
|
331
|
+
|
|
332
|
+
let filter = r => hostnameMatches(allowedHostnames, r.url);
|
|
333
|
+
|
|
334
|
+
return page.network.idle(filter, networkIdleTimeout);
|
|
335
|
+
} // Used to cache resources across core instances
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
const RESOURCE_CACHE_KEY = Symbol('resource-cache'); // Discovers resources for a snapshot using a browser page to intercept requests. The callback
|
|
339
|
+
// function will be called with the snapshot name (for additional snapshots) and an array of
|
|
340
|
+
// discovered resources. When additional snapshots are provided, the callback will be called once
|
|
341
|
+
// for each snapshot.
|
|
342
|
+
|
|
343
|
+
export async function* discoverSnapshotResources(percy, snapshot, callback) {
|
|
344
|
+
debugSnapshotConfig(snapshot, percy.dryRun); // when dry-running, invoke the callback for each snapshot and immediately return
|
|
345
|
+
|
|
346
|
+
let allSnapshots = [snapshot, ...(snapshot.additionalSnapshots || [])];
|
|
347
|
+
if (percy.dryRun) return allSnapshots.map(s => callback(s)); // keep a global resource cache across snapshots
|
|
348
|
+
|
|
349
|
+
let cache = percy[RESOURCE_CACHE_KEY] || (percy[RESOURCE_CACHE_KEY] = new Map()); // copy widths to prevent mutation later
|
|
350
|
+
|
|
351
|
+
let widths = snapshot.widths.slice(); // preload the root resource for existing dom snapshots
|
|
352
|
+
|
|
353
|
+
let resources = new Map(snapshot.domSnapshot && [createRootResource(snapshot.url, snapshot.domSnapshot)].map(resource => [resource.url, resource])); // open a new browser page
|
|
354
|
+
|
|
355
|
+
let page = yield percy.browser.page({
|
|
356
|
+
enableJavaScript: snapshot.enableJavaScript ?? !snapshot.domSnapshot,
|
|
357
|
+
networkIdleTimeout: snapshot.discovery.networkIdleTimeout,
|
|
358
|
+
requestHeaders: snapshot.discovery.requestHeaders,
|
|
359
|
+
authorization: snapshot.discovery.authorization,
|
|
360
|
+
userAgent: snapshot.discovery.userAgent,
|
|
361
|
+
meta: snapshot.meta,
|
|
362
|
+
// enable network inteception
|
|
363
|
+
intercept: {
|
|
364
|
+
enableJavaScript: snapshot.enableJavaScript,
|
|
365
|
+
disableCache: snapshot.discovery.disableCache,
|
|
366
|
+
allowedHostnames: snapshot.discovery.allowedHostnames,
|
|
367
|
+
disallowedHostnames: snapshot.discovery.disallowedHostnames,
|
|
368
|
+
getResource: u => resources.get(u) || cache.get(u),
|
|
369
|
+
saveResource: r => resources.set(r.url, r) && cache.set(r.url, r)
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
var _snapshot$execute;
|
|
375
|
+
|
|
376
|
+
// set the initial page size
|
|
377
|
+
yield page.resize({
|
|
378
|
+
width: widths.shift(),
|
|
379
|
+
height: snapshot.minHeight
|
|
380
|
+
}); // navigate to the url
|
|
381
|
+
|
|
382
|
+
yield page.goto(snapshot.url);
|
|
383
|
+
yield page.evaluate((_snapshot$execute = snapshot.execute) === null || _snapshot$execute === void 0 ? void 0 : _snapshot$execute.afterNavigation); // trigger resize events for other widths
|
|
384
|
+
|
|
385
|
+
for (let width of widths) {
|
|
386
|
+
var _snapshot$execute2, _snapshot$execute3;
|
|
387
|
+
|
|
388
|
+
yield page.evaluate((_snapshot$execute2 = snapshot.execute) === null || _snapshot$execute2 === void 0 ? void 0 : _snapshot$execute2.beforeResize);
|
|
389
|
+
yield waitForDiscoveryNetworkIdle(page, snapshot.discovery);
|
|
390
|
+
yield page.resize({
|
|
391
|
+
width,
|
|
392
|
+
height: snapshot.minHeight
|
|
393
|
+
});
|
|
394
|
+
yield page.evaluate((_snapshot$execute3 = snapshot.execute) === null || _snapshot$execute3 === void 0 ? void 0 : _snapshot$execute3.afterResize);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (snapshot.domSnapshot) {
|
|
398
|
+
// ensure discovery has finished and handle resources
|
|
399
|
+
yield waitForDiscoveryNetworkIdle(page, snapshot.discovery);
|
|
400
|
+
handleSnapshotResources(snapshot, resources, callback);
|
|
401
|
+
} else {
|
|
402
|
+
let {
|
|
403
|
+
enableJavaScript
|
|
404
|
+
} = snapshot; // capture snapshots sequentially
|
|
405
|
+
|
|
406
|
+
for (let snap of allSnapshots) {
|
|
407
|
+
// will wait for timeouts, selectors, and additional network activity
|
|
408
|
+
let {
|
|
409
|
+
url,
|
|
410
|
+
dom
|
|
411
|
+
} = yield page.snapshot({
|
|
412
|
+
enableJavaScript,
|
|
413
|
+
...snap
|
|
414
|
+
});
|
|
415
|
+
let root = createRootResource(url, dom); // use the normalized root url to prevent duplicates
|
|
416
|
+
|
|
417
|
+
resources.set(root.url, root); // shallow merge with root snapshot options
|
|
418
|
+
|
|
419
|
+
handleSnapshotResources({ ...snapshot,
|
|
420
|
+
...snap
|
|
421
|
+
}, resources, callback); // remove the previously captured dom snapshot
|
|
422
|
+
|
|
423
|
+
resources.delete(root.url);
|
|
424
|
+
}
|
|
425
|
+
} // page clean up
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
await page.close();
|
|
429
|
+
} catch (error) {
|
|
430
|
+
await page.close();
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { sha256hash } from '@percy/client/utils';
|
|
2
|
+
export { request, getPackageJSON, hostnameMatches } from '@percy/client/utils'; // Returns the hostname portion of a URL.
|
|
3
|
+
|
|
4
|
+
export function hostname(url) {
|
|
5
|
+
return new URL(url).hostname;
|
|
6
|
+
} // Normalizes a URL by stripping hashes to ensure unique resources.
|
|
7
|
+
|
|
8
|
+
export function normalizeURL(url) {
|
|
9
|
+
let {
|
|
10
|
+
protocol,
|
|
11
|
+
host,
|
|
12
|
+
pathname,
|
|
13
|
+
search
|
|
14
|
+
} = new URL(url);
|
|
15
|
+
return `${protocol}//${host}${pathname}${search}`;
|
|
16
|
+
} // Creates a local resource object containing the resource URL, mimetype, content, sha, and any
|
|
17
|
+
// other additional resources attributes.
|
|
18
|
+
|
|
19
|
+
export function createResource(url, content, mimetype, attrs) {
|
|
20
|
+
return { ...attrs,
|
|
21
|
+
sha: sha256hash(content),
|
|
22
|
+
mimetype,
|
|
23
|
+
content,
|
|
24
|
+
url
|
|
25
|
+
};
|
|
26
|
+
} // Creates a root resource object with an additional `root: true` property. The URL is normalized
|
|
27
|
+
// here as a convenience since root resources are usually created outside of asset discovery.
|
|
28
|
+
|
|
29
|
+
export function createRootResource(url, content) {
|
|
30
|
+
return createResource(normalizeURL(url), content, 'text/html', {
|
|
31
|
+
root: true
|
|
32
|
+
});
|
|
33
|
+
} // Creates a Percy CSS resource object.
|
|
34
|
+
|
|
35
|
+
export function createPercyCSSResource(url, css) {
|
|
36
|
+
let {
|
|
37
|
+
href,
|
|
38
|
+
pathname
|
|
39
|
+
} = new URL(`/percy-specific.${Date.now()}.css`, url);
|
|
40
|
+
return createResource(href, css, 'text/css', {
|
|
41
|
+
pathname
|
|
42
|
+
});
|
|
43
|
+
} // Creates a log resource object.
|
|
44
|
+
|
|
45
|
+
export function createLogResource(logs) {
|
|
46
|
+
return createResource(`/percy.${Date.now()}.log`, JSON.stringify(logs), 'text/plain');
|
|
47
|
+
} // Creates a thennable, cancelable, generator instance
|
|
48
|
+
|
|
49
|
+
export function generatePromise(gen) {
|
|
50
|
+
var _gen, _gen2;
|
|
51
|
+
|
|
52
|
+
// ensure a generator is provided
|
|
53
|
+
if (typeof gen === 'function') gen = gen();
|
|
54
|
+
if (typeof ((_gen = gen) === null || _gen === void 0 ? void 0 : _gen.then) === 'function') return gen;
|
|
55
|
+
if (typeof ((_gen2 = gen) === null || _gen2 === void 0 ? void 0 : _gen2.next) !== 'function' || !(typeof gen[Symbol.iterator] === 'function' || typeof gen[Symbol.asyncIterator] === 'function')) return Promise.resolve(gen); // used to trigger cancelation
|
|
56
|
+
|
|
57
|
+
class Canceled extends Error {
|
|
58
|
+
name = 'Canceled';
|
|
59
|
+
canceled = true;
|
|
60
|
+
} // recursively runs the generator, maybe throwing an error when canceled
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
let handleNext = async (g, last) => {
|
|
64
|
+
let canceled = g.cancel.triggered;
|
|
65
|
+
let {
|
|
66
|
+
done,
|
|
67
|
+
value
|
|
68
|
+
} = canceled ? await g.throw(canceled) : await g.next(last);
|
|
69
|
+
if (canceled) delete g.cancel.triggered;
|
|
70
|
+
return done ? value : handleNext(g, value);
|
|
71
|
+
}; // handle cancelation errors by calling any cancel handlers
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
let cancelable = async function* () {
|
|
75
|
+
try {
|
|
76
|
+
return yield* gen;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error.canceled) {
|
|
79
|
+
let cancelers = cancelable.cancelers || [];
|
|
80
|
+
|
|
81
|
+
for (let c of cancelers) await c(error);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}(); // augment the cancelable generator with promise-like and cancel methods
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
return Object.assign(cancelable, {
|
|
90
|
+
run: () => cancelable.promise || (cancelable.promise = handleNext(cancelable)),
|
|
91
|
+
then: (resolve, reject) => cancelable.run().then(resolve, reject),
|
|
92
|
+
catch: reject => cancelable.run().catch(reject),
|
|
93
|
+
cancel: message => {
|
|
94
|
+
cancelable.cancel.triggered = new Canceled(message);
|
|
95
|
+
return cancelable;
|
|
96
|
+
},
|
|
97
|
+
canceled: handler => {
|
|
98
|
+
(cancelable.cancelers || (cancelable.cancelers = [])).push(handler);
|
|
99
|
+
return cancelable;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
} // Resolves when the predicate function returns true within the timeout. If an idle option is
|
|
103
|
+
// provided, the predicate will be checked again before resolving, after the idle period. The poll
|
|
104
|
+
// option determines how often the predicate check will be run.
|
|
105
|
+
|
|
106
|
+
export function waitFor(predicate, options) {
|
|
107
|
+
let {
|
|
108
|
+
poll = 10,
|
|
109
|
+
timeout,
|
|
110
|
+
idle
|
|
111
|
+
} = Number.isInteger(options) ? {
|
|
112
|
+
timeout: options
|
|
113
|
+
} : options || {};
|
|
114
|
+
return generatePromise(async function* check(start, done) {
|
|
115
|
+
while (true) {
|
|
116
|
+
if (timeout && Date.now() - start >= timeout) {
|
|
117
|
+
throw new Error(`Timeout of ${timeout}ms exceeded.`);
|
|
118
|
+
} else if (!predicate()) {
|
|
119
|
+
yield new Promise(r => setTimeout(r, poll, done = false));
|
|
120
|
+
} else if (idle && !done) {
|
|
121
|
+
yield new Promise(r => setTimeout(r, idle, done = true));
|
|
122
|
+
} else {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}(Date.now()));
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,42 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"
|
|
6
|
-
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/percy/cli",
|
|
8
|
+
"directory": "packages/core"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=14"
|
|
15
|
+
},
|
|
7
16
|
"files": [
|
|
8
17
|
"dist",
|
|
9
|
-
"
|
|
18
|
+
"post-install.js",
|
|
19
|
+
"types/index.d.ts",
|
|
20
|
+
"test/helpers/server.js"
|
|
10
21
|
],
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"types": "types/index.d.ts",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./dist/index.js",
|
|
27
|
+
"./utils": "./dist/utils.js",
|
|
28
|
+
"./config": "./dist/config.js",
|
|
29
|
+
"./install": "./dist/install.js",
|
|
30
|
+
"./test/helpers": "./test/helpers/index.js"
|
|
31
|
+
},
|
|
11
32
|
"scripts": {
|
|
12
|
-
"build": "
|
|
33
|
+
"build": "node ../../scripts/build",
|
|
13
34
|
"lint": "eslint --ignore-path ../../.gitignore .",
|
|
14
|
-
"
|
|
15
|
-
"test": "
|
|
16
|
-
"test:coverage": "
|
|
35
|
+
"postinstall": "node ./post-install",
|
|
36
|
+
"test": "node ../../scripts/test",
|
|
37
|
+
"test:coverage": "yarn test --coverage",
|
|
17
38
|
"test:types": "tsd"
|
|
18
39
|
},
|
|
19
|
-
"publishConfig": {
|
|
20
|
-
"access": "public"
|
|
21
|
-
},
|
|
22
|
-
"mocha": {
|
|
23
|
-
"require": "../../scripts/babel-register",
|
|
24
|
-
"timeout": 10000
|
|
25
|
-
},
|
|
26
40
|
"dependencies": {
|
|
27
|
-
"@percy/client": "
|
|
28
|
-
"@percy/
|
|
29
|
-
"@percy/
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
"@percy/client": "1.0.1",
|
|
42
|
+
"@percy/config": "1.0.1",
|
|
43
|
+
"@percy/dom": "1.0.1",
|
|
44
|
+
"@percy/logger": "1.0.1",
|
|
45
|
+
"content-disposition": "^0.5.4",
|
|
46
|
+
"cross-spawn": "^7.0.3",
|
|
47
|
+
"extract-zip": "^2.0.1",
|
|
48
|
+
"fast-glob": "^3.2.11",
|
|
49
|
+
"micromatch": "^4.0.4",
|
|
50
|
+
"mime-types": "^2.1.34",
|
|
51
|
+
"path-to-regexp": "^6.2.0",
|
|
38
52
|
"rimraf": "^3.0.2",
|
|
39
|
-
"
|
|
53
|
+
"ws": "^8.0.0"
|
|
40
54
|
},
|
|
41
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "38917e6027299d6cd86008e2ccd005d90bbf89c0"
|
|
42
56
|
}
|
package/post-install.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
if (process.env.PERCY_POSTINSTALL_BROWSER) {
|
|
5
|
+
// Automatically download and install Chromium if PERCY_POSTINSTALL_BROWSER is set
|
|
6
|
+
await import('./dist/install.js').then(install => install.chromium());
|
|
7
|
+
} else if (!process.send && fs.existsSync('./src')) {
|
|
8
|
+
// In development, fork this script with the development loader and always install
|
|
9
|
+
await import('child_process').then(cp => cp.fork('./post-install.js', {
|
|
10
|
+
execArgv: ['--no-warnings', '--loader=../../scripts/loader.js'],
|
|
11
|
+
env: { PERCY_POSTINSTALL_BROWSER: true }
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const { logger } = await import('@percy/logger');
|
|
16
|
+
const log = logger('core:post-install');
|
|
17
|
+
|
|
18
|
+
log.error('Encountered an error while installing Chromium');
|
|
19
|
+
log.error(error);
|
|
20
|
+
}
|