@percy/core 1.31.9-beta.2 → 1.31.9-beta.4

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/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;
@@ -382,12 +383,19 @@ export function createSnapshotsQueue(percy) {
382
383
  });
383
384
  let url = data.attributes['web-url'];
384
385
  let number = data.attributes['build-number'];
386
+ let usageWarning = data.attributes['usage-warning'];
385
387
  percy.client.buildType = (_data$attributes = data.attributes) === null || _data$attributes === void 0 ? void 0 : _data$attributes.type;
386
388
  Object.assign(build, {
387
389
  id: data.id,
388
390
  url,
389
391
  number
390
392
  });
393
+
394
+ // Display usage warning if present
395
+ if (usageWarning) {
396
+ percy.log.warn(usageWarning);
397
+ }
398
+
391
399
  // immediately run the queue if not delayed or deferred
392
400
  if (!percy.delayUploads && !percy.deferUploads) queue.run();
393
401
  } catch (err) {
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.2",
3
+ "version": "1.31.9-beta.4",
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.2",
47
- "@percy/config": "1.31.9-beta.2",
48
- "@percy/dom": "1.31.9-beta.2",
49
- "@percy/logger": "1.31.9-beta.2",
50
- "@percy/monitoring": "1.31.9-beta.2",
51
- "@percy/webdriver-utils": "1.31.9-beta.2",
46
+ "@percy/client": "1.31.9-beta.4",
47
+ "@percy/config": "1.31.9-beta.4",
48
+ "@percy/dom": "1.31.9-beta.4",
49
+ "@percy/logger": "1.31.9-beta.4",
50
+ "@percy/monitoring": "1.31.9-beta.4",
51
+ "@percy/webdriver-utils": "1.31.9-beta.4",
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": "34c4a2808d110f13a47a863b10b2105ab71eb4ea"
64
+ "gitHead": "2fc7e5cc27ba04e9d0d47fd267c00c756446e134"
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 {