@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 +4 -2
- package/dist/config.js +4 -0
- package/dist/discovery.js +5 -0
- package/dist/network.js +60 -1
- package/dist/percy.js +105 -0
- package/dist/snapshot.js +2 -1
- package/dist/utils.js +41 -0
- package/package.json +8 -8
- package/types/index.d.ts +2 -1
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
|
-
|
|
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
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
|
+
"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.
|
|
47
|
-
"@percy/config": "1.31.9-beta.
|
|
48
|
-
"@percy/dom": "1.31.9-beta.
|
|
49
|
-
"@percy/logger": "1.31.9-beta.
|
|
50
|
-
"@percy/monitoring": "1.31.9-beta.
|
|
51
|
-
"@percy/webdriver-utils": "1.31.9-beta.
|
|
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": "
|
|
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 {
|