@percy/core 1.12.0 → 1.13.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/dist/snapshot.js CHANGED
@@ -3,45 +3,46 @@ import PercyConfig from '@percy/config';
3
3
  import micromatch from 'micromatch';
4
4
  import { configSchema } from './config.js';
5
5
  import Queue from './queue.js';
6
- import { request, hostnameMatches, yieldTo } from './utils.js'; // Throw a better error message for missing or invalid urls
6
+ import { request, hostnameMatches, yieldTo } from './utils.js';
7
7
 
8
+ // Throw a better error message for missing or invalid urls
8
9
  function validURL(url, base) {
9
10
  if (!url) {
10
11
  throw new Error('Missing required URL for snapshot');
11
12
  }
12
-
13
13
  try {
14
14
  return new URL(url, base);
15
15
  } catch (e) {
16
16
  throw new Error(`Invalid snapshot URL: ${e.input}`);
17
17
  }
18
- } // used to deserialize regular expression strings
18
+ }
19
19
 
20
+ // used to deserialize regular expression strings
21
+ const RE_REGEXP = /^\/(.+)\/(\w+)?$/;
20
22
 
21
- const RE_REGEXP = /^\/(.+)\/(\w+)?$/; // Returns true or false if a snapshot matches the provided include and exclude predicates. A
23
+ // Returns true or false if a snapshot matches the provided include and exclude predicates. A
22
24
  // predicate can be an array of predicates, a regular expression, a glob pattern, or a function.
23
-
24
25
  function snapshotMatches(snapshot, include, exclude) {
25
26
  var _include, _include2;
26
-
27
27
  // support an options object as the second argument
28
28
  if ((_include = include) !== null && _include !== void 0 && _include.include || (_include2 = include) !== null && _include2 !== void 0 && _include2.exclude) ({
29
29
  include,
30
30
  exclude
31
- } = include); // recursive predicate test function
31
+ } = include);
32
32
 
33
+ // recursive predicate test function
33
34
  let test = (predicate, fallback) => {
34
35
  if (predicate && typeof predicate === 'string') {
35
36
  // snapshot name matches exactly or matches a glob
36
- let result = snapshot.name === predicate || micromatch.isMatch(snapshot.name, predicate); // snapshot might match a string-based regexp pattern
37
+ let result = snapshot.name === predicate || micromatch.isMatch(snapshot.name, predicate);
37
38
 
39
+ // snapshot might match a string-based regexp pattern
38
40
  if (!result) {
39
41
  try {
40
42
  let [, parsed, flags] = RE_REGEXP.exec(predicate) || [];
41
43
  result = !!parsed && new RegExp(parsed, flags).test(snapshot.name);
42
44
  } catch {}
43
45
  }
44
-
45
46
  return result;
46
47
  } else if (predicate instanceof RegExp) {
47
48
  // snapshot matches a regular expression
@@ -56,46 +57,49 @@ function snapshotMatches(snapshot, include, exclude) {
56
57
  // default fallback
57
58
  return fallback;
58
59
  }
59
- }; // nothing to match, return true
60
-
61
-
62
- if (!include && !exclude) return true; // not excluded or explicitly included
60
+ };
63
61
 
62
+ // nothing to match, return true
63
+ if (!include && !exclude) return true;
64
+ // not excluded or explicitly included
64
65
  return !test(exclude, false) && test(include, true);
65
- } // Accepts an array of snapshots to filter and map with matching options.
66
-
66
+ }
67
67
 
68
+ // Accepts an array of snapshots to filter and map with matching options.
68
69
  function mapSnapshotOptions(snapshots, context) {
69
- if (!(snapshots !== null && snapshots !== void 0 && snapshots.length)) return []; // reduce options into a single function
70
+ if (!(snapshots !== null && snapshots !== void 0 && snapshots.length)) return [];
70
71
 
72
+ // reduce options into a single function
71
73
  let applyOptions = [].concat((context === null || context === void 0 ? void 0 : context.options) || []).reduceRight((next, {
72
74
  include,
73
75
  exclude,
74
76
  ...opts
75
- }) => snap => next( // assign additional options to included snaphots
76
- snapshotMatches(snap, include, exclude) ? Object.assign(snap, opts) : snap), snap => getSnapshotOptions(snap, context)); // reduce snapshots with overrides
77
+ }) => snap => next(
78
+ // assign additional options to included snaphots
79
+ snapshotMatches(snap, include, exclude) ? Object.assign(snap, opts) : snap), snap => getSnapshotOptions(snap, context));
77
80
 
81
+ // reduce snapshots with overrides
78
82
  return snapshots.reduce((acc, snapshot) => {
79
83
  var _snapshot;
80
-
81
84
  // transform snapshot URL shorthand into an object
82
85
  if (typeof snapshot === 'string') snapshot = {
83
86
  url: snapshot
84
- }; // normalize the snapshot url and use it for the default name
87
+ };
85
88
 
89
+ // normalize the snapshot url and use it for the default name
86
90
  let url = validURL(snapshot.url, context === null || context === void 0 ? void 0 : context.baseUrl);
87
91
  (_snapshot = snapshot).name || (_snapshot.name = `${url.pathname}${url.search}${url.hash}`);
88
- snapshot.url = url.href; // use the snapshot when matching include/exclude
92
+ snapshot.url = url.href;
89
93
 
94
+ // use the snapshot when matching include/exclude
90
95
  if (snapshotMatches(snapshot, context)) {
91
96
  acc.push(applyOptions(snapshot));
92
97
  }
93
-
94
98
  return acc;
95
99
  }, []);
96
- } // Return snapshot options merged with defaults and global config.
97
-
100
+ }
98
101
 
102
+ // Return snapshot options merged with defaults and global config.
99
103
  function getSnapshotOptions(options, {
100
104
  config,
101
105
  meta
@@ -105,7 +109,8 @@ function getSnapshotOptions(options, {
105
109
  discovery: {
106
110
  allowedHostnames: [validURL(options.url).hostname]
107
111
  },
108
- meta: { ...meta,
112
+ meta: {
113
+ ...meta,
109
114
  snapshot: {
110
115
  name: options.name
111
116
  }
@@ -123,26 +128,22 @@ function getSnapshotOptions(options, {
123
128
  }
124
129
  }, options], (path, prev, next) => {
125
130
  var _next, _next2;
126
-
127
131
  switch (path.map(k => k.toString()).join('.')) {
128
132
  case 'widths':
129
133
  // dedup, sort, and override widths when not empty
130
134
  return [path, !((_next = next) !== null && _next !== void 0 && _next.length) ? prev : [...new Set(next)].sort((a, b) => a - b)];
131
-
132
135
  case 'percyCSS':
133
136
  // concatenate percy css
134
137
  return [path, [prev, next].filter(Boolean).join('\n')];
135
-
136
138
  case 'execute':
137
139
  // shorthand for execute.beforeSnapshot
138
140
  return Array.isArray(next) || typeof next !== 'object' ? [path.concat('beforeSnapshot'), next] : [path];
139
-
140
141
  case 'discovery.disallowedHostnames':
141
142
  // prevent disallowing the root hostname
142
143
  return [path, !((_next2 = next) !== null && _next2 !== void 0 && _next2.length) ? prev : (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
143
- } // ensure additional snapshots have complete names
144
-
144
+ }
145
145
 
146
+ // ensure additional snapshots have complete names
146
147
  if (path[0] === 'additionalSnapshots' && path.length === 2) {
147
148
  let {
148
149
  prefix = '',
@@ -156,14 +157,13 @@ function getSnapshotOptions(options, {
156
157
  return [path, next];
157
158
  }
158
159
  });
159
- } // Validates and migrates snapshot options against the correct schema based on provided
160
+ }
161
+
162
+ // Validates and migrates snapshot options against the correct schema based on provided
160
163
  // properties. Eagerly throws an error when missing a URL for any snapshot, and warns about all
161
164
  // other invalid options which are also scrubbed from the returned migrated options.
162
-
163
-
164
165
  export function validateSnapshotOptions(options) {
165
166
  var _migrated$baseUrl;
166
-
167
167
  // decide which schema to validate against
168
168
  let schema = ['domSnapshot', 'dom-snapshot', 'dom_snapshot'].some(k => k in options) && '/snapshot/dom' || 'url' in options && '/snapshot' || 'sitemap' in options && '/snapshot/sitemap' || 'serve' in options && '/snapshot/server' || 'snapshots' in options && '/snapshot/list' || '/snapshot';
169
169
  let {
@@ -172,85 +172,88 @@ export function validateSnapshotOptions(options) {
172
172
  environmentInfo,
173
173
  snapshots,
174
174
  ...migrated
175
- } = PercyConfig.migrate(options, schema); // maintain a trailing slash for base URLs to normalize them
175
+ } = PercyConfig.migrate(options, schema);
176
176
 
177
+ // maintain a trailing slash for base URLs to normalize them
177
178
  if (((_migrated$baseUrl = migrated.baseUrl) === null || _migrated$baseUrl === void 0 ? void 0 : _migrated$baseUrl.endsWith('/')) === false) migrated.baseUrl += '/';
178
- let baseUrl = schema === '/snapshot/server' ? 'http://localhost/' : migrated.baseUrl; // gather info for validating individual snapshot URLs
179
+ let baseUrl = schema === '/snapshot/server' ? 'http://localhost/' : migrated.baseUrl;
179
180
 
181
+ // gather info for validating individual snapshot URLs
180
182
  let isSnapshot = schema === '/snapshot/dom' || schema === '/snapshot';
181
183
  let snaps = isSnapshot ? [migrated] : Array.isArray(snapshots) ? snapshots : [];
184
+ for (let snap of snaps) validURL(typeof snap === 'string' ? snap : snap.url, baseUrl);
182
185
 
183
- 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
184
-
185
-
186
+ // add back snapshots before validating and scrubbing; function snapshots are validated later
186
187
  if (snapshots) migrated.snapshots = typeof snapshots === 'function' ? [] : snapshots;else if (!isSnapshot && options.snapshots) migrated.snapshots = [];
187
188
  let errors = PercyConfig.validate(migrated, schema);
188
-
189
189
  if (errors) {
190
190
  // warn on validation errors
191
191
  let log = logger('core:snapshot');
192
192
  log.warn('Invalid snapshot options:');
193
-
194
193
  for (let e of errors) log.warn(`- ${e.path}: ${e.message}`);
195
- } // add back the snapshots function if there was one
196
-
197
-
198
- if (typeof snapshots === 'function') migrated.snapshots = snapshots; // add back an empty array if all server snapshots were scrubbed
194
+ }
199
195
 
196
+ // add back the snapshots function if there was one
197
+ if (typeof snapshots === 'function') migrated.snapshots = snapshots;
198
+ // add back an empty array if all server snapshots were scrubbed
200
199
  if ('serve' in options && 'snapshots' in options) migrated.snapshots ?? (migrated.snapshots = []);
201
200
  return {
202
201
  clientInfo,
203
202
  environmentInfo,
204
203
  ...migrated
205
204
  };
206
- } // Fetches a sitemap and parses it into a list of URLs for taking snapshots. Duplicate URLs,
207
- // including a trailing slash, are removed from the resulting list.
205
+ }
208
206
 
207
+ // Fetches a sitemap and parses it into a list of URLs for taking snapshots. Duplicate URLs,
208
+ // including a trailing slash, are removed from the resulting list.
209
209
  async function getSitemapSnapshots(options) {
210
210
  return request(options.sitemap, (body, res) => {
211
211
  // validate sitemap content-type
212
212
  let [contentType] = res.headers['content-type'].split(';');
213
-
214
213
  if (!/^(application|text)\/xml$/.test(contentType)) {
215
214
  throw new Error('The sitemap must be an XML document, ' + `but the content-type was "${contentType}"`);
216
- } // parse XML content into a list of URLs
217
-
215
+ }
218
216
 
219
- let urls = body.match(/(?<=<loc>)(.*?)(?=<\/loc>)/ig) ?? []; // filter out duplicate URLs that differ by a trailing slash
217
+ // parse XML content into a list of URLs
218
+ let urls = body.match(/(?<=<loc>)(.*?)(?=<\/loc>)/ig) ?? [];
220
219
 
220
+ // filter out duplicate URLs that differ by a trailing slash
221
221
  return urls.filter((url, i) => {
222
222
  let match = urls.indexOf(url.replace(/\/$/, ''));
223
223
  return match === -1 || match === i;
224
224
  });
225
225
  });
226
- } // Returns an array of derived snapshot options
227
-
226
+ }
228
227
 
228
+ // Returns an array of derived snapshot options
229
229
  export async function* gatherSnapshots(options, context) {
230
230
  let {
231
231
  baseUrl,
232
232
  snapshots
233
233
  } = options;
234
234
  if ('url' in options) [snapshots, options] = [[options], {}];
235
- if ('sitemap' in options) snapshots = yield getSitemapSnapshots(options); // validate evaluated snapshots
235
+ if ('sitemap' in options) snapshots = yield getSitemapSnapshots(options);
236
236
 
237
+ // validate evaluated snapshots
237
238
  if (typeof snapshots === 'function') {
238
239
  snapshots = yield* yieldTo(snapshots(baseUrl));
239
240
  snapshots = validateSnapshotOptions({
240
241
  baseUrl,
241
242
  snapshots
242
243
  }).snapshots;
243
- } // map snapshots with snapshot options
244
-
244
+ }
245
245
 
246
- snapshots = mapSnapshotOptions(snapshots, { ...options,
246
+ // map snapshots with snapshot options
247
+ snapshots = mapSnapshotOptions(snapshots, {
248
+ ...options,
247
249
  ...context
248
250
  });
249
251
  if (!snapshots.length) throw new Error('No snapshots found');
250
252
  return snapshots;
251
- } // Merges snapshots and deduplicates resource arrays. Duplicate log resources are replaced, root
252
- // resources are deduplicated by widths, and all other resources are deduplicated by their URL.
253
+ }
253
254
 
255
+ // Merges snapshots and deduplicates resource arrays. Duplicate log resources are replaced, root
256
+ // resources are deduplicated by widths, and all other resources are deduplicated by their URL.
254
257
  function mergeSnapshotOptions(prev = {}, next) {
255
258
  let {
256
259
  resources: oldResources = [],
@@ -261,37 +264,41 @@ function mergeSnapshotOptions(prev = {}, next) {
261
264
  widths = [],
262
265
  width,
263
266
  ...incoming
264
- } = next; // prioritize singular widths over mutilple widths
267
+ } = next;
265
268
 
266
- widths = width ? [width] : widths; // deduplicate resources by associated widths and url
269
+ // prioritize singular widths over mutilple widths
270
+ widths = width ? [width] : widths;
267
271
 
272
+ // deduplicate resources by associated widths and url
268
273
  let resources = oldResources.reduce((all, resource) => {
269
274
  if (resource.log || resource.widths.every(w => widths.includes(w))) return all;
270
275
  if (!resource.root && all.some(r => r.url === resource.url)) return all;
271
276
  resource.widths = resource.widths.filter(w => !widths.includes(w));
272
277
  return all.concat(resource);
273
- }, newResources.map(r => ({ ...r,
278
+ }, newResources.map(r => ({
279
+ ...r,
274
280
  widths
275
- }))); // sort resources after merging; roots first by min-width & logs last
281
+ })));
276
282
 
283
+ // sort resources after merging; roots first by min-width & logs last
277
284
  resources.sort((a, b) => {
278
285
  if (a.root && b.root) return Math.min(...b.widths) - Math.min(...a.widths);
279
286
  return a.root || b.log ? -1 : a.log || b.root ? 1 : 0;
280
- }); // overwrite resources and ensure unique widths
287
+ });
281
288
 
289
+ // overwrite resources and ensure unique widths
282
290
  return PercyConfig.merge([existing, incoming, {
283
291
  widths,
284
292
  resources
285
293
  }], (path, prev, next) => {
286
294
  if (path[0] === 'resources') return [path, next];
287
-
288
295
  if (path[0] === 'widths' && prev && next) {
289
296
  return [path, [...new Set([...prev, ...next])]];
290
297
  }
291
298
  });
292
- } // Creates a snapshots queue that manages a Percy build and uploads snapshots.
293
-
299
+ }
294
300
 
301
+ // Creates a snapshots queue that manages a Percy build and uploads snapshots.
295
302
  export function createSnapshotsQueue(percy) {
296
303
  let {
297
304
  concurrency
@@ -300,7 +307,8 @@ export function createSnapshotsQueue(percy) {
300
307
  let build;
301
308
  return queue.set({
302
309
  concurrency
303
- }) // on start, create a new Percy build
310
+ })
311
+ // on start, create a new Percy build
304
312
  .handle('start', async () => {
305
313
  try {
306
314
  build = percy.build = {};
@@ -313,8 +321,8 @@ export function createSnapshotsQueue(percy) {
313
321
  id: data.id,
314
322
  url,
315
323
  number
316
- }); // immediately run the queue if not delayed or deferred
317
-
324
+ });
325
+ // immediately run the queue if not delayed or deferred
318
326
  if (!percy.delayUploads && !percy.deferUploads) queue.run();
319
327
  } catch (err) {
320
328
  // immediately throw the error if not delayed or deferred
@@ -326,12 +334,11 @@ export function createSnapshotsQueue(percy) {
326
334
  percy.log.error(err);
327
335
  queue.close(true);
328
336
  }
329
- }) // on end, maybe finalize the build and log about build info
337
+ })
338
+ // on end, maybe finalize the build and log about build info
330
339
  .handle('end', async () => {
331
340
  var _build, _build2;
332
-
333
341
  if (!percy.readyState) return;
334
-
335
342
  if ((_build = build) !== null && _build !== void 0 && _build.failed) {
336
343
  percy.log.warn(`Build #${build.number} failed: ${build.url}`, {
337
344
  build
@@ -346,25 +353,30 @@ export function createSnapshotsQueue(percy) {
346
353
  build
347
354
  });
348
355
  }
349
- }) // snapshots are unique by name alone
356
+ })
357
+ // snapshots are unique by name alone
350
358
  .handle('find', ({
351
359
  name
352
- }, snapshot) => snapshot.name === name) // when pushed, maybe flush old snapshots or possibly merge with existing snapshots
360
+ }, snapshot) => snapshot.name === name)
361
+ // when pushed, maybe flush old snapshots or possibly merge with existing snapshots
353
362
  .handle('push', (snapshot, existing) => {
354
363
  let {
355
364
  name,
356
365
  meta
357
- } = snapshot; // log immediately when not deferred or dry-running
366
+ } = snapshot;
358
367
 
368
+ // log immediately when not deferred or dry-running
359
369
  if (!percy.deferUploads) percy.log.info(`Snapshot taken: ${name}`, meta);
360
- if (percy.dryRun) percy.log.info(`Snapshot found: ${name}`, meta); // immediately flush when uploads are delayed but not skipped
361
-
362
- if (percy.delayUploads && !percy.deferUploads) queue.flush(); // overwrite any existing snapshot when not deferred or when resources is a function
363
-
364
- if (!percy.deferUploads || typeof snapshot.resources === 'function') return snapshot; // merge snapshot options when uploads are deferred
370
+ if (percy.dryRun) percy.log.info(`Snapshot found: ${name}`, meta);
365
371
 
372
+ // immediately flush when uploads are delayed but not skipped
373
+ if (percy.delayUploads && !percy.deferUploads) queue.flush();
374
+ // overwrite any existing snapshot when not deferred or when resources is a function
375
+ if (!percy.deferUploads || typeof snapshot.resources === 'function') return snapshot;
376
+ // merge snapshot options when uploads are deferred
366
377
  return mergeSnapshotOptions(existing, snapshot);
367
- }) // send snapshots to be uploaded to the build
378
+ })
379
+ // send snapshots to be uploaded to the build
368
380
  .handle('task', async function* ({
369
381
  resources,
370
382
  ...snapshot
@@ -372,21 +384,25 @@ export function createSnapshotsQueue(percy) {
372
384
  let {
373
385
  name,
374
386
  meta
375
- } = snapshot; // yield to evaluated snapshot resources
387
+ } = snapshot;
376
388
 
377
- snapshot.resources = typeof resources === 'function' ? yield* yieldTo(resources()) : resources; // upload the snapshot and log when deferred
389
+ // yield to evaluated snapshot resources
390
+ snapshot.resources = typeof resources === 'function' ? yield* yieldTo(resources()) : resources;
378
391
 
392
+ // upload the snapshot and log when deferred
379
393
  let send = 'tag' in snapshot ? 'sendComparison' : 'sendSnapshot';
380
394
  let response = yield percy.client[send](build.id, snapshot);
381
395
  if (percy.deferUploads) percy.log.info(`Snapshot uploaded: ${name}`, meta);
382
- return { ...snapshot,
396
+ return {
397
+ ...snapshot,
383
398
  response
384
399
  };
385
- }) // handle possible build errors returned by the API
400
+ })
401
+ // handle possible build errors returned by the API
386
402
  .handle('error', (snapshot, error) => {
387
403
  var _error$response;
388
-
389
- let result = { ...snapshot,
404
+ let result = {
405
+ ...snapshot,
390
406
  error
391
407
  };
392
408
  let {
@@ -397,16 +413,13 @@ export function createSnapshotsQueue(percy) {
397
413
  if (error.name === 'AbortError') return result;
398
414
  let failed = ((_error$response = error.response) === null || _error$response === void 0 ? void 0 : _error$response.statusCode) === 422 && error.response.body.errors.find(e => {
399
415
  var _e$source;
400
-
401
416
  return ((_e$source = e.source) === null || _e$source === void 0 ? void 0 : _e$source.pointer) === '/data/attributes/build';
402
417
  });
403
-
404
418
  if (failed) {
405
419
  build.error = error.message = failed.detail;
406
420
  build.failed = true;
407
421
  queue.close(true);
408
422
  }
409
-
410
423
  percy.log.error(`Encountered an error uploading snapshot: ${name}`, meta);
411
424
  percy.log.error(error, meta);
412
425
  return result;