@percy/core 1.1.4 → 1.2.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/browser.js CHANGED
@@ -41,52 +41,49 @@ export class Browser extends EventEmitter {
41
41
  '--use-mock-keychain', // enable remote debugging on the first available port
42
42
  '--remote-debugging-port=0'];
43
43
 
44
- constructor({
45
- executable = process.env.PERCY_BROWSER_EXECUTABLE,
46
- timeout = 30000,
47
- headless = true,
48
- cookies = [],
49
- args = []
50
- }) {
51
- super();
52
- this.launchTimeout = timeout;
53
- this.executable = executable;
54
- this.headless = headless;
55
- /* istanbul ignore next: only false for debugging */
56
-
57
- if (this.headless) this.args.push('--headless', '--hide-scrollbars', '--mute-audio');
58
-
59
- for (let a of args) if (!this.args.includes(a)) this.args.push(a); // transform cookies object to an array of cookie params
60
-
61
-
62
- this.cookies = Array.isArray(cookies) ? cookies : Object.entries(cookies).map(([name, value]) => ({
63
- name,
64
- value
65
- }));
44
+ constructor(percy) {
45
+ super().percy = percy;
66
46
  }
67
47
 
68
48
  async launch() {
69
49
  // already launching or launched
70
50
  if (this.readyState != null) return;
71
- this.readyState = 0; // check if any provided executable exists
51
+ this.readyState = 0;
52
+ let {
53
+ cookies = [],
54
+ launchOptions = {}
55
+ } = this.percy.config.discovery;
56
+ let {
57
+ executable,
58
+ headless = true,
59
+ args = [],
60
+ timeout
61
+ } = launchOptions; // transform cookies object to an array of cookie params
72
62
 
73
- if (this.executable && !fs.existsSync(this.executable)) {
74
- this.log.error(`Browser executable not found: ${this.executable}`);
75
- this.executable = null;
63
+ this.cookies = Array.isArray(cookies) ? cookies : Object.entries(cookies).map(([name, value]) => ({
64
+ name,
65
+ value
66
+ })); // check if any provided executable exists
67
+
68
+ if (executable && !fs.existsSync(executable)) {
69
+ this.log.error(`Browser executable not found: ${executable}`);
70
+ executable = null;
76
71
  } // download and install the browser if not already present
77
72
 
78
73
 
79
- this.executable || (this.executable = await install.chromium()); // create a temporary profile directory
74
+ this.executable = executable || (await install.chromium());
75
+ this.log.debug('Launching browser'); // create a temporary profile directory and collect additional launch arguments
80
76
 
81
- this.profile = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'percy-browser-')); // spawn the browser process detached in its own group and session
77
+ this.profile = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'percy-browser-'));
78
+ /* istanbul ignore next: only false for debugging */
82
79
 
83
- let args = this.args.concat(`--user-data-dir=${this.profile}`);
84
- this.log.debug('Launching browser');
85
- this.process = spawn(this.executable, args, {
86
- detached: process.platform !== 'win32'
87
- }); // connect a websocket to the devtools address
80
+ if (headless) this.args.push('--headless', '--hide-scrollbars', '--mute-audio');
88
81
 
89
- this.ws = new WebSocket(await this.address(), {
82
+ for (let a of args) if (!this.args.includes(a)) this.args.push(a);
83
+
84
+ this.args.push(`--user-data-dir=${this.profile}`); // spawn the browser process and connect a websocket to the devtools address
85
+
86
+ this.ws = new WebSocket(await this.spawn(timeout), {
90
87
  perMessageDeflate: false
91
88
  }); // wait until the websocket has connected
92
89
 
@@ -214,13 +211,15 @@ export class Browser extends EventEmitter {
214
211
  });
215
212
  });
216
213
  }
217
- } // Returns the devtools websocket address. If not already known, will watch the browser's
218
- // stderr and resolves when it emits the devtools protocol address or rejects if the process
219
- // exits for any reason or if the address does not appear after the timeout.
214
+ }
220
215
 
216
+ async spawn(timeout = 30000) {
217
+ // spawn the browser process detached in its own group and session
218
+ this.process = spawn(this.executable, this.args, {
219
+ detached: process.platform !== 'win32'
220
+ }); // watch the process stderr and resolve when it emits the devtools protocol address
221
221
 
222
- async address(timeout = this.launchTimeout) {
223
- this._address || (this._address = await new Promise((resolve, reject) => {
222
+ this.address = await new Promise((resolve, reject) => {
224
223
  let stderr = '';
225
224
 
226
225
  let handleData = chunk => {
@@ -247,8 +246,8 @@ export class Browser extends EventEmitter {
247
246
  this.process.stderr.on('close', handleExitClose);
248
247
  this.process.on('exit', handleExitClose);
249
248
  this.process.on('error', handleError);
250
- }));
251
- return this._address;
249
+ });
250
+ return this.address;
252
251
  }
253
252
 
254
253
  _handleMessage(data) {
package/dist/page.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import fs from 'fs';
2
2
  import logger from '@percy/logger';
3
3
  import Network from './network.js';
4
- import { hostname, generatePromise, waitFor } from './utils.js';
5
4
  import { PERCY_DOM } from './api.js';
5
+ import { hostname, waitFor, waitForTimeout as sleep, serializeFunction } from './utils.js';
6
6
  export class Page {
7
7
  static TIMEOUT = 30000;
8
8
  log = logger('core:page');
9
9
 
10
10
  constructor(session, options) {
11
11
  this.session = session;
12
+ this.browser = session.browser;
12
13
  this.enableJavaScript = options.enableJavaScript ?? true;
13
14
  this.network = new Network(this, options);
14
15
  this.meta = options.meta;
@@ -79,10 +80,7 @@ export class Page {
79
80
  try {
80
81
  // trigger navigation and poll for handlers to have finished
81
82
  await Promise.all([navigate(), waitFor(() => {
82
- if (this.session.closedReason) {
83
- throw new Error(this.session.closedReason);
84
- }
85
-
83
+ if (this.session.closedReason) throw new Error(this.session.closedReason);
86
84
  return handlers.every(handler => handler.finished);
87
85
  }, Page.TIMEOUT)]);
88
86
  } catch (error) {
@@ -99,38 +97,11 @@ export class Page {
99
97
 
100
98
 
101
99
  async eval(fn, ...args) {
102
- let fnbody = fn.toString(); // we might have a function shorthand if this fails
103
-
104
- /* eslint-disable-next-line no-new, no-new-func */
105
-
106
- try {
107
- new Function(`(${fnbody})`);
108
- } catch (error) {
109
- fnbody = fnbody.startsWith('async ') ? fnbody.replace(/^async/, 'async function') : `function ${fnbody}`;
110
- /* eslint-disable-next-line no-new, no-new-func */
111
-
112
- try {
113
- new Function(`(${fnbody})`);
114
- } catch (error) {
115
- throw new Error('The provided function is not serializable');
116
- }
117
- } // wrap the function body with percy helpers
118
-
119
-
120
- fnbody = 'function withPercyHelpers() {\n' + [`return (${fnbody})({ generatePromise, waitFor }, ...arguments);`, `${generatePromise}`, `${waitFor}`].join('\n\n') + '}';
121
- /* istanbul ignore else: ironic. */
122
-
123
- if (fnbody.includes('cov_')) {
124
- // remove coverage statements during testing
125
- fnbody = fnbody.replace(/cov_.*?(;\n?|,)\s*/g, '');
126
- } // send the call function command
127
-
128
-
129
100
  let {
130
101
  result,
131
102
  exceptionDetails
132
103
  } = await this.session.send('Runtime.callFunctionOn', {
133
- functionDeclaration: fnbody,
104
+ functionDeclaration: serializeFunction(fn),
134
105
  arguments: args.map(value => ({
135
106
  value
136
107
  })),
@@ -151,19 +122,12 @@ export class Page {
151
122
  async evaluate(scripts) {
152
123
  var _scripts;
153
124
 
154
- scripts && (scripts = [].concat(scripts));
155
- if (!((_scripts = scripts) !== null && _scripts !== void 0 && _scripts.length)) return;
125
+ if (!((_scripts = scripts && (scripts = [].concat(scripts))) !== null && _scripts !== void 0 && _scripts.length)) return;
156
126
  this.log.debug('Evaluate JavaScript', { ...this.meta,
157
127
  scripts
158
128
  });
159
129
 
160
- for (let script of scripts) {
161
- if (typeof script === 'string') {
162
- script = `async eval() {\n${script}\n}`;
163
- }
164
-
165
- await this.eval(script);
166
- }
130
+ for (let script of scripts) await this.eval(script);
167
131
  } // Take a snapshot after waiting for any timeout, waiting for any selector, executing any scripts,
168
132
  // and waiting for the network idle
169
133
 
@@ -173,29 +137,28 @@ export class Page {
173
137
  waitForTimeout,
174
138
  waitForSelector,
175
139
  execute,
140
+ meta,
176
141
  ...options
177
142
  }) {
178
143
  this.log.debug(`Taking snapshot: ${name}`, this.meta); // wait for any specified timeout
179
144
 
180
145
  if (waitForTimeout) {
181
146
  this.log.debug(`Wait for ${waitForTimeout}ms timeout`, this.meta);
182
- await new Promise(resolve => setTimeout(resolve, waitForTimeout));
147
+ await sleep(waitForTimeout);
183
148
  } // wait for any specified selector
184
149
 
185
150
 
186
151
  if (waitForSelector) {
187
152
  this.log.debug(`Wait for selector: ${waitForSelector}`, this.meta);
188
- /* istanbul ignore next: no instrumenting injected code */
189
-
190
- await this.eval(function waitForSelector({
191
- waitFor
192
- }, selector, timeout) {
193
- return waitFor(() => !!document.querySelector(selector), timeout).catch(() => Promise.reject(new Error(`Failed to find "${selector}"`)));
194
- }, waitForSelector, Page.TIMEOUT);
153
+ await this.eval(`await waitForSelector(${JSON.stringify(waitForSelector)}, ${Page.TIMEOUT})`);
195
154
  } // execute any javascript
196
155
 
197
156
 
198
- await this.evaluate(typeof execute === 'object' && !Array.isArray(execute) ? execute.beforeSnapshot : execute); // wait for any final network activity before capturing the dom snapshot
157
+ if (execute) {
158
+ let execBefore = typeof execute === 'object' && !Array.isArray(execute);
159
+ await this.evaluate(execBefore ? execute.beforeSnapshot : execute);
160
+ } // wait for any final network activity before capturing the dom snapshot
161
+
199
162
 
200
163
  await this.network.idle(); // inject @percy/dom for serialization by evaluating the file contents which adds a global
201
164
  // PercyDOM object that we can later check against
@@ -248,7 +211,11 @@ export class Page {
248
211
 
249
212
  _handleExecutionContextCreated = event => {
250
213
  if (this.session.targetId === event.context.auxData.frameId) {
251
- this.contextId = event.context.id;
214
+ this.contextId = event.context.id; // inject global percy config as soon as possible
215
+
216
+ this.eval(`window.__PERCY__ = ${JSON.stringify({
217
+ config: this.browser.percy.config
218
+ })};`).catch(this.session._handleClosedError);
252
219
  }
253
220
  };
254
221
  _handleExecutionContextDestroyed = event => {
package/dist/percy.js CHANGED
@@ -67,20 +67,14 @@ export class Percy {
67
67
  clientInfo,
68
68
  environmentInfo
69
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
-
70
+ if (server) this.server = createPercyServer(this, port);
71
+ this.browser = new Browser(this); // generator methods are wrapped to autorun and return promises
78
72
 
79
73
  for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot']) {
80
74
  // the original generator can be referenced with percy.yield.<method>
81
75
  let method = (this.yield || (this.yield = {}))[m] = this[m].bind(this);
82
76
 
83
- this[m] = (...args) => generatePromise(method(...args)).then();
77
+ this[m] = (...args) => generatePromise(method(...args));
84
78
  }
85
79
  } // Shortcut for controlling the global logger's log level.
86
80
 
@@ -205,9 +199,9 @@ export class Percy {
205
199
  await ((_this$server3 = this.server) === null || _this$server3 === void 0 ? void 0 : _this$server3.close());
206
200
  await this.browser.close(); // mark instance as closed
207
201
 
208
- this.readyState = 3; // when uploads are deferred, cancel build creation
202
+ this.readyState = 3; // when uploads are deferred, cancel build creation on abort
209
203
 
210
- if (error.canceled && this.deferUploads) {
204
+ if (this.deferUploads && error.name === 'AbortError') {
211
205
  this.#uploads.cancel('build/create');
212
206
  this.readyState = null;
213
207
  } // throw an easier-to-understand error when the port is taken
@@ -249,10 +243,10 @@ export class Percy {
249
243
  }
250
244
  }
251
245
  } catch (error) {
252
- // reopen closed queues when canceled
246
+ // reopen closed queues when aborted
253
247
 
254
248
  /* istanbul ignore else: all errors bubble */
255
- if (close && error.canceled) {
249
+ if (close && error.name === 'AbortError') {
256
250
  this.#snapshots.open();
257
251
  this.#uploads.open();
258
252
  }
@@ -285,10 +279,10 @@ export class Percy {
285
279
  // process uploads and close queues
286
280
  yield* this.yield.flush(true);
287
281
  } catch (error) {
288
- // reset ready state when canceled
282
+ // reset ready state when aborted
289
283
 
290
284
  /* istanbul ignore else: all errors bubble */
291
- if (error.canceled) this.readyState = 1;
285
+ if (error.name === 'AbortError') this.readyState = 1;
292
286
  throw error;
293
287
  } // if dry-running, log the total number of snapshots
294
288
 
@@ -437,8 +431,8 @@ export class Percy {
437
431
  });
438
432
  });
439
433
  } catch (error) {
440
- if (error.canceled) {
441
- this.log.error('Received a duplicate snapshot name, ' + `the previous snapshot was canceled: ${snapshot.name}`);
434
+ if (error.name === 'AbortError') {
435
+ this.log.error('Received a duplicate snapshot name, ' + `the previous snapshot was aborted: ${snapshot.name}`, snapshot.meta);
442
436
  } else {
443
437
  this.log.error(`Encountered an error taking snapshot: ${snapshot.name}`, snapshot.meta);
444
438
  this.log.error(error, snapshot.meta);
package/dist/queue.js CHANGED
@@ -1,4 +1,4 @@
1
- import { generatePromise, waitFor } from './utils.js';
1
+ import { yieldFor, generatePromise, AbortController } from './utils.js';
2
2
  export class Queue {
3
3
  running = true;
4
4
  closed = false;
@@ -9,13 +9,13 @@ export class Queue {
9
9
  this.concurrency = concurrency;
10
10
  }
11
11
 
12
- push(id, callback, priority) {
12
+ push(id, generator, priority) {
13
13
  /* istanbul ignore next: race condition paranoia */
14
14
  if (this.closed && !id.startsWith('@@/')) return;
15
15
  this.cancel(id);
16
16
  let task = {
17
17
  id,
18
- callback,
18
+ generator,
19
19
  priority
20
20
  };
21
21
  task.promise = new Promise((resolve, reject) => {
@@ -31,9 +31,9 @@ export class Queue {
31
31
  }
32
32
 
33
33
  cancel(id) {
34
- var _this$pending$get, _this$pending$get$can;
34
+ var _this$pending$get, _this$pending$get$ctr;
35
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);
36
+ (_this$pending$get = this.#pending.get(id)) === null || _this$pending$get === void 0 ? void 0 : (_this$pending$get$ctr = _this$pending$get.ctrl) === null || _this$pending$get$ctr === void 0 ? void 0 : _this$pending$get$ctr.abort();
37
37
  this.#pending.delete(id);
38
38
  this.#queued.delete(id);
39
39
  }
@@ -76,7 +76,7 @@ export class Queue {
76
76
  }
77
77
 
78
78
  idle(callback) {
79
- return waitFor(() => {
79
+ return yieldFor(() => {
80
80
  callback === null || callback === void 0 ? void 0 : callback(this.#pending.size);
81
81
  return !this.#pending.size;
82
82
  }, {
@@ -85,7 +85,7 @@ export class Queue {
85
85
  }
86
86
 
87
87
  empty(callback) {
88
- return waitFor(() => {
88
+ return yieldFor(() => {
89
89
  callback === null || callback === void 0 ? void 0 : callback(this.size);
90
90
  return !this.size;
91
91
  }, {
@@ -93,19 +93,23 @@ export class Queue {
93
93
  });
94
94
  }
95
95
 
96
- flush(callback) {
96
+ async *flush(callback) {
97
97
  let stopped = !this.running;
98
98
  this.run().push('@@/flush', () => {
99
99
  if (stopped) this.stop();
100
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(() => {
101
+
102
+ try {
103
+ yield* this.idle(pend => {
104
+ let left = [...this.#queued.keys()].indexOf('@@/flush');
105
+ if (!~left && !this.#pending.has('@@/flush')) left = 0;
106
+ callback === null || callback === void 0 ? void 0 : callback(pend + left);
107
+ });
108
+ } catch (error) {
106
109
  if (stopped) this.stop();
107
110
  this.cancel('@@/flush');
108
- });
111
+ throw error;
112
+ }
109
113
  }
110
114
 
111
115
  next() {
@@ -126,26 +130,12 @@ export class Queue {
126
130
  if (!task) return;
127
131
  this.#queued.delete(task.id);
128
132
  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
- }
133
+ let ctrl = task.ctrl = new AbortController();
134
+ return generatePromise(task.generator, ctrl.signal, (err, val) => {
135
+ if (!ctrl.signal.aborted) this.#pending.delete(task.id);
136
+ task[err ? 'reject' : 'resolve'](err ?? val);
137
+ return this._dequeue();
138
+ });
149
139
  }
150
140
 
151
141
  }
package/dist/session.js CHANGED
@@ -95,7 +95,7 @@ export class Session extends EventEmitter {
95
95
  /* istanbul ignore next: encountered during closing races */
96
96
 
97
97
  _handleClosedError = error => {
98
- if (!error.message.endsWith(this.closedReason)) {
98
+ if (!(error.message ?? error).endsWith(this.closedReason)) {
99
99
  this.log.debug(error, this.meta);
100
100
  }
101
101
  };
package/dist/snapshot.js CHANGED
@@ -31,14 +31,12 @@ export function snapshotMatches(snapshot, include, exclude) {
31
31
  let test = (predicate, fallback) => {
32
32
  if (predicate && typeof predicate === 'string') {
33
33
  // snapshot name matches exactly or matches a glob
34
- let result = snapshot.name === predicate || micromatch.isMatch(snapshot.name, predicate, {
35
- basename: !predicate.startsWith('/')
36
- }); // snapshot might match a string-based regexp pattern
34
+ let result = snapshot.name === predicate || micromatch.isMatch(snapshot.name, predicate); // snapshot might match a string-based regexp pattern
37
35
 
38
36
  if (!result) {
39
37
  try {
40
- let [, parsed = predicate, flags] = RE_REGEXP.exec(predicate) || [];
41
- result = new RegExp(parsed, flags).test(snapshot.name);
38
+ let [, parsed, flags] = RE_REGEXP.exec(predicate) || [];
39
+ result = !!parsed && new RegExp(parsed, flags).test(snapshot.name);
42
40
  } catch {}
43
41
  }
44
42
 
@@ -203,12 +201,12 @@ export function getSnapshotConfig(percy, options) {
203
201
  userAgent: percy.config.discovery.userAgent
204
202
  }
205
203
  }, options], (path, prev, next) => {
206
- var _next;
204
+ var _next, _next2;
207
205
 
208
206
  switch (path.map(k => k.toString()).join('.')) {
209
207
  case 'widths':
210
208
  // dedup, sort, and override widths when not empty
211
- return [path, (_next = next) !== null && _next !== void 0 && _next.length ? Array.from(new Set(next)).sort((a, b) => a - b) : prev];
209
+ return [path, !((_next = next) !== null && _next !== void 0 && _next.length) ? prev : Array.from(new Set(next)).sort((a, b) => a - b)];
212
210
 
213
211
  case 'percyCSS':
214
212
  // concatenate percy css
@@ -220,7 +218,7 @@ export function getSnapshotConfig(percy, options) {
220
218
 
221
219
  case 'discovery.disallowedHostnames':
222
220
  // prevent disallowing the root hostname
223
- return [path, (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
221
+ return [path, !((_next2 = next) !== null && _next2 !== void 0 && _next2.length) ? prev : (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
224
222
  } // ensure additional snapshots have complete names
225
223
 
226
224
 
@@ -357,8 +355,6 @@ export async function* discoverSnapshotResources(percy, snapshot, callback) {
357
355
  });
358
356
 
359
357
  try {
360
- var _snapshot$execute;
361
-
362
358
  // set the initial page size
363
359
  yield page.resize({
364
360
  width: widths.shift(),
@@ -366,18 +362,26 @@ export async function* discoverSnapshotResources(percy, snapshot, callback) {
366
362
  }); // navigate to the url
367
363
 
368
364
  yield page.goto(snapshot.url);
369
- yield page.evaluate((_snapshot$execute = snapshot.execute) === null || _snapshot$execute === void 0 ? void 0 : _snapshot$execute.afterNavigation); // trigger resize events for other widths
365
+
366
+ if (snapshot.execute) {
367
+ // when any execute options are provided, inject snapshot options
368
+
369
+ /* istanbul ignore next: cannot detect coverage of injected code */
370
+ yield page.eval((_, s) => window.__PERCY__.snapshot = s, snapshot);
371
+ yield page.evaluate(snapshot.execute.afterNavigation);
372
+ } // trigger resize events for other widths
373
+
370
374
 
371
375
  for (let width of widths) {
372
- var _snapshot$execute2, _snapshot$execute3;
376
+ var _snapshot$execute, _snapshot$execute2;
373
377
 
374
- yield page.evaluate((_snapshot$execute2 = snapshot.execute) === null || _snapshot$execute2 === void 0 ? void 0 : _snapshot$execute2.beforeResize);
378
+ yield page.evaluate((_snapshot$execute = snapshot.execute) === null || _snapshot$execute === void 0 ? void 0 : _snapshot$execute.beforeResize);
375
379
  yield waitForDiscoveryNetworkIdle(page, snapshot.discovery);
376
380
  yield page.resize({
377
381
  width,
378
382
  height: snapshot.minHeight
379
383
  });
380
- yield page.evaluate((_snapshot$execute3 = snapshot.execute) === null || _snapshot$execute3 === void 0 ? void 0 : _snapshot$execute3.afterResize);
384
+ yield page.evaluate((_snapshot$execute2 = snapshot.execute) === null || _snapshot$execute2 === void 0 ? void 0 : _snapshot$execute2.afterResize);
381
385
  }
382
386
 
383
387
  if (snapshot.domSnapshot) {
package/dist/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import EventEmitter from 'events';
1
2
  import { sha256hash } from '@percy/client/utils';
2
3
  export { request, getPackageJSON, hostnameMatches } from '@percy/client/utils'; // Returns the hostname portion of a URL.
3
4
 
@@ -44,84 +45,171 @@ export function createPercyCSSResource(url, css) {
44
45
 
45
46
  export function createLogResource(logs) {
46
47
  return createResource(`/percy.${Date.now()}.log`, JSON.stringify(logs), 'text/plain');
47
- } // Creates a thennable, cancelable, generator instance
48
+ } // Iterates over the provided generator and resolves to the final value when done. With an
49
+ // AbortSignal, the generator will throw with the abort reason when aborted. Also accepts an
50
+ // optional node-style callback, called before the returned promise resolves.
48
51
 
49
- export function generatePromise(gen) {
50
- var _gen, _gen2;
52
+ export async function generatePromise(gen, signal, cb) {
53
+ try {
54
+ var _gen;
51
55
 
52
- // ensure a generator is provided
53
- if (typeof gen === 'function') gen = gen();
54
- if (typeof ((_gen = gen) === null || _gen === void 0 ? void 0 : _gen.then) === 'function') return gen;
55
- if (typeof ((_gen2 = gen) === null || _gen2 === void 0 ? void 0 : _gen2.next) !== 'function' || !(typeof gen[Symbol.iterator] === 'function' || typeof gen[Symbol.asyncIterator] === 'function')) return Promise.resolve(gen); // used to trigger cancelation
56
-
57
- class Canceled extends Error {
58
- name = 'Canceled';
59
- canceled = true;
60
- } // recursively runs the generator, maybe throwing an error when canceled
61
-
62
-
63
- let handleNext = async (g, last) => {
64
- let canceled = g.cancel.triggered;
56
+ if (typeof signal === 'function') [cb, signal] = [signal];
57
+ if (typeof gen === 'function') gen = await gen();
65
58
  let {
66
59
  done,
67
60
  value
68
- } = canceled ? await g.throw(canceled) : await g.next(last);
69
- if (canceled) delete g.cancel.triggered;
70
- return done ? value : handleNext(g, value);
71
- }; // handle cancelation errors by calling any cancel handlers
61
+ } = typeof ((_gen = gen) === null || _gen === void 0 ? void 0 : _gen.next) === 'function' && (typeof gen[Symbol.iterator] === 'function' || typeof gen[Symbol.asyncIterator] === 'function') ? await gen.next() : {
62
+ done: true,
63
+ value: await gen
64
+ };
65
+
66
+ while (!done) {
67
+ var _signal;
68
+
69
+ ({
70
+ done,
71
+ value
72
+ } = (_signal = signal) !== null && _signal !== void 0 && _signal.aborted ? await gen.throw(signal.reason) : await gen.next(value));
73
+ }
74
+
75
+ if (!cb) return value;
76
+ return cb(null, value);
77
+ } catch (error) {
78
+ if (!cb) throw error;
79
+ return cb(error);
80
+ }
81
+ } // Bare minimum AbortController polyfill for Node < 16.14
82
+
83
+ export class AbortController {
84
+ signal = new EventEmitter();
85
+
86
+ abort(reason = new AbortError()) {
87
+ if (this.signal.aborted) return;
88
+ Object.assign(this.signal, {
89
+ reason,
90
+ aborted: true
91
+ });
92
+ this.signal.emit('abort', reason);
93
+ }
94
+
95
+ } // Similar to DOMException[AbortError] but accepts additional properties
96
+
97
+ export class AbortError extends Error {
98
+ constructor(msg = 'This operation was aborted', props) {
99
+ Object.assign(super(msg), {
100
+ name: 'AbortError',
101
+ ...props
102
+ });
103
+ }
104
+
105
+ } // An async generator that infinitely yields to the predicate function until a truthy value is
106
+ // returned. When a timeout is provided, an error will be thrown during the next iteration after the
107
+ // timeout has been exceeded. If an idle option is provided, the predicate will be yielded to a
108
+ // second time, after the idle period, to ensure the yielded value is still truthy. The poll option
109
+ // determines how long to wait before yielding to the predicate function during each iteration.
110
+
111
+ export async function* yieldFor(predicate, options = {}) {
112
+ if (Number.isInteger(options)) options = {
113
+ timeout: options
114
+ };
115
+ let {
116
+ timeout,
117
+ idle,
118
+ poll = 10
119
+ } = options;
120
+ let start = Date.now();
121
+ let done, value;
122
+
123
+ while (true) {
124
+ if (timeout && Date.now() - start >= timeout) {
125
+ throw new Error(`Timeout of ${timeout}ms exceeded.`);
126
+ } else if (!(value = yield predicate())) {
127
+ done = await waitForTimeout(poll, false);
128
+ } else if (idle && !done) {
129
+ done = await waitForTimeout(idle, true);
130
+ } else {
131
+ return value;
132
+ }
133
+ }
134
+ } // Promisified version of `yieldFor` above.
135
+
136
+ export function waitFor() {
137
+ return generatePromise(yieldFor(...arguments));
138
+ } // Promisified version of `setTimeout` (no callback argument).
139
+
140
+ export function waitForTimeout() {
141
+ return new Promise(resolve => setTimeout(resolve, ...arguments));
142
+ } // Browser-specific util to wait for a query selector to exist within an optional timeout.
143
+
144
+ /* istanbul ignore next: tested, but coverage is stripped */
145
+
146
+ async function waitForSelector(selector, timeout) {
147
+ try {
148
+ return await waitFor(() => document.querySelector(selector), timeout);
149
+ } catch {
150
+ throw new Error(`Unable to find: ${selector}`);
151
+ }
152
+ } // Browser-specific util to wait for an xpath selector to exist within an optional timeout.
153
+
154
+ /* istanbul ignore next: tested, but coverage is stripped */
155
+
156
+
157
+ async function waitForXPath(selector, timeout) {
158
+ try {
159
+ let xpath = () => document.evaluate(selector, document, null, 9, null);
160
+
161
+ return await waitFor(() => xpath().singleNodeValue, timeout);
162
+ } catch {
163
+ throw new Error(`Unable to find: ${selector}`);
164
+ }
165
+ } // Browser-specific util to scroll to the bottom of a page, optionally calling the provided function
166
+ // after each window segment has been scrolled.
72
167
 
168
+ /* istanbul ignore next: tested, but coverage is stripped */
169
+
170
+
171
+ async function scrollToBottom(options, onScroll) {
172
+ if (typeof options === 'function') [onScroll, options] = [options];
173
+
174
+ let size = () => Math.ceil(document.body.scrollHeight / window.innerHeight);
175
+
176
+ for (let s, i = 1; i < (s = size()); i++) {
177
+ var _onScroll;
178
+
179
+ window.scrollTo({ ...options,
180
+ top: window.innerHeight * i
181
+ });
182
+ await ((_onScroll = onScroll) === null || _onScroll === void 0 ? void 0 : _onScroll(i, s));
183
+ }
184
+ } // Serializes the provided function with percy helpers for use in evaluating browser scripts
185
+
186
+
187
+ export function serializeFunction(fn) {
188
+ let fnbody = typeof fn === 'string' ? `async eval() {\n${fn}\n}` : fn.toString(); // we might have a function shorthand if this fails
189
+
190
+ /* eslint-disable-next-line no-new, no-new-func */
191
+
192
+ try {
193
+ new Function(`(${fnbody})`);
194
+ } catch (error) {
195
+ fnbody = fnbody.startsWith('async ') ? fnbody.replace(/^async/, 'async function') : `function ${fnbody}`;
196
+ /* eslint-disable-next-line no-new, no-new-func */
73
197
 
74
- let cancelable = async function* () {
75
198
  try {
76
- return yield* gen;
199
+ new Function(`(${fnbody})`);
77
200
  } catch (error) {
78
- if (error.canceled) {
79
- let cancelers = cancelable.cancelers || [];
201
+ throw new Error('The provided function is not serializable');
202
+ }
203
+ } // wrap the function body with percy helpers
80
204
 
81
- for (let c of cancelers) await c(error);
82
- }
83
205
 
84
- throw error;
85
- }
86
- }(); // augment the cancelable generator with promise-like and cancel methods
87
-
88
-
89
- return Object.assign(cancelable, {
90
- run: () => cancelable.promise || (cancelable.promise = handleNext(cancelable)),
91
- then: (resolve, reject) => cancelable.run().then(resolve, reject),
92
- catch: reject => cancelable.run().catch(reject),
93
- cancel: message => {
94
- cancelable.cancel.triggered = new Canceled(message);
95
- return cancelable;
96
- },
97
- canceled: handler => {
98
- (cancelable.cancelers || (cancelable.cancelers = [])).push(handler);
99
- return cancelable;
100
- }
101
- });
102
- } // Resolves when the predicate function returns true within the timeout. If an idle option is
103
- // provided, the predicate will be checked again before resolving, after the idle period. The poll
104
- // option determines how often the predicate check will be run.
206
+ fnbody = 'function withPercyHelpers() {\n' + ['const { config, snapshot } = window.__PERCY__ ?? {};', `return (${fnbody})({`, ' config, snapshot, generatePromise, yieldFor,', ' waitFor, waitForTimeout, waitForSelector, waitForXPath,', ' scrollToBottom', '}, ...arguments);', `${generatePromise}`, `${yieldFor}`, `${waitFor}`, `${waitForTimeout}`, `${waitForSelector}`, `${waitForXPath}`, `${scrollToBottom}`].join('\n') + '\n}';
207
+ /* istanbul ignore else: ironic. */
105
208
 
106
- export function waitFor(predicate, options) {
107
- let {
108
- poll = 10,
109
- timeout,
110
- idle
111
- } = Number.isInteger(options) ? {
112
- timeout: options
113
- } : options || {};
114
- return generatePromise(async function* check(start, done) {
115
- while (true) {
116
- if (timeout && Date.now() - start >= timeout) {
117
- throw new Error(`Timeout of ${timeout}ms exceeded.`);
118
- } else if (!predicate()) {
119
- yield new Promise(r => setTimeout(r, poll, done = false));
120
- } else if (idle && !done) {
121
- yield new Promise(r => setTimeout(r, idle, done = true));
122
- } else {
123
- return;
124
- }
125
- }
126
- }(Date.now()));
209
+ if (fnbody.includes('cov_')) {
210
+ // remove coverage statements during testing
211
+ fnbody = fnbody.replace(/cov_.*?(;\n?|,)\s*/g, '');
212
+ }
213
+
214
+ return fnbody;
127
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,10 +39,10 @@
39
39
  "test:types": "tsd"
40
40
  },
41
41
  "dependencies": {
42
- "@percy/client": "1.1.4",
43
- "@percy/config": "1.1.4",
44
- "@percy/dom": "1.1.4",
45
- "@percy/logger": "1.1.4",
42
+ "@percy/client": "1.2.0",
43
+ "@percy/config": "1.2.0",
44
+ "@percy/dom": "1.2.0",
45
+ "@percy/logger": "1.2.0",
46
46
  "content-disposition": "^0.5.4",
47
47
  "cross-spawn": "^7.0.3",
48
48
  "extract-zip": "^2.0.1",
@@ -53,5 +53,5 @@
53
53
  "rimraf": "^3.0.2",
54
54
  "ws": "^8.0.0"
55
55
  },
56
- "gitHead": "ca09298265b043703b94dd5c37dd9f2489312049"
56
+ "gitHead": "ab7652e08dab49b3efbe2968339c5a70391ed4ff"
57
57
  }