@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/network.js CHANGED
@@ -1,11 +1,16 @@
1
+ import mime from 'mime-types';
1
2
  import logger from '@percy/logger';
2
- import { waitFor } from './utils.js';
3
- import { createRequestHandler, createRequestFinishedHandler, createRequestFailedHandler } from './discovery.js'; // The Interceptor class creates common handlers for dealing with intercepting asset requests
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:network');
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.interceptEnabled = !!options.intercept;
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.interceptEnabled && session.isDocument) {
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
- let msg = 'Timed out waiting for network requests to idle.';
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.interceptEnabled) {
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 ((_this$onRequest = this.onRequest) === null || _this$onRequest === void 0 ? void 0 : _this$onRequest.call(this, { ...request,
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 ((_this$onRequestFinish = this.onRequestFinished) === null || _this$onRequestFinish === void 0 ? void 0 : _this$onRequestFinish.call(this, request));
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 = async event => {
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
- request.error = event.errorText;
293
- await ((_this$onRequestFailed = this.onRequestFailed) === null || _this$onRequestFailed === void 0 ? void 0 : _this$onRequestFailed.call(this, request));
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 handlers and modify the error message
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
- throw Object.assign(error, {
93
- message: `Navigation failed: ${error.message}`
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
- } // Take a snapshot after waiting for any timeout, waiting for any selector, executing any scripts,
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
- meta,
143
- ...options
148
+ ...snapshot
144
149
  }) {
145
- this.log.debug(`Taking snapshot: ${name}`, this.meta); // wait for any specified timeout
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
- return await this.eval((_, options) => ({
191
+ let capture = await this.eval((_, options) => ({
182
192
  /* eslint-disable-next-line no-undef */
183
- dom: PercyDOM.serialize(options),
193
+ domSnapshot: PercyDOM.serialize(options),
184
194
  url: document.URL
185
- }), options);
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