@percy/core 1.27.0-alpha.0 → 1.27.0-beta.1

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 CHANGED
@@ -4,9 +4,7 @@ import { createRequire } from 'module';
4
4
  import logger from '@percy/logger';
5
5
  import { normalize } from '@percy/config/utils';
6
6
  import { getPackageJSON, Server, percyAutomateRequestHandler } from './utils.js';
7
- // TODO Remove below esline disable once we publish webdriver-util
8
- import WebdriverUtils from '@percy/webdriver-utils'; // eslint-disable-line import/no-extraneous-dependencies
9
-
7
+ import WebdriverUtils from '@percy/webdriver-utils';
10
8
  // need require.resolve until import.meta.resolve can be transpiled
11
9
  export const PERCY_DOM = createRequire(import.meta.url).resolve('@percy/dom');
12
10
 
@@ -140,7 +138,7 @@ export function createPercyServer(percy, port) {
140
138
  .route('post', '/percy/flush', async (req, res) => res.json(200, {
141
139
  success: await percy.flush(req.body).then(() => true)
142
140
  })).route('post', '/percy/automateScreenshot', async (req, res) => {
143
- req = percyAutomateRequestHandler(req);
141
+ req = percyAutomateRequestHandler(req, percy.build);
144
142
  res.json(200, {
145
143
  success: await percy.upload(await new WebdriverUtils(req.body).automateScreenshot()).then(() => true)
146
144
  });
package/dist/config.js CHANGED
@@ -541,6 +541,39 @@ export const snapshotSchema = {
541
541
  }
542
542
  }
543
543
  };
544
+ const regionsSchema = {
545
+ type: 'array',
546
+ items: {
547
+ type: 'object',
548
+ additionalProperties: false,
549
+ properties: {
550
+ selector: {
551
+ type: 'string'
552
+ },
553
+ coOrdinates: {
554
+ type: 'object',
555
+ properties: {
556
+ top: {
557
+ type: 'integer',
558
+ minimum: 0
559
+ },
560
+ left: {
561
+ type: 'integer',
562
+ minimum: 0
563
+ },
564
+ bottom: {
565
+ type: 'integer',
566
+ minimum: 0
567
+ },
568
+ right: {
569
+ type: 'integer',
570
+ minimum: 0
571
+ }
572
+ }
573
+ }
574
+ }
575
+ }
576
+ };
544
577
 
545
578
  // Comparison upload options
546
579
  export const comparisonSchema = {
@@ -639,39 +672,15 @@ export const comparisonSchema = {
639
672
  additionalProperties: false,
640
673
  required: ['ignoreElementsData'],
641
674
  properties: {
642
- ignoreElementsData: {
643
- type: 'array',
644
- items: {
645
- type: 'object',
646
- additionalProperties: false,
647
- properties: {
648
- selector: {
649
- type: 'string'
650
- },
651
- coOrdinates: {
652
- type: 'object',
653
- properties: {
654
- top: {
655
- type: 'integer',
656
- minimum: 0
657
- },
658
- left: {
659
- type: 'integer',
660
- minimum: 0
661
- },
662
- bottom: {
663
- type: 'integer',
664
- minimum: 0
665
- },
666
- right: {
667
- type: 'integer',
668
- minimum: 0
669
- }
670
- }
671
- }
672
- }
673
- }
674
- }
675
+ ignoreElementsData: regionsSchema
676
+ }
677
+ },
678
+ consideredElementsData: {
679
+ type: 'object',
680
+ additionalProperties: false,
681
+ required: ['considerElementsData'],
682
+ properties: {
683
+ considerElementsData: regionsSchema
675
684
  }
676
685
  }
677
686
  }
package/dist/network.js CHANGED
@@ -5,16 +5,18 @@ import { normalizeURL, hostnameMatches, createResource, waitFor } from './utils.
5
5
  const MAX_RESOURCE_SIZE = 25 * 1024 ** 2; // 25MB
6
6
  const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
7
7
  const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
8
+ const ABORTED_MESSAGE = 'Request was aborted by browser';
8
9
 
9
10
  // The Interceptor class creates common handlers for dealing with intercepting asset requests
10
11
  // for a given page using various devtools protocol events and commands.
11
12
  export class Network {
12
- static TIMEOUT = 30000;
13
+ static TIMEOUT = undefined;
13
14
  log = logger('core:discovery');
14
15
  #pending = new Map();
15
16
  #requests = new Map();
16
17
  #intercepts = new Map();
17
18
  #authentications = new Set();
19
+ #aborted = new Set();
18
20
  constructor(page, options) {
19
21
  this.page = page;
20
22
  this.timeout = options.networkIdleTimeout ?? 100;
@@ -25,6 +27,7 @@ export class Network {
25
27
  page.session.browser.version.userAgent.replace('Headless', '');
26
28
  this.intercept = options.intercept;
27
29
  this.meta = options.meta;
30
+ this._initializeNetworkIdleWaitTimeout();
28
31
  }
29
32
  watch(session) {
30
33
  session.on('Network.requestWillBeSent', this._handleRequestWillBeSent);
@@ -76,6 +79,23 @@ export class Network {
76
79
  });
77
80
  }
78
81
 
82
+ // Validates that requestId is still valid as sometimes request gets cancelled and we have already executed
83
+ // _forgetRequest for the same, but we still attempt to make a call for it and it fails
84
+ // with Protocol error (Fetch.failRequest): Invalid InterceptionId.
85
+ async send(session, method, params) {
86
+ /* istanbul ignore else: currently all send have requestId */
87
+ if (params.requestId) {
88
+ /* istanbul ignore if: race condition, very hard to mock this */
89
+ if (this.isAborted(params.requestId)) {
90
+ throw new Error(ABORTED_MESSAGE);
91
+ }
92
+ }
93
+ return await session.send(method, params);
94
+ }
95
+ isAborted(requestId) {
96
+ return this.#aborted.has(requestId);
97
+ }
98
+
79
99
  // Throw a better network timeout error
80
100
  _throwTimeoutError(msg, filter = () => true) {
81
101
  if (this.log.shouldLog('debug')) {
@@ -115,7 +135,7 @@ export class Network {
115
135
  response = 'ProvideCredentials';
116
136
  this.#authentications.add(requestId);
117
137
  }
118
- await session.send('Fetch.continueWithAuth', {
138
+ await this.send(session, 'Fetch.continueWithAuth', {
119
139
  requestId: event.requestId,
120
140
  authChallengeResponse: {
121
141
  response,
@@ -221,7 +241,7 @@ export class Network {
221
241
  if (!request) return;
222
242
  request.response = response;
223
243
  request.response.buffer = async () => {
224
- let result = await session.send('Network.getResponseBody', {
244
+ let result = await this.send(session, 'Network.getResponseBody', {
225
245
  requestId
226
246
  });
227
247
  return Buffer.from(result.body, result.base64Encoded ? 'base64' : 'utf-8');
@@ -252,8 +272,18 @@ export class Network {
252
272
  /* istanbul ignore if: race condition paranioa */
253
273
  if (!request) return;
254
274
 
255
- // do not log generic messages since the real error was likely logged elsewhere
256
- if (event.errorText !== 'net::ERR_FAILED') {
275
+ // If request was aborted, keep track of it as we need to cancel any in process callbacks for
276
+ // such a request to avoid Invalid InterceptionId errors
277
+ // Note: 404s also show up under ERR_ABORTED and not ERR_FAILED
278
+ if (event.errorText === 'net::ERR_ABORTED') {
279
+ let message = `Request aborted for ${request.url}: ${event.errorText}`;
280
+ this.log.debug(message, {
281
+ ...this.meta,
282
+ url: request.url
283
+ });
284
+ this.#aborted.add(request.requestId);
285
+ } else if (event.errorText !== 'net::ERR_FAILED') {
286
+ // do not log generic messages since the real error was likely logged elsewhere
257
287
  let message = `Request failed for ${request.url}: ${event.errorText}`;
258
288
  this.log.debug(message, {
259
289
  ...this.meta,
@@ -262,6 +292,13 @@ export class Network {
262
292
  }
263
293
  this._forgetRequest(request);
264
294
  };
295
+ _initializeNetworkIdleWaitTimeout() {
296
+ if (Network.TIMEOUT) return;
297
+ Network.TIMEOUT = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT) || 30000;
298
+ if (Network.TIMEOUT > 60000) {
299
+ this.log.warn('Setting PERCY_NETWORK_IDLE_WAIT_TIMEOUT over 60000ms is not recommended. ' + 'If your page needs more than 60000ms to idle due to CPU/Network load, ' + 'its recommended to increase CI resources where this cli is running.');
300
+ }
301
+ }
265
302
  }
266
303
 
267
304
  // Returns the normalized origin URL of a request
@@ -281,18 +318,19 @@ async function sendResponseResource(network, request, session) {
281
318
  ...network.meta,
282
319
  url
283
320
  };
321
+ let send = (method, params) => network.send(session, method, params);
284
322
  try {
285
323
  let resource = network.intercept.getResource(url);
286
324
  network.log.debug(`Handling request: ${url}`, meta);
287
325
  if (!(resource !== null && resource !== void 0 && resource.root) && hostnameMatches(disallowedHostnames, url)) {
288
326
  log.debug('- Skipping disallowed hostname', meta);
289
- await session.send('Fetch.failRequest', {
327
+ await send('Fetch.failRequest', {
290
328
  requestId: request.interceptId,
291
329
  errorReason: 'Aborted'
292
330
  });
293
331
  } else if (resource && (resource.root || resource.provided || !disableCache)) {
294
332
  log.debug(resource.root ? '- Serving root resource' : '- Resource cache hit', meta);
295
- await session.send('Fetch.fulfillRequest', {
333
+ await send('Fetch.fulfillRequest', {
296
334
  requestId: request.interceptId,
297
335
  responseCode: resource.status || 200,
298
336
  body: Buffer.from(resource.content).toString('base64'),
@@ -302,18 +340,35 @@ async function sendResponseResource(network, request, session) {
302
340
  }))
303
341
  });
304
342
  } else {
305
- await session.send('Fetch.continueRequest', {
343
+ await send('Fetch.continueRequest', {
306
344
  requestId: request.interceptId
307
345
  });
308
346
  }
309
347
  } catch (error) {
310
348
  /* istanbul ignore next: too hard to test (create race condition) */
311
349
  if (session.closing && error.message.includes('close')) return;
350
+
351
+ // if failure is due to an already aborted request, ignore it
352
+ // due to race condition we might get aborted event later and see a `Invalid InterceptionId`
353
+ // error before, in which case we should wait for a tick and check again
354
+ // Note: its not a necessity that we would get aborted callback in a tick, its just that if we
355
+ // already have it then we can safely ignore this error
356
+ // Its very hard to test it as this function should be called and request should get cancelled before
357
+ if (error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) {
358
+ // defer this to the end of queue to make sure that any incoming aborted messages were
359
+ // handled and network.#aborted is updated
360
+ await new Promise((res, _) => process.nextTick(res));
361
+ /* istanbul ignore else: too hard to create race where abortion event is delayed */
362
+ if (network.isAborted(request.requestId)) {
363
+ log.debug(`Ignoring further steps for ${url} as request was aborted by the browser.`);
364
+ return;
365
+ }
366
+ }
312
367
  log.debug(`Encountered an error handling request: ${url}`, meta);
313
368
  log.debug(error);
314
369
 
315
370
  /* istanbul ignore next: catch race condition */
316
- await session.send('Fetch.failRequest', {
371
+ await send('Fetch.failRequest', {
317
372
  requestId: request.interceptId,
318
373
  errorReason: 'Failed'
319
374
  }).catch(e => log.debug(e, meta));
@@ -376,12 +431,16 @@ async function saveResponseResource(network, request) {
376
431
  } else if (!enableJavaScript && !ALLOWED_RESOURCES.includes(request.type)) {
377
432
  return log.debug(`- Skipping disallowed resource type [${request.type}]`, meta);
378
433
  }
434
+ let detectedMime = mime.lookup(response.url);
379
435
  let mimeType =
380
436
  // ensure the mimetype is correct for text/plain responses
381
- response.mimeType === 'text/plain' && mime.lookup(response.url) || response.mimeType;
437
+ response.mimeType === 'text/plain' && detectedMime || response.mimeType;
382
438
 
383
- // font responses from the browser may not be properly encoded, so request them directly
384
- if (mimeType !== null && mimeType !== void 0 && mimeType.includes('font')) {
439
+ // if we detect a font mime, we dont want to override it as different browsers may behave
440
+ // differently for incorrect mimetype in font response, but we want to treat it as a
441
+ // font anyway as font responses from the browser may not be properly encoded,
442
+ // so request them directly.
443
+ if (mimeType !== null && mimeType !== void 0 && mimeType.includes('font') || detectedMime && detectedMime.includes('font')) {
385
444
  log.debug('- Requesting asset directly');
386
445
  body = await makeDirectRequest(network, request);
387
446
  }
package/dist/page.js CHANGED
@@ -4,7 +4,7 @@ import Network from './network.js';
4
4
  import { PERCY_DOM } from './api.js';
5
5
  import { hostname, waitFor, waitForTimeout as sleep, serializeFunction } from './utils.js';
6
6
  export class Page {
7
- static TIMEOUT = 30000;
7
+ static TIMEOUT = undefined;
8
8
  log = logger('core:page');
9
9
  constructor(session, options) {
10
10
  this.session = session;
@@ -12,6 +12,7 @@ export class Page {
12
12
  this.enableJavaScript = options.enableJavaScript ?? true;
13
13
  this.network = new Network(this, options);
14
14
  this.meta = options.meta;
15
+ this._initializeLoadTimeout();
15
16
  session.on('Runtime.executionContextCreated', this._handleExecutionContextCreated);
16
17
  session.on('Runtime.executionContextDestroyed', this._handleExecutionContextDestroyed);
17
18
  session.on('Runtime.executionContextsCleared', this._handleExecutionContextsCleared);
@@ -241,5 +242,12 @@ export class Page {
241
242
  _handleExecutionContextsCleared = () => {
242
243
  this.contextId = null;
243
244
  };
245
+ _initializeLoadTimeout() {
246
+ if (Page.TIMEOUT) return;
247
+ Page.TIMEOUT = parseInt(process.env.PERCY_PAGE_LOAD_TIMEOUT) || 30000;
248
+ if (Page.TIMEOUT > 60000) {
249
+ this.log.warn('Setting PERCY_PAGE_LOAD_TIMEOUT over 60000ms is not recommended. ' + 'If your page needs more than 60000ms to load due to CPU/Network load, ' + 'its recommended to increase CI resources where this cli is running.');
250
+ }
251
+ }
244
252
  }
245
253
  export default Page;
package/dist/snapshot.js CHANGED
@@ -78,7 +78,7 @@ function mapSnapshotOptions(snapshots, context) {
78
78
  // assign additional options to included snaphots
79
79
  snapshotMatches(snap, include, exclude) ? Object.assign(snap, opts) : snap), snap => getSnapshotOptions(snap, context));
80
80
 
81
- // reduce snapshots with overrides
81
+ // reduce snapshots with options
82
82
  return snapshots.reduce((acc, snapshot) => {
83
83
  var _snapshot;
84
84
  // transform snapshot URL shorthand into an object
@@ -417,7 +417,7 @@ export function createSnapshotsQueue(percy) {
417
417
  })
418
418
  // handle possible build errors returned by the API
419
419
  .handle('error', (snapshot, error) => {
420
- var _error$response;
420
+ var _error$response, _error$response2, _error$response2$body;
421
421
  let result = {
422
422
  ...snapshot,
423
423
  error
@@ -437,6 +437,14 @@ export function createSnapshotsQueue(percy) {
437
437
  build.failed = true;
438
438
  queue.close(true);
439
439
  }
440
+ let errors = (_error$response2 = error.response) === null || _error$response2 === void 0 ? void 0 : (_error$response2$body = _error$response2.body) === null || _error$response2$body === void 0 ? void 0 : _error$response2$body.errors;
441
+ let duplicate = (errors === null || errors === void 0 ? void 0 : errors.length) > 1 && errors[1].detail.includes('must be unique');
442
+ if (duplicate) {
443
+ if (process.env.PERCY_IGNORE_DUPLICATES !== 'true') {
444
+ percy.log.warn(`Ignored duplicate snapshot. ${errors[1].detail}`);
445
+ }
446
+ return result;
447
+ }
440
448
  percy.log.error(`Encountered an error uploading snapshot: ${name}`, meta);
441
449
  percy.log.error(error, meta);
442
450
  return result;
package/dist/utils.js CHANGED
@@ -20,7 +20,7 @@ export function normalizeURL(url) {
20
20
  }
21
21
 
22
22
  // Returns the body for automateScreenshot in structure
23
- export function percyAutomateRequestHandler(req) {
23
+ export function percyAutomateRequestHandler(req, buildInfo) {
24
24
  if (req.body.client_info) {
25
25
  req.body.clientInfo = req.body.client_info;
26
26
  }
@@ -30,6 +30,7 @@ export function percyAutomateRequestHandler(req) {
30
30
  if (!req.body.options) {
31
31
  req.body.options = {};
32
32
  }
33
+ req.body.buildInfo = buildInfo;
33
34
  return req;
34
35
  }
35
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.27.0-alpha.0",
3
+ "version": "1.27.0-beta.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "publishConfig": {
11
11
  "access": "public",
12
- "tag": "alpha"
12
+ "tag": "beta"
13
13
  },
14
14
  "engines": {
15
15
  "node": ">=14"
@@ -24,7 +24,10 @@
24
24
  "types": "./types/index.d.ts",
25
25
  "type": "module",
26
26
  "exports": {
27
- ".": "./dist/index.js",
27
+ ".": {
28
+ "types": "./types/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
28
31
  "./utils": "./dist/utils.js",
29
32
  "./config": "./dist/config.js",
30
33
  "./install": "./dist/install.js",
@@ -40,10 +43,11 @@
40
43
  "test:types": "tsd"
41
44
  },
42
45
  "dependencies": {
43
- "@percy/client": "1.27.0-alpha.0",
44
- "@percy/config": "1.27.0-alpha.0",
45
- "@percy/dom": "1.27.0-alpha.0",
46
- "@percy/logger": "1.27.0-alpha.0",
46
+ "@percy/client": "1.27.0-beta.1",
47
+ "@percy/config": "1.27.0-beta.1",
48
+ "@percy/dom": "1.27.0-beta.1",
49
+ "@percy/logger": "1.27.0-beta.1",
50
+ "@percy/webdriver-utils": "1.27.0-beta.1",
47
51
  "content-disposition": "^0.5.4",
48
52
  "cross-spawn": "^7.0.3",
49
53
  "extract-zip": "^2.0.1",
@@ -54,5 +58,5 @@
54
58
  "rimraf": "^3.0.2",
55
59
  "ws": "^8.0.0"
56
60
  },
57
- "gitHead": "16f2c87641d844c6af6c3e198f6aff1c08ee0ec1"
61
+ "gitHead": "40cdf9c38613ccaf5e3707cd2cd2d2778ffbd5dd"
58
62
  }
package/dist/work.js DELETED
@@ -1,30 +0,0 @@
1
- const webdriver = require('selenium-webdriver');
2
- const remote = require('selenium-webdriver/remote');
3
- export default class PoaDriver {
4
- sessionId = '';
5
- commandExecutorUrl = '';
6
- capabilities = {};
7
- driver = null;
8
- constructor(sessionId, commandExecutorUrl, capabilities) {
9
- this.sessionId = sessionId;
10
- this.commandExecutorUrl = commandExecutorUrl;
11
- this.capabilities = capabilities;
12
- this.createDriver();
13
- this.localScreenshot();
14
- }
15
- createDriver() {
16
- this.driver = new webdriver.Remote({
17
- sessionId: this.sessionId,
18
- commandExecutor: new remote.SeleniumWebDriverError(this.commandExecutorUrl),
19
- desiredCapabilities: this.capabilities
20
- });
21
- }
22
- localScreenshot() {
23
- console.log(this.driver);
24
- this.driver.takeScreenshot().then(function (image, err) {
25
- require('fs').writeFile('./out1234.png', image, 'base64', function (err) {
26
- console.log(err);
27
- });
28
- });
29
- }
30
- }