@percy/core 1.0.0 → 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/dist/config.js ADDED
@@ -0,0 +1,551 @@
1
+ // Common config options used in Percy commands
2
+ export const configSchema = {
3
+ snapshot: {
4
+ type: 'object',
5
+ additionalProperties: false,
6
+ properties: {
7
+ widths: {
8
+ type: 'array',
9
+ default: [375, 1280],
10
+ items: {
11
+ type: 'integer',
12
+ maximum: 2000,
13
+ minimum: 10
14
+ }
15
+ },
16
+ minHeight: {
17
+ type: 'integer',
18
+ default: 1024,
19
+ maximum: 2000,
20
+ minimum: 10
21
+ },
22
+ percyCSS: {
23
+ type: 'string',
24
+ default: ''
25
+ },
26
+ enableJavaScript: {
27
+ type: 'boolean'
28
+ }
29
+ }
30
+ },
31
+ discovery: {
32
+ type: 'object',
33
+ additionalProperties: false,
34
+ properties: {
35
+ allowedHostnames: {
36
+ type: 'array',
37
+ default: [],
38
+ items: {
39
+ type: 'string',
40
+ allOf: [{
41
+ not: {
42
+ pattern: '[^/]/'
43
+ },
44
+ error: 'must not include a pathname'
45
+ }, {
46
+ not: {
47
+ pattern: '^([a-zA-Z]+:)?//'
48
+ },
49
+ error: 'must not include a protocol'
50
+ }]
51
+ }
52
+ },
53
+ disallowedHostnames: {
54
+ type: 'array',
55
+ default: [],
56
+ items: {
57
+ type: 'string',
58
+ allOf: [{
59
+ not: {
60
+ pattern: '[^/]/'
61
+ },
62
+ error: 'must not include a pathname'
63
+ }, {
64
+ not: {
65
+ pattern: '^([a-zA-Z]+:)?//'
66
+ },
67
+ error: 'must not include a protocol'
68
+ }]
69
+ }
70
+ },
71
+ networkIdleTimeout: {
72
+ type: 'integer',
73
+ default: 100,
74
+ maximum: 750,
75
+ minimum: 1
76
+ },
77
+ disableCache: {
78
+ type: 'boolean'
79
+ },
80
+ requestHeaders: {
81
+ type: 'object',
82
+ normalize: false,
83
+ additionalProperties: {
84
+ type: 'string'
85
+ }
86
+ },
87
+ authorization: {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ properties: {
91
+ username: {
92
+ type: 'string'
93
+ },
94
+ password: {
95
+ type: 'string'
96
+ }
97
+ }
98
+ },
99
+ cookies: {
100
+ anyOf: [{
101
+ type: 'object',
102
+ normalize: false,
103
+ additionalProperties: {
104
+ type: 'string'
105
+ }
106
+ }, {
107
+ type: 'array',
108
+ normalize: false,
109
+ items: {
110
+ type: 'object',
111
+ required: ['name', 'value'],
112
+ properties: {
113
+ name: {
114
+ type: 'string'
115
+ },
116
+ value: {
117
+ type: 'string'
118
+ }
119
+ }
120
+ }
121
+ }]
122
+ },
123
+ userAgent: {
124
+ type: 'string'
125
+ },
126
+ concurrency: {
127
+ type: 'integer',
128
+ minimum: 1
129
+ },
130
+ launchOptions: {
131
+ type: 'object',
132
+ additionalProperties: false,
133
+ properties: {
134
+ executable: {
135
+ type: 'string'
136
+ },
137
+ timeout: {
138
+ type: 'integer'
139
+ },
140
+ args: {
141
+ type: 'array',
142
+ items: {
143
+ type: 'string'
144
+ }
145
+ },
146
+ headless: {
147
+ type: 'boolean'
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }; // Common per-snapshot capture options
154
+
155
+ export const snapshotSchema = {
156
+ $id: '/snapshot',
157
+ $ref: '#/$defs/snapshot',
158
+ $defs: {
159
+ common: {
160
+ type: 'object',
161
+ properties: {
162
+ widths: {
163
+ $ref: '/config/snapshot#/properties/widths'
164
+ },
165
+ minHeight: {
166
+ $ref: '/config/snapshot#/properties/minHeight'
167
+ },
168
+ percyCSS: {
169
+ $ref: '/config/snapshot#/properties/percyCSS'
170
+ },
171
+ enableJavaScript: {
172
+ $ref: '/config/snapshot#/properties/enableJavaScript'
173
+ },
174
+ discovery: {
175
+ type: 'object',
176
+ additionalProperties: false,
177
+ properties: {
178
+ allowedHostnames: {
179
+ $ref: '/config/discovery#/properties/allowedHostnames'
180
+ },
181
+ disallowedHostnames: {
182
+ $ref: '/config/discovery#/properties/disallowedHostnames'
183
+ },
184
+ requestHeaders: {
185
+ $ref: '/config/discovery#/properties/requestHeaders'
186
+ },
187
+ authorization: {
188
+ $ref: '/config/discovery#/properties/authorization'
189
+ },
190
+ disableCache: {
191
+ $ref: '/config/discovery#/properties/disableCache'
192
+ },
193
+ userAgent: {
194
+ $ref: '/config/discovery#/properties/userAgent'
195
+ }
196
+ }
197
+ }
198
+ }
199
+ },
200
+ exec: {
201
+ error: 'must be a function, function body, or array of functions',
202
+ oneOf: [{
203
+ oneOf: [{
204
+ type: 'string'
205
+ }, {
206
+ instanceof: 'Function'
207
+ }]
208
+ }, {
209
+ type: 'array',
210
+ items: {
211
+ $ref: '/snapshot#/$defs/exec/oneOf/0'
212
+ }
213
+ }]
214
+ },
215
+ precapture: {
216
+ type: 'object',
217
+ properties: {
218
+ waitForSelector: {
219
+ type: 'string'
220
+ },
221
+ waitForTimeout: {
222
+ type: 'integer',
223
+ minimum: 1,
224
+ maximum: 30000
225
+ }
226
+ }
227
+ },
228
+ capture: {
229
+ type: 'object',
230
+ allOf: [{
231
+ $ref: '/snapshot#/$defs/common'
232
+ }, {
233
+ $ref: '/snapshot#/$defs/precapture'
234
+ }],
235
+ properties: {
236
+ name: {
237
+ type: 'string'
238
+ },
239
+ execute: {
240
+ oneOf: [{
241
+ $ref: '/snapshot#/$defs/exec'
242
+ }, {
243
+ type: 'object',
244
+ additionalProperties: false,
245
+ properties: {
246
+ afterNavigation: {
247
+ $ref: '/snapshot#/$defs/exec'
248
+ },
249
+ beforeResize: {
250
+ $ref: '/snapshot#/$defs/exec'
251
+ },
252
+ afterResize: {
253
+ $ref: '/snapshot#/$defs/exec'
254
+ },
255
+ beforeSnapshot: {
256
+ $ref: '/snapshot#/$defs/exec'
257
+ }
258
+ }
259
+ }]
260
+ },
261
+ additionalSnapshots: {
262
+ type: 'array',
263
+ items: {
264
+ type: 'object',
265
+ $ref: '/snapshot#/$defs/precapture',
266
+ unevaluatedProperties: false,
267
+ oneOf: [{
268
+ required: ['name']
269
+ }, {
270
+ anyOf: [{
271
+ required: ['prefix']
272
+ }, {
273
+ required: ['suffix']
274
+ }]
275
+ }],
276
+ properties: {
277
+ name: {
278
+ type: 'string'
279
+ },
280
+ prefix: {
281
+ type: 'string'
282
+ },
283
+ suffix: {
284
+ type: 'string'
285
+ },
286
+ execute: {
287
+ $ref: '/snapshot#/$defs/exec'
288
+ }
289
+ },
290
+ errors: {
291
+ oneOf: ({
292
+ params
293
+ }) => params.passingSchemas ? 'prefix & suffix are ignored when a name is provided' : 'missing required name, prefix, or suffix'
294
+ }
295
+ }
296
+ }
297
+ }
298
+ },
299
+ predicate: {
300
+ error: 'must be a pattern or an array of patterns',
301
+ oneOf: [{
302
+ oneOf: [{
303
+ type: 'string'
304
+ }, {
305
+ instanceof: 'RegExp'
306
+ }, {
307
+ instanceof: 'Function'
308
+ }]
309
+ }, {
310
+ type: 'array',
311
+ items: {
312
+ $ref: '/snapshot#/$defs/predicate/oneOf/0'
313
+ }
314
+ }]
315
+ },
316
+ filter: {
317
+ type: 'object',
318
+ properties: {
319
+ include: {
320
+ $ref: '/snapshot#/$defs/predicate'
321
+ },
322
+ exclude: {
323
+ $ref: '/snapshot#/$defs/predicate'
324
+ }
325
+ }
326
+ },
327
+ options: {
328
+ oneOf: [{
329
+ type: 'object',
330
+ unevaluatedProperties: false,
331
+ allOf: [{
332
+ $ref: '/snapshot#/$defs/filter'
333
+ }, {
334
+ $ref: '/snapshot#/$defs/capture'
335
+ }]
336
+ }, {
337
+ type: 'array',
338
+ items: {
339
+ $ref: '/snapshot#/$defs/options/oneOf/0'
340
+ }
341
+ }]
342
+ },
343
+ snapshot: {
344
+ type: 'object',
345
+ required: ['url'],
346
+ $ref: '/snapshot#/$defs/capture',
347
+ unevaluatedProperties: false,
348
+ properties: {
349
+ url: {
350
+ type: 'string'
351
+ }
352
+ }
353
+ },
354
+ snapshots: {
355
+ type: 'array',
356
+ items: {
357
+ oneOf: [{
358
+ $ref: '/snapshot#/$defs/snapshot'
359
+ }, {
360
+ $ref: '/snapshot#/$defs/snapshot/properties/url'
361
+ }]
362
+ }
363
+ },
364
+ dom: {
365
+ type: 'object',
366
+ $id: '/snapshot/dom',
367
+ $ref: '/snapshot#/$defs/common',
368
+ required: ['url', 'domSnapshot'],
369
+ unevaluatedProperties: false,
370
+ properties: {
371
+ url: {
372
+ type: 'string'
373
+ },
374
+ name: {
375
+ type: 'string'
376
+ },
377
+ domSnapshot: {
378
+ type: 'string'
379
+ }
380
+ },
381
+ errors: {
382
+ unevaluatedProperties: e => snapshotSchema.$defs.precapture.properties[e.params.unevaluatedProperty] || snapshotSchema.$defs.capture.properties[e.params.unevaluatedProperty] ? 'not accepted with DOM snapshots' : 'unknown property'
383
+ }
384
+ },
385
+ list: {
386
+ type: 'object',
387
+ $id: '/snapshot/list',
388
+ $ref: '/snapshot#/$defs/filter',
389
+ unevaluatedProperties: false,
390
+ required: ['snapshots'],
391
+ properties: {
392
+ baseUrl: {
393
+ type: 'string',
394
+ pattern: '^https?://',
395
+ errors: {
396
+ pattern: 'must include a protocol and hostname'
397
+ }
398
+ },
399
+ snapshots: {
400
+ $ref: '/snapshot#/$defs/snapshots'
401
+ },
402
+ options: {
403
+ $ref: '/snapshot#/$defs/options'
404
+ }
405
+ }
406
+ },
407
+ server: {
408
+ type: 'object',
409
+ $id: '/snapshot/server',
410
+ $ref: '/snapshot#/$defs/filter',
411
+ unevaluatedProperties: false,
412
+ required: ['serve'],
413
+ properties: {
414
+ serve: {
415
+ type: 'string'
416
+ },
417
+ port: {
418
+ type: 'integer'
419
+ },
420
+ baseUrl: {
421
+ type: 'string',
422
+ pattern: '^/',
423
+ errors: {
424
+ pattern: 'must start with a forward slash (/)'
425
+ }
426
+ },
427
+ cleanUrls: {
428
+ type: 'boolean'
429
+ },
430
+ rewrites: {
431
+ type: 'object',
432
+ normalize: false,
433
+ additionalProperties: {
434
+ type: 'string'
435
+ }
436
+ },
437
+ snapshots: {
438
+ $ref: '/snapshot#/$defs/snapshots'
439
+ },
440
+ options: {
441
+ $ref: '/snapshot#/$defs/options'
442
+ }
443
+ }
444
+ },
445
+ sitemap: {
446
+ type: 'object',
447
+ $id: '/snapshot/sitemap',
448
+ $ref: '/snapshot#/$defs/filter',
449
+ required: ['sitemap'],
450
+ unevaluatedProperties: false,
451
+ properties: {
452
+ sitemap: {
453
+ type: 'string'
454
+ },
455
+ options: {
456
+ $ref: '/snapshot#/$defs/options'
457
+ }
458
+ }
459
+ }
460
+ }
461
+ }; // Grouped schemas for easier registration
462
+
463
+ export const schemas = [configSchema, snapshotSchema]; // Config migrate function
464
+
465
+ export function configMigration(config, util) {
466
+ /* eslint-disable curly */
467
+ if (config.version < 2) {
468
+ // discovery options have moved
469
+ util.map('agent.assetDiscovery.allowedHostnames', 'discovery.allowedHostnames');
470
+ util.map('agent.assetDiscovery.networkIdleTimeout', 'discovery.networkIdleTimeout');
471
+ util.map('agent.assetDiscovery.cacheResponses', 'discovery.disableCache', v => !v);
472
+ util.map('agent.assetDiscovery.requestHeaders', 'discovery.requestHeaders');
473
+ util.map('agent.assetDiscovery.pagePoolSizeMax', 'discovery.concurrency');
474
+ util.del('agent');
475
+ } else {
476
+ let notice = {
477
+ type: 'config',
478
+ until: '1.0.0'
479
+ }; // snapshot discovery options have moved
480
+
481
+ util.deprecate('snapshot.authorization', {
482
+ map: 'discovery.authorization',
483
+ ...notice
484
+ });
485
+ util.deprecate('snapshot.requestHeaders', {
486
+ map: 'discovery.requestHeaders',
487
+ ...notice
488
+ });
489
+ }
490
+ } // Snapshot option migrate function
491
+
492
+ export function snapshotMigration(config, util, root = '') {
493
+ let notice = {
494
+ type: 'snapshot',
495
+ until: '1.0.0',
496
+ warn: true
497
+ }; // discovery options have moved
498
+
499
+ util.deprecate(`${root}.authorization`, {
500
+ map: `${root}.discovery.authorization`,
501
+ ...notice
502
+ });
503
+ util.deprecate(`${root}.requestHeaders`, {
504
+ map: `${root}.discovery.requestHeaders`,
505
+ ...notice
506
+ }); // snapshots option was renamed
507
+
508
+ util.deprecate(`${root}.snapshots`, {
509
+ map: `${root}.additionalSnapshots`,
510
+ ...notice
511
+ });
512
+ } // Snapshot list options migrate function
513
+
514
+ export function snapshotListMigration(config, util) {
515
+ if (config.snapshots) {
516
+ // migrate each snapshot options
517
+ for (let i in config.snapshots) {
518
+ if (typeof config.snapshots[i] !== 'string') {
519
+ snapshotMigration(config, util, `snapshots[${i}]`);
520
+ }
521
+ }
522
+ } // overrides option was renamed
523
+
524
+
525
+ let notice = {
526
+ type: 'snapshot',
527
+ until: '1.0.0',
528
+ warn: true
529
+ };
530
+ util.deprecate('overrides', {
531
+ map: 'options',
532
+ ...notice
533
+ }); // migrate options
534
+
535
+ if (Array.isArray(config.options)) {
536
+ for (let i in config.options) {
537
+ snapshotMigration(config, util, `options[${i}]`);
538
+ }
539
+ } else {
540
+ snapshotMigration(config, util, 'options');
541
+ }
542
+ } // Grouped migrations for easier registration
543
+
544
+ export const migrations = {
545
+ '/config': configMigration,
546
+ '/snapshot': snapshotMigration,
547
+ '/snapshot/dom': snapshotMigration,
548
+ '/snapshot/list': snapshotListMigration,
549
+ '/snapshot/server': snapshotListMigration,
550
+ '/snapshot/sitemap': snapshotListMigration
551
+ };
@@ -0,0 +1,118 @@
1
+ import logger from '@percy/logger';
2
+ import { normalizeURL, hostnameMatches, createResource } from './utils.js';
3
+ const MAX_RESOURCE_SIZE = 15 * 1024 ** 2; // 15MB
4
+
5
+ const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
6
+ const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
7
+ export function createRequestHandler(network, {
8
+ disableCache,
9
+ disallowedHostnames,
10
+ getResource
11
+ }) {
12
+ let log = logger('core:discovery');
13
+ return async request => {
14
+ let url = request.url;
15
+ let meta = { ...network.meta,
16
+ url
17
+ };
18
+
19
+ try {
20
+ log.debug(`Handling request: ${url}`, meta);
21
+ let resource = getResource(url);
22
+
23
+ if (resource !== null && resource !== void 0 && resource.root) {
24
+ log.debug('- Serving root resource', meta);
25
+ await request.respond(resource);
26
+ } else if (hostnameMatches(disallowedHostnames, url)) {
27
+ log.debug('- Skipping disallowed hostname', meta);
28
+ await request.abort(true);
29
+ } else if (resource && !disableCache) {
30
+ log.debug('- Resource cache hit', meta);
31
+ await request.respond(resource);
32
+ } else {
33
+ await request.continue();
34
+ }
35
+ } catch (error) {
36
+ log.debug(`Encountered an error handling request: ${url}`, meta);
37
+ log.debug(error);
38
+ /* istanbul ignore next: race condition */
39
+
40
+ await request.abort(error).catch(e => log.debug(e, meta));
41
+ }
42
+ };
43
+ }
44
+ export function createRequestFinishedHandler(network, {
45
+ enableJavaScript,
46
+ allowedHostnames,
47
+ disableCache,
48
+ getResource,
49
+ saveResource
50
+ }) {
51
+ let log = logger('core:discovery');
52
+ return async request => {
53
+ let origin = request.redirectChain[0] || request;
54
+ let url = normalizeURL(origin.url);
55
+ let meta = { ...network.meta,
56
+ url
57
+ };
58
+
59
+ try {
60
+ var _resource;
61
+
62
+ let resource = getResource(url); // process and cache the response and resource
63
+
64
+ if (!((_resource = resource) !== null && _resource !== void 0 && _resource.root) && (!resource || disableCache)) {
65
+ let response = request.response;
66
+ let capture = response && hostnameMatches(allowedHostnames, url);
67
+ let body = capture && (await response.buffer());
68
+ log.debug(`Processing resource: ${url}`, meta);
69
+ /* istanbul ignore next: sanity check */
70
+
71
+ if (!response) {
72
+ return log.debug('- Skipping no response', meta);
73
+ } else if (!capture) {
74
+ return log.debug('- Skipping remote resource', meta);
75
+ } else if (!body.length) {
76
+ return log.debug('- Skipping empty response', meta);
77
+ } else if (body.length > MAX_RESOURCE_SIZE) {
78
+ return log.debug('- Skipping resource larger than 15MB', meta);
79
+ } else if (!ALLOWED_STATUSES.includes(response.status)) {
80
+ return log.debug(`- Skipping disallowed status [${response.status}]`, meta);
81
+ } else if (!enableJavaScript && !ALLOWED_RESOURCES.includes(request.type)) {
82
+ return log.debug(`- Skipping disallowed resource type [${request.type}]`, meta);
83
+ }
84
+
85
+ resource = createResource(url, body, response.mimeType, {
86
+ status: response.status,
87
+ // 'Network.responseReceived' returns headers split by newlines, however
88
+ // `Fetch.fulfillRequest` (used for cached responses) will hang with newlines.
89
+ headers: Object.entries(response.headers).reduce((norm, [key, value]) => Object.assign(norm, {
90
+ [key]: value.split('\n')
91
+ }), {})
92
+ });
93
+ log.debug(`- sha: ${resource.sha}`, meta);
94
+ log.debug(`- mimetype: ${resource.mimetype}`, meta);
95
+ }
96
+
97
+ saveResource(resource);
98
+ } catch (error) {
99
+ log.debug(`Encountered an error processing resource: ${url}`, meta);
100
+ log.debug(error);
101
+ }
102
+ };
103
+ }
104
+ export function createRequestFailedHandler(network) {
105
+ let log = logger('core:discovery');
106
+ return ({
107
+ url,
108
+ error
109
+ }) => {
110
+ // do not log generic failures since the real error was most likely
111
+ // already logged from elsewhere
112
+ if (error !== 'net::ERR_FAILED') {
113
+ log.debug(`Request failed for ${url}: ${error}`, { ...network.meta,
114
+ url
115
+ });
116
+ }
117
+ };
118
+ }
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import PercyConfig from '@percy/config';
2
+ import * as CoreConfig from './config.js';
3
+ PercyConfig.addSchema(CoreConfig.schemas);
4
+ PercyConfig.addMigration(CoreConfig.migrations);
5
+ export { default, Percy } from './percy.js';