@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/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', {
|
|
@@ -163,7 +162,7 @@ export class Network {
|
|
|
163
162
|
|
|
164
163
|
if (request.url.startsWith('data:')) return;
|
|
165
164
|
|
|
166
|
-
if (this.
|
|
165
|
+
if (this.intercept) {
|
|
167
166
|
let intercept = this.#intercepts.get(requestId);
|
|
168
167
|
this.#pending.set(requestId, event);
|
|
169
168
|
|
|
@@ -186,8 +185,6 @@ export class Network {
|
|
|
186
185
|
// or abort a request. One of the callbacks is required to be called and only one.
|
|
187
186
|
|
|
188
187
|
_handleRequest = async (session, event) => {
|
|
189
|
-
var _this$onRequest;
|
|
190
|
-
|
|
191
188
|
let {
|
|
192
189
|
request,
|
|
193
190
|
requestId,
|
|
@@ -208,37 +205,7 @@ export class Network {
|
|
|
208
205
|
request.interceptId = interceptId;
|
|
209
206
|
request.redirectChain = redirectChain;
|
|
210
207
|
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
|
-
}));
|
|
208
|
+
await sendResponseResource(this, request, session);
|
|
242
209
|
}; // Called when a response has been received for a specific request. Associates the response with
|
|
243
210
|
// the request data and adds a buffer method to fetch the response body when needed.
|
|
244
211
|
|
|
@@ -271,28 +238,172 @@ export class Network {
|
|
|
271
238
|
// callback. The request should have an associated response and be finished with any redirects.
|
|
272
239
|
|
|
273
240
|
_handleLoadingFinished = async event => {
|
|
274
|
-
var _this$onRequestFinish;
|
|
275
|
-
|
|
276
241
|
let request = this.#requests.get(event.requestId);
|
|
277
242
|
/* istanbul ignore if: race condition paranioa */
|
|
278
243
|
|
|
279
244
|
if (!request) return;
|
|
280
|
-
await (
|
|
245
|
+
await saveResponseResource(this, request);
|
|
281
246
|
|
|
282
247
|
this._forgetRequest(request);
|
|
283
248
|
}; // Called when a request has failed loading and triggers the this.onrequestfailed callback.
|
|
284
249
|
|
|
285
|
-
_handleLoadingFailed =
|
|
286
|
-
var _this$onRequestFailed;
|
|
287
|
-
|
|
250
|
+
_handleLoadingFailed = event => {
|
|
288
251
|
let request = this.#requests.get(event.requestId);
|
|
289
252
|
/* istanbul ignore if: race condition paranioa */
|
|
290
253
|
|
|
291
|
-
if (!request) return;
|
|
292
|
-
|
|
293
|
-
|
|
254
|
+
if (!request) return; // do not log generic messages since the real error was likely logged elsewhere
|
|
255
|
+
|
|
256
|
+
if (event.errorText !== 'net::ERR_FAILED') {
|
|
257
|
+
let message = `Request failed for ${request.url}: ${event.errorText}`;
|
|
258
|
+
this.log.debug(message, { ...this.meta,
|
|
259
|
+
url: request.url
|
|
260
|
+
});
|
|
261
|
+
}
|
|
294
262
|
|
|
295
263
|
this._forgetRequest(request);
|
|
296
264
|
};
|
|
265
|
+
} // Returns the normalized origin URL of a request
|
|
266
|
+
|
|
267
|
+
function originURL(request) {
|
|
268
|
+
return normalizeURL((request.redirectChain[0] || request).url);
|
|
269
|
+
} // Send a response for a given request, responding with cached resources when able
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async function sendResponseResource(network, request, session) {
|
|
273
|
+
let {
|
|
274
|
+
disallowedHostnames,
|
|
275
|
+
disableCache
|
|
276
|
+
} = network.intercept;
|
|
277
|
+
let log = network.log;
|
|
278
|
+
let url = originURL(request);
|
|
279
|
+
let meta = { ...network.meta,
|
|
280
|
+
url
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
let resource = network.intercept.getResource(url);
|
|
285
|
+
network.log.debug(`Handling request: ${url}`, meta);
|
|
286
|
+
|
|
287
|
+
if (!(resource !== null && resource !== void 0 && resource.root) && hostnameMatches(disallowedHostnames, url)) {
|
|
288
|
+
log.debug('- Skipping disallowed hostname', meta);
|
|
289
|
+
await session.send('Fetch.failRequest', {
|
|
290
|
+
requestId: request.interceptId,
|
|
291
|
+
errorReason: 'Aborted'
|
|
292
|
+
});
|
|
293
|
+
} else if (resource && (resource.root || !disableCache)) {
|
|
294
|
+
log.debug(resource.root ? '- Serving root resource' : '- Resource cache hit', meta);
|
|
295
|
+
await session.send('Fetch.fulfillRequest', {
|
|
296
|
+
requestId: request.interceptId,
|
|
297
|
+
responseCode: resource.status || 200,
|
|
298
|
+
body: Buffer.from(resource.content).toString('base64'),
|
|
299
|
+
responseHeaders: Object.entries(resource.headers || {}).map(([k, v]) => ({
|
|
300
|
+
name: k.toLowerCase(),
|
|
301
|
+
value: String(v)
|
|
302
|
+
}))
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
await session.send('Fetch.continueRequest', {
|
|
306
|
+
requestId: request.interceptId
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
log.debug(`Encountered an error handling request: ${url}`, meta);
|
|
311
|
+
log.debug(error);
|
|
312
|
+
/* istanbul ignore next: catch race condition */
|
|
313
|
+
|
|
314
|
+
await session.send('Fetch.failRequest', {
|
|
315
|
+
requestId: request.interceptId,
|
|
316
|
+
errorReason: 'Failed'
|
|
317
|
+
}).catch(e => log.debug(e, meta));
|
|
318
|
+
}
|
|
319
|
+
} // Make a new request with Node based on a network request
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
function makeDirectRequest(network, request) {
|
|
323
|
+
var _network$authorizatio;
|
|
324
|
+
|
|
325
|
+
let headers = { ...request.headers
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
if ((_network$authorizatio = network.authorization) !== null && _network$authorizatio !== void 0 && _network$authorizatio.username) {
|
|
329
|
+
// include basic authorization username and password
|
|
330
|
+
let {
|
|
331
|
+
username,
|
|
332
|
+
password
|
|
333
|
+
} = network.authorization;
|
|
334
|
+
let token = Buffer.from([username, password || ''].join(':')).toString('base64');
|
|
335
|
+
headers.Authorization = `Basic ${token}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return makeRequest(request.url, {
|
|
339
|
+
buffer: true,
|
|
340
|
+
headers
|
|
341
|
+
});
|
|
342
|
+
} // Save a resource from a request, skipping it if specific paramters are not met
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async function saveResponseResource(network, request) {
|
|
346
|
+
let {
|
|
347
|
+
disableCache,
|
|
348
|
+
allowedHostnames,
|
|
349
|
+
enableJavaScript
|
|
350
|
+
} = network.intercept;
|
|
351
|
+
let log = network.log;
|
|
352
|
+
let url = originURL(request);
|
|
353
|
+
let response = request.response;
|
|
354
|
+
let meta = { ...network.meta,
|
|
355
|
+
url
|
|
356
|
+
};
|
|
357
|
+
let resource = network.intercept.getResource(url);
|
|
358
|
+
|
|
359
|
+
if (!resource || !resource.root && disableCache) {
|
|
360
|
+
try {
|
|
361
|
+
log.debug(`Processing resource: ${url}`, meta);
|
|
362
|
+
let shouldCapture = response && hostnameMatches(allowedHostnames, url);
|
|
363
|
+
let body = shouldCapture && (await response.buffer());
|
|
364
|
+
/* istanbul ignore if: first check is a sanity check */
|
|
365
|
+
|
|
366
|
+
if (!response) {
|
|
367
|
+
return log.debug('- Skipping no response', meta);
|
|
368
|
+
} else if (!shouldCapture) {
|
|
369
|
+
return log.debug('- Skipping remote resource', meta);
|
|
370
|
+
} else if (!body.length) {
|
|
371
|
+
return log.debug('- Skipping empty response', meta);
|
|
372
|
+
} else if (body.length > MAX_RESOURCE_SIZE) {
|
|
373
|
+
return log.debug('- Skipping resource larger than 15MB', meta);
|
|
374
|
+
} else if (!ALLOWED_STATUSES.includes(response.status)) {
|
|
375
|
+
return log.debug(`- Skipping disallowed status [${response.status}]`, meta);
|
|
376
|
+
} else if (!enableJavaScript && !ALLOWED_RESOURCES.includes(request.type)) {
|
|
377
|
+
return log.debug(`- Skipping disallowed resource type [${request.type}]`, meta);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let mimeType = // ensure the mimetype is correct for text/plain responses
|
|
381
|
+
response.mimeType === 'text/plain' && mime.lookup(response.url) || response.mimeType; // font responses from the browser may not be properly encoded, so request them directly
|
|
382
|
+
|
|
383
|
+
if (mimeType !== null && mimeType !== void 0 && mimeType.includes('font')) {
|
|
384
|
+
log.debug('- Requesting asset directly');
|
|
385
|
+
body = await makeDirectRequest(network, request);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
resource = createResource(url, body, mimeType, {
|
|
389
|
+
status: response.status,
|
|
390
|
+
// 'Network.responseReceived' returns headers split by newlines, however
|
|
391
|
+
// `Fetch.fulfillRequest` (used for cached responses) will hang with newlines.
|
|
392
|
+
headers: Object.entries(response.headers).reduce((norm, [key, value]) => Object.assign(norm, {
|
|
393
|
+
[key]: value.split('\n')
|
|
394
|
+
}), {})
|
|
395
|
+
});
|
|
396
|
+
log.debug(`- sha: ${resource.sha}`, meta);
|
|
397
|
+
log.debug(`- mimetype: ${resource.mimetype}`, meta);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
log.debug(`Encountered an error processing resource: ${url}`, meta);
|
|
400
|
+
log.debug(error);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (resource) {
|
|
405
|
+
network.intercept.saveResource(resource);
|
|
406
|
+
}
|
|
297
407
|
}
|
|
408
|
+
|
|
298
409
|
export default Network;
|
package/dist/page.js
CHANGED
|
@@ -130,19 +130,23 @@ export class Page {
|
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
for (let script of scripts) await this.eval(script);
|
|
133
|
-
} //
|
|
134
|
-
// and waiting for the network idle
|
|
133
|
+
} // Takes a snapshot after waiting for any timeout, waiting for any selector, executing any
|
|
134
|
+
// scripts, and waiting for the network idle. Returns all other provided snapshot options along
|
|
135
|
+
// with the captured URL and DOM snapshot.
|
|
135
136
|
|
|
136
137
|
|
|
137
138
|
async snapshot({
|
|
138
|
-
name,
|
|
139
139
|
waitForTimeout,
|
|
140
140
|
waitForSelector,
|
|
141
141
|
execute,
|
|
142
|
-
|
|
143
|
-
...options
|
|
142
|
+
...snapshot
|
|
144
143
|
}) {
|
|
145
|
-
|
|
144
|
+
let {
|
|
145
|
+
name,
|
|
146
|
+
width,
|
|
147
|
+
enableJavaScript
|
|
148
|
+
} = snapshot;
|
|
149
|
+
this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta); // wait for any specified timeout
|
|
146
150
|
|
|
147
151
|
if (waitForTimeout) {
|
|
148
152
|
this.log.debug(`Wait for ${waitForTimeout}ms timeout`, this.meta);
|
|
@@ -178,11 +182,16 @@ export class Page {
|
|
|
178
182
|
this.log.debug('Serialize DOM', this.meta);
|
|
179
183
|
/* istanbul ignore next: no instrumenting injected code */
|
|
180
184
|
|
|
181
|
-
|
|
185
|
+
let capture = await this.eval((_, options) => ({
|
|
182
186
|
/* eslint-disable-next-line no-undef */
|
|
183
|
-
|
|
187
|
+
domSnapshot: PercyDOM.serialize(options),
|
|
184
188
|
url: document.URL
|
|
185
|
-
}),
|
|
189
|
+
}), {
|
|
190
|
+
enableJavaScript
|
|
191
|
+
});
|
|
192
|
+
return { ...snapshot,
|
|
193
|
+
...capture
|
|
194
|
+
};
|
|
186
195
|
} // Initialize newly attached pages and iframes with page options
|
|
187
196
|
|
|
188
197
|
|