@percy/dom 1.31.4 → 1.31.5-beta.1

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.
Files changed (2) hide show
  1. package/dist/bundle.js +213 -21
  2. package/package.json +3 -3
package/dist/bundle.js CHANGED
@@ -7,7 +7,7 @@
7
7
  process.env.__PERCY_BROWSERIFIED__ = true;
8
8
 
9
9
  // Creates a resource object from an element's unique ID and data URL
10
- function resourceFromDataURL(uid, dataURL) {
10
+ function resourceFromDataURL$1(uid, dataURL) {
11
11
  // split dataURL into desired parts
12
12
  let [data, content] = dataURL.split(',');
13
13
  let [, mimetype] = data.split(':');
@@ -142,8 +142,8 @@
142
142
  (_dom$querySelector = dom.querySelector('head')) === null || _dom$querySelector === void 0 || _dom$querySelector.prepend($base);
143
143
  }
144
144
 
145
- // Recursively serializes iframe documents into srcdoc attributes.
146
- function serializeFrames({
145
+ // Recursively serializes iframe documents into srcdoc attributes (ASYNC).
146
+ async function serializeFrames({
147
147
  dom,
148
148
  clone,
149
149
  warnings,
@@ -170,8 +170,8 @@
170
170
  // the frame has yet to load and wasn't built with js, it is unsafe to serialize
171
171
  if (!builtWithJs && !frame.contentWindow.performance.timing.loadEventEnd) continue;
172
172
 
173
- // recersively serialize contents
174
- let serialized = serializeDOM({
173
+ // recersively serialize contents (AWAIT async call)
174
+ let serialized = await serializeDOM({
175
175
  domTransformation: setBaseURI,
176
176
  dom: frame.contentDocument,
177
177
  enableJavaScript,
@@ -196,7 +196,7 @@
196
196
  }
197
197
 
198
198
  // Returns a mostly random uid.
199
- function uid() {
199
+ function uid$1() {
200
200
  return `_${Math.random().toString(36).substr(2, 9)}`;
201
201
  }
202
202
  function markElement(domElement, disableShadowDOM, forceShadowAsLightDOM) {
@@ -204,7 +204,7 @@
204
204
  // Mark elements that are to be serialized later with a data attribute.
205
205
  if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style'].includes((_domElement$tagName = domElement.tagName) === null || _domElement$tagName === void 0 ? void 0 : _domElement$tagName.toLowerCase())) {
206
206
  if (!domElement.getAttribute('data-percy-element-id')) {
207
- domElement.setAttribute('data-percy-element-id', uid());
207
+ domElement.setAttribute('data-percy-element-id', uid$1());
208
208
  }
209
209
  }
210
210
 
@@ -215,7 +215,7 @@
215
215
  domElement.setAttribute('data-percy-shadow-host', '');
216
216
  }
217
217
  if (!domElement.getAttribute('data-percy-element-id')) {
218
- domElement.setAttribute('data-percy-element-id', uid());
218
+ domElement.setAttribute('data-percy-element-id', uid$1());
219
219
  }
220
220
  }
221
221
  }
@@ -250,7 +250,7 @@
250
250
  }
251
251
  function createStyleResource(styleSheet) {
252
252
  const styles = Array.from(styleSheet.cssRules).map(cssRule => cssRule.cssText).join('\n');
253
- let resource = resourceFromText(uid(), 'text/css', styles);
253
+ let resource = resourceFromText(uid$1(), 'text/css', styles);
254
254
  return resource;
255
255
  }
256
256
  function serializeCSSOM(ctx) {
@@ -374,9 +374,11 @@
374
374
  img.setAttribute('data-percy-canvas-serialized', '');
375
375
  img.setAttribute('data-percy-serialized-attribute-src', imageUrl);
376
376
 
377
- // set the maxWidth only if it was set on the canvas
378
- if (canvas.style.maxWidth) {
379
- img.style.maxWidth = canvas.style.maxWidth;
377
+ // set a default max width to account for canvases that might resize with JS
378
+ // Check if width is "static" (fixed pixels) vs "dynamic" (%, vw, etc.)
379
+ const hasStaticWidth = canvas.style.width && canvas.style.width.match(/^\d+px$/);
380
+ if (!hasStaticWidth) {
381
+ img.style.maxWidth = img.style.maxWidth || '100%';
380
382
  }
381
383
 
382
384
  // insert the image into the cloned DOM and remove the cloned canvas element
@@ -414,7 +416,7 @@
414
416
  if (!dataUrl || dataUrl === 'data:,') continue;
415
417
 
416
418
  // create a resource for the canvas data
417
- let resource = resourceFromDataURL(percyElementId, dataUrl);
419
+ let resource = resourceFromDataURL$1(percyElementId, dataUrl);
418
420
  resources.add(resource);
419
421
 
420
422
  // create and insert image element with the resource URL
@@ -465,7 +467,7 @@
465
467
  if (!dataUrl || dataUrl === 'data:,') continue;
466
468
 
467
469
  // create a resource from the serialized data url
468
- let resource = resourceFromDataURL(videoId, dataUrl);
470
+ let resource = resourceFromDataURL$1(videoId, dataUrl);
469
471
  resources.add(resource);
470
472
 
471
473
  // use a data attribute to avoid making a real request
@@ -476,6 +478,188 @@
476
478
  }
477
479
  }
478
480
 
481
+ /* global fetch, FileReader */
482
+
483
+ // Helper: Generate unique ID for resources
484
+ function uid() {
485
+ return `_${Math.random().toString(36).substr(2, 9)}`;
486
+ }
487
+
488
+ // Helper: Create resource from data URL
489
+ function resourceFromDataURL(id, dataURL) {
490
+ let [data, content] = dataURL.split(',');
491
+ let [, mimetype] = data.split(':');
492
+ [mimetype] = mimetype.split(';');
493
+ let [, ext] = mimetype.split('/');
494
+ let path = `/__serialized__/${id}.${ext}`;
495
+ let url = new URL(path, document.URL).toString();
496
+ return {
497
+ url,
498
+ content,
499
+ mimetype
500
+ };
501
+ }
502
+
503
+ // Find all blob URLs in the DOM
504
+ function findAllBlobUrls(dom) {
505
+ const blobUrls = [];
506
+
507
+ // Find blob URLs in src attributes (img, video, audio, source, iframe)
508
+ const elementsWithSrc = dom.querySelectorAll('[src^="blob:"]');
509
+ for (const el of elementsWithSrc) {
510
+ if (!el.getAttribute('data-percy-element-id')) {
511
+ el.setAttribute('data-percy-element-id', uid());
512
+ }
513
+ blobUrls.push({
514
+ element: el,
515
+ blobUrl: el.src,
516
+ property: 'src',
517
+ id: el.getAttribute('data-percy-element-id')
518
+ });
519
+ }
520
+
521
+ // Find blob URLs in href attributes
522
+ // Skip stylesheet links as they're handled by serializeCSSOM
523
+ const elementsWithHref = dom.querySelectorAll('[href^="blob:"]');
524
+ for (const el of elementsWithHref) {
525
+ // Skip <link rel="stylesheet"> elements - they're handled by serializeCSSOM
526
+ if (el.tagName === 'LINK' && el.getAttribute('rel') === 'stylesheet') {
527
+ continue;
528
+ }
529
+ if (!el.getAttribute('data-percy-element-id')) {
530
+ el.setAttribute('data-percy-element-id', uid());
531
+ }
532
+ blobUrls.push({
533
+ element: el,
534
+ blobUrl: el.href,
535
+ property: 'href',
536
+ id: el.getAttribute('data-percy-element-id')
537
+ });
538
+ }
539
+
540
+ // Find blob URLs in inline styles (background-image, etc.)
541
+ const allElements = dom.querySelectorAll('*');
542
+ for (const el of allElements) {
543
+ const inlineStyle = el.getAttribute('style');
544
+ if (inlineStyle && inlineStyle.includes('blob:')) {
545
+ const blobMatches = inlineStyle.match(/url\(["']?(blob:[^"')]+)["']?\)/g);
546
+ if (blobMatches) {
547
+ if (!el.getAttribute('data-percy-element-id')) {
548
+ el.setAttribute('data-percy-element-id', uid());
549
+ }
550
+ for (const match of blobMatches) {
551
+ const urlMatch = match.match(/url\(["']?(blob:[^"')]+)["']?\)/);
552
+ blobUrls.push({
553
+ element: el,
554
+ blobUrl: urlMatch[1],
555
+ property: 'style',
556
+ id: el.getAttribute('data-percy-element-id')
557
+ });
558
+ }
559
+ }
560
+ }
561
+ }
562
+ return blobUrls;
563
+ }
564
+
565
+ // Convert blob URL to Percy resource (async)
566
+ async function convertBlobToResource(blobInfo) {
567
+ const {
568
+ element,
569
+ blobUrl,
570
+ property,
571
+ id
572
+ } = blobInfo;
573
+ try {
574
+ // Fetch the blob data
575
+ const response = await fetch(blobUrl);
576
+ const blob = await response.blob();
577
+
578
+ // Convert blob to data URL using FileReader
579
+ const dataUrl = await new Promise((resolve, reject) => {
580
+ /* eslint-disable-next-line no-undef */
581
+ const reader = new FileReader();
582
+ reader.onloadend = () => resolve(reader.result);
583
+ reader.onerror = reject;
584
+ reader.readAsDataURL(blob);
585
+ });
586
+
587
+ // Create Percy resource
588
+ const resource = resourceFromDataURL(id, dataUrl);
589
+
590
+ /* istanbul ignore else: property is always one of 'src', 'href', or 'style' */
591
+ if (property === 'src') {
592
+ element.removeAttribute('src');
593
+ element.setAttribute('data-percy-serialized-attribute-src', resource.url);
594
+ } else if (property === 'href') {
595
+ element.removeAttribute('href');
596
+ element.setAttribute('data-percy-serialized-attribute-href', resource.url);
597
+ } else if (property === 'style') {
598
+ const currentStyle = element.getAttribute('style');
599
+ const updatedStyle = currentStyle.replace(blobUrl, resource.url);
600
+ element.setAttribute('style', updatedStyle);
601
+ }
602
+ return resource;
603
+ } catch (err) {
604
+ throw new Error(`Failed to convert blob URL ${blobUrl}: ${err.message}`);
605
+ }
606
+ }
607
+
608
+ // Process lazy-loaded images (data-src to src)
609
+ function processLazyLoadedImages(dom) {
610
+ const lazyImages = dom.querySelectorAll('img[data-src], source[data-src]');
611
+ let processedCount = 0;
612
+ for (const el of lazyImages) {
613
+ const dataSrc = el.getAttribute('data-src');
614
+ if (dataSrc) {
615
+ try {
616
+ const url = new URL(dataSrc, window.location.origin);
617
+ if (['http:', 'https:', 'data:', 'blob:'].includes(url.protocol)) {
618
+ el.setAttribute('src', url.href);
619
+ processedCount++;
620
+ }
621
+ } catch (e) {
622
+ // Invalid URL, skip
623
+ }
624
+ }
625
+ }
626
+ return processedCount;
627
+ }
628
+
629
+ // Preprocess dynamic resources (async) - runs before cloning
630
+ async function preprocessDynamicResources(dom, resources, warnings) {
631
+ const processedResources = [];
632
+
633
+ // 1. Process lazy-loaded images
634
+ const lazyCount = processLazyLoadedImages(dom);
635
+ if (lazyCount > 0) {
636
+ console.debug(`Percy: Processed ${lazyCount} lazy-loaded images`);
637
+ }
638
+
639
+ // 2. Find all blob URLs
640
+ const blobUrls = findAllBlobUrls(dom);
641
+ if (blobUrls.length > 0) {
642
+ console.debug(`Percy: Found ${blobUrls.length} blob URLs to convert`);
643
+
644
+ // 3. Convert all blob URLs to resources (parallel)
645
+ const conversions = blobUrls.map(blobInfo => convertBlobToResource(blobInfo).then(resource => {
646
+ resources.add(resource);
647
+ processedResources.push(resource);
648
+ return resource;
649
+ }).catch(err => {
650
+ warnings.add(err.message);
651
+ console.warn(`Percy: ${err.message}`);
652
+ return null;
653
+ }));
654
+
655
+ // Wait for all conversions
656
+ await Promise.all(conversions);
657
+ const successCount = processedResources.length;
658
+ console.debug(`Percy: Successfully converted ${successCount}/${blobUrls.length} blob URLs`);
659
+ }
660
+ return processedResources;
661
+ }
662
+
479
663
  // Drop loading attribute. We do not scroll page in discovery stage but we want to make sure that
480
664
  // all resources are requested, so we drop loading attribute [as it can be set to lazy]
481
665
  function dropLoadingAttribute(domElement) {
@@ -527,7 +711,7 @@
527
711
  if (base64String == null) return;
528
712
  if (!cache.has(base64String)) {
529
713
  // create a resource from the serialized data url
530
- let resource = resourceFromText(uid(), mimetype, base64String);
714
+ let resource = resourceFromText(uid$1(), mimetype, base64String);
531
715
  resources.add(resource);
532
716
  cache.set(base64String, resource.url);
533
717
  }
@@ -747,9 +931,9 @@
747
931
  // include the doctype with the html string
748
932
  return doctype(ctx.dom) + html;
749
933
  }
750
- function serializeElements(ctx) {
934
+ async function serializeElements(ctx) {
751
935
  serializeInputElements(ctx);
752
- serializeFrames(ctx);
936
+ await serializeFrames(ctx); // AWAIT async iframe serialization
753
937
  serializeVideos(ctx);
754
938
  if (!ctx.enableJavaScript) {
755
939
  serializeCSSOM(ctx);
@@ -764,7 +948,8 @@
764
948
  // getHTML requires shadowRoot to be passed explicitly
765
949
  // to serialize the shadow elements properly
766
950
  ctx.shadowRootElements.push(cloneShadowHost.shadowRoot);
767
- serializeElements({
951
+ await serializeElements({
952
+ // AWAIT recursive call for shadow DOM
768
953
  ...ctx,
769
954
  dom: shadowHost.shadowRoot,
770
955
  clone: cloneShadowHost.shadowRoot
@@ -792,8 +977,8 @@
792
977
  window.resizeCount = 0;
793
978
  }
794
979
 
795
- // Serializes a document and returns the resulting DOM string.
796
- function serializeDOM(options) {
980
+ // Serializes a document and returns the resulting DOM string (ASYNC).
981
+ async function serializeDOM(options) {
797
982
  var _ctx$clone$body;
798
983
  let {
799
984
  dom = document,
@@ -822,8 +1007,15 @@
822
1007
  forceShadowAsLightDOM
823
1008
  };
824
1009
  ctx.dom = dom;
1010
+
1011
+ // STEP 1: Preprocess dynamic resources (async) - before cloning
1012
+ await preprocessDynamicResources(ctx.dom, ctx.resources, ctx.warnings);
1013
+
1014
+ // STEP 2: Clone the DOM
825
1015
  ctx.clone = cloneNodeAndShadow(ctx);
826
- serializeElements(ctx);
1016
+
1017
+ // STEP 3: Serialize elements (AWAIT async call)
1018
+ await serializeElements(ctx);
827
1019
  if (domTransformation) {
828
1020
  try {
829
1021
  // eslint-disable-next-line no-eval
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/dom",
3
- "version": "1.31.4",
3
+ "version": "1.31.5-beta.1",
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": "latest"
12
+ "tag": "beta"
13
13
  },
14
14
  "main": "dist/bundle.js",
15
15
  "browser": "dist/bundle.js",
@@ -35,5 +35,5 @@
35
35
  "devDependencies": {
36
36
  "interactor.js": "^2.0.0-beta.10"
37
37
  },
38
- "gitHead": "ae9af7043b17186a26e4b8c584faa45559260f9e"
38
+ "gitHead": "074c9bfd98f79d863b3375bff9d72602cff2798d"
39
39
  }