@percy/core 1.31.0-alpha.3 → 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
@@ -26,6 +26,33 @@ export const configSchema = {
26
26
  snapshot: {
27
27
  type: 'object',
28
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
+ },
29
56
  properties: {
30
57
  widths: {
31
58
  type: 'array',
@@ -95,6 +122,14 @@ export const configSchema = {
95
122
  thTestCaseExecutionId: {
96
123
  type: 'string'
97
124
  },
125
+ browsers: {
126
+ type: 'array',
127
+ items: {
128
+ type: 'string',
129
+ minLength: 1
130
+ },
131
+ onlyWeb: true
132
+ },
98
133
  fullPage: {
99
134
  type: 'boolean',
100
135
  onlyAutomate: true
@@ -164,6 +199,88 @@ export const configSchema = {
164
199
  }
165
200
  }
166
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'
167
284
  }
168
285
  }
169
286
  },
@@ -221,6 +338,10 @@ export const configSchema = {
221
338
  minimum: 1,
222
339
  maximum: 30000
223
340
  },
341
+ scrollToBottom: {
342
+ type: 'boolean',
343
+ default: false
344
+ },
224
345
  disableCache: {
225
346
  type: 'boolean'
226
347
  },
@@ -310,6 +431,10 @@ export const configSchema = {
310
431
  },
311
432
  headless: {
312
433
  type: 'boolean'
434
+ },
435
+ closeBrowser: {
436
+ type: 'boolean',
437
+ default: true
313
438
  }
314
439
  }
315
440
  }
@@ -367,9 +492,21 @@ export const snapshotSchema = {
367
492
  thTestCaseExecutionId: {
368
493
  $ref: '/config/snapshot#/properties/thTestCaseExecutionId'
369
494
  },
495
+ browsers: {
496
+ $ref: '/config/snapshot#/properties/browsers'
497
+ },
370
498
  reshuffleInvalidTags: {
371
499
  $ref: '/config/snapshot#/properties/reshuffleInvalidTags'
372
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
+ },
373
510
  scopeOptions: {
374
511
  $ref: '/config/snapshot#/properties/scopeOptions'
375
512
  },
@@ -412,6 +549,9 @@ export const snapshotSchema = {
412
549
  },
413
550
  retry: {
414
551
  $ref: '/config/discovery#/properties/retry'
552
+ },
553
+ scrollToBottom: {
554
+ $ref: '/config/discovery#/properties/scrollToBottom'
415
555
  }
416
556
  }
417
557
  }
@@ -913,6 +1053,15 @@ export const comparisonSchema = {
913
1053
  ignoreElementsData: regionsSchema
914
1054
  }
915
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
+ },
916
1065
  consideredElementsData: {
917
1066
  type: 'object',
918
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 {
@@ -222,8 +224,12 @@ function processSnapshotResources({
222
224
  })));
223
225
  if (process.env.PERCY_GZIP) {
224
226
  for (let index = 0; index < resources.length; index++) {
225
- resources[index].content = Pako.gzip(resources[index].content);
226
- 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
+ }
227
233
  }
228
234
  }
229
235
  return {
@@ -323,6 +329,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
323
329
  yield page.eval((_, s) => window.__PERCY__.snapshot = s, snapshot);
324
330
  yield page.evaluate(snapshot.execute.afterNavigation);
325
331
  }
332
+ yield* maybeScrollToBottom(page, discovery);
326
333
 
327
334
  // Running before page idle since this will trigger many network calls
328
335
  // so need to run as early as possible. plus it is just reading urls from dom srcset
@@ -361,6 +368,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
361
368
  });
362
369
  }
363
370
  yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.afterResize);
371
+ yield* maybeScrollToBottom(page, discovery);
364
372
  }
365
373
  }
366
374
  if (capture && !snapshot.domSnapshot) {
@@ -397,7 +405,8 @@ export async function* discoverSnapshotResources(queue, options, callback) {
397
405
  let {
398
406
  snapshots,
399
407
  skipDiscovery,
400
- dryRun
408
+ dryRun,
409
+ checkAndUpdateConcurrency
401
410
  } = options;
402
411
  yield* yieldAll(snapshots.reduce((all, snapshot) => {
403
412
  debugSnapshotOptions(snapshot);
@@ -411,6 +420,10 @@ export async function* discoverSnapshotResources(queue, options, callback) {
411
420
  callback(dryRun ? snap : processSnapshotResources(snap));
412
421
  }
413
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();
414
427
  all.push(queue.push(snapshot, callback));
415
428
  }
416
429
  return all;
@@ -498,6 +511,12 @@ export function createDiscoveryQueue(percy) {
498
511
  return resource;
499
512
  },
500
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
+ }
501
520
  snapshot.resources.set(r.url, r);
502
521
  if (!snapshot.discovery.disableCache) {
503
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.3",
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.3",
47
- "@percy/config": "1.31.0-alpha.3",
48
- "@percy/dom": "1.31.0-alpha.3",
49
- "@percy/logger": "1.31.0-alpha.3",
50
- "@percy/webdriver-utils": "1.31.0-alpha.3",
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": "14ae0b4319c1c2ae28729d56cba3b159e80386a8"
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 {