@percy/core 1.31.0-alpha.2 → 1.31.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 CHANGED
@@ -1,13 +1,30 @@
1
1
  import fs from 'fs';
2
- import path from 'path';
3
- import { createRequire } from 'module';
2
+ import path, { dirname, resolve } from 'path';
4
3
  import logger from '@percy/logger';
5
4
  import { normalize } from '@percy/config/utils';
6
5
  import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler } from './utils.js';
7
6
  import WebdriverUtils from '@percy/webdriver-utils';
8
7
  import { handleSyncJob } from './snapshot.js';
9
- // need require.resolve until import.meta.resolve can be transpiled
10
- export const PERCY_DOM = createRequire(import.meta.url).resolve('@percy/dom');
8
+ // Previously, we used `createRequire(import.meta.url).resolve` to resolve the path to the module.
9
+ // This approach relied on `createRequire`, which is Node.js-specific and less compatible with modern ESM (ECMAScript Module) standards.
10
+ // This was leading to hard coded paths when CLI is used as a dependency in another project.
11
+ // Now, we use `fileURLToPath` and `path.resolve` to determine the absolute path in a way that's more aligned with ESM conventions.
12
+ // This change ensures better compatibility and avoids relying on Node.js-specific APIs that might cause issues in ESM environments.
13
+ import { fileURLToPath } from 'url';
14
+ import { createRequire } from 'module';
15
+ export const getPercyDomPath = url => {
16
+ try {
17
+ return createRequire(url).resolve('@percy/dom');
18
+ } catch (error) {
19
+ logger('core:server').warn(['Failed to resolve @percy/dom path using createRequire.', 'Falling back to using fileURLToPath and path.resolve.'].join(' '));
20
+ }
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ return resolve(__dirname, 'node_modules/@percy/dom');
24
+ };
25
+
26
+ // Resolved path for PERCY_DOM
27
+ export const PERCY_DOM = getPercyDomPath(import.meta.url);
11
28
 
12
29
  // Returns a URL encoded string of nested query params
13
30
  function encodeURLSearchParams(subj, prefix) {
package/dist/browser.js CHANGED
@@ -72,7 +72,7 @@ export class Browser extends EventEmitter {
72
72
  this.readyState = 0;
73
73
  let {
74
74
  cookies = [],
75
- launchOptions = {}
75
+ launchOptions
76
76
  } = this.percy.config.discovery;
77
77
  let {
78
78
  executable,
@@ -123,8 +123,14 @@ export class Browser extends EventEmitter {
123
123
  var _this$ws;
124
124
  return ((_this$ws = this.ws) === null || _this$ws === void 0 ? void 0 : _this$ws.readyState) === WebSocket.OPEN;
125
125
  }
126
- async close() {
127
- var _this$process, _this$ws2;
126
+ async close(force = false) {
127
+ var _this$percy$config$di, _this$process, _this$ws2;
128
+ // Check for the new closeBrowser option
129
+ if (!force && ((_this$percy$config$di = this.percy.config.discovery) === null || _this$percy$config$di === void 0 || (_this$percy$config$di = _this$percy$config$di.launchOptions) === null || _this$percy$config$di === void 0 ? void 0 : _this$percy$config$di.closeBrowser) === false) {
130
+ this.log.debug('Skipping browser close due to closeBrowser:false option');
131
+ return true;
132
+ }
133
+
128
134
  // not running, already closed, or closing
129
135
  if (this._closed) return this._closed;
130
136
  this.readyState = 2;
package/dist/config.js CHANGED
@@ -16,12 +16,43 @@ export const configSchema = {
16
16
  },
17
17
  labels: {
18
18
  type: 'string'
19
+ },
20
+ skipBaseBuild: {
21
+ type: 'boolean',
22
+ default: false
19
23
  }
20
24
  }
21
25
  },
22
26
  snapshot: {
23
27
  type: 'object',
24
28
  additionalProperties: false,
29
+ definitions: {
30
+ configurationProperties: {
31
+ type: 'object',
32
+ additionalProperties: false,
33
+ properties: {
34
+ diffSensitivity: {
35
+ type: 'integer',
36
+ minimum: 0,
37
+ maximum: 4
38
+ },
39
+ imageIgnoreThreshold: {
40
+ type: 'number',
41
+ minimum: 0,
42
+ maximum: 1
43
+ },
44
+ carouselsEnabled: {
45
+ type: 'boolean'
46
+ },
47
+ bannersEnabled: {
48
+ type: 'boolean'
49
+ },
50
+ adsEnabled: {
51
+ type: 'boolean'
52
+ }
53
+ }
54
+ }
55
+ },
25
56
  properties: {
26
57
  widths: {
27
58
  type: 'array',
@@ -91,6 +122,14 @@ export const configSchema = {
91
122
  thTestCaseExecutionId: {
92
123
  type: 'string'
93
124
  },
125
+ browsers: {
126
+ type: 'array',
127
+ items: {
128
+ type: 'string',
129
+ minLength: 1
130
+ },
131
+ onlyWeb: true
132
+ },
94
133
  fullPage: {
95
134
  type: 'boolean',
96
135
  onlyAutomate: true
@@ -160,6 +199,88 @@ export const configSchema = {
160
199
  }
161
200
  }
162
201
  }
202
+ },
203
+ regions: {
204
+ type: 'array',
205
+ items: {
206
+ type: 'object',
207
+ properties: {
208
+ elementSelector: {
209
+ type: 'object',
210
+ additionalProperties: false,
211
+ properties: {
212
+ boundingBox: {
213
+ type: 'object',
214
+ additionalProperties: false,
215
+ properties: {
216
+ x: {
217
+ type: 'integer'
218
+ },
219
+ y: {
220
+ type: 'integer'
221
+ },
222
+ width: {
223
+ type: 'integer'
224
+ },
225
+ height: {
226
+ type: 'integer'
227
+ }
228
+ }
229
+ },
230
+ elementXpath: {
231
+ type: 'string'
232
+ },
233
+ elementCSS: {
234
+ type: 'string'
235
+ }
236
+ }
237
+ },
238
+ padding: {
239
+ type: 'object',
240
+ additionalProperties: false,
241
+ properties: {
242
+ top: {
243
+ type: 'integer'
244
+ },
245
+ bottom: {
246
+ type: 'integer'
247
+ },
248
+ left: {
249
+ type: 'integer'
250
+ },
251
+ right: {
252
+ type: 'integer'
253
+ }
254
+ }
255
+ },
256
+ algorithm: {
257
+ type: 'string',
258
+ enum: ['standard', 'layout', 'ignore', 'intelliignore']
259
+ },
260
+ configuration: {
261
+ $ref: '#/definitions/configurationProperties'
262
+ },
263
+ assertion: {
264
+ type: 'object',
265
+ additionalProperties: false,
266
+ properties: {
267
+ diffIgnoreThreshold: {
268
+ type: 'number',
269
+ minimum: 0,
270
+ maximum: 1
271
+ }
272
+ }
273
+ }
274
+ },
275
+ required: ['algorithm']
276
+ }
277
+ },
278
+ algorithm: {
279
+ type: 'string',
280
+ enum: ['standard', 'layout', 'intelliignore']
281
+ },
282
+ algorithmConfiguration: {
283
+ $ref: '#/definitions/configurationProperties'
163
284
  }
164
285
  }
165
286
  },
@@ -217,6 +338,10 @@ export const configSchema = {
217
338
  minimum: 1,
218
339
  maximum: 30000
219
340
  },
341
+ scrollToBottom: {
342
+ type: 'boolean',
343
+ default: false
344
+ },
220
345
  disableCache: {
221
346
  type: 'boolean'
222
347
  },
@@ -306,6 +431,10 @@ export const configSchema = {
306
431
  },
307
432
  headless: {
308
433
  type: 'boolean'
434
+ },
435
+ closeBrowser: {
436
+ type: 'boolean',
437
+ default: true
309
438
  }
310
439
  }
311
440
  }
@@ -363,9 +492,21 @@ export const snapshotSchema = {
363
492
  thTestCaseExecutionId: {
364
493
  $ref: '/config/snapshot#/properties/thTestCaseExecutionId'
365
494
  },
495
+ browsers: {
496
+ $ref: '/config/snapshot#/properties/browsers'
497
+ },
366
498
  reshuffleInvalidTags: {
367
499
  $ref: '/config/snapshot#/properties/reshuffleInvalidTags'
368
500
  },
501
+ regions: {
502
+ $ref: '/config/snapshot#/properties/regions'
503
+ },
504
+ algorithm: {
505
+ $ref: '/config/snapshot#/properties/algorithm'
506
+ },
507
+ algorithmConfiguration: {
508
+ $ref: '/config/snapshot#/properties/algorithmConfiguration'
509
+ },
369
510
  scopeOptions: {
370
511
  $ref: '/config/snapshot#/properties/scopeOptions'
371
512
  },
@@ -408,6 +549,9 @@ export const snapshotSchema = {
408
549
  },
409
550
  retry: {
410
551
  $ref: '/config/discovery#/properties/retry'
552
+ },
553
+ scrollToBottom: {
554
+ $ref: '/config/discovery#/properties/scrollToBottom'
411
555
  }
412
556
  }
413
557
  }
@@ -909,6 +1053,15 @@ export const comparisonSchema = {
909
1053
  ignoreElementsData: regionsSchema
910
1054
  }
911
1055
  },
1056
+ regions: {
1057
+ $ref: '/config/snapshot#/properties/regions'
1058
+ },
1059
+ algorithm: {
1060
+ $ref: '/config/snapshot#/properties/algorithm'
1061
+ },
1062
+ algorithmConfiguration: {
1063
+ $ref: '/config/snapshot#/properties/algorithmConfiguration'
1064
+ },
912
1065
  consideredElementsData: {
913
1066
  type: 'object',
914
1067
  additionalProperties: false,
package/dist/discovery.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import logger from '@percy/logger';
2
2
  import Queue from './queue.js';
3
3
  import Page from './page.js';
4
- import { normalizeURL, hostnameMatches, createResource, createRootResource, createPercyCSSResource, createLogResource, yieldAll, snapshotLogName, waitForTimeout, withRetries, waitForSelectorInsideBrowser } from './utils.js';
4
+ import { normalizeURL, hostnameMatches, createResource, createRootResource, createPercyCSSResource, createLogResource, yieldAll, snapshotLogName, waitForTimeout, withRetries, waitForSelectorInsideBrowser, isGzipped, maybeScrollToBottom } from './utils.js';
5
5
  import { sha256hash } from '@percy/client/utils';
6
6
  import Pako from 'pako';
7
7
 
@@ -36,6 +36,7 @@ function debugSnapshotOptions(snapshot) {
36
36
  debugProp(snapshot, 'waitForTimeout');
37
37
  debugProp(snapshot, 'waitForSelector');
38
38
  debugProp(snapshot, 'scopeOptions.scroll');
39
+ debugProp(snapshot, 'browsers');
39
40
  debugProp(snapshot, 'execute.afterNavigation');
40
41
  debugProp(snapshot, 'execute.beforeResize');
41
42
  debugProp(snapshot, 'execute.afterResize');
@@ -52,6 +53,7 @@ function debugSnapshotOptions(snapshot) {
52
53
  debugProp(snapshot, 'clientInfo');
53
54
  debugProp(snapshot, 'environmentInfo');
54
55
  debugProp(snapshot, 'domSnapshot', Boolean);
56
+ debugProp(snapshot, 'discovery.scrollToBottom');
55
57
  if (Array.isArray(snapshot.domSnapshot)) {
56
58
  debugProp(snapshot, 'domSnapshot.0.userAgent');
57
59
  } else {
@@ -107,6 +109,11 @@ function waitForDiscoveryNetworkIdle(page, options) {
107
109
  let filter = r => hostnameMatches(allowedHostnames, r.url);
108
110
  return page.network.idle(filter, networkIdleTimeout, captureResponsiveAssetsEnabled);
109
111
  }
112
+ async function waitForFontLoading(page) {
113
+ return await logger.measure('core:discovery', 'waitForFontLoading', undefined, async () => {
114
+ return await Promise.race([page.eval('await document.fonts.ready;'), new Promise(res => setTimeout(res, 5000))]);
115
+ });
116
+ }
110
117
 
111
118
  // Creates an initial resource map for a snapshot containing serialized DOM
112
119
  function parseDomResources({
@@ -217,8 +224,12 @@ function processSnapshotResources({
217
224
  })));
218
225
  if (process.env.PERCY_GZIP) {
219
226
  for (let index = 0; index < resources.length; index++) {
220
- resources[index].content = Pako.gzip(resources[index].content);
221
- resources[index].sha = sha256hash(resources[index].content);
227
+ const alreadyZipped = isGzipped(resources[index].content);
228
+ /* istanbul ignore next: very hard to mock true */
229
+ if (!alreadyZipped) {
230
+ resources[index].content = Pako.gzip(resources[index].content);
231
+ resources[index].sha = sha256hash(resources[index].content);
232
+ }
222
233
  }
223
234
  }
224
235
  return {
@@ -264,6 +275,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
264
275
  deviceScaleFactor: device.deviceScaleFactor,
265
276
  mobile: true
266
277
  });
278
+ yield waitForFontLoading(page);
267
279
  yield waitForDiscoveryNetworkIdle(page, discovery);
268
280
  }
269
281
  }
@@ -317,6 +329,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
317
329
  yield page.eval((_, s) => window.__PERCY__.snapshot = s, snapshot);
318
330
  yield page.evaluate(snapshot.execute.afterNavigation);
319
331
  }
332
+ yield* maybeScrollToBottom(page, discovery);
320
333
 
321
334
  // Running before page idle since this will trigger many network calls
322
335
  // so need to run as early as possible. plus it is just reading urls from dom srcset
@@ -345,6 +358,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
345
358
  for (let i = 0; i < widths.length - 1; i++) {
346
359
  if (captureWidths) yield* takeSnapshot(snap, width);
347
360
  yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.beforeResize);
361
+ yield waitForFontLoading(page);
348
362
  yield waitForDiscoveryNetworkIdle(page, discovery);
349
363
  yield resizePage(width = widths[i + 1]);
350
364
  if (snapshot.responsiveSnapshotCapture) {
@@ -354,6 +368,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
354
368
  });
355
369
  }
356
370
  yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.afterResize);
371
+ yield* maybeScrollToBottom(page, discovery);
357
372
  }
358
373
  }
359
374
  if (capture && !snapshot.domSnapshot) {
@@ -376,6 +391,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
376
391
 
377
392
  // wait for final network idle when not capturing DOM
378
393
  if (capture && snapshot.domSnapshot) {
394
+ yield waitForFontLoading(page);
379
395
  yield waitForDiscoveryNetworkIdle(page, discovery);
380
396
  yield* captureResponsiveAssets();
381
397
  capture(processSnapshotResources(snapshot));
@@ -389,7 +405,8 @@ export async function* discoverSnapshotResources(queue, options, callback) {
389
405
  let {
390
406
  snapshots,
391
407
  skipDiscovery,
392
- dryRun
408
+ dryRun,
409
+ checkAndUpdateConcurrency
393
410
  } = options;
394
411
  yield* yieldAll(snapshots.reduce((all, snapshot) => {
395
412
  debugSnapshotOptions(snapshot);
@@ -403,6 +420,10 @@ export async function* discoverSnapshotResources(queue, options, callback) {
403
420
  callback(dryRun ? snap : processSnapshotResources(snap));
404
421
  }
405
422
  } else {
423
+ // update concurrency before pushing new job in discovery queue
424
+ // if case of monitoring is stopped due to in-activity,
425
+ // it can take upto 1 sec to execute this fun
426
+ checkAndUpdateConcurrency();
406
427
  all.push(queue.push(snapshot, callback));
407
428
  }
408
429
  return all;
@@ -490,6 +511,12 @@ export function createDiscoveryQueue(percy) {
490
511
  return resource;
491
512
  },
492
513
  saveResource: r => {
514
+ const limitResources = process.env.LIMIT_SNAPSHOT_RESOURCES || false;
515
+ const MAX_RESOURCES = Number(process.env.MAX_SNAPSHOT_RESOURCES) || 749;
516
+ if (limitResources && snapshot.resources.size >= MAX_RESOURCES) {
517
+ percy.log.debug(`Skipping resource ${r.url} — resource limit reached`);
518
+ return;
519
+ }
493
520
  snapshot.resources.set(r.url, r);
494
521
  if (!snapshot.discovery.disableCache) {
495
522
  cache.set(r.url, r);
package/dist/install.js CHANGED
@@ -162,13 +162,13 @@ export function chromium({
162
162
  });
163
163
  }
164
164
 
165
- // default chromium revisions corresponds to v123.0.6312.58
165
+ // default chromium revisions corresponds to v126.0.6478.184
166
166
  chromium.revisions = {
167
- linux: '1262506',
168
- win64: '1262500',
169
- win32: '1262500',
170
- darwin: '1262506',
171
- darwinArm: '1262509'
167
+ linux: '1300309',
168
+ win64: '1300297',
169
+ win32: '1300295',
170
+ darwin: '1300293',
171
+ darwinArm: '1300314'
172
172
  };
173
173
 
174
174
  // export the namespace by default
package/dist/network.js CHANGED
@@ -491,6 +491,7 @@ async function makeDirectRequest(network, request, session) {
491
491
 
492
492
  // Save a resource from a request, skipping it if specific paramters are not met
493
493
  async function saveResponseResource(network, request, session) {
494
+ var _response$headers;
494
495
  let {
495
496
  disableCache,
496
497
  allowedHostnames,
@@ -504,6 +505,14 @@ async function saveResponseResource(network, request, session) {
504
505
  url,
505
506
  responseStatus: response === null || response === void 0 ? void 0 : response.status
506
507
  };
508
+ // Checing for content length more than 100MB, to prevent websocket error which is governed by
509
+ // maxPayload option of websocket defaulted to 100MB.
510
+ // If content-length is more than our allowed 25MB, no need to process that resouce we can return log.
511
+ let contentLength = (_response$headers = response.headers) === null || _response$headers === void 0 ? void 0 : _response$headers[Object.keys(response.headers).find(key => key.toLowerCase() === 'content-length')];
512
+ contentLength = parseInt(contentLength);
513
+ if (contentLength > MAX_RESOURCE_SIZE) {
514
+ return log.debug('- Skipping resource larger than 25MB', meta);
515
+ }
507
516
  let resource = network.intercept.getResource(url);
508
517
  if (!resource || !resource.root && !resource.provided && disableCache) {
509
518
  try {
@@ -521,6 +530,7 @@ async function saveResponseResource(network, request, session) {
521
530
  } else if (!body.length) {
522
531
  return log.debug('- Skipping empty response', meta);
523
532
  } else if (body.length > MAX_RESOURCE_SIZE) {
533
+ log.debug('- Missing headers for the requested resource.', meta);
524
534
  return log.debug('- Skipping resource larger than 25MB', meta);
525
535
  } else if (!ALLOWED_STATUSES.includes(response.status)) {
526
536
  return log.debug(`- Skipping disallowed status [${response.status}]`, meta);
package/dist/percy.js CHANGED
@@ -17,9 +17,15 @@ import { base64encode, generatePromise, yieldAll, yieldTo, redactSecrets, detect
17
17
  import { createPercyServer, createStaticServer } from './api.js';
18
18
  import { gatherSnapshots, createSnapshotsQueue, validateSnapshotOptions } from './snapshot.js';
19
19
  import { discoverSnapshotResources, createDiscoveryQueue } from './discovery.js';
20
+ import Monitoring from '@percy/monitoring';
20
21
  import { WaitForJob } from './wait-for-job.js';
21
22
  const MAX_SUGGESTION_CALLS = 10;
22
23
 
24
+ // If no activity is done for 5 mins, we will stop monitoring
25
+ // system metric eg: (cpu load && memory usage)
26
+ const MONITOR_ACTIVITY_TIMEOUT = 300000;
27
+ const MONITORING_INTERVAL_MS = 5000; // 5 sec
28
+
23
29
  // A Percy instance will create a new build when started, handle snapshot creation, asset discovery,
24
30
  // and resource uploads, and will finalize the build when stopped. Snapshots are processed
25
31
  // concurrently and the build is not finalized until all snapshots have been handled.
@@ -100,7 +106,13 @@ export class Percy {
100
106
  if (server) this.server = createPercyServer(this, port);
101
107
  this.browser = new Browser(this);
102
108
  _classPrivateFieldSet(_discovery, this, createDiscoveryQueue(this));
109
+ this.discoveryMaxConcurrency = _classPrivateFieldGet(_discovery, this).concurrency;
103
110
  _classPrivateFieldSet(_snapshots, this, createSnapshotsQueue(this));
111
+ this.monitoring = new Monitoring();
112
+ // used continue monitoring if there is activity going on
113
+ // if there is none, stop it
114
+ this.resetMonitoringId = null;
115
+ this.monitoringCheckLastExecutedAt = null;
104
116
 
105
117
  // generator methods are wrapped to autorun and return promises
106
118
  for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot', 'upload']) {
@@ -109,6 +121,28 @@ export class Percy {
109
121
  this[m] = (...args) => generatePromise(method(...args));
110
122
  }
111
123
  }
124
+ systemMonitoringEnabled() {
125
+ return process.env.PERCY_DISABLE_SYSTEM_MONITORING !== 'true';
126
+ }
127
+ async configureSystemMonitor() {
128
+ await this.monitoring.startMonitoring({
129
+ interval: MONITORING_INTERVAL_MS
130
+ });
131
+ this.resetSystemMonitor();
132
+ }
133
+
134
+ // Debouncing logic to only stop Monitoring system
135
+ // if there is no any activity for 5 mins
136
+ // means, no job is pushed in queue from 5 mins
137
+ resetSystemMonitor() {
138
+ if (this.resetMonitoringId) {
139
+ clearTimeout(this.resetMonitoringId);
140
+ this.resetMonitoringId = null;
141
+ }
142
+ this.resetMonitoringId = setTimeout(() => {
143
+ this.monitoring.stopMonitoring();
144
+ }, MONITOR_ACTIVITY_TIMEOUT);
145
+ }
112
146
 
113
147
  // Shortcut for controlling the global logger's log level.
114
148
  loglevel(level) {
@@ -157,7 +191,7 @@ export class Percy {
157
191
  concurrency
158
192
  });
159
193
  _classPrivateFieldGet(_snapshots, this).set({
160
- snapshotConcurrency
194
+ concurrency: snapshotConcurrency
161
195
  });
162
196
  return this.config;
163
197
  }
@@ -169,6 +203,12 @@ export class Percy {
169
203
  this.readyState = 0;
170
204
  this.cliStartTime = new Date().toISOString();
171
205
  try {
206
+ // started monitoring system metrics
207
+
208
+ if (this.systemMonitoringEnabled()) {
209
+ await this.configureSystemMonitor();
210
+ await this.monitoring.logSystemInfo();
211
+ }
172
212
  if (process.env.PERCY_CLIENT_ERROR_LOGS !== 'false') {
173
213
  this.log.warn('Notice: Percy collects CI logs to improve service and enhance your experience. These logs help us debug issues and provide insights on your dashboards, making it easier to optimize the product experience. Logs are stored securely for 30 days. You can opt out anytime with export PERCY_CLIENT_ERROR_LOGS=false, but keeping this enabled helps us offer the best support and features.');
174
214
  }
@@ -296,12 +336,66 @@ export class Percy {
296
336
  this.log.error(err);
297
337
  throw err;
298
338
  } finally {
339
+ // stop monitoring system metric, if not already stopped
340
+ this.monitoring.stopMonitoring();
341
+ clearTimeout(this.resetMonitoringId);
342
+
299
343
  // This issue doesn't comes under regular error logs,
300
344
  // it's detected if we just and stop percy server
301
345
  await this.checkForNoSnapshotCommandError();
302
346
  await this.sendBuildLogs();
303
347
  }
304
348
  }
349
+ checkAndUpdateConcurrency() {
350
+ // early exit if monitoring is disabled
351
+ if (!this.systemMonitoringEnabled()) return;
352
+
353
+ // early exit if asset discovery concurrency change is disabled
354
+ // NOTE: system monitoring will still be running as only concurrency
355
+ // change is disabled
356
+ if (process.env.PERCY_DISABLE_CONCURRENCY_CHANGE === 'true') return;
357
+
358
+ // start system monitoring if not already doing...
359
+ // this doesn't handle cases where there is suggest cpu spikes
360
+ // in less 1 sec range and if monitoring is not in running state
361
+ if (this.monitoringCheckLastExecutedAt && Date.now() - this.monitoringCheckLastExecutedAt < MONITORING_INTERVAL_MS) return;
362
+ if (!this.monitoring.running) this.configureSystemMonitor();else this.resetSystemMonitor();
363
+
364
+ // early return if last executed was less than 5 seconds
365
+ // as we will get the same cpu/mem info under 5 sec interval
366
+ const {
367
+ cpuInfo,
368
+ memoryUsageInfo
369
+ } = this.monitoring.getMonitoringInfo();
370
+ this.log.debug(`cpuInfo: ${JSON.stringify(cpuInfo)}`);
371
+ this.log.debug(`memoryInfo: ${JSON.stringify(memoryUsageInfo)}`);
372
+ if (cpuInfo.currentUsagePercent >= 80 || memoryUsageInfo.currentUsagePercent >= 80) {
373
+ let currentConcurrent = _classPrivateFieldGet(_discovery, this).concurrency;
374
+
375
+ // concurrency must be betweeen [1, (default/user defined value)]
376
+ let newConcurrency = Math.max(1, parseInt(currentConcurrent / 2));
377
+ newConcurrency = Math.min(this.discoveryMaxConcurrency, newConcurrency);
378
+ this.log.debug(`Downscaling discovery browser concurrency from ${_classPrivateFieldGet(_discovery, this).concurrency} to ${newConcurrency}`);
379
+ _classPrivateFieldGet(_discovery, this).set({
380
+ concurrency: newConcurrency
381
+ });
382
+ } else if (cpuInfo.currentUsagePercent <= 50 && memoryUsageInfo.currentUsagePercent <= 50) {
383
+ let currentConcurrent = _classPrivateFieldGet(_discovery, this).concurrency;
384
+ let newConcurrency = currentConcurrent + 2;
385
+
386
+ // concurrency must be betweeen [1, (default/user-defined value)]
387
+ newConcurrency = Math.min(this.discoveryMaxConcurrency, newConcurrency);
388
+ newConcurrency = Math.max(1, newConcurrency);
389
+ this.log.debug(`Upscaling discovery browser concurrency from ${_classPrivateFieldGet(_discovery, this).concurrency} to ${newConcurrency}`);
390
+ _classPrivateFieldGet(_discovery, this).set({
391
+ concurrency: newConcurrency
392
+ });
393
+ }
394
+
395
+ // reset timeout to stop monitoring after no-activity of 5 mins
396
+ this.resetSystemMonitor();
397
+ this.monitoringCheckLastExecutedAt = Date.now();
398
+ }
305
399
 
306
400
  // Takes one or more snapshots of a page while discovering resources to upload with the resulting
307
401
  // snapshots. Once asset discovery has completed for the provided snapshots, the queued task will
@@ -354,6 +448,7 @@ export class Percy {
354
448
  yield* discoverSnapshotResources(_classPrivateFieldGet(_discovery, this), {
355
449
  skipDiscovery: this.skipDiscovery,
356
450
  dryRun: this.dryRun,
451
+ checkAndUpdateConcurrency: this.checkAndUpdateConcurrency.bind(this),
357
452
  snapshots: yield* gatherSnapshots(options, {
358
453
  meta: {
359
454
  build: this.build
package/dist/queue.js CHANGED
@@ -115,6 +115,13 @@ export class Queue {
115
115
  // return the deferred task promise
116
116
  return task.deferred;
117
117
  }
118
+ logQueueSize() {
119
+ this.log.debug(`${this.name} queueInfo: ${JSON.stringify({
120
+ queued: _classPrivateFieldGet(_queued, this).size,
121
+ pending: _classPrivateFieldGet(_pending, this).size,
122
+ total: _classPrivateFieldGet(_pending, this).size + _classPrivateFieldGet(_queued, this).size
123
+ })}`);
124
+ }
118
125
  // Cancels and aborts a specific item task.
119
126
  cancel(item) {
120
127
  let task = _assertClassBrand(_Queue_brand, this, _find).call(this, item);
@@ -243,6 +250,7 @@ export class Queue {
243
250
  // Repeatedly yields, calling the callback with the position of the task within the queue
244
251
  }
245
252
  function _dequeue() {
253
+ this.logQueueSize();
246
254
  if (!_classPrivateFieldGet(_queued, this).size || this.readyState < 2) return;
247
255
  if (_classPrivateFieldGet(_pending, this).size >= this.concurrency) return;
248
256
  let [task] = _classPrivateFieldGet(_queued, this);
package/dist/session.js CHANGED
@@ -20,6 +20,12 @@ export class Session extends EventEmitter {
20
20
  this.on('Inspector.targetCrashed', this._handleTargetCrashed);
21
21
  }
22
22
  async close() {
23
+ var _this$browser;
24
+ // Check for the new closeBrowser option
25
+ if (((_this$browser = this.browser) === null || _this$browser === void 0 || (_this$browser = _this$browser.percy.config.discovery) === null || _this$browser === void 0 || (_this$browser = _this$browser.launchOptions) === null || _this$browser === void 0 ? void 0 : _this$browser.closeBrowser) === false) {
26
+ this.log.debug('Skipping session close due to closeBrowser:false option');
27
+ return true;
28
+ }
23
29
  if (!this.browser || this.closing) return;
24
30
  this.closing = true;
25
31
  await this.browser.send('Target.closeTarget', {
package/dist/snapshot.js CHANGED
@@ -3,7 +3,7 @@ import PercyConfig from '@percy/config';
3
3
  import micromatch from 'micromatch';
4
4
  import { configSchema } from './config.js';
5
5
  import Queue from './queue.js';
6
- import { request, hostnameMatches, yieldTo, snapshotLogName, decodeAndEncodeURLWithLogging, compareObjectTypes } from './utils.js';
6
+ import { request, hostnameMatches, yieldTo, snapshotLogName, decodeAndEncodeURLWithLogging, compareObjectTypes, normalizeOptions } from './utils.js';
7
7
  import { JobData } from './wait-for-job.js';
8
8
 
9
9
  // Throw a better error message for missing or invalid urls
@@ -151,14 +151,17 @@ function getSnapshotOptions(options, {
151
151
  captureMockedServiceWorker: config.discovery.captureMockedServiceWorker,
152
152
  captureSrcset: config.discovery.captureSrcset,
153
153
  userAgent: config.discovery.userAgent,
154
- retry: config.discovery.retry
154
+ retry: config.discovery.retry,
155
+ scrollToBottom: config.discovery.scrollToBottom
155
156
  }
156
157
  }, options], (path, prev, next) => {
157
- var _next, _next2;
158
+ var _next, _next2, _next3;
158
159
  switch (path.map(k => k.toString()).join('.')) {
159
160
  case 'widths':
160
161
  // dedup, sort, and override widths when not empty
161
162
  return [path, !((_next = next) !== null && _next !== void 0 && _next.length) ? prev : [...new Set(next)].sort((a, b) => a - b)];
163
+ case 'browsers':
164
+ return [path, !((_next2 = next) !== null && _next2 !== void 0 && _next2.length) ? prev : [...new Set(next)]];
162
165
  case 'percyCSS':
163
166
  // concatenate percy css
164
167
  return [path, [prev, next].filter(Boolean).join('\n')];
@@ -167,7 +170,7 @@ function getSnapshotOptions(options, {
167
170
  return Array.isArray(next) || typeof next !== 'object' ? [path.concat('beforeSnapshot'), next] : [path];
168
171
  case 'discovery.disallowedHostnames':
169
172
  // prevent disallowing the root hostname
170
- return [path, !((_next2 = next) !== null && _next2 !== void 0 && _next2.length) ? prev : (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
173
+ return [path, !((_next3 = next) !== null && _next3 !== void 0 && _next3.length) ? prev : (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
171
174
  }
172
175
 
173
176
  // ensure additional snapshots have complete names
@@ -192,9 +195,9 @@ function getSnapshotOptions(options, {
192
195
  export function validateSnapshotOptions(options) {
193
196
  var _migrated$baseUrl, _migrated$domSnapshot;
194
197
  let log = logger('core:snapshot');
195
-
196
198
  // decide which schema to validate against
197
199
  let schema = ['domSnapshot', 'dom-snapshot', 'dom_snapshot'].some(k => k in options) && '/snapshot/dom' || 'url' in options && '/snapshot' || 'sitemap' in options && '/snapshot/sitemap' || 'serve' in options && '/snapshot/server' || 'snapshots' in options && '/snapshot/list' || '/snapshot';
200
+ options = normalizeOptions(options);
198
201
  let {
199
202
  // normalize, migrate, and remove certain properties from validating
200
203
  clientInfo,
@@ -226,7 +229,6 @@ export function validateSnapshotOptions(options) {
226
229
  log.warn('Encountered snapshot serialization warnings:');
227
230
  for (let w of domWarnings) log.warn(`- ${w}`);
228
231
  }
229
-
230
232
  // warn on validation errors
231
233
  let errors = PercyConfig.validate(migrated, schema);
232
234
  if ((errors === null || errors === void 0 ? void 0 : errors.length) > 0) {
package/dist/utils.js CHANGED
@@ -52,6 +52,9 @@ export function percyAutomateRequestHandler(req, percy) {
52
52
  ignoreRegionXpaths: (_percy$config$snapsho4 = percy.config.snapshot.ignoreRegions) === null || _percy$config$snapsho4 === void 0 ? void 0 : _percy$config$snapsho4.ignoreRegionXpaths,
53
53
  considerRegionSelectors: (_percy$config$snapsho5 = percy.config.snapshot.considerRegions) === null || _percy$config$snapsho5 === void 0 ? void 0 : _percy$config$snapsho5.considerRegionSelectors,
54
54
  considerRegionXpaths: (_percy$config$snapsho6 = percy.config.snapshot.considerRegions) === null || _percy$config$snapsho6 === void 0 ? void 0 : _percy$config$snapsho6.considerRegionXpaths,
55
+ regions: percy.config.snapshot.regions,
56
+ algorithm: percy.config.snapshot.algorithm,
57
+ algorithmConfiguration: percy.config.snapshot.algorithmConfiguration,
55
58
  sync: percy.config.snapshot.sync,
56
59
  version: 'v2'
57
60
  }, camelCasedOptions], (path, prev, next) => {
@@ -389,6 +392,20 @@ export function redactSecrets(data) {
389
392
  export function base64encode(content) {
390
393
  return Buffer.from(content).toString('base64');
391
394
  }
395
+
396
+ // It checks if content is already gzipped or not.
397
+ // We don't want to gzip already gzipped content.
398
+ export function isGzipped(content) {
399
+ if (!(content instanceof Uint8Array || content instanceof ArrayBuffer)) {
400
+ return false;
401
+ }
402
+
403
+ // Ensure content is a Uint8Array
404
+ const data = content instanceof ArrayBuffer ? new Uint8Array(content) : content;
405
+
406
+ // Gzip magic number: 0x1f8b
407
+ return data.length > 2 && data[0] === 0x1f && data[1] === 0x8b;
408
+ }
392
409
  const RESERVED_CHARACTERS = {
393
410
  '%3A': ':',
394
411
  '%23': '#',
@@ -526,4 +543,37 @@ export function compareObjectTypes(obj1, obj2) {
526
543
  if (!keys2.includes(key) || !compareObjectTypes(obj1[key], obj2[key])) return false;
527
544
  }
528
545
  return true;
546
+ }
547
+ const OPTION_MAPPINGS = {
548
+ name: 'name',
549
+ widths: 'widths',
550
+ scope: 'scope',
551
+ scopeoptions: 'scopeOptions',
552
+ minheight: 'minHeight',
553
+ enablejavascript: 'enableJavaScript',
554
+ enablelayout: 'enableLayout',
555
+ clientinfo: 'clientInfo',
556
+ environmentinfo: 'environmentInfo',
557
+ sync: 'sync',
558
+ testcase: 'testCase',
559
+ labels: 'labels',
560
+ thtestcaseexecutionid: 'thTestCaseExecutionId',
561
+ browsers: 'browsers',
562
+ resources: 'resources',
563
+ meta: 'meta',
564
+ snapshot: 'snapshot'
565
+ };
566
+ export function normalizeOptions(options) {
567
+ const normalizedOptions = {};
568
+ for (const key in options) {
569
+ const lowerCaseKey = key.toLowerCase().replace(/[-_]/g, '');
570
+ const normalizedKey = OPTION_MAPPINGS[lowerCaseKey] ? OPTION_MAPPINGS[lowerCaseKey] : key;
571
+ normalizedOptions[normalizedKey] = options[key];
572
+ }
573
+ return normalizedOptions;
574
+ }
575
+ export async function* maybeScrollToBottom(page, discovery) {
576
+ if (discovery.scrollToBottom && page.enableJavaScript) {
577
+ yield page.eval('await scrollToBottom()');
578
+ }
529
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.31.0-alpha.2",
3
+ "version": "1.31.0",
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": "latest"
13
13
  },
14
14
  "engines": {
15
15
  "node": ">=14"
@@ -43,11 +43,12 @@
43
43
  "test:types": "tsd"
44
44
  },
45
45
  "dependencies": {
46
- "@percy/client": "1.31.0-alpha.2",
47
- "@percy/config": "1.31.0-alpha.2",
48
- "@percy/dom": "1.31.0-alpha.2",
49
- "@percy/logger": "1.31.0-alpha.2",
50
- "@percy/webdriver-utils": "1.31.0-alpha.2",
46
+ "@percy/client": "1.31.0",
47
+ "@percy/config": "1.31.0",
48
+ "@percy/dom": "1.31.0",
49
+ "@percy/logger": "1.31.0",
50
+ "@percy/monitoring": "1.31.0",
51
+ "@percy/webdriver-utils": "1.31.0",
51
52
  "content-disposition": "^0.5.4",
52
53
  "cross-spawn": "^7.0.3",
53
54
  "extract-zip": "^2.0.1",
@@ -60,5 +61,5 @@
60
61
  "ws": "^8.17.1",
61
62
  "yaml": "^2.4.1"
62
63
  },
63
- "gitHead": "1c7711f564fd4a112e1e1490a772db757df0535d"
64
+ "gitHead": "49895470c0dfa7242881db43e293317d1fb8f8b6"
64
65
  }
@@ -1,18 +1,25 @@
1
1
  // aliased to src during tests
2
2
  import Server from '../../dist/server.js';
3
3
 
4
- export function createTestServer({ default: defaultReply, ...replies }, port = 8000) {
4
+ export function createTestServer({ default: defaultReply, ...replies }, port = 8000, options = {}) {
5
5
  let server = new Server();
6
6
 
7
7
  // alternate route handling
8
- let handleReply = reply => async (req, res) => {
8
+ let handleReply = (reply, options = {}) => async (req, res) => {
9
9
  let [status, headers, body] = typeof reply === 'function' ? await reply(req) : reply;
10
10
  if (!Buffer.isBuffer(body) && typeof body !== 'string') body = JSON.stringify(body);
11
+
12
+ if (options.noHeaders) {
13
+ return res.writeHead(status).end(body);
14
+ }
15
+ if (options.headersOverride) {
16
+ headers = { ...headers, ...options.headersOverride };
17
+ }
11
18
  return res.send(status, headers, body);
12
19
  };
13
20
 
14
21
  // map replies to alternate route handlers
15
- server.reply = (p, reply) => (replies[p] = handleReply(reply), null);
22
+ server.reply = (p, reply, options = {}) => (replies[p] = handleReply(reply, options), null);
16
23
  for (let [p, reply] of Object.entries(replies)) server.reply(p, reply);
17
24
  if (defaultReply) defaultReply = handleReply(defaultReply);
18
25
 
package/types/index.d.ts CHANGED
@@ -57,6 +57,7 @@ interface CommonSnapshotOptions {
57
57
  reshuffleInvalidTags?: boolean;
58
58
  devicePixelRatio?: number;
59
59
  scopeOptions?: ScopeOptions;
60
+ browsers?: string[];
60
61
  }
61
62
 
62
63
  export interface SnapshotOptions extends CommonSnapshotOptions {