@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/percy.js ADDED
@@ -0,0 +1,484 @@
1
+ import PercyClient from '@percy/client';
2
+ import PercyConfig from '@percy/config';
3
+ import logger from '@percy/logger';
4
+ import Queue from './queue.js';
5
+ import Browser from './browser.js';
6
+ import { createPercyServer, createStaticServer } from './api.js';
7
+ import { gatherSnapshots, validateSnapshotOptions, discoverSnapshotResources } from './snapshot.js';
8
+ import { generatePromise } from './utils.js'; // A Percy instance will create a new build when started, handle snapshot
9
+ // creation, asset discovery, and resource uploads, and will finalize the build
10
+ // when stopped. Snapshots are processed concurrently and the build is not
11
+ // finalized until all snapshots have been handled.
12
+
13
+ export class Percy {
14
+ log = logger('core');
15
+ readyState = null;
16
+ #uploads = new Queue();
17
+ #snapshots = new Queue(); // Static shortcut to create and start an instance in one call
18
+
19
+ static async start(options) {
20
+ let instance = new this(options);
21
+ await instance.start();
22
+ return instance;
23
+ }
24
+
25
+ constructor({
26
+ // initial log level
27
+ loglevel,
28
+ // do not eagerly upload snapshots
29
+ deferUploads,
30
+ // run without uploading anything
31
+ skipUploads,
32
+ // implies `skipUploads` and also skips asset discovery
33
+ dryRun,
34
+ // configuration filepath
35
+ config,
36
+ // provided to @percy/client
37
+ token,
38
+ clientInfo = '',
39
+ environmentInfo = '',
40
+ // snapshot server options
41
+ server = true,
42
+ port = 5338,
43
+ // options such as `snapshot` and `discovery` that are valid Percy config
44
+ // options which will become accessible via the `.config` property
45
+ ...options
46
+ } = {}) {
47
+ if (loglevel) this.loglevel(loglevel);
48
+ this.dryRun = !!dryRun;
49
+ this.skipUploads = this.dryRun || !!skipUploads;
50
+ this.deferUploads = this.skipUploads || !!deferUploads;
51
+ if (this.deferUploads) this.#uploads.stop();
52
+ this.config = PercyConfig.load({
53
+ overrides: options,
54
+ path: config
55
+ });
56
+
57
+ if (this.config.discovery.concurrency) {
58
+ let {
59
+ concurrency
60
+ } = this.config.discovery;
61
+ this.#uploads.concurrency = concurrency;
62
+ this.#snapshots.concurrency = concurrency;
63
+ }
64
+
65
+ this.client = new PercyClient({
66
+ token,
67
+ clientInfo,
68
+ environmentInfo
69
+ });
70
+ this.browser = new Browser({ ...this.config.discovery.launchOptions,
71
+ cookies: this.config.discovery.cookies
72
+ });
73
+
74
+ if (server) {
75
+ this.server = createPercyServer(this, port);
76
+ } // generator methods are wrapped to autorun and return promises
77
+
78
+
79
+ for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot']) {
80
+ // the original generator can be referenced with percy.yield.<method>
81
+ let method = (this.yield || (this.yield = {}))[m] = this[m].bind(this);
82
+
83
+ this[m] = (...args) => generatePromise(method(...args)).then();
84
+ }
85
+ } // Shortcut for controlling the global logger's log level.
86
+
87
+
88
+ loglevel(level) {
89
+ return logger.loglevel(level);
90
+ } // Snapshot server API address
91
+
92
+
93
+ address() {
94
+ var _this$server;
95
+
96
+ return (_this$server = this.server) === null || _this$server === void 0 ? void 0 : _this$server.address();
97
+ } // Set client & environment info, and override loaded config options
98
+
99
+
100
+ setConfig({
101
+ clientInfo,
102
+ environmentInfo,
103
+ ...config
104
+ }) {
105
+ this.client.addClientInfo(clientInfo);
106
+ this.client.addEnvironmentInfo(environmentInfo); // normalize config and do nothing if empty
107
+
108
+ config = PercyConfig.normalize(config, {
109
+ schema: '/config'
110
+ });
111
+ if (!config) return this.config; // validate provided config options
112
+
113
+ let errors = PercyConfig.validate(config);
114
+
115
+ if (errors) {
116
+ this.log.warn('Invalid config:');
117
+
118
+ for (let e of errors) this.log.warn(`- ${e.path}: ${e.message}`);
119
+ } // merge and override existing config options
120
+
121
+
122
+ this.config = PercyConfig.merge([this.config, config], (path, prev, next) => {
123
+ // replace arrays instead of merging
124
+ return Array.isArray(next) && [path, next];
125
+ }); // adjust concurrency if necessary
126
+
127
+ if (this.config.discovery.concurrency) {
128
+ let {
129
+ concurrency
130
+ } = this.config.discovery;
131
+ this.#uploads.concurrency = concurrency;
132
+ this.#snapshots.concurrency = concurrency;
133
+ }
134
+
135
+ return this.config;
136
+ } // Resolves once snapshot and upload queues are idle
137
+
138
+
139
+ async *idle() {
140
+ yield* this.#snapshots.idle();
141
+ yield* this.#uploads.idle();
142
+ } // Immediately stops all queues, preventing any more tasks from running
143
+
144
+
145
+ close() {
146
+ this.#snapshots.close(true);
147
+ this.#uploads.close(true);
148
+ } // Starts a local API server, a browser process, and queues creating a new Percy build which will run
149
+ // at a later time when uploads are deferred, or run immediately when not deferred.
150
+
151
+
152
+ async *start(options) {
153
+ // already starting or started
154
+ if (this.readyState != null) return;
155
+ this.readyState = 0; // create a percy build as the first immediately queued task
156
+
157
+ let buildTask = this.#uploads.push('build/create', () => {
158
+ // pause other queued tasks until after the build is created
159
+ this.#uploads.stop();
160
+ return this.client.createBuild().then(({
161
+ data: {
162
+ id,
163
+ attributes
164
+ }
165
+ }) => {
166
+ this.build = {
167
+ id
168
+ };
169
+ this.build.number = attributes['build-number'];
170
+ this.build.url = attributes['web-url'];
171
+ this.#uploads.run();
172
+ });
173
+ }, 0); // handle deferred build errors
174
+
175
+ if (this.deferUploads) {
176
+ buildTask.catch(err => {
177
+ this.build = {
178
+ error: 'Failed to create build'
179
+ };
180
+ this.log.error(this.build.error);
181
+ this.log.error(err);
182
+ this.close();
183
+ });
184
+ }
185
+
186
+ try {
187
+ var _this$server2;
188
+
189
+ // when not deferred, wait until the build is created first
190
+ if (!this.deferUploads) await buildTask; // maybe launch the discovery browser
191
+
192
+ if (!this.dryRun && (options === null || options === void 0 ? void 0 : options.browser) !== false) {
193
+ yield this.browser.launch();
194
+ } // start the server after everything else is ready
195
+
196
+
197
+ yield (_this$server2 = this.server) === null || _this$server2 === void 0 ? void 0 : _this$server2.listen(); // mark instance as started
198
+
199
+ this.log.info('Percy has started!');
200
+ this.readyState = 1;
201
+ } catch (error) {
202
+ var _this$server3;
203
+
204
+ // on error, close any running server and browser
205
+ await ((_this$server3 = this.server) === null || _this$server3 === void 0 ? void 0 : _this$server3.close());
206
+ await this.browser.close(); // mark instance as closed
207
+
208
+ this.readyState = 3; // when uploads are deferred, cancel build creation
209
+
210
+ if (error.canceled && this.deferUploads) {
211
+ this.#uploads.cancel('build/create');
212
+ this.readyState = null;
213
+ } // throw an easier-to-understand error when the port is taken
214
+
215
+
216
+ if (error.code === 'EADDRINUSE') {
217
+ throw new Error('Percy is already running or the port is in use');
218
+ } else {
219
+ throw error;
220
+ }
221
+ }
222
+ } // Wait for currently queued snapshots then run and wait for resulting uploads
223
+
224
+
225
+ async *flush(close) {
226
+ try {
227
+ // wait until the next event loop for synchronous snapshots
228
+ yield new Promise(r => setImmediate(r)); // close the snapshot queue and wait for it to empty
229
+
230
+ if (this.#snapshots.size) {
231
+ if (close) this.#snapshots.close();
232
+ yield* this.#snapshots.flush(s => {
233
+ // do not log a count when not closing or while dry-running
234
+ if (!close || this.dryRun) return;
235
+ this.log.progress(`Processing ${s} snapshot${s !== 1 ? 's' : ''}...`, !!s);
236
+ });
237
+ } // run, close, and wait for the upload queue to empty
238
+
239
+
240
+ if (!this.skipUploads && this.#uploads.size) {
241
+ if (close) this.#uploads.close(); // prevent creating an empty build when deferred
242
+
243
+ if (!this.deferUploads || !this.#uploads.has('build/create') || this.#uploads.size > 1) {
244
+ yield* this.#uploads.flush(s => {
245
+ // do not log a count when not closing or while creating a build
246
+ if (!close || this.#uploads.has('build/create')) return;
247
+ this.log.progress(`Uploading ${s} snapshot${s !== 1 ? 's' : ''}...`, !!s);
248
+ });
249
+ }
250
+ }
251
+ } catch (error) {
252
+ // reopen closed queues when canceled
253
+
254
+ /* istanbul ignore else: all errors bubble */
255
+ if (close && error.canceled) {
256
+ this.#snapshots.open();
257
+ this.#uploads.open();
258
+ }
259
+
260
+ throw error;
261
+ }
262
+ } // Stops the local API server and browser once snapshots have completed and finalizes the Percy
263
+ // build. Does nothing if not running. When `force` is true, any queued tasks are cleared.
264
+
265
+
266
+ async *stop(force) {
267
+ var _this$server4, _this$build, _this$build2;
268
+
269
+ // not started, but the browser was launched
270
+ if (!this.readyState && this.browser.isConnected()) {
271
+ await this.browser.close();
272
+ } // not started or already stopped
273
+
274
+
275
+ if (!this.readyState || this.readyState > 2) return; // close queues asap
276
+
277
+ if (force) this.close(); // already stopping
278
+
279
+ if (this.readyState === 2) return;
280
+ this.readyState = 2; // log when force stopping
281
+
282
+ if (force) this.log.info('Stopping percy...');
283
+
284
+ try {
285
+ // process uploads and close queues
286
+ yield* this.yield.flush(true);
287
+ } catch (error) {
288
+ // reset ready state when canceled
289
+
290
+ /* istanbul ignore else: all errors bubble */
291
+ if (error.canceled) this.readyState = 1;
292
+ throw error;
293
+ } // if dry-running, log the total number of snapshots
294
+
295
+
296
+ if (this.dryRun && this.#uploads.size) {
297
+ let total = this.#uploads.size - 1; // subtract the build task
298
+
299
+ this.log.info(`Found ${total} snapshot${total !== 1 ? 's' : ''}`);
300
+ } // close any running server and browser
301
+
302
+
303
+ await ((_this$server4 = this.server) === null || _this$server4 === void 0 ? void 0 : _this$server4.close());
304
+ await this.browser.close(); // finalize and log build info
305
+
306
+ let meta = {
307
+ build: this.build
308
+ };
309
+
310
+ if ((_this$build = this.build) !== null && _this$build !== void 0 && _this$build.failed) {
311
+ // do not finalize failed builds
312
+ this.log.warn(`Build #${this.build.number} failed: ${this.build.url}`, meta);
313
+ } else if ((_this$build2 = this.build) !== null && _this$build2 !== void 0 && _this$build2.id) {
314
+ // finalize the build
315
+ await this.client.finalizeBuild(this.build.id);
316
+ this.log.info(`Finalized build #${this.build.number}: ${this.build.url}`, meta);
317
+ } else {
318
+ // no build was ever created (likely failed while deferred)
319
+ this.log.warn('Build not created', meta);
320
+ } // mark instance as stopped
321
+
322
+
323
+ this.readyState = 3;
324
+ } // Deprecated capture method
325
+
326
+
327
+ capture(options) {
328
+ this.log.deprecated('The #capture() method will be ' + 'removed in 1.0.0. Use #snapshot() instead.');
329
+ return this.snapshot(options);
330
+ } // Takes one or more snapshots of a page while discovering resources to upload with the
331
+ // snapshot. Once asset discovery has completed, the queued snapshot will resolve and an upload
332
+ // task will be queued separately. Accepts several different syntaxes for taking snapshots using
333
+ // various methods.
334
+ //
335
+ // snapshot(url|{url}|[...url|{url}])
336
+ // - requires fully qualified resolvable urls
337
+ // - snapshot options may be provided with the object syntax
338
+ //
339
+ // snapshot({snapshots:[...url|{url}]})
340
+ // - optional `baseUrl` prepended to snapshot urls
341
+ // - optional `options` apply to all or specific snapshots
342
+ //
343
+ // snapshot(sitemap|{sitemap})
344
+ // - required to be a fully qualified resolvable url ending in `.xml`
345
+ // - optional `include`/`exclude` to filter snapshots
346
+ // - optional `options` apply to all or specific snapshots
347
+ //
348
+ // snapshot({serve})
349
+ // - server address is prepended to snapshot urls
350
+ // - optional `baseUrl` used when serving pages
351
+ // - optional `rewrites`/`cleanUrls` to control snapshot urls
352
+ // - optional `include`/`exclude` to filter snapshots
353
+ // - optional `snapshots`, with fallback to built-in sitemap.xml
354
+ // - optional `options` apply to all or specific snapshots
355
+ //
356
+ // All available syntaxes will eventually push snapshots to the snapshot queue without the need to
357
+ // await on this method directly. This method resolves after the snapshot upload is queued, but
358
+ // does not await on the upload to complete.
359
+
360
+
361
+ snapshot(options) {
362
+ var _this$build3;
363
+
364
+ if (this.readyState !== 1) {
365
+ throw new Error('Not running');
366
+ } else if ((_this$build3 = this.build) !== null && _this$build3 !== void 0 && _this$build3.error) {
367
+ throw new Error(this.build.error);
368
+ } else if (Array.isArray(options)) {
369
+ return Promise.all(options.map(o => this.snapshot(o)));
370
+ }
371
+
372
+ if (typeof options === 'string') {
373
+ options = options.endsWith('.xml') ? {
374
+ sitemap: options
375
+ } : {
376
+ url: options
377
+ };
378
+ } // validate options and add client & environment info
379
+
380
+
381
+ options = validateSnapshotOptions(options);
382
+ this.client.addClientInfo(options.clientInfo);
383
+ this.client.addEnvironmentInfo(options.environmentInfo); // return an async generator to allow cancelation
384
+
385
+ return async function* () {
386
+ let server = 'serve' in options ? await createStaticServer(options).listen() : null;
387
+
388
+ try {
389
+ if (server) {
390
+ // automatically set specific static server options
391
+ options.baseUrl = new URL(options.baseUrl || '', server.address()).href;
392
+ if (!options.snapshots) options.sitemap = new URL('sitemap.xml', options.baseUrl).href;
393
+ } // gather snapshots from options
394
+
395
+
396
+ let snapshots = yield gatherSnapshots(this, options);
397
+
398
+ try {
399
+ // yield each task individually to allow canceling
400
+ let tasks = snapshots.map(s => this._takeSnapshot(s));
401
+
402
+ for (let task of tasks) yield task;
403
+ } catch (error) {
404
+ // cancel queued snapshots that may not have started
405
+ snapshots.map(s => this._cancelSnapshot(s));
406
+ throw error;
407
+ }
408
+ } finally {
409
+ await (server === null || server === void 0 ? void 0 : server.close());
410
+ }
411
+ }.call(this);
412
+ } // Cancel any pending snapshot or snapshot uploads
413
+
414
+
415
+ _cancelSnapshot(snapshot) {
416
+ this.#snapshots.cancel(`snapshot/${snapshot.name}`);
417
+
418
+ for (let {
419
+ name
420
+ } of [snapshot, ...(snapshot.additionalSnapshots || [])]) {
421
+ this.#uploads.cancel(`upload/${name}`);
422
+ }
423
+ } // Resolves after asset discovery has finished and uploads have been queued
424
+
425
+
426
+ _takeSnapshot(snapshot) {
427
+ // cancel any existing snapshot with the same name
428
+ this._cancelSnapshot(snapshot);
429
+
430
+ return this.#snapshots.push(`snapshot/${snapshot.name}`, async function* () {
431
+ try {
432
+ yield* discoverSnapshotResources(this, snapshot, (snap, resources) => {
433
+ if (!this.dryRun) this.log.info(`Snapshot taken: ${snap.name}`, snap.meta);
434
+
435
+ this._scheduleUpload(snap.name, { ...snap,
436
+ resources
437
+ });
438
+ });
439
+ } catch (error) {
440
+ if (error.canceled) {
441
+ this.log.error('Received a duplicate snapshot name, ' + `the previous snapshot was canceled: ${snapshot.name}`);
442
+ } else {
443
+ this.log.error(`Encountered an error taking snapshot: ${snapshot.name}`, snapshot.meta);
444
+ this.log.error(error, snapshot.meta);
445
+ }
446
+ }
447
+ }.bind(this));
448
+ } // Queues a snapshot upload with the provided options
449
+
450
+
451
+ _scheduleUpload(name, options) {
452
+ var _this$build4;
453
+
454
+ if ((_this$build4 = this.build) !== null && _this$build4 !== void 0 && _this$build4.error) {
455
+ throw new Error(this.build.error);
456
+ }
457
+
458
+ return this.#uploads.push(`upload/${name}`, async () => {
459
+ try {
460
+ /* istanbul ignore if: useful for other internal packages */
461
+ if (typeof options === 'function') options = await options();
462
+ await this.client.sendSnapshot(this.build.id, options);
463
+ } catch (error) {
464
+ var _error$response;
465
+
466
+ let failed = ((_error$response = error.response) === null || _error$response === void 0 ? void 0 : _error$response.statusCode) === 422 && error.response.body.errors.find(e => {
467
+ var _e$source;
468
+
469
+ return ((_e$source = e.source) === null || _e$source === void 0 ? void 0 : _e$source.pointer) === '/data/attributes/build';
470
+ });
471
+ this.log.error(`Encountered an error uploading snapshot: ${name}`, options.meta);
472
+ this.log.error((failed === null || failed === void 0 ? void 0 : failed.detail) ?? error, options.meta); // build failed at some point, stop accepting snapshots
473
+
474
+ if (failed) {
475
+ this.build.error = failed.detail;
476
+ this.build.failed = true;
477
+ this.close();
478
+ }
479
+ }
480
+ });
481
+ }
482
+
483
+ }
484
+ export default Percy;
package/dist/queue.js ADDED
@@ -0,0 +1,152 @@
1
+ import { generatePromise, waitFor } from './utils.js';
2
+ export class Queue {
3
+ running = true;
4
+ closed = false;
5
+ #queued = new Map();
6
+ #pending = new Map();
7
+
8
+ constructor(concurrency = 10) {
9
+ this.concurrency = concurrency;
10
+ }
11
+
12
+ push(id, callback, priority) {
13
+ /* istanbul ignore next: race condition paranoia */
14
+ if (this.closed && !id.startsWith('@@/')) return;
15
+ this.cancel(id);
16
+ let task = {
17
+ id,
18
+ callback,
19
+ priority
20
+ };
21
+ task.promise = new Promise((resolve, reject) => {
22
+ Object.assign(task, {
23
+ resolve,
24
+ reject
25
+ });
26
+ this.#queued.set(id, task);
27
+
28
+ this._dequeue();
29
+ });
30
+ return task.promise;
31
+ }
32
+
33
+ cancel(id) {
34
+ var _this$pending$get, _this$pending$get$can;
35
+
36
+ (_this$pending$get = this.#pending.get(id)) === null || _this$pending$get === void 0 ? void 0 : (_this$pending$get$can = _this$pending$get.cancel) === null || _this$pending$get$can === void 0 ? void 0 : _this$pending$get$can.call(_this$pending$get);
37
+ this.#pending.delete(id);
38
+ this.#queued.delete(id);
39
+ }
40
+
41
+ has(id) {
42
+ return this.#queued.has(id) || this.#pending.has(id);
43
+ }
44
+
45
+ clear() {
46
+ this.#queued.clear();
47
+ return this.size;
48
+ }
49
+
50
+ get size() {
51
+ return this.#queued.size + this.#pending.size;
52
+ }
53
+
54
+ run() {
55
+ this.running = true;
56
+
57
+ while (this.running && this.#queued.size && this.#pending.size < this.concurrency) this._dequeue();
58
+
59
+ return this;
60
+ }
61
+
62
+ stop() {
63
+ this.running = false;
64
+ return this;
65
+ }
66
+
67
+ open() {
68
+ this.closed = false;
69
+ return this;
70
+ }
71
+
72
+ close(abort) {
73
+ if (abort) this.stop().clear();
74
+ this.closed = true;
75
+ return this;
76
+ }
77
+
78
+ idle(callback) {
79
+ return waitFor(() => {
80
+ callback === null || callback === void 0 ? void 0 : callback(this.#pending.size);
81
+ return !this.#pending.size;
82
+ }, {
83
+ idle: 10
84
+ });
85
+ }
86
+
87
+ empty(callback) {
88
+ return waitFor(() => {
89
+ callback === null || callback === void 0 ? void 0 : callback(this.size);
90
+ return !this.size;
91
+ }, {
92
+ idle: 10
93
+ });
94
+ }
95
+
96
+ flush(callback) {
97
+ let stopped = !this.running;
98
+ this.run().push('@@/flush', () => {
99
+ if (stopped) this.stop();
100
+ });
101
+ return this.idle(pend => {
102
+ let left = [...this.#queued.keys()].indexOf('@@/flush');
103
+ if (!~left && !this.#pending.has('@@/flush')) left = 0;
104
+ callback === null || callback === void 0 ? void 0 : callback(pend + left);
105
+ }).canceled(() => {
106
+ if (stopped) this.stop();
107
+ this.cancel('@@/flush');
108
+ });
109
+ }
110
+
111
+ next() {
112
+ let next;
113
+
114
+ for (let [id, task] of this.#queued) {
115
+ if (!next || task.priority != null && next.priority == null || task.priority < next.priority) next = task;
116
+ if (id === '@@/flush') break;
117
+ }
118
+
119
+ return next;
120
+ }
121
+
122
+ _dequeue() {
123
+ if (!this.running) return;
124
+ if (this.#pending.size >= this.concurrency) return;
125
+ let task = this.next();
126
+ if (!task) return;
127
+ this.#queued.delete(task.id);
128
+ this.#pending.set(task.id, task);
129
+
130
+ let done = callback => arg => {
131
+ var _task$cancel;
132
+
133
+ if (!((_task$cancel = task.cancel) !== null && _task$cancel !== void 0 && _task$cancel.triggered)) {
134
+ this.#pending.delete(task.id);
135
+ }
136
+
137
+ callback(arg);
138
+
139
+ this._dequeue();
140
+ };
141
+
142
+ try {
143
+ let gen = generatePromise(task.callback);
144
+ task.cancel = gen.cancel;
145
+ return gen.then(done(task.resolve), done(task.reject));
146
+ } catch (err) {
147
+ done(task.reject)(err);
148
+ }
149
+ }
150
+
151
+ }
152
+ export default Queue;