@percy/core 1.31.9-beta.3 → 1.31.9-beta.5

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
@@ -97,6 +97,7 @@ export function createPercyServer(percy, port) {
97
97
  // This will only be used if width is not passed in options
98
98
  config: percy.config.snapshot.widths
99
99
  },
100
+ deviceDetails: percy.deviceDetails || [],
100
101
  success: true,
101
102
  type: percy.client.tokenType()
102
103
  });
@@ -249,11 +250,12 @@ export function createPercyServer(percy, port) {
249
250
  } else if (cmd === 'config') {
250
251
  var _body$mobile;
251
252
  percy.config.snapshot.widths = body.config;
252
- percy.deviceDetails = (_body$mobile = body.mobile) === null || _body$mobile === void 0 ? void 0 : _body$mobile.map(w => {
253
+ // Support setting deviceDetails directly or deriving from mobile widths
254
+ percy.deviceDetails = body.deviceDetails || ((_body$mobile = body.mobile) === null || _body$mobile === void 0 ? void 0 : _body$mobile.map(w => {
253
255
  return {
254
256
  width: w
255
257
  };
256
- });
258
+ }));
257
259
  percy.config.snapshot.responsiveSnapshotCapture = !!body.responsive;
258
260
  percy.config.percy.deferUploads = !!body.deferUploads;
259
261
  } else if (cmd === 'error' || cmd === 'disconnect') {
package/dist/config.js CHANGED
@@ -526,6 +526,10 @@ export const configSchema = {
526
526
  error: 'must not include a protocol'
527
527
  }]
528
528
  }
529
+ },
530
+ autoConfigureAllowedHostnames: {
531
+ type: 'boolean',
532
+ default: true
529
533
  }
530
534
  }
531
535
  }
package/dist/discovery.js CHANGED
@@ -58,6 +58,7 @@ function debugSnapshotOptions(snapshot) {
58
58
  debugProp(snapshot, 'ignoreCanvasSerializationErrors');
59
59
  debugProp(snapshot, 'ignoreStyleSheetSerializationErrors');
60
60
  debugProp(snapshot, 'pseudoClassEnabledElements', JSON.stringify);
61
+ debugProp(snapshot, 'discovery.autoConfigureAllowedHostnames');
61
62
  if (Array.isArray(snapshot.domSnapshot)) {
62
63
  debugProp(snapshot, 'domSnapshot.0.userAgent');
63
64
  } else {
@@ -498,6 +499,10 @@ export function createDiscoveryQueue(percy) {
498
499
  ...snapshot.meta,
499
500
  snapshotURL: snapshot.url
500
501
  },
502
+ // pass domain validation context for auto-allowlisting
503
+ domainValidation: percy.domainValidation,
504
+ client: percy.client,
505
+ autoConfigureAllowedHostnames: snapshot.discovery.autoConfigureAllowedHostnames,
501
506
  // enable network inteception
502
507
  intercept: {
503
508
  enableJavaScript: snapshot.enableJavaScript,
package/dist/network.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { request as makeRequest } from '@percy/client/utils';
2
2
  import logger from '@percy/logger';
3
3
  import mime from 'mime-types';
4
- import { DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType } from './utils.js';
4
+ import { DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation } from './utils.js';
5
5
  const MAX_RESOURCE_SIZE = 25 * 1024 ** 2 * 0.63; // 25MB, 0.63 factor for accounting for base64 encoding
6
6
  const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
7
7
  const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
@@ -41,6 +41,10 @@ export class Network {
41
41
  this.fontDomains = options.fontDomains || [];
42
42
  this.intercept = options.intercept;
43
43
  this.meta = options.meta;
44
+ // domain validation context for auto-allowlisting
45
+ this.domainValidation = options.domainValidation;
46
+ this.client = options.client;
47
+ this.autoConfigureAllowedHostnames = options.autoConfigureAllowedHostnames ?? true;
44
48
  this._initializeNetworkIdleWaitTimeout();
45
49
  }
46
50
  watch(session) {
@@ -446,6 +450,47 @@ function originURL(request) {
446
450
  return normalizeURL((request.redirectChain[0] || request).url);
447
451
  }
448
452
 
453
+ // Validate domain for auto-allowlisting feature
454
+ // Only validates domains that returned 200 status
455
+ async function validateDomainForAllowlist(network, hostname, url, statusCode) {
456
+ const {
457
+ domainValidation,
458
+ client,
459
+ autoConfigureAllowedHostnames
460
+ } = network;
461
+ const {
462
+ autoConfiguredHosts,
463
+ processedHosts,
464
+ pending,
465
+ workerUrl,
466
+ processedDomains
467
+ } = domainValidation;
468
+
469
+ // Skip validation if autoConfigureAllowedHostnames is disabled
470
+ if (!autoConfigureAllowedHostnames || statusCode !== 200 || !workerUrl) {
471
+ return false;
472
+ }
473
+ if (autoConfiguredHosts.has(hostname)) {
474
+ // Adding autoConfigured hosts to processedHosts so that we can track the latest
475
+ // usage of autoConfigured hosts for cleanup later.
476
+ processedHosts.add(hostname);
477
+ return true;
478
+ }
479
+
480
+ // If validation is already pending for this hostname, await the same promise (mutex)
481
+ if (pending.has(hostname)) {
482
+ return pending.get(hostname);
483
+ }
484
+ if (processedDomains.has(hostname)) {
485
+ return processedDomains.get(hostname);
486
+ }
487
+
488
+ // Perform external validation using worker URL from API
489
+ const validationPromise = executeDomainValidation(network, hostname, url, domainValidation, client, workerUrl);
490
+ pending.set(hostname, validationPromise);
491
+ return validationPromise;
492
+ }
493
+
449
494
  // Send a response for a given request, responding with cached resources when able
450
495
  async function sendResponseResource(network, request, session) {
451
496
  let {
@@ -594,7 +639,21 @@ async function saveResponseResource(network, request, session) {
594
639
  var _mimeType;
595
640
  // Don't rename the below log line as it is used in getting network logs in api
596
641
  log.debug(`Processing resource: ${url}`, meta);
642
+
643
+ // Check if domain should be captured via auto-validation
644
+ let hostname = new URL(url).host;
645
+
646
+ // Check if domain is allowed via manual config or auto-validation
597
647
  let shouldCapture = response && hostnameMatches(allowedHostnames, url);
648
+
649
+ // Also capture if domain is auto-validated as allowed (autoConfiguredHosts or processedHosts)
650
+ if (!shouldCapture && hostname) {
651
+ const domainResult = await validateDomainForAllowlist(network, hostname, url, response.status);
652
+ if (domainResult) {
653
+ shouldCapture = true;
654
+ log.debug(`- Capturing auto-validated domain: ${hostname}`, meta);
655
+ }
656
+ }
598
657
  let body = shouldCapture && (await response.buffer());
599
658
 
600
659
  // Don't rename the below log line as it is used in getting network logs in api
package/dist/percy.js CHANGED
@@ -115,6 +115,21 @@ export class Percy {
115
115
  this.monitoringCheckLastExecutedAt = null;
116
116
  this.sdkInfoDisplayed = false;
117
117
 
118
+ // Domain validation state for auto domain allow-listing
119
+ this.domainValidation = {
120
+ autoConfiguredHosts: new Set(),
121
+ // Domains from project config
122
+ processedHosts: new Set(),
123
+ // Newly discovered allowed domains this session
124
+ newErrorHosts: new Set(),
125
+ // Newly discovered blocked domains this session
126
+ pending: new Map(),
127
+ // Domain -> Promise (deduplication)
128
+ workerUrl: null,
129
+ // Domain validator worker URL from API
130
+ processedDomains: new Map()
131
+ };
132
+
118
133
  // generator methods are wrapped to autorun and return promises
119
134
  for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot', 'upload']) {
120
135
  // the original generator can be referenced with percy.yield.<method>
@@ -216,6 +231,13 @@ export class Percy {
216
231
  // Not awaiting proxy check as this can be asynchronous when not enabled
217
232
  const detectProxy = detectSystemProxyAndLog(this.config.percy.useSystemProxy);
218
233
  if (this.config.percy.useSystemProxy) await detectProxy;
234
+
235
+ // Load domain config early if domain validation is enabled
236
+ // This ensures pre-approved domains are loaded before discovery starts
237
+ if (!this.skipUploads && !this.skipDiscovery) {
238
+ await this.loadAutoConfiguredHostnames();
239
+ }
240
+
219
241
  // start the snapshots queue immediately when not delayed or deferred
220
242
  if (!this.delayUploads && !this.deferUploads) yield _classPrivateFieldGet(_snapshots, this).start();
221
243
  // do not start the discovery queue when not needed
@@ -326,6 +348,11 @@ export class Percy {
326
348
  this.log.info(info('Found', _classPrivateFieldGet(_snapshots, this).size));
327
349
  }
328
350
 
351
+ // Save domain validation config before closing
352
+ if (!this.skipUploads && !this.skipDiscovery) {
353
+ await this.saveHostnamesToAutoConfigure();
354
+ }
355
+
329
356
  // close server and end queues
330
357
  await ((_this$server3 = this.server) === null || _this$server3 === void 0 ? void 0 : _this$server3.close());
331
358
  await _classPrivateFieldGet(_discovery, this).end();
@@ -658,6 +685,84 @@ export class Percy {
658
685
  this.log.warn('Could not send the builds logs');
659
686
  }
660
687
  }
688
+
689
+ // This method fetched auto configured hostnames from project settings for asset discovery
690
+ async loadAutoConfiguredHostnames() {
691
+ // Skip if autoConfigureAllowedHostnames is disabled
692
+ if (this.config.discovery.autoConfigureAllowedHostnames === false) {
693
+ this.log.debug('Auto configure allowed hostnames is disabled, skipping load');
694
+ return;
695
+ }
696
+ try {
697
+ this.log.debug('Fetching auto configured hostnames from project settings');
698
+ const {
699
+ workerUrl,
700
+ domainConfig
701
+ } = await this.client.getProjectDomainConfig();
702
+
703
+ // Store domain validator worker URL from API
704
+ if (workerUrl) {
705
+ this.domainValidation.workerUrl = workerUrl;
706
+ this.log.debug(`Domain validation Worker URL set to ${this.domainValidation.workerUrl}`);
707
+ } else {
708
+ this.log.debug('No Domain validation Worker URL found for project');
709
+ }
710
+ if (domainConfig) {
711
+ var _domainConfig$allowed;
712
+ // Populate pre-approved domains
713
+ if ((_domainConfig$allowed = domainConfig['allowed-domains']) !== null && _domainConfig$allowed !== void 0 && _domainConfig$allowed.length) {
714
+ domainConfig['allowed-domains'].forEach(domain => {
715
+ this.domainValidation.autoConfiguredHosts.add(domain);
716
+ });
717
+ this.log.debug(`Auto configured hosts: ${JSON.stringify(Array.from(this.domainValidation.autoConfiguredHosts))}`);
718
+ }
719
+ } else {
720
+ this.log.debug('No existing auto configured hostnames found for project');
721
+ }
722
+ } catch (error) {
723
+ this.log.debug(`Could not fetch auto configured hostnames (${error.message})`);
724
+ }
725
+ }
726
+
727
+ // Save newly discovered auto configured hostnames back to project
728
+ async saveHostnamesToAutoConfigure() {
729
+ // Skip if autoConfigureAllowedHostnames is disabled
730
+ if (this.config.discovery.autoConfigureAllowedHostnames === false) {
731
+ this.log.debug('Auto configure allowed hostnames is disabled, skipping save');
732
+ return;
733
+ }
734
+ const {
735
+ processedHosts,
736
+ newErrorHosts,
737
+ autoConfiguredHosts
738
+ } = this.domainValidation;
739
+
740
+ // Only save if there are new domains discovered in this session
741
+ if (processedHosts.size === 0 && newErrorHosts.size === 0) {
742
+ return;
743
+ }
744
+
745
+ // Check if there are truly new domains not already in autoConfiguredHosts
746
+ const newAllowedDomains = Array.from(processedHosts).filter(domain => !autoConfiguredHosts.has(domain));
747
+ const hasNewDomains = newAllowedDomains.length > 0 || newErrorHosts.size > 0;
748
+ try {
749
+ var _this$build7;
750
+ await this.client.updateProjectDomainConfig({
751
+ buildId: (_this$build7 = this.build) === null || _this$build7 === void 0 ? void 0 : _this$build7.id,
752
+ allowedDomains: Array.from(processedHosts),
753
+ errorDomains: Array.from(newErrorHosts)
754
+ });
755
+ if (hasNewDomains) {
756
+ const errorDomainsArray = Array.from(newErrorHosts);
757
+ const allowedDomainsStr = newAllowedDomains.length > 0 ? `Saved new allowed domains: ${newAllowedDomains.join(', ')}` : '';
758
+ const errorDomainsStr = errorDomainsArray.length > 0 ? `${allowedDomainsStr ? ' and ' : 'Saved '}error domains: ${errorDomainsArray.join(', ')}` : '';
759
+ this.log.info(`${allowedDomainsStr}${errorDomainsStr}`);
760
+ }
761
+ } catch (error) {
762
+ this.log.warn(`Failed to save auto configured hostnames - ${error.message}`);
763
+ this.log.debug(error);
764
+ }
765
+ }
661
766
  }
662
767
  function _displaySuggestionLogs(suggestions, options = {}) {
663
768
  if (!(suggestions !== null && suggestions !== void 0 && suggestions.length)) return;
package/dist/snapshot.js CHANGED
@@ -153,7 +153,8 @@ function getSnapshotOptions(options, {
153
153
  userAgent: config.discovery.userAgent,
154
154
  retry: config.discovery.retry,
155
155
  scrollToBottom: config.discovery.scrollToBottom,
156
- fontDomains: config.discovery.fontDomains
156
+ fontDomains: config.discovery.fontDomains,
157
+ autoConfigureAllowedHostnames: config.discovery.autoConfigureAllowedHostnames
157
158
  }
158
159
  }, options], (path, prev, next) => {
159
160
  var _next, _next2, _next3;
package/dist/utils.js CHANGED
@@ -81,6 +81,47 @@ export function handleIncorrectFontMimeType(urlObj, mimeType, body, userConfigur
81
81
  return mimeType;
82
82
  }
83
83
 
84
+ // Domain validation timeout constant
85
+ const DOMAIN_VALIDATION_TIMEOUT = 5000;
86
+
87
+ // Executes domain validation via external service
88
+ export async function executeDomainValidation(network, hostname, url, domainValidation, client, workerUrl) {
89
+ const {
90
+ processedHosts,
91
+ newErrorHosts,
92
+ processedDomains,
93
+ pending
94
+ } = domainValidation;
95
+ try {
96
+ network.log.debug(`Domain validation: Validating ${hostname} via external service`, network.meta);
97
+ const result = await client.validateDomain(url, {
98
+ validationEndpoint: workerUrl,
99
+ timeout: DOMAIN_VALIDATION_TIMEOUT
100
+ });
101
+
102
+ // Worker returns 'accessible' field, not 'allowed'
103
+ if (result !== null && result !== void 0 && result.error) {
104
+ newErrorHosts.add(hostname);
105
+ network.log.debug(`Domain validation: ${hostname} validated as BLOCKED - ${result === null || result === void 0 ? void 0 : result.reason}`, network.meta);
106
+ processedDomains.set(hostname, false);
107
+ return false;
108
+ } else if (!(result !== null && result !== void 0 && result.accessible)) {
109
+ processedHosts.add(hostname);
110
+ network.log.debug(`Domain validation: ${hostname} validated as ALLOWED`, network.meta);
111
+ processedDomains.set(hostname, true);
112
+ return true;
113
+ }
114
+ return false;
115
+ } catch (error) {
116
+ // On error, default to allowing (fail-open for better UX)
117
+ network.log.warn(`Domain validation: Failed to validate ${hostname} - ${error.message}`, network.meta);
118
+ processedDomains.set(hostname, false);
119
+ return false;
120
+ } finally {
121
+ pending.delete(hostname);
122
+ }
123
+ }
124
+
84
125
  /* istanbul ignore next: tested, but coverage is stripped */
85
126
  // Returns the body for automateScreenshot in structure
86
127
  export function percyAutomateRequestHandler(req, percy) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.31.9-beta.3",
3
+ "version": "1.31.9-beta.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,12 +43,12 @@
43
43
  "test:types": "tsd"
44
44
  },
45
45
  "dependencies": {
46
- "@percy/client": "1.31.9-beta.3",
47
- "@percy/config": "1.31.9-beta.3",
48
- "@percy/dom": "1.31.9-beta.3",
49
- "@percy/logger": "1.31.9-beta.3",
50
- "@percy/monitoring": "1.31.9-beta.3",
51
- "@percy/webdriver-utils": "1.31.9-beta.3",
46
+ "@percy/client": "1.31.9-beta.5",
47
+ "@percy/config": "1.31.9-beta.5",
48
+ "@percy/dom": "1.31.9-beta.5",
49
+ "@percy/logger": "1.31.9-beta.5",
50
+ "@percy/monitoring": "1.31.9-beta.5",
51
+ "@percy/webdriver-utils": "1.31.9-beta.5",
52
52
  "content-disposition": "^0.5.4",
53
53
  "cross-spawn": "^7.0.3",
54
54
  "extract-zip": "^2.0.1",
@@ -61,5 +61,5 @@
61
61
  "ws": "^8.17.1",
62
62
  "yaml": "^2.4.1"
63
63
  },
64
- "gitHead": "38b33a40a51939de5d35df38b7652840e2bd6d9c"
64
+ "gitHead": "0fa4c3c4560b63cf50ed61a93a8772c745c70b6e"
65
65
  }
package/types/index.d.ts CHANGED
@@ -20,7 +20,8 @@ interface DiscoveryOptions {
20
20
  disableCache?: boolean;
21
21
  captureMockedServiceWorker?: boolean;
22
22
  captureSrcset?: boolean;
23
- devicePixelRatio?: number;
23
+ devicePixelRatio?: number;
24
+ autoConfigureAllowedHostnames?: boolean;
24
25
  }
25
26
 
26
27
  interface ScopeOptions {