@percy/core 1.10.4 → 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/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', {
@@ -163,7 +162,7 @@ export class Network {
163
162
 
164
163
  if (request.url.startsWith('data:')) return;
165
164
 
166
- if (this.interceptEnabled) {
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 ((_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
- }));
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 ((_this$onRequestFinish = this.onRequestFinished) === null || _this$onRequestFinish === void 0 ? void 0 : _this$onRequestFinish.call(this, request));
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 = async event => {
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
- request.error = event.errorText;
293
- await ((_this$onRequestFailed = this.onRequestFailed) === null || _this$onRequestFailed === void 0 ? void 0 : _this$onRequestFailed.call(this, request));
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
- } // Take a snapshot after waiting for any timeout, waiting for any selector, executing any scripts,
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
- meta,
143
- ...options
142
+ ...snapshot
144
143
  }) {
145
- this.log.debug(`Taking snapshot: ${name}`, this.meta); // wait for any specified timeout
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
- return await this.eval((_, options) => ({
185
+ let capture = await this.eval((_, options) => ({
182
186
  /* eslint-disable-next-line no-undef */
183
- dom: PercyDOM.serialize(options),
187
+ domSnapshot: PercyDOM.serialize(options),
184
188
  url: document.URL
185
- }), options);
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