@percy/core 1.0.0-beta.9 → 1.0.2

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 CHANGED
@@ -1,55 +1,21 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.default = void 0;
7
-
8
- var _client = _interopRequireDefault(require("@percy/client"));
9
-
10
- var _config = _interopRequireDefault(require("@percy/config"));
11
-
12
- var _logger = _interopRequireDefault(require("@percy/logger"));
13
-
14
- var _config2 = require("./config");
15
-
16
- var _discoverer = _interopRequireDefault(require("./discoverer"));
17
-
18
- var _percyCss = _interopRequireDefault(require("./percy-css"));
19
-
20
- var _server = require("./server");
21
-
22
- var _queue = _interopRequireDefault(require("./queue"));
23
-
24
- var _assert = _interopRequireDefault(require("./utils/assert"));
25
-
26
- var _resources = require("./utils/resources");
27
-
28
- var _url = require("./utils/url");
29
-
30
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
31
-
32
- function _classPrivateFieldGet(receiver, privateMap) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to get private field on non-instance"); } if (descriptor.get) { return descriptor.get.call(receiver); } return descriptor.value; }
33
-
34
- function _classPrivateFieldSet(receiver, privateMap, value) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to set private field on non-instance"); } if (descriptor.set) { descriptor.set.call(receiver, value); } else { if (!descriptor.writable) { throw new TypeError("attempted to set read only private field"); } descriptor.value = value; } return value; }
35
-
36
- // Register core config options
37
- _config.default.addSchema(_config2.schema); // A Percy instance will create a new build when started, handle snapshot
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
38
9
  // creation, asset discovery, and resource uploads, and will finalize the build
39
10
  // when stopped. Snapshots are processed concurrently and the build is not
40
11
  // finalized until all snapshots have been handled.
41
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
42
18
 
43
- var _captures = new WeakMap();
44
-
45
- var _snapshots = new WeakMap();
46
-
47
- var _stopping = new WeakMap();
48
-
49
- var _running = new WeakMap();
50
-
51
- class Percy {
52
- // Static shortcut to create and start an instance in one call
53
19
  static async start(options) {
54
20
  let instance = new this(options);
55
21
  await instance.start();
@@ -57,6 +23,16 @@ class Percy {
57
23
  }
58
24
 
59
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,
60
36
  // provided to @percy/client
61
37
  token,
62
38
  clientInfo = '',
@@ -64,117 +40,178 @@ class Percy {
64
40
  // snapshot server options
65
41
  server = true,
66
42
  port = 5338,
67
- // capture concurrency
68
- concurrency = 5,
69
- // initial log level
70
- loglevel,
71
- // configuration filepath
72
- config,
73
43
  // options such as `snapshot` and `discovery` that are valid Percy config
74
- // options which will become accessible via the `#config` property
44
+ // options which will become accessible via the `.config` property
75
45
  ...options
76
46
  } = {}) {
77
- _captures.set(this, {
78
- writable: true,
79
- value: null
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
80
55
  });
81
56
 
82
- _snapshots.set(this, {
83
- writable: true,
84
- value: null
85
- });
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
+ }
86
64
 
87
- _stopping.set(this, {
88
- writable: true,
89
- value: false
65
+ this.client = new PercyClient({
66
+ token,
67
+ clientInfo,
68
+ environmentInfo
90
69
  });
91
-
92
- _running.set(this, {
93
- writable: true,
94
- value: false
70
+ this.browser = new Browser({ ...this.config.discovery.launchOptions,
71
+ cookies: this.config.discovery.cookies
95
72
  });
96
73
 
97
- if (loglevel) {
98
- this.loglevel(loglevel);
99
- }
100
-
101
74
  if (server) {
102
- this.port = port;
103
- this.app = (0, _server.createServerApp)(this);
104
- }
75
+ this.server = createPercyServer(this, port);
76
+ } // generator methods are wrapped to autorun and return promises
105
77
 
106
- _classPrivateFieldSet(this, _snapshots, new _queue.default());
107
78
 
108
- _classPrivateFieldSet(this, _captures, new _queue.default(concurrency));
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);
109
82
 
110
- this.config = config === false ? _config.default.getDefaults(options) : _config.default.load({
111
- path: config,
112
- overrides: options
113
- });
114
- this.discoverer = new _discoverer.default(this.config.discovery);
115
- this.client = new _client.default({
116
- token,
117
- clientInfo,
118
- environmentInfo
119
- });
83
+ this[m] = (...args) => generatePromise(method(...args)).then();
84
+ }
120
85
  } // Shortcut for controlling the global logger's log level.
121
86
 
122
87
 
123
88
  loglevel(level) {
124
- return _logger.default.loglevel(level);
89
+ return logger.loglevel(level);
125
90
  } // Snapshot server API address
126
91
 
127
92
 
128
- apiAddress() {
129
- var _this$server, _address;
93
+ address() {
94
+ var _this$server;
130
95
 
131
- let {
132
- address
133
- } = ((_this$server = this.server) === null || _this$server === void 0 ? void 0 : _this$server.address()) || {};
134
- address = ((_address = address) === null || _address === void 0 ? void 0 : _address.includes(':')) ? `[${address}]` : address;
135
- return address && `http://${address}:${this.port}/percy`;
136
- } // Returns a boolean indicating if this instance is running.
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
137
98
 
138
99
 
139
- isRunning() {
140
- return _classPrivateFieldGet(this, _running);
141
- } // Starts the local API server, the asset discovery process, and creates a new
142
- // Percy build. When an error is encountered, the discoverer and server are closed.
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
143
107
 
108
+ config = PercyConfig.normalize(config, {
109
+ schema: '/config'
110
+ });
111
+ if (!config) return this.config; // validate provided config options
144
112
 
145
- async start() {
146
- // throws when the token is missing
147
- this.client.getToken();
113
+ let errors = PercyConfig.validate(config);
148
114
 
149
- try {
150
- // if there is an exress app, a server should be started
151
- if (this.app) {
152
- this.server = await (0, _server.startServer)(this.app, this.port);
153
- } // launch the discoverer browser and create a percy build
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
154
143
 
155
144
 
156
- await this.discoverer.launch();
157
- await this.client.createBuild(); // log build details
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.
158
150
 
159
- let build = this.client.build;
160
- let meta = {
161
- build: {
162
- id: build.id
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
163
164
  }
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;
165
188
 
166
- _logger.default.info('Percy has started!', meta);
189
+ // when not deferred, wait until the build is created first
190
+ if (!this.deferUploads) await buildTask; // maybe launch the discovery browser
167
191
 
168
- _logger.default.info(`Created build #${build.number}: ${build.url}`, meta); // mark this process as running
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
169
195
 
170
196
 
171
- _classPrivateFieldSet(this, _running, true);
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;
172
201
  } catch (error) {
173
- var _this$server2;
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
174
214
 
175
- // on error, close any running browser or server
176
- await this.discoverer.close();
177
- (_this$server2 = this.server) === null || _this$server2 === void 0 ? void 0 : _this$server2.close(); // throw an easier-to understand error when the port is taken
178
215
 
179
216
  if (error.code === 'EADDRINUSE') {
180
217
  throw new Error('Percy is already running or the port is in use');
@@ -182,247 +219,266 @@ class Percy {
182
219
  throw error;
183
220
  }
184
221
  }
185
- } // Stops the local API server and discoverer once snapshots have completed and
186
- // finalizes the Percy build. Does nothing if not running.
187
-
188
-
189
- async stop() {
190
- // do nothing if not running or already stopping
191
- if (this.isRunning() && !_classPrivateFieldGet(this, _stopping)) {
192
- var _this$server3;
222
+ } // Wait for currently queued snapshots then run and wait for resulting uploads
193
223
 
194
- _classPrivateFieldSet(this, _stopping, true);
195
224
 
196
- let build = this.client.build;
197
- let meta = {
198
- build: {
199
- id: build.id
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
+ });
200
249
  }
201
- };
202
-
203
- _logger.default.info('Stopping percy...', meta); // log about queued captures or uploads
250
+ }
251
+ } catch (error) {
252
+ // reopen closed queues when canceled
204
253
 
254
+ /* istanbul ignore else: all errors bubble */
255
+ if (close && error.canceled) {
256
+ this.#snapshots.open();
257
+ this.#uploads.open();
258
+ }
205
259
 
206
- if (_classPrivateFieldGet(this, _captures).length) {
207
- _logger.default.info(`Waiting for ${_classPrivateFieldGet(this, _captures).length} page(s) to finish snapshotting`, meta);
208
- } else if (_classPrivateFieldGet(this, _snapshots).length) {
209
- _logger.default.info(`Waiting for ${_classPrivateFieldGet(this, _snapshots).length} snapshot(s) to finish uploading`, meta);
210
- } // wait for any queued captures or snapshots
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.
211
264
 
212
265
 
213
- await this.idle(); // close the server and browser
266
+ async *stop(force) {
267
+ var _this$server4, _this$build, _this$build2;
214
268
 
215
- (_this$server3 = this.server) === null || _this$server3 === void 0 ? void 0 : _this$server3.close();
216
- await this.discoverer.close();
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
217
273
 
218
- _classPrivateFieldSet(this, _running, false); // log build info
219
274
 
275
+ if (!this.readyState || this.readyState > 2) return; // close queues asap
220
276
 
221
- await this.client.finalizeBuild();
277
+ if (force) this.close(); // already stopping
222
278
 
223
- _logger.default.info(`Finalized build #${build.number}: ${build.url}`, meta);
279
+ if (this.readyState === 2) return;
280
+ this.readyState = 2; // log when force stopping
224
281
 
225
- _logger.default.info('Done!');
226
- }
227
- } // Resolves when captures and snapshots are idle.
228
-
229
-
230
- async idle() {
231
- await Promise.all([_classPrivateFieldGet(this, _captures).idle(), _classPrivateFieldGet(this, _snapshots).idle()]);
232
- } // Handles asset discovery for the URL and DOM snapshot at each requested
233
- // width with the provided options. Resolves when the snapshot has been taken
234
- // and asset discovery is finished, but does not gaurantee that the snapshot
235
- // will be succesfully uploaded.
236
-
237
-
238
- snapshot({
239
- url,
240
- name,
241
- domSnapshot,
242
- widths,
243
- minHeight,
244
- percyCSS,
245
- requestHeaders,
246
- enableJavaScript,
247
- clientInfo,
248
- environmentInfo
249
- }) {
250
- var _widths, _widths2, _minHeight, _ref, _enableJavaScript;
282
+ if (force) this.log.info('Stopping percy...');
251
283
 
252
- // required assertions
253
- (0, _assert.default)(this.isRunning(), 'Not running');
254
- (0, _assert.default)(url, 'Missing required argument: url');
255
- (0, _assert.default)(name, 'Missing required argument: name');
256
- (0, _assert.default)(domSnapshot, 'Missing required argument: domSnapshot'); // fallback to instance snapshot widths
284
+ try {
285
+ // process uploads and close queues
286
+ yield* this.yield.flush(true);
287
+ } catch (error) {
288
+ // reset ready state when canceled
257
289
 
258
- widths = ((_widths = widths) === null || _widths === void 0 ? void 0 : _widths.length) ? widths : this.config.snapshot.widths;
259
- (0, _assert.default)((_widths2 = widths) === null || _widths2 === void 0 ? void 0 : _widths2.length, 'Missing required argument: widths');
260
- (0, _assert.default)(widths.length <= 10, 'too many widths'); // normalize the URL
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
261
294
 
262
- url = (0, _url.normalizeURL)(url); // fallback to instance minimum height
263
295
 
264
- minHeight = (_minHeight = minHeight) !== null && _minHeight !== void 0 ? _minHeight : this.config.snapshot.minHeight; // combine snapshot Percy CSS with instance Percy CSS
296
+ if (this.dryRun && this.#uploads.size) {
297
+ let total = this.#uploads.size - 1; // subtract the build task
265
298
 
266
- percyCSS = [this.config.snapshot.percyCSS, percyCSS].filter(Boolean).join('\n'); // combine snapshot request headers with instance request headers
299
+ this.log.info(`Found ${total} snapshot${total !== 1 ? 's' : ''}`);
300
+ } // close any running server and browser
267
301
 
268
- requestHeaders = { ...this.config.snapshot.requestHeaders,
269
- ...requestHeaders
270
- }; // fallback to instance enable JS flag
271
302
 
272
- enableJavaScript = (_ref = (_enableJavaScript = enableJavaScript) !== null && _enableJavaScript !== void 0 ? _enableJavaScript : this.config.snapshot.enableJavaScript) !== null && _ref !== void 0 ? _ref : false; // useful meta info for the logfile
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
273
305
 
274
306
  let meta = {
275
- snapshot: {
276
- name
277
- },
278
- build: {
279
- id: this.client.build.id
280
- }
307
+ build: this.build
281
308
  };
282
309
 
283
- _logger.default.debug('---------');
284
-
285
- _logger.default.debug('Handling snapshot:', meta);
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
+ }
286
371
 
287
- _logger.default.debug(`-> name: ${name}`, meta);
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
288
379
 
289
- _logger.default.debug(`-> url: ${url}`, meta);
290
380
 
291
- _logger.default.debug(`-> widths: ${widths.join('px, ')}px`, meta);
381
+ options = validateSnapshotOptions(options);
382
+ this.client.addClientInfo(options.clientInfo);
383
+ this.client.addEnvironmentInfo(options.environmentInfo); // return an async generator to allow cancelation
292
384
 
293
- _logger.default.debug(`-> clientInfo: ${clientInfo}`, meta);
385
+ return async function* () {
386
+ let server = 'serve' in options ? await createStaticServer(options).listen() : null;
294
387
 
295
- _logger.default.debug(`-> environmentInfo: ${environmentInfo}`, meta);
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
296
394
 
297
- _logger.default.debug(`-> requestHeaders: ${JSON.stringify(requestHeaders)}`, meta);
298
395
 
299
- _logger.default.debug(`-> domSnapshot:\n${domSnapshot.length <= 1024 ? domSnapshot : domSnapshot.substr(0, 1024) + '... [truncated]'}`, meta); // use a promise as a try-catch so we can do the remaining work
300
- // asynchronously, but perform the above synchronously
396
+ let snapshots = yield gatherSnapshots(this, options);
301
397
 
398
+ try {
399
+ // yield each task individually to allow canceling
400
+ let tasks = snapshots.map(s => this._takeSnapshot(s));
302
401
 
303
- return Promise.resolve().then(async () => {
304
- // inject Percy CSS
305
- let [percyDOM, percyCSSResource] = (0, _percyCss.default)(url, domSnapshot, percyCSS, meta); // use a map so resources remain unique by url
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
306
413
 
307
- let resources = new Map([[url, (0, _resources.createRootResource)(url, percyDOM)]]); // include the Percy CSS resource if there was one
308
414
 
309
- if (percyCSSResource) resources.set('percy-css', percyCSSResource); // gather resources at each width concurrently
415
+ _cancelSnapshot(snapshot) {
416
+ this.#snapshots.cancel(`snapshot/${snapshot.name}`);
310
417
 
311
- await Promise.all(widths.map(width => this.discoverer.gatherResources(resources, {
312
- rootUrl: url,
313
- rootDom: domSnapshot,
314
- enableJavaScript,
315
- requestHeaders,
316
- width,
317
- meta
318
- }))); // include a log resource for debugging
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
319
424
 
320
- let logs = await _logger.default.query({
321
- filter: ({
322
- snapshot: s
323
- }) => (s === null || s === void 0 ? void 0 : s.name) === name
324
- });
325
- resources.set('percy-logs', (0, _resources.createLogResource)(logs)); // log that the snapshot has been taken before uploading it
326
425
 
327
- _logger.default.info(`Snapshot taken: ${name}`, meta); // upload within the async snapshot queue
426
+ _takeSnapshot(snapshot) {
427
+ // cancel any existing snapshot with the same name
428
+ this._cancelSnapshot(snapshot);
328
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);
329
434
 
330
- _classPrivateFieldGet(this, _snapshots).push(() => this.client.sendSnapshot({
331
- name,
332
- widths,
333
- minHeight,
334
- enableJavaScript,
335
- clientInfo,
336
- environmentInfo,
337
- resources: Array.from(resources.values())
338
- }).catch(error => {
339
- _logger.default.error(`Encountered an error uploading snapshot: ${name}`, meta);
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
340
449
 
341
- _logger.default.error(error);
342
- }));
343
- }).catch(error => {
344
- _logger.default.error(`Encountered an error taking snapshot: ${name}`, meta);
345
450
 
346
- _logger.default.error(error);
347
- });
348
- }
451
+ _scheduleUpload(name, options) {
452
+ var _this$build4;
349
453
 
350
- capture({
351
- url,
352
- name,
353
- waitFor,
354
- execute,
355
- snapshots = [],
356
- ...options
357
- }) {
358
- (0, _assert.default)(this.isRunning(), 'Not running');
359
- (0, _assert.default)(url, `Missing URL for${name ? ` ${name}` : ' snapshots'}`);
360
- snapshots = name ? [{
361
- name,
362
- execute
363
- }].concat(snapshots) : snapshots;
364
- (0, _assert.default)(snapshots.length && snapshots.every(s => s.name), `Missing name for ${url}`); // the entire capture process happens within the async capture queue
365
-
366
- return _classPrivateFieldGet(this, _captures).push(async () => {
367
- let results = [];
368
- let page;
454
+ if ((_this$build4 = this.build) !== null && _this$build4 !== void 0 && _this$build4.error) {
455
+ throw new Error(this.build.error);
456
+ }
369
457
 
458
+ return this.#uploads.push(`upload/${name}`, async () => {
370
459
  try {
371
- // borrow a page from the discoverer
372
- page = await this.discoverer.page(); // allow @percy/dom injection
373
-
374
- await page.setBypassCSP(true); // set any request headers
375
-
376
- await page.setExtraHTTPHeaders(options.requestHeaders || {}); // @todo - resize viewport
377
- // go to and wait for network idle
378
-
379
- await page.goto(url, {
380
- waitUntil: 'networkidle2'
381
- }); // inject @percy/dom for serialization
382
-
383
- await page.addScriptTag({
384
- path: require.resolve('@percy/dom')
385
- }); // wait for any other elements or timeout before snapshotting
386
-
387
- if (waitFor) await page.waitFor(waitFor); // multiple snapshots can be captured on a single page
388
-
389
- for (let {
390
- name,
391
- execute
392
- } of snapshots) {
393
- // optionally execute a script to interact with the page
394
- if (execute) await execute(page); // serialize and capture a DOM snapshot
395
-
396
- /* istanbul ignore next: no instrumenting injected code */
397
-
398
- let domSnapshot = await page.evaluate(({
399
- enableJavaScript
400
- }) =>
401
- /* eslint-disable-next-line no-undef */
402
- PercyDOM.serialize({
403
- enableJavaScript
404
- }), options); // snapshots are awaited on concurrently after sequentially capturing their DOM
405
-
406
- results.push(this.snapshot({ ...options,
407
- url,
408
- name,
409
- domSnapshot
410
- }));
411
- }
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);
412
463
  } catch (error) {
413
- _logger.default.error(`Encountered an error for page: ${url}`);
464
+ var _error$response;
414
465
 
415
- _logger.default.error(error);
416
- } finally {
417
- var _page;
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;
418
468
 
419
- // await on any resulting snapshots
420
- await Promise.all(results);
421
- await ((_page = page) === null || _page === void 0 ? void 0 : _page.close());
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
+ }
422
479
  }
423
480
  });
424
481
  }
425
482
 
426
483
  }
427
-
428
- exports.default = Percy;
484
+ export default Percy;