@percy/core 1.10.4 → 1.12.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 +92 -2
- package/dist/discovery.js +288 -126
- package/dist/network.js +178 -62
- package/dist/page.js +29 -14
- package/dist/percy.js +156 -279
- package/dist/queue.js +335 -99
- package/dist/session.js +2 -1
- package/dist/snapshot.js +243 -277
- package/dist/utils.js +4 -1
- package/package.json +6 -6
package/dist/network.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import mime from 'mime-types';
|
|
1
2
|
import logger from '@percy/logger';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { request as makeRequest } from '@percy/client/utils';
|
|
4
|
+
import { normalizeURL, hostnameMatches, createResource, waitFor } from './utils.js';
|
|
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']; // The Interceptor class creates common handlers for dealing with intercepting asset requests
|
|
4
9
|
// for a given page using various devtools protocol events and commands.
|
|
5
10
|
|
|
6
11
|
export class Network {
|
|
7
12
|
static TIMEOUT = 30000;
|
|
8
|
-
log = logger('core:
|
|
13
|
+
log = logger('core:discovery');
|
|
9
14
|
#pending = new Map();
|
|
10
15
|
#requests = new Map();
|
|
11
16
|
#intercepts = new Map();
|
|
@@ -18,14 +23,8 @@ export class Network {
|
|
|
18
23
|
this.requestHeaders = options.requestHeaders ?? {};
|
|
19
24
|
this.userAgent = options.userAgent ?? // by default, emulate a non-headless browser
|
|
20
25
|
page.session.browser.version.userAgent.replace('Headless', '');
|
|
21
|
-
this.
|
|
26
|
+
this.intercept = options.intercept;
|
|
22
27
|
this.meta = options.meta;
|
|
23
|
-
|
|
24
|
-
if (this.interceptEnabled) {
|
|
25
|
-
this.onRequest = createRequestHandler(this, options.intercept);
|
|
26
|
-
this.onRequestFinished = createRequestFinishedHandler(this, options.intercept);
|
|
27
|
-
this.onRequestFailed = createRequestFailedHandler(this, options.intercept);
|
|
28
|
-
}
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
watch(session) {
|
|
@@ -44,7 +43,7 @@ export class Network {
|
|
|
44
43
|
headers: this.requestHeaders
|
|
45
44
|
})];
|
|
46
45
|
|
|
47
|
-
if (this.
|
|
46
|
+
if (this.intercept && session.isDocument) {
|
|
48
47
|
session.on('Fetch.requestPaused', this._handleRequestPaused.bind(this, session));
|
|
49
48
|
session.on('Fetch.authRequired', this._handleAuthRequired.bind(this, session));
|
|
50
49
|
commands.push(session.send('Fetch.enable', {
|
|
@@ -73,19 +72,22 @@ export class Network {
|
|
|
73
72
|
timeout: Network.TIMEOUT,
|
|
74
73
|
idle: timeout
|
|
75
74
|
}).catch(error => {
|
|
76
|
-
// throw a better timeout error
|
|
77
75
|
if (error.message.startsWith('Timeout')) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (this.log.shouldLog('debug')) {
|
|
81
|
-
msg += `\n\n ${['Active requests:', ...requests.map(r => r.url)].join('\n - ')}\n`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
throw new Error(msg);
|
|
76
|
+
this._throwTimeoutError('Timed out waiting for network requests to idle.', filter);
|
|
85
77
|
} else {
|
|
86
78
|
throw error;
|
|
87
79
|
}
|
|
88
80
|
});
|
|
81
|
+
} // Throw a better network timeout error
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_throwTimeoutError(msg, filter = () => true) {
|
|
85
|
+
if (this.log.shouldLog('debug')) {
|
|
86
|
+
let reqs = Array.from(this.#requests.values()).filter(filter).map(r => r.url);
|
|
87
|
+
msg += `\n\n ${['Active requests:', ...reqs].join('\n - ')}\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw new Error(msg);
|
|
89
91
|
} // Called when a request should be removed from various trackers
|
|
90
92
|
|
|
91
93
|
|
|
@@ -163,7 +165,7 @@ export class Network {
|
|
|
163
165
|
|
|
164
166
|
if (request.url.startsWith('data:')) return;
|
|
165
167
|
|
|
166
|
-
if (this.
|
|
168
|
+
if (this.intercept) {
|
|
167
169
|
let intercept = this.#intercepts.get(requestId);
|
|
168
170
|
this.#pending.set(requestId, event);
|
|
169
171
|
|
|
@@ -186,8 +188,6 @@ export class Network {
|
|
|
186
188
|
// or abort a request. One of the callbacks is required to be called and only one.
|
|
187
189
|
|
|
188
190
|
_handleRequest = async (session, event) => {
|
|
189
|
-
var _this$onRequest;
|
|
190
|
-
|
|
191
191
|
let {
|
|
192
192
|
request,
|
|
193
193
|
requestId,
|
|
@@ -208,37 +208,7 @@ export class Network {
|
|
|
208
208
|
request.interceptId = interceptId;
|
|
209
209
|
request.redirectChain = redirectChain;
|
|
210
210
|
this.#requests.set(requestId, request);
|
|
211
|
-
await (
|
|
212
|
-
// call to continue the request as-is
|
|
213
|
-
continue: () => session.send('Fetch.continueRequest', {
|
|
214
|
-
requestId: interceptId
|
|
215
|
-
}),
|
|
216
|
-
// call to respond with a specific status, content, and headers
|
|
217
|
-
respond: ({
|
|
218
|
-
status,
|
|
219
|
-
content,
|
|
220
|
-
headers
|
|
221
|
-
}) => session.send('Fetch.fulfillRequest', {
|
|
222
|
-
requestId: interceptId,
|
|
223
|
-
responseCode: status || 200,
|
|
224
|
-
body: Buffer.from(content).toString('base64'),
|
|
225
|
-
responseHeaders: Object.entries(headers || {}).map(([name, value]) => {
|
|
226
|
-
return {
|
|
227
|
-
name: name.toLowerCase(),
|
|
228
|
-
value: String(value)
|
|
229
|
-
};
|
|
230
|
-
})
|
|
231
|
-
}),
|
|
232
|
-
// call to fail or abort the request
|
|
233
|
-
abort: error => session.send('Fetch.failRequest', {
|
|
234
|
-
requestId: interceptId,
|
|
235
|
-
// istanbul note: this check used to be necessary and might be again in the future if we
|
|
236
|
-
// ever need to abort a request due to reasons other than failures
|
|
237
|
-
errorReason: error ? 'Failed' :
|
|
238
|
-
/* istanbul ignore next */
|
|
239
|
-
'Aborted'
|
|
240
|
-
})
|
|
241
|
-
}));
|
|
211
|
+
await sendResponseResource(this, request, session);
|
|
242
212
|
}; // Called when a response has been received for a specific request. Associates the response with
|
|
243
213
|
// the request data and adds a buffer method to fetch the response body when needed.
|
|
244
214
|
|
|
@@ -271,28 +241,174 @@ export class Network {
|
|
|
271
241
|
// callback. The request should have an associated response and be finished with any redirects.
|
|
272
242
|
|
|
273
243
|
_handleLoadingFinished = async event => {
|
|
274
|
-
var _this$onRequestFinish;
|
|
275
|
-
|
|
276
244
|
let request = this.#requests.get(event.requestId);
|
|
277
245
|
/* istanbul ignore if: race condition paranioa */
|
|
278
246
|
|
|
279
247
|
if (!request) return;
|
|
280
|
-
await (
|
|
248
|
+
await saveResponseResource(this, request);
|
|
281
249
|
|
|
282
250
|
this._forgetRequest(request);
|
|
283
251
|
}; // Called when a request has failed loading and triggers the this.onrequestfailed callback.
|
|
284
252
|
|
|
285
|
-
_handleLoadingFailed =
|
|
286
|
-
var _this$onRequestFailed;
|
|
287
|
-
|
|
253
|
+
_handleLoadingFailed = event => {
|
|
288
254
|
let request = this.#requests.get(event.requestId);
|
|
289
255
|
/* istanbul ignore if: race condition paranioa */
|
|
290
256
|
|
|
291
|
-
if (!request) return;
|
|
292
|
-
|
|
293
|
-
|
|
257
|
+
if (!request) return; // do not log generic messages since the real error was likely logged elsewhere
|
|
258
|
+
|
|
259
|
+
if (event.errorText !== 'net::ERR_FAILED') {
|
|
260
|
+
let message = `Request failed for ${request.url}: ${event.errorText}`;
|
|
261
|
+
this.log.debug(message, { ...this.meta,
|
|
262
|
+
url: request.url
|
|
263
|
+
});
|
|
264
|
+
}
|
|
294
265
|
|
|
295
266
|
this._forgetRequest(request);
|
|
296
267
|
};
|
|
268
|
+
} // Returns the normalized origin URL of a request
|
|
269
|
+
|
|
270
|
+
function originURL(request) {
|
|
271
|
+
return normalizeURL((request.redirectChain[0] || request).url);
|
|
272
|
+
} // Send a response for a given request, responding with cached resources when able
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async function sendResponseResource(network, request, session) {
|
|
276
|
+
let {
|
|
277
|
+
disallowedHostnames,
|
|
278
|
+
disableCache
|
|
279
|
+
} = network.intercept;
|
|
280
|
+
let log = network.log;
|
|
281
|
+
let url = originURL(request);
|
|
282
|
+
let meta = { ...network.meta,
|
|
283
|
+
url
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
let resource = network.intercept.getResource(url);
|
|
288
|
+
network.log.debug(`Handling request: ${url}`, meta);
|
|
289
|
+
|
|
290
|
+
if (!(resource !== null && resource !== void 0 && resource.root) && hostnameMatches(disallowedHostnames, url)) {
|
|
291
|
+
log.debug('- Skipping disallowed hostname', meta);
|
|
292
|
+
await session.send('Fetch.failRequest', {
|
|
293
|
+
requestId: request.interceptId,
|
|
294
|
+
errorReason: 'Aborted'
|
|
295
|
+
});
|
|
296
|
+
} else if (resource && (resource.root || !disableCache)) {
|
|
297
|
+
log.debug(resource.root ? '- Serving root resource' : '- Resource cache hit', meta);
|
|
298
|
+
await session.send('Fetch.fulfillRequest', {
|
|
299
|
+
requestId: request.interceptId,
|
|
300
|
+
responseCode: resource.status || 200,
|
|
301
|
+
body: Buffer.from(resource.content).toString('base64'),
|
|
302
|
+
responseHeaders: Object.entries(resource.headers || {}).map(([k, v]) => ({
|
|
303
|
+
name: k.toLowerCase(),
|
|
304
|
+
value: String(v)
|
|
305
|
+
}))
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
await session.send('Fetch.continueRequest', {
|
|
309
|
+
requestId: request.interceptId
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
/* istanbul ignore next: too hard to test (create race condition) */
|
|
314
|
+
if (session.closing && error.message.includes('close')) return;
|
|
315
|
+
log.debug(`Encountered an error handling request: ${url}`, meta);
|
|
316
|
+
log.debug(error);
|
|
317
|
+
/* istanbul ignore next: catch race condition */
|
|
318
|
+
|
|
319
|
+
await session.send('Fetch.failRequest', {
|
|
320
|
+
requestId: request.interceptId,
|
|
321
|
+
errorReason: 'Failed'
|
|
322
|
+
}).catch(e => log.debug(e, meta));
|
|
323
|
+
}
|
|
324
|
+
} // Make a new request with Node based on a network request
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
function makeDirectRequest(network, request) {
|
|
328
|
+
var _network$authorizatio;
|
|
329
|
+
|
|
330
|
+
let headers = { ...request.headers
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
if ((_network$authorizatio = network.authorization) !== null && _network$authorizatio !== void 0 && _network$authorizatio.username) {
|
|
334
|
+
// include basic authorization username and password
|
|
335
|
+
let {
|
|
336
|
+
username,
|
|
337
|
+
password
|
|
338
|
+
} = network.authorization;
|
|
339
|
+
let token = Buffer.from([username, password || ''].join(':')).toString('base64');
|
|
340
|
+
headers.Authorization = `Basic ${token}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return makeRequest(request.url, {
|
|
344
|
+
buffer: true,
|
|
345
|
+
headers
|
|
346
|
+
});
|
|
347
|
+
} // Save a resource from a request, skipping it if specific paramters are not met
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async function saveResponseResource(network, request) {
|
|
351
|
+
let {
|
|
352
|
+
disableCache,
|
|
353
|
+
allowedHostnames,
|
|
354
|
+
enableJavaScript
|
|
355
|
+
} = network.intercept;
|
|
356
|
+
let log = network.log;
|
|
357
|
+
let url = originURL(request);
|
|
358
|
+
let response = request.response;
|
|
359
|
+
let meta = { ...network.meta,
|
|
360
|
+
url
|
|
361
|
+
};
|
|
362
|
+
let resource = network.intercept.getResource(url);
|
|
363
|
+
|
|
364
|
+
if (!resource || !resource.root && disableCache) {
|
|
365
|
+
try {
|
|
366
|
+
log.debug(`Processing resource: ${url}`, meta);
|
|
367
|
+
let shouldCapture = response && hostnameMatches(allowedHostnames, url);
|
|
368
|
+
let body = shouldCapture && (await response.buffer());
|
|
369
|
+
/* istanbul ignore if: first check is a sanity check */
|
|
370
|
+
|
|
371
|
+
if (!response) {
|
|
372
|
+
return log.debug('- Skipping no response', meta);
|
|
373
|
+
} else if (!shouldCapture) {
|
|
374
|
+
return log.debug('- Skipping remote resource', meta);
|
|
375
|
+
} else if (!body.length) {
|
|
376
|
+
return log.debug('- Skipping empty response', meta);
|
|
377
|
+
} else if (body.length > MAX_RESOURCE_SIZE) {
|
|
378
|
+
return log.debug('- Skipping resource larger than 15MB', meta);
|
|
379
|
+
} else if (!ALLOWED_STATUSES.includes(response.status)) {
|
|
380
|
+
return log.debug(`- Skipping disallowed status [${response.status}]`, meta);
|
|
381
|
+
} else if (!enableJavaScript && !ALLOWED_RESOURCES.includes(request.type)) {
|
|
382
|
+
return log.debug(`- Skipping disallowed resource type [${request.type}]`, meta);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let mimeType = // ensure the mimetype is correct for text/plain responses
|
|
386
|
+
response.mimeType === 'text/plain' && mime.lookup(response.url) || response.mimeType; // font responses from the browser may not be properly encoded, so request them directly
|
|
387
|
+
|
|
388
|
+
if (mimeType !== null && mimeType !== void 0 && mimeType.includes('font')) {
|
|
389
|
+
log.debug('- Requesting asset directly');
|
|
390
|
+
body = await makeDirectRequest(network, request);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
resource = createResource(url, body, mimeType, {
|
|
394
|
+
status: response.status,
|
|
395
|
+
// 'Network.responseReceived' returns headers split by newlines, however
|
|
396
|
+
// `Fetch.fulfillRequest` (used for cached responses) will hang with newlines.
|
|
397
|
+
headers: Object.entries(response.headers).reduce((norm, [key, value]) => Object.assign(norm, {
|
|
398
|
+
[key]: value.split('\n')
|
|
399
|
+
}), {})
|
|
400
|
+
});
|
|
401
|
+
log.debug(`- sha: ${resource.sha}`, meta);
|
|
402
|
+
log.debug(`- mimetype: ${resource.mimetype}`, meta);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
log.debug(`Encountered an error processing resource: ${url}`, meta);
|
|
405
|
+
log.debug(error);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (resource) {
|
|
410
|
+
network.intercept.saveResource(resource);
|
|
411
|
+
}
|
|
297
412
|
}
|
|
413
|
+
|
|
298
414
|
export default Network;
|
package/dist/page.js
CHANGED
|
@@ -86,12 +86,18 @@ export class Page {
|
|
|
86
86
|
return handlers.every(handler => handler.finished);
|
|
87
87
|
}, Page.TIMEOUT)]);
|
|
88
88
|
} catch (error) {
|
|
89
|
-
// remove
|
|
90
|
-
for (let handler of handlers) handler.off();
|
|
89
|
+
// remove any unused handlers
|
|
90
|
+
for (let handler of handlers) handler.off(); // assign context to unknown errors
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
|
|
93
|
+
if (!error.message.startsWith('Timeout')) {
|
|
94
|
+
throw Object.assign(error, {
|
|
95
|
+
message: `Navigation failed: ${error.message}`
|
|
96
|
+
});
|
|
97
|
+
} // throw a network error to show active requests
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
this.network._throwTimeoutError(`Navigation failed: Timed out waiting for the page ${waitUntil} event`);
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
this.log.debug('Page navigated', this.meta);
|
|
@@ -130,19 +136,23 @@ export class Page {
|
|
|
130
136
|
});
|
|
131
137
|
|
|
132
138
|
for (let script of scripts) await this.eval(script);
|
|
133
|
-
} //
|
|
134
|
-
// and waiting for the network idle
|
|
139
|
+
} // Takes a snapshot after waiting for any timeout, waiting for any selector, executing any
|
|
140
|
+
// scripts, and waiting for the network idle. Returns all other provided snapshot options along
|
|
141
|
+
// with the captured URL and DOM snapshot.
|
|
135
142
|
|
|
136
143
|
|
|
137
144
|
async snapshot({
|
|
138
|
-
name,
|
|
139
145
|
waitForTimeout,
|
|
140
146
|
waitForSelector,
|
|
141
147
|
execute,
|
|
142
|
-
|
|
143
|
-
...options
|
|
148
|
+
...snapshot
|
|
144
149
|
}) {
|
|
145
|
-
|
|
150
|
+
let {
|
|
151
|
+
name,
|
|
152
|
+
width,
|
|
153
|
+
enableJavaScript
|
|
154
|
+
} = snapshot;
|
|
155
|
+
this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta); // wait for any specified timeout
|
|
146
156
|
|
|
147
157
|
if (waitForTimeout) {
|
|
148
158
|
this.log.debug(`Wait for ${waitForTimeout}ms timeout`, this.meta);
|
|
@@ -178,11 +188,16 @@ export class Page {
|
|
|
178
188
|
this.log.debug('Serialize DOM', this.meta);
|
|
179
189
|
/* istanbul ignore next: no instrumenting injected code */
|
|
180
190
|
|
|
181
|
-
|
|
191
|
+
let capture = await this.eval((_, options) => ({
|
|
182
192
|
/* eslint-disable-next-line no-undef */
|
|
183
|
-
|
|
193
|
+
domSnapshot: PercyDOM.serialize(options),
|
|
184
194
|
url: document.URL
|
|
185
|
-
}),
|
|
195
|
+
}), {
|
|
196
|
+
enableJavaScript
|
|
197
|
+
});
|
|
198
|
+
return { ...snapshot,
|
|
199
|
+
...capture
|
|
200
|
+
};
|
|
186
201
|
} // Initialize newly attached pages and iframes with page options
|
|
187
202
|
|
|
188
203
|
|