@percy/core 1.31.9-beta.5 → 1.31.10-alpha.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
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path, { dirname, resolve } from 'path';
3
3
  import logger from '@percy/logger';
4
4
  import { normalize } from '@percy/config/utils';
5
- import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler } from './utils.js';
5
+ import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler, computeResponsiveWidths } from './utils.js';
6
6
  import WebdriverUtils from '@percy/webdriver-utils';
7
7
  import { handleSyncJob } from './snapshot.js';
8
8
  // Previously, we used `createRequire(import.meta.url).resolve` to resolve the path to the module.
@@ -102,6 +102,22 @@ export function createPercyServer(percy, port) {
102
102
  type: percy.client.tokenType()
103
103
  });
104
104
  })
105
+ // compute widths configuration with heights
106
+ .route('get', '/percy/widths-config', (req, res) => {
107
+ // Parse widths from query parameters (e.g., ?widths=375,1280)
108
+ const widthsParam = req.url.searchParams.get('widths');
109
+ const userPassedWidths = widthsParam ? widthsParam.split(',').map(w => parseInt(w.trim(), 10)).filter(w => !isNaN(w)) : [];
110
+ const eligibleWidths = {
111
+ mobile: percy.deviceDetails ? percy.deviceDetails.map(d => d.width) : [],
112
+ config: percy.config.snapshot.widths
113
+ };
114
+ const deviceDetails = percy.deviceDetails || [];
115
+ const widths = computeResponsiveWidths(userPassedWidths, eligibleWidths, deviceDetails);
116
+ return res.json(200, {
117
+ widths,
118
+ success: true
119
+ });
120
+ })
105
121
  // get or set config options
106
122
  .route(['get', 'post'], '/percy/config', async (req, res) => res.json(200, {
107
123
  config: req.body ? percy.set(req.body) : percy.config,
package/dist/config.js CHANGED
@@ -905,6 +905,44 @@ export const snapshotSchema = {
905
905
  items: {
906
906
  type: 'string'
907
907
  }
908
+ },
909
+ corsIframes: {
910
+ type: 'array',
911
+ items: {
912
+ type: 'object',
913
+ additionalProperties: false,
914
+ properties: {
915
+ frameUrl: {
916
+ type: 'string',
917
+ description: 'The URL of the cross-origin iframe'
918
+ },
919
+ iframeData: {
920
+ type: 'object',
921
+ additionalProperties: false,
922
+ properties: {
923
+ percyElementId: {
924
+ type: 'string',
925
+ description: 'Unique identifier for the iframe element in the DOM'
926
+ }
927
+ }
928
+ },
929
+ iframeSnapshot: {
930
+ type: 'object',
931
+ required: ['html'],
932
+ additionalProperties: false,
933
+ properties: {
934
+ html: {
935
+ type: 'string',
936
+ description: 'Serialized HTML content of the iframe'
937
+ },
938
+ resources: {
939
+ $ref: '/snapshot/dom#/properties/domSnapshot/oneOf/1/properties/resources',
940
+ description: 'Resources discovered within the iframe'
941
+ }
942
+ }
943
+ }
944
+ }
945
+ }
908
946
  }
909
947
  }
910
948
  }, {
package/dist/percy.js CHANGED
@@ -13,7 +13,7 @@ import logger from '@percy/logger';
13
13
  import { getProxy } from '@percy/client/utils';
14
14
  import Browser from './browser.js';
15
15
  import Pako from 'pako';
16
- import { base64encode, generatePromise, yieldAll, yieldTo, redactSecrets, detectSystemProxyAndLog, checkSDKVersion } from './utils.js';
16
+ import { base64encode, generatePromise, yieldAll, yieldTo, redactSecrets, detectSystemProxyAndLog, checkSDKVersion, processCorsIframes } from './utils.js';
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';
@@ -449,6 +449,10 @@ export class Percy {
449
449
 
450
450
  // validate options and add client & environment info
451
451
  options = validateSnapshotOptions(options);
452
+ // process CORS iframes in domSnapshot before validation
453
+ if (options.domSnapshot) {
454
+ options.domSnapshot = processCorsIframes(options.domSnapshot);
455
+ }
452
456
  this.client.addClientInfo(options.clientInfo);
453
457
  this.client.addEnvironmentInfo(options.environmentInfo);
454
458
 
package/dist/utils.js CHANGED
@@ -26,6 +26,87 @@ export function normalizeURL(url) {
26
26
  return `${protocol}//${host}${pathname}${search}`;
27
27
  }
28
28
 
29
+ // Appends a search parameter to a URL. Returns the original URL if the value is not provided.
30
+ export function appendUrlSearchParam(urlString, key, value) {
31
+ if (!value) return urlString;
32
+ try {
33
+ const url = new URL(urlString);
34
+ url.searchParams.set(key, String(value));
35
+ return url.toString();
36
+ } catch (error) {
37
+ logger('core:utils').debug(`Failed to append search param to URL: ${urlString}`, error);
38
+ return urlString;
39
+ }
40
+ }
41
+
42
+ // Process CORS iframes in a single domSnapshot object
43
+ export function processCorsIframesInDomSnapshot(domSnapshot) {
44
+ var _domSnapshot$corsIfra;
45
+ if (!(domSnapshot !== null && domSnapshot !== void 0 && (_domSnapshot$corsIfra = domSnapshot.corsIframes) !== null && _domSnapshot$corsIfra !== void 0 && _domSnapshot$corsIfra.length)) {
46
+ return domSnapshot;
47
+ }
48
+ const crossOriginFrames = domSnapshot.corsIframes;
49
+
50
+ // Initialize resources array if it doesn't exist
51
+ if (!domSnapshot.resources) {
52
+ domSnapshot.resources = [];
53
+ }
54
+ for (const frame of crossOriginFrames) {
55
+ const {
56
+ iframeData,
57
+ iframeSnapshot,
58
+ frameUrl
59
+ } = frame;
60
+
61
+ // Validate required fields and skip malformed entries
62
+ if (!frameUrl || !(iframeSnapshot !== null && iframeSnapshot !== void 0 && iframeSnapshot.html)) {
63
+ logger('core:utils').debug('Skipping malformed corsIframes entry: missing frameUrl or iframeSnapshot.html', frame);
64
+ continue;
65
+ }
66
+
67
+ // width is only passed in case of responsiveSnapshotCapture
68
+ // Build frame URL with width parameter if available
69
+ const frameUrlWithWidth = domSnapshot.width ? appendUrlSearchParam(frameUrl, 'percy_width', domSnapshot.width) : frameUrl;
70
+
71
+ // Add iframe snapshot resources to main resources
72
+ if (iframeSnapshot !== null && iframeSnapshot !== void 0 && iframeSnapshot.resources) {
73
+ domSnapshot.resources.push(...iframeSnapshot.resources);
74
+ }
75
+
76
+ // Create a new resource for the iframe's HTML
77
+ const iframeResource = {
78
+ url: frameUrlWithWidth,
79
+ content: iframeSnapshot.html,
80
+ mimetype: 'text/html'
81
+ };
82
+ domSnapshot.resources.push(iframeResource);
83
+
84
+ // Update iframe src attribute in HTML
85
+ if (iframeData !== null && iframeData !== void 0 && iframeData.percyElementId) {
86
+ const escapedId = iframeData.percyElementId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
88
+ const regex = new RegExp(`(<iframe[^>]*data-percy-element-id=["']${escapedId}["'][^>]*>)`);
89
+ const match = domSnapshot.html.match(regex);
90
+ /* istanbul ignore next: iframe matching logic depends on DOM structure */
91
+ if (match) {
92
+ const iframeTag = match[1];
93
+ const newIframeTag = iframeTag.replace(/src="[^"]*"/i, `src="${frameUrlWithWidth}"`);
94
+ domSnapshot.html = domSnapshot.html.replace(iframeTag, newIframeTag);
95
+ }
96
+ }
97
+ }
98
+ return domSnapshot;
99
+ }
100
+
101
+ // Process CORS iframes - handles both single object and array of domSnapshots
102
+ export function processCorsIframes(domSnapshot) {
103
+ if (!domSnapshot) return domSnapshot;
104
+ if (Array.isArray(domSnapshot)) {
105
+ return domSnapshot.map(snap => processCorsIframesInDomSnapshot(snap));
106
+ }
107
+ return processCorsIframesInDomSnapshot(domSnapshot);
108
+ }
109
+
29
110
  /**
30
111
  * Detects font MIME type from file content by checking magic bytes.
31
112
  * Handles string-based signatures (WOFF, OTTO) and binary signatures (TTF).
@@ -737,4 +818,43 @@ export async function checkSDKVersion(clientInfo) {
737
818
  } catch (error) {
738
819
  log.debug('Could not check SDK version', error);
739
820
  }
821
+ }
822
+
823
+ /**
824
+ * Computes widths configuration with heights for responsive snapshot capture
825
+ * @param {Array<number>} userPassedWidths - Widths passed by the user
826
+ * @param {Object} eligibleWidths - Object containing mobile and config widths
827
+ * @param {Array<Object>} deviceDetails - Array of device objects with width and height
828
+ * @returns {Array<Object>} Array of width objects sorted in ascending order
829
+ */
830
+ export function computeResponsiveWidths(userPassedWidths, eligibleWidths, deviceDetails) {
831
+ const widthHeightMap = new Map();
832
+
833
+ // Add mobile widths with their associated heights from deviceDetails
834
+ if (eligibleWidths.mobile.length !== 0) {
835
+ eligibleWidths.mobile.forEach(width => {
836
+ if (!widthHeightMap.has(width)) {
837
+ const deviceInfo = deviceDetails.find(device => device.width === width);
838
+ if (deviceInfo !== null && deviceInfo !== void 0 && deviceInfo.height) {
839
+ widthHeightMap.set(width, {
840
+ width,
841
+ height: deviceInfo.height
842
+ });
843
+ }
844
+ }
845
+ });
846
+ }
847
+
848
+ // Add user passed or config widths without height
849
+ // If a width exists in both mobile and user-passed/config, user-passed/config takes precedence (without height)
850
+ // This ensures consistency with percy-storybook SDK behavior
851
+ const otherWidths = userPassedWidths.length !== 0 ? userPassedWidths : eligibleWidths.config;
852
+ otherWidths.forEach(width => {
853
+ widthHeightMap.set(width, {
854
+ width
855
+ });
856
+ });
857
+
858
+ // Convert to array and sort by width in ascending order
859
+ return Array.from(widthHeightMap.values()).sort((a, b) => a.width - b.width);
740
860
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.31.9-beta.5",
3
+ "version": "1.31.10-alpha.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": "beta"
12
+ "tag": "alpha"
13
13
  },
14
14
  "engines": {
15
15
  "node": ">=14"
@@ -43,12 +43,12 @@
43
43
  "test:types": "tsd"
44
44
  },
45
45
  "dependencies": {
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",
46
+ "@percy/client": "1.31.10-alpha.0",
47
+ "@percy/config": "1.31.10-alpha.0",
48
+ "@percy/dom": "1.31.10-alpha.0",
49
+ "@percy/logger": "1.31.10-alpha.0",
50
+ "@percy/monitoring": "1.31.10-alpha.0",
51
+ "@percy/webdriver-utils": "1.31.10-alpha.0",
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": "0fa4c3c4560b63cf50ed61a93a8772c745c70b6e"
64
+ "gitHead": "9ee22836105a578d5f602d376a660c349bf6da0c"
65
65
  }