@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 +40 -41
- package/dist/page.js +19 -52
- package/dist/percy.js +11 -17
- package/dist/queue.js +24 -34
- package/dist/session.js +1 -1
- package/dist/snapshot.js +18 -14
- package/dist/utils.js +156 -68
- package/package.json +6 -6
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
|
-
|
|
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;
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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-'));
|
|
77
|
+
this.profile = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'percy-browser-'));
|
|
78
|
+
/* istanbul ignore next: only false for debugging */
|
|
82
79
|
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
71
|
-
|
|
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))
|
|
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 (
|
|
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
|
|
246
|
+
// reopen closed queues when aborted
|
|
253
247
|
|
|
254
248
|
/* istanbul ignore else: all errors bubble */
|
|
255
|
-
if (close && error.
|
|
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
|
|
282
|
+
// reset ready state when aborted
|
|
289
283
|
|
|
290
284
|
/* istanbul ignore else: all errors bubble */
|
|
291
|
-
if (error.
|
|
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.
|
|
441
|
-
this.log.error('Received a duplicate snapshot name, ' + `the previous snapshot was
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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$
|
|
376
|
+
var _snapshot$execute, _snapshot$execute2;
|
|
373
377
|
|
|
374
|
-
yield page.evaluate((_snapshot$
|
|
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$
|
|
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
|
-
} //
|
|
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
|
-
|
|
52
|
+
export async function generatePromise(gen, signal, cb) {
|
|
53
|
+
try {
|
|
54
|
+
var _gen;
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
} =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
199
|
+
new Function(`(${fnbody})`);
|
|
77
200
|
} catch (error) {
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"@percy/config": "1.
|
|
44
|
-
"@percy/dom": "1.
|
|
45
|
-
"@percy/logger": "1.
|
|
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": "
|
|
56
|
+
"gitHead": "ab7652e08dab49b3efbe2968339c5a70391ed4ff"
|
|
57
57
|
}
|