@percy/core 1.10.3 → 1.11.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/api.js +42 -4
- package/dist/config.js +106 -46
- package/dist/discovery.js +288 -126
- package/dist/network.js +165 -54
- package/dist/page.js +18 -9
- package/dist/percy.js +156 -279
- package/dist/queue.js +335 -99
- package/dist/snapshot.js +243 -277
- package/dist/utils.js +4 -1
- package/package.json +6 -6
package/dist/discovery.js
CHANGED
|
@@ -1,140 +1,302 @@
|
|
|
1
|
-
import mime from 'mime-types';
|
|
2
1
|
import logger from '@percy/logger';
|
|
3
|
-
import
|
|
4
|
-
import { normalizeURL, hostnameMatches,
|
|
5
|
-
const MAX_RESOURCE_SIZE = 15 * 1024 ** 2; // 15MB
|
|
6
|
-
|
|
7
|
-
const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
|
|
8
|
-
const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
|
|
9
|
-
export function createRequestHandler(network, {
|
|
10
|
-
disableCache,
|
|
11
|
-
disallowedHostnames,
|
|
12
|
-
getResource
|
|
13
|
-
}) {
|
|
14
|
-
let log = logger('core:discovery');
|
|
15
|
-
return async request => {
|
|
16
|
-
let url = request.url;
|
|
17
|
-
let meta = { ...network.meta,
|
|
18
|
-
url
|
|
19
|
-
};
|
|
2
|
+
import Queue from './queue.js';
|
|
3
|
+
import { normalizeURL, hostnameMatches, createRootResource, createPercyCSSResource, createLogResource, yieldAll } from './utils.js'; // Logs verbose debug logs detailing various snapshot options.
|
|
20
4
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
let resource = getResource(url);
|
|
24
|
-
|
|
25
|
-
if (resource !== null && resource !== void 0 && resource.root) {
|
|
26
|
-
log.debug('- Serving root resource', meta);
|
|
27
|
-
await request.respond(resource);
|
|
28
|
-
} else if (hostnameMatches(disallowedHostnames, url)) {
|
|
29
|
-
log.debug('- Skipping disallowed hostname', meta);
|
|
30
|
-
await request.abort(true);
|
|
31
|
-
} else if (resource && !disableCache) {
|
|
32
|
-
log.debug('- Resource cache hit', meta);
|
|
33
|
-
await request.respond(resource);
|
|
34
|
-
} else {
|
|
35
|
-
await request.continue();
|
|
36
|
-
}
|
|
37
|
-
} catch (error) {
|
|
38
|
-
log.debug(`Encountered an error handling request: ${url}`, meta);
|
|
39
|
-
log.debug(error);
|
|
40
|
-
/* istanbul ignore next: race condition */
|
|
5
|
+
function debugSnapshotOptions(snapshot) {
|
|
6
|
+
let log = logger('core:snapshot'); // log snapshot info
|
|
41
7
|
|
|
42
|
-
|
|
8
|
+
log.debug('---------', snapshot.meta);
|
|
9
|
+
log.debug(`Received snapshot: ${snapshot.name}`, snapshot.meta); // will log debug info for an object property if its value is defined
|
|
10
|
+
|
|
11
|
+
let debugProp = (obj, prop, format = String) => {
|
|
12
|
+
let val = prop.split('.').reduce((o, k) => o === null || o === void 0 ? void 0 : o[k], obj);
|
|
13
|
+
|
|
14
|
+
if (val != null) {
|
|
15
|
+
// join formatted array values with a space
|
|
16
|
+
val = [].concat(val).map(format).join(', ');
|
|
17
|
+
log.debug(`- ${prop}: ${val}`, snapshot.meta);
|
|
43
18
|
}
|
|
44
19
|
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
20
|
+
|
|
21
|
+
debugProp(snapshot, 'url');
|
|
22
|
+
debugProp(snapshot, 'scope');
|
|
23
|
+
debugProp(snapshot, 'widths', v => `${v}px`);
|
|
24
|
+
debugProp(snapshot, 'minHeight', v => `${v}px`);
|
|
25
|
+
debugProp(snapshot, 'enableJavaScript');
|
|
26
|
+
debugProp(snapshot, 'deviceScaleFactor');
|
|
27
|
+
debugProp(snapshot, 'waitForTimeout');
|
|
28
|
+
debugProp(snapshot, 'waitForSelector');
|
|
29
|
+
debugProp(snapshot, 'execute.afterNavigation');
|
|
30
|
+
debugProp(snapshot, 'execute.beforeResize');
|
|
31
|
+
debugProp(snapshot, 'execute.afterResize');
|
|
32
|
+
debugProp(snapshot, 'execute.beforeSnapshot');
|
|
33
|
+
debugProp(snapshot, 'discovery.allowedHostnames');
|
|
34
|
+
debugProp(snapshot, 'discovery.disallowedHostnames');
|
|
35
|
+
debugProp(snapshot, 'discovery.requestHeaders', JSON.stringify);
|
|
36
|
+
debugProp(snapshot, 'discovery.authorization', JSON.stringify);
|
|
37
|
+
debugProp(snapshot, 'discovery.disableCache');
|
|
38
|
+
debugProp(snapshot, 'discovery.userAgent');
|
|
39
|
+
debugProp(snapshot, 'clientInfo');
|
|
40
|
+
debugProp(snapshot, 'environmentInfo');
|
|
41
|
+
debugProp(snapshot, 'domSnapshot', Boolean);
|
|
42
|
+
|
|
43
|
+
for (let added of snapshot.additionalSnapshots || []) {
|
|
44
|
+
log.debug(`Additional snapshot: ${added.name}`, snapshot.meta);
|
|
45
|
+
debugProp(added, 'waitForTimeout');
|
|
46
|
+
debugProp(added, 'waitForSelector');
|
|
47
|
+
debugProp(added, 'execute');
|
|
48
|
+
}
|
|
49
|
+
} // Wait for a page's asset discovery network to idle
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
function waitForDiscoveryNetworkIdle(page, options) {
|
|
53
|
+
let {
|
|
54
|
+
allowedHostnames,
|
|
55
|
+
networkIdleTimeout
|
|
56
|
+
} = options;
|
|
57
|
+
|
|
58
|
+
let filter = r => hostnameMatches(allowedHostnames, r.url);
|
|
59
|
+
|
|
60
|
+
return page.network.idle(filter, networkIdleTimeout);
|
|
61
|
+
} // Calls the provided callback with additional resources
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
function processSnapshotResources({
|
|
65
|
+
domSnapshot,
|
|
66
|
+
resources,
|
|
67
|
+
...snapshot
|
|
52
68
|
}) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
var _resources;
|
|
70
|
+
|
|
71
|
+
resources = [...(((_resources = resources) === null || _resources === void 0 ? void 0 : _resources.values()) ?? [])]; // find or create a root resource if one does not exist
|
|
72
|
+
|
|
73
|
+
let root = resources.find(r => r.content === domSnapshot);
|
|
74
|
+
|
|
75
|
+
if (!root) {
|
|
76
|
+
root = createRootResource(snapshot.url, domSnapshot);
|
|
77
|
+
resources.unshift(root);
|
|
78
|
+
} // inject Percy CSS
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if (snapshot.percyCSS) {
|
|
82
|
+
let css = createPercyCSSResource(root.url, snapshot.percyCSS);
|
|
83
|
+
resources.push(css); // replace root contents and associated properties
|
|
84
|
+
|
|
85
|
+
Object.assign(root, createRootResource(root.url, root.content.replace(/(<\/body>)(?!.*\1)/is, `<link data-percy-specific-css rel="stylesheet" href="${css.pathname}"/>` + '$&')));
|
|
86
|
+
} // include associated snapshot logs matched by meta information
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
resources.push(createLogResource(logger.query(log => {
|
|
90
|
+
var _log$meta$snapshot;
|
|
91
|
+
|
|
92
|
+
return ((_log$meta$snapshot = log.meta.snapshot) === null || _log$meta$snapshot === void 0 ? void 0 : _log$meta$snapshot.name) === snapshot.meta.snapshot.name;
|
|
93
|
+
})));
|
|
94
|
+
return { ...snapshot,
|
|
95
|
+
resources
|
|
96
|
+
};
|
|
97
|
+
} // Triggers the capture of resource requests for a page by iterating over snapshot widths to resize
|
|
98
|
+
// the page and calling any provided execute options.
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async function* captureSnapshotResources(page, snapshot, options) {
|
|
102
|
+
let {
|
|
103
|
+
discovery,
|
|
104
|
+
additionalSnapshots = [],
|
|
105
|
+
...baseSnapshot
|
|
106
|
+
} = snapshot;
|
|
107
|
+
let {
|
|
108
|
+
capture,
|
|
109
|
+
captureWidths,
|
|
110
|
+
deviceScaleFactor,
|
|
111
|
+
mobile
|
|
112
|
+
} = options; // used to take snapshots and remove any discovered root resource
|
|
113
|
+
|
|
114
|
+
let takeSnapshot = async (options, width) => {
|
|
115
|
+
if (captureWidths) options = { ...options,
|
|
116
|
+
width
|
|
59
117
|
};
|
|
118
|
+
let captured = await page.snapshot(options);
|
|
119
|
+
captured.resources.delete(normalizeURL(captured.url));
|
|
120
|
+
capture(processSnapshotResources(captured));
|
|
121
|
+
return captured;
|
|
122
|
+
}; // used to resize the using capture options
|
|
60
123
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
body = await makeRequest(response.url, {
|
|
102
|
-
buffer: true,
|
|
103
|
-
headers
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
resource = createResource(url, body, mimeType, {
|
|
108
|
-
status: response.status,
|
|
109
|
-
// 'Network.responseReceived' returns headers split by newlines, however
|
|
110
|
-
// `Fetch.fulfillRequest` (used for cached responses) will hang with newlines.
|
|
111
|
-
headers: Object.entries(response.headers).reduce((norm, [key, value]) => Object.assign(norm, {
|
|
112
|
-
[key]: value.split('\n')
|
|
113
|
-
}), {})
|
|
114
|
-
});
|
|
115
|
-
log.debug(`- sha: ${resource.sha}`, meta);
|
|
116
|
-
log.debug(`- mimetype: ${resource.mimetype}`, meta);
|
|
124
|
+
|
|
125
|
+
let resizePage = width => page.resize({
|
|
126
|
+
height: snapshot.minHeight,
|
|
127
|
+
deviceScaleFactor,
|
|
128
|
+
mobile,
|
|
129
|
+
width
|
|
130
|
+
}); // navigate to the url
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
yield resizePage(snapshot.widths[0]);
|
|
134
|
+
yield page.goto(snapshot.url);
|
|
135
|
+
|
|
136
|
+
if (snapshot.execute) {
|
|
137
|
+
// when any execute options are provided, inject snapshot options
|
|
138
|
+
|
|
139
|
+
/* istanbul ignore next: cannot detect coverage of injected code */
|
|
140
|
+
yield page.eval((_, s) => window.__PERCY__.snapshot = s, snapshot);
|
|
141
|
+
yield page.evaluate(snapshot.execute.afterNavigation);
|
|
142
|
+
} // iterate over additional snapshots for proper DOM capturing
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
for (let additionalSnapshot of [baseSnapshot, ...additionalSnapshots]) {
|
|
146
|
+
let isBaseSnapshot = additionalSnapshot === baseSnapshot;
|
|
147
|
+
let snap = { ...baseSnapshot,
|
|
148
|
+
...additionalSnapshot
|
|
149
|
+
};
|
|
150
|
+
let {
|
|
151
|
+
widths,
|
|
152
|
+
execute
|
|
153
|
+
} = snap;
|
|
154
|
+
let [width] = widths; // iterate over widths to trigger reqeusts and capture other widths
|
|
155
|
+
|
|
156
|
+
if (isBaseSnapshot || captureWidths) {
|
|
157
|
+
for (let i = 0; i < widths.length - 1; i++) {
|
|
158
|
+
if (captureWidths) yield takeSnapshot(snap, width);
|
|
159
|
+
yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.beforeResize);
|
|
160
|
+
yield waitForDiscoveryNetworkIdle(page, discovery);
|
|
161
|
+
yield resizePage(width = widths[i + 1]);
|
|
162
|
+
yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.afterResize);
|
|
117
163
|
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (capture && !snapshot.domSnapshot) {
|
|
167
|
+
// capture this snapshot and update the base snapshot after capture
|
|
168
|
+
let captured = yield takeSnapshot(snap, width);
|
|
169
|
+
if (isBaseSnapshot) baseSnapshot = captured; // resize back to the initial width when capturing additional snapshot widths
|
|
118
170
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
171
|
+
if (captureWidths && additionalSnapshots.length) {
|
|
172
|
+
let l = additionalSnapshots.indexOf(additionalSnapshot) + 1;
|
|
173
|
+
if (l < additionalSnapshots.length) yield resizePage(snapshot.widths[0]);
|
|
174
|
+
}
|
|
123
175
|
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
176
|
+
} // recursively trigger resource requests for any alternate device pixel ratio
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if (deviceScaleFactor !== discovery.devicePixelRatio) {
|
|
180
|
+
yield waitForDiscoveryNetworkIdle(page, discovery);
|
|
181
|
+
yield* captureSnapshotResources(page, snapshot, {
|
|
182
|
+
deviceScaleFactor: discovery.devicePixelRatio,
|
|
183
|
+
mobile: true
|
|
184
|
+
});
|
|
185
|
+
} // wait for final network idle when not capturing DOM
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if (capture && snapshot.domSnapshot) {
|
|
189
|
+
yield waitForDiscoveryNetworkIdle(page, discovery);
|
|
190
|
+
capture(processSnapshotResources(snapshot));
|
|
191
|
+
}
|
|
192
|
+
} // Pushes all provided snapshots to a discovery queue with the provided callback, yielding to each
|
|
193
|
+
// one concurrently. When skipping asset discovery, the callback is called immediately for each
|
|
194
|
+
// snapshot, also processing snapshot resources when not dry-running.
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
export async function* discoverSnapshotResources(queue, options, callback) {
|
|
198
|
+
let {
|
|
199
|
+
snapshots,
|
|
200
|
+
skipDiscovery,
|
|
201
|
+
dryRun
|
|
202
|
+
} = options;
|
|
203
|
+
yield* yieldAll(snapshots.reduce((all, snapshot) => {
|
|
204
|
+
debugSnapshotOptions(snapshot);
|
|
205
|
+
|
|
206
|
+
if (skipDiscovery) {
|
|
207
|
+
let {
|
|
208
|
+
additionalSnapshots,
|
|
209
|
+
...baseSnapshot
|
|
210
|
+
} = snapshot;
|
|
211
|
+
additionalSnapshots = dryRun && additionalSnapshots || [];
|
|
212
|
+
|
|
213
|
+
for (let snap of [baseSnapshot, ...additionalSnapshots]) {
|
|
214
|
+
callback(dryRun ? snap : processSnapshotResources(snap));
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
all.push(queue.push(snapshot, callback));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return all;
|
|
221
|
+
}, []));
|
|
222
|
+
} // Used to cache resources across core instances
|
|
223
|
+
|
|
224
|
+
const RESOURCE_CACHE_KEY = Symbol('resource-cache'); // Creates an asset discovery queue that uses the percy browser instance to create a page for each
|
|
225
|
+
// snapshot which is used to intercept and capture snapshot resource requests.
|
|
226
|
+
|
|
227
|
+
export function createDiscoveryQueue(percy) {
|
|
228
|
+
let {
|
|
229
|
+
concurrency
|
|
230
|
+
} = percy.config.discovery;
|
|
231
|
+
let queue = new Queue();
|
|
232
|
+
let cache;
|
|
233
|
+
return queue.set({
|
|
234
|
+
concurrency
|
|
235
|
+
}) // on start, launch the browser and run the queue
|
|
236
|
+
.handle('start', async () => {
|
|
237
|
+
cache = percy[RESOURCE_CACHE_KEY] = new Map();
|
|
238
|
+
await percy.browser.launch();
|
|
239
|
+
queue.run();
|
|
240
|
+
}) // on end, close the browser
|
|
241
|
+
.handle('end', async () => {
|
|
242
|
+
await percy.browser.close();
|
|
243
|
+
}) // snapshots are unique by name; when deferred also by widths
|
|
244
|
+
.handle('find', ({
|
|
245
|
+
name,
|
|
246
|
+
widths
|
|
247
|
+
}, snapshot) => snapshot.name === name && (!percy.deferUploads || !widths || widths.join() === snapshot.widths.join())) // initialize the root resource for DOM snapshots
|
|
248
|
+
.handle('push', snapshot => {
|
|
249
|
+
let {
|
|
250
|
+
url,
|
|
251
|
+
domSnapshot
|
|
252
|
+
} = snapshot;
|
|
253
|
+
let root = domSnapshot && createRootResource(url, domSnapshot);
|
|
254
|
+
let resources = new Map(root ? [[root.url, root]] : []);
|
|
255
|
+
return { ...snapshot,
|
|
256
|
+
resources
|
|
257
|
+
};
|
|
258
|
+
}) // discovery resources for snapshots and call the callback for each discovered snapshot
|
|
259
|
+
.handle('task', async function* (snapshot, callback) {
|
|
260
|
+
percy.log.debug(`Discovering resources: ${snapshot.name}`, snapshot.meta); // create a new browser page
|
|
261
|
+
|
|
262
|
+
let page = yield percy.browser.page({
|
|
263
|
+
enableJavaScript: snapshot.enableJavaScript ?? !snapshot.domSnapshot,
|
|
264
|
+
networkIdleTimeout: snapshot.discovery.networkIdleTimeout,
|
|
265
|
+
requestHeaders: snapshot.discovery.requestHeaders,
|
|
266
|
+
authorization: snapshot.discovery.authorization,
|
|
267
|
+
userAgent: snapshot.discovery.userAgent,
|
|
268
|
+
meta: snapshot.meta,
|
|
269
|
+
// enable network inteception
|
|
270
|
+
intercept: {
|
|
271
|
+
enableJavaScript: snapshot.enableJavaScript,
|
|
272
|
+
disableCache: snapshot.discovery.disableCache,
|
|
273
|
+
allowedHostnames: snapshot.discovery.allowedHostnames,
|
|
274
|
+
disallowedHostnames: snapshot.discovery.disallowedHostnames,
|
|
275
|
+
getResource: u => snapshot.resources.get(u) || cache.get(u),
|
|
276
|
+
saveResource: r => snapshot.resources.set(r.url, r) && cache.set(r.url, r)
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
yield* captureSnapshotResources(page, snapshot, {
|
|
282
|
+
captureWidths: !snapshot.domSnapshot && percy.deferUploads,
|
|
283
|
+
capture: callback
|
|
137
284
|
});
|
|
285
|
+
} finally {
|
|
286
|
+
// always close the page when done
|
|
287
|
+
await page.close();
|
|
138
288
|
}
|
|
139
|
-
}
|
|
289
|
+
}).handle('error', ({
|
|
290
|
+
name,
|
|
291
|
+
meta
|
|
292
|
+
}, error) => {
|
|
293
|
+
if (error.name === 'AbortError' && queue.readyState < 3) {
|
|
294
|
+
// only error about aborted snapshots when not closed
|
|
295
|
+
percy.log.error('Received a duplicate snapshot, ' + `the previous snapshot was aborted: ${name}`, meta);
|
|
296
|
+
} else {
|
|
297
|
+
// log all other encountered errors
|
|
298
|
+
percy.log.error(`Encountered an error taking snapshot: ${name}`, meta);
|
|
299
|
+
percy.log.error(error, meta);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
140
302
|
}
|