@percy/dom 1.31.14-beta.2 → 1.31.14-beta.3

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 +633 -120
  2. package/package.json +2 -2
package/dist/bundle.js CHANGED
@@ -6,6 +6,16 @@
6
6
  process.env = process.env || {};
7
7
  process.env.__PERCY_BROWSERIFIED__ = true;
8
8
 
9
+ // Custom element names are required by spec to contain a hyphen. Returns
10
+ // false for text/comment nodes (which don't have a tagName). This is the
11
+ // single source of truth used across prepare-dom, clone-dom, and the
12
+ // serializers — keep checks consistent by importing this rather than
13
+ // inlining `tagName?.includes('-')`.
14
+ function isCustomElement(element) {
15
+ var _element$tagName;
16
+ return !!(element !== null && element !== void 0 && (_element$tagName = element.tagName) !== null && _element$tagName !== void 0 && _element$tagName.includes('-'));
17
+ }
18
+
9
19
  // Creates a resource object from an element's unique ID and data URL
10
20
  function resourceFromDataURL(uid, dataURL) {
11
21
  // split dataURL into desired parts
@@ -165,20 +175,80 @@
165
175
  (_dom$querySelector = dom.querySelector('head')) === null || _dom$querySelector === void 0 || _dom$querySelector.prepend($base);
166
176
  }
167
177
 
168
- // Recursively serializes iframe documents into srcdoc attributes.
178
+ // Per-spec: nested iframes are captured up to a configurable depth (default 3).
179
+ // Beyond that we skip recursion to bound runtime and prevent pathological pages
180
+ // (e.g. cyclic iframe trees) from blowing the call stack.
181
+ //
182
+ // MIRROR: these constants + `clampIframeDepth` are duplicated in
183
+ // @percy/sdk-utils/src/index.js (where external SDKs read them to clamp their
184
+ // own pre-CLI config to the same bounds). The values must stay aligned —
185
+ // drift is enforced by a parity test in @percy/sdk-utils/test/index.test.js.
186
+ // Don't change one without changing the other.
187
+ const DEFAULT_MAX_IFRAME_DEPTH = 3;
188
+ // Hard ceiling for any user-supplied maxIframeDepth — values above this are
189
+ // clamped down. 10 levels is well past any realistic UI nesting and keeps
190
+ // the recursion cost predictable.
191
+ const HARD_MAX_IFRAME_DEPTH = 10;
192
+ function clampIframeDepth(raw) {
193
+ let n = Number(raw);
194
+ if (!Number.isFinite(n) || n < 1) return DEFAULT_MAX_IFRAME_DEPTH;
195
+ return Math.min(Math.floor(n), HARD_MAX_IFRAME_DEPTH);
196
+ }
197
+
198
+ // Recursively serializes iframe documents into srcdoc attributes. `iframeDepth`
199
+ // is the current nesting level (0 at the top-level document, +1 per recursion).
200
+ // The default fires for direct callers that don't set it (e.g. tests, or
201
+ // any future caller that doesn't go through serializeDOM).
169
202
  function serializeFrames({
170
203
  dom,
171
204
  clone,
172
205
  warnings,
173
206
  resources,
174
207
  enableJavaScript,
175
- disableShadowDOM
208
+ disableShadowDOM,
209
+ ignoreIframeSelectors,
210
+ forceShadowAsLightDOM,
211
+ maxIframeDepth,
212
+ iframeDepth = 0
176
213
  }) {
214
+ maxIframeDepth = clampIframeDepth(maxIframeDepth);
177
215
  for (let frame of dom.querySelectorAll('iframe')) {
178
216
  var _clone$head;
179
217
  let percyElementId = frame.getAttribute('data-percy-element-id');
180
218
  let cloneEl = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
219
+
220
+ // Skip iframes with data-percy-ignore attribute or matching configured selectors
221
+ let matchesSelector = (ignoreIframeSelectors === null || ignoreIframeSelectors === void 0 ? void 0 : ignoreIframeSelectors.length) && ignoreIframeSelectors.some(sel => {
222
+ try {
223
+ return frame.matches(sel);
224
+ } catch {
225
+ return false;
226
+ }
227
+ });
228
+ if (frame.hasAttribute('data-percy-ignore') || matchesSelector) {
229
+ cloneEl === null || cloneEl === void 0 || cloneEl.remove();
230
+ continue;
231
+ }
181
232
  let builtWithJs = !frame.srcdoc && (!frame.src || frame.src.split(':')[0] === 'javascript');
233
+ let sandboxAttr = frame.getAttribute('sandbox');
234
+
235
+ // Warn about sandboxed iframes lacking the permissions Percy needs to
236
+ // render with fidelity. Fully-permissive sandboxes (allow-scripts +
237
+ // allow-same-origin) capture fine.
238
+ if (sandboxAttr !== null) {
239
+ let frameLabel = frame.id || frame.src || frame.getAttribute('name') || '<unnamed iframe>';
240
+ let tokens = sandboxAttr.split(/\s+/).filter(Boolean);
241
+ if (tokens.length === 0) {
242
+ warnings.add(`Sandboxed iframe "${frameLabel}" has no permissions — content may not render with full fidelity in Percy`);
243
+ } else {
244
+ if (!tokens.includes('allow-scripts')) {
245
+ warnings.add(`Sandboxed iframe "${frameLabel}" has scripts disabled — JS-dependent content will not render in Percy`);
246
+ }
247
+ if (!tokens.includes('allow-same-origin')) {
248
+ warnings.add(`Sandboxed iframe "${frameLabel}" lacks allow-same-origin — styles and resources may not load correctly in Percy`);
249
+ }
250
+ }
251
+ }
182
252
 
183
253
  // delete frames within the head since they usually break pages when
184
254
  // rerendered and do not effect the visuals of a page
@@ -193,12 +263,22 @@
193
263
  // the frame has yet to load and wasn't built with js, it is unsafe to serialize
194
264
  if (!builtWithJs && !frame.contentWindow.performance.timing.loadEventEnd) continue;
195
265
 
196
- // recersively serialize contents
266
+ // Bound recursion at the configured depth so nested iframes can't
267
+ // blow the call stack on pathological pages.
268
+ if (iframeDepth + 1 >= maxIframeDepth) continue;
269
+
270
+ // recersively serialize contents — propagate ignoreIframeSelectors,
271
+ // forceShadowAsLightDOM, and the depth counter so nested iframes/shadow
272
+ // trees honor the same user options as the top-level capture.
197
273
  let serialized = serializeDOM({
198
274
  domTransformation: dom => setBaseURI(dom, warnings),
199
275
  dom: frame.contentDocument,
200
276
  enableJavaScript,
201
- disableShadowDOM
277
+ disableShadowDOM,
278
+ forceShadowAsLightDOM,
279
+ ignoreIframeSelectors,
280
+ maxIframeDepth,
281
+ iframeDepth: iframeDepth + 1
202
282
  });
203
283
 
204
284
  // append serialized warnings and resources
@@ -221,6 +301,55 @@
221
301
  }
222
302
  }
223
303
 
304
+ // Shared traversal helpers for walking a document plus every shadow root
305
+ // it contains (open or closed-via-CDP). Centralizes access to the
306
+ // closed-shadow WeakMap so each call site honors the *iframe's* runtime
307
+ // window — a top-level `window.__percyClosedShadowRoots` lookup misses
308
+ // shadow roots stored on per-document WeakMaps inside iframes.
309
+
310
+ // Resolve the runtime window for any node (Document/Element/ShadowRoot).
311
+ // For a node inside an iframe, returns the iframe's window — which is where
312
+ // the per-document closed-shadow WeakMap is installed. Returns null when
313
+ // imported outside a browser realm (Node/Worker) so callers can no-op.
314
+ function getRuntime(node) {
315
+ const doc = (node === null || node === void 0 ? void 0 : node.ownerDocument) || node;
316
+ if (doc !== null && doc !== void 0 && doc.defaultView) return doc.defaultView;
317
+ /* istanbul ignore next: the global-window fallback only fires when this
318
+ module is imported outside a browser realm (Node/Worker). The karma
319
+ browser test runner always has a global window, so neither branch of
320
+ this final fallback is reachable from tests. */
321
+ return typeof window !== 'undefined' ? window : null;
322
+ }
323
+
324
+ // Closed-shadow-root WeakMap installed by exposeClosedShadowRoots via CDP,
325
+ // scoped to the node's owning document.
326
+ function getClosedShadowRoot(host) {
327
+ var _getRuntime;
328
+ return ((_getRuntime = getRuntime(host)) === null || _getRuntime === void 0 || (_getRuntime = _getRuntime.__percyClosedShadowRoots) === null || _getRuntime === void 0 ? void 0 : _getRuntime.get(host)) || null;
329
+ }
330
+ function hasClosedShadowRoot(host) {
331
+ var _getRuntime2;
332
+ return !!((_getRuntime2 = getRuntime(host)) !== null && _getRuntime2 !== void 0 && (_getRuntime2 = _getRuntime2.__percyClosedShadowRoots) !== null && _getRuntime2 !== void 0 && _getRuntime2.has(host));
333
+ }
334
+
335
+ // Resolve a shadow host's root, including closed roots captured via CDP.
336
+ // Returns null when the host has no shadow root reachable.
337
+ function getShadowRoot(host) {
338
+ if (host !== null && host !== void 0 && host.shadowRoot) return host.shadowRoot;
339
+ return getClosedShadowRoot(host);
340
+ }
341
+
342
+ // Walk root + every shadow root descendant, calling visit(scope) on each.
343
+ // `scope` is either the original root or a shadow root.
344
+ function walkShadowDOM(root, visit) {
345
+ visit(root);
346
+ if (!root.querySelectorAll) return;
347
+ for (const host of root.querySelectorAll('[data-percy-shadow-host]')) {
348
+ const shadow = getShadowRoot(host);
349
+ if (shadow) walkShadowDOM(shadow, visit);
350
+ }
351
+ }
352
+
224
353
  // Returns a mostly random uid.
225
354
  function uid() {
226
355
  return `_${Math.random().toString(36).substr(2, 9)}`;
@@ -228,14 +357,17 @@
228
357
  function markElement(domElement, disableShadowDOM, forceShadowAsLightDOM) {
229
358
  var _domElement$tagName;
230
359
  // Mark elements that are to be serialized later with a data attribute.
231
- if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes((_domElement$tagName = domElement.tagName) === null || _domElement$tagName === void 0 ? void 0 : _domElement$tagName.toLowerCase())) {
360
+ // Custom elements with ElementInternals or closed shadow roots also get
361
+ // stamped so the post-clone state-fallback can locate their clones.
362
+ if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes((_domElement$tagName = domElement.tagName) === null || _domElement$tagName === void 0 ? void 0 : _domElement$tagName.toLowerCase()) || isCustomElement(domElement)) {
232
363
  if (!domElement.getAttribute('data-percy-element-id')) {
233
364
  domElement.setAttribute('data-percy-element-id', uid());
234
365
  }
235
366
  }
236
367
 
237
- // add special marker for shadow host
238
- if (!disableShadowDOM && domElement.shadowRoot) {
368
+ // add special marker for shadow host (including closed shadow roots captured via CDP)
369
+ let shadowRoot = getShadowRoot(domElement);
370
+ if (!disableShadowDOM && shadowRoot) {
239
371
  // When forceShadowAsLightDOM is true, don't mark as shadow host
240
372
  if (!forceShadowAsLightDOM) {
241
373
  domElement.setAttribute('data-percy-shadow-host', '');
@@ -505,9 +637,214 @@
505
637
  }
506
638
  }
507
639
 
640
+ // Serializes ElementInternals custom-element :state() into Percy's clone
641
+
642
+ // State names that survive into the rewritten attribute selector. Anything
643
+ // else (quotes, brackets, '</style>') would let a hostile page CSS escape
644
+ // the rewritten <style> block or inject extra rules.
645
+ const SAFE_STATE_NAME_RE = /^[-\w]+$/;
646
+ const STATE_ATTR_TEMPLATE = name => `[data-percy-custom-state~="${name}"]`;
647
+ function rewriteCustomStateCSS(ctx) {
648
+ const stateNames = new Set();
649
+ const styleElements = collectStyleElements(ctx.clone);
650
+ for (const style of styleElements) {
651
+ const css = style.textContent;
652
+ if (!css) continue;
653
+ const modified = rewriteCustomStateSelectors(css, stateNames);
654
+ if (modified !== css) {
655
+ // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method
656
+ style.textContent = modified;
657
+ }
658
+ }
659
+ if (stateNames.size > 0) {
660
+ addCustomStateAttributes(ctx, stateNames);
661
+ }
662
+ }
663
+
664
+ // Rewrites `:state(name)` and the legacy `:--name` to attribute selectors.
665
+ // Names are validated against SAFE_STATE_NAME_RE; unsafe names are left
666
+ // alone so authors notice the bad input rather than getting silent
667
+ // zero-match rules.
668
+ function rewriteCustomStateSelectors(text, stateNames) {
669
+ text = text.replace(/:state\(([^)]+)\)/g, (m, name) => {
670
+ name = name.trim();
671
+ if (!SAFE_STATE_NAME_RE.test(name)) return m;
672
+ stateNames.add(name);
673
+ return STATE_ATTR_TEMPLATE(name);
674
+ });
675
+ text = text.replace(/:--([a-zA-Z][\w-]*)/g, (m, name) => {
676
+ stateNames.add(name);
677
+ return STATE_ATTR_TEMPLATE(name);
678
+ });
679
+ return text;
680
+ }
681
+
682
+ // Collect <style> elements from the document and every shadow root.
683
+ function collectStyleElements(root) {
684
+ const styles = [];
685
+ walkShadowDOM(root, scope => {
686
+ if (!scope.querySelectorAll) return;
687
+ for (const el of scope.querySelectorAll('style')) styles.push(el);
688
+ });
689
+ return styles;
690
+ }
691
+
692
+ // :state() supports both the function form and the legacy :--name form.
693
+ // element.matches() may throw on unsupported syntax; tolerate and try both.
694
+ function elementInState(el, name) {
695
+ for (const sel of [`:state(${name})`, `:--${name}`]) {
696
+ try {
697
+ if (el.matches(sel)) return true;
698
+ } catch (e) {
699
+ // not supported in this browser — try the next form
700
+ }
701
+ }
702
+ return false;
703
+ }
704
+
705
+ // Test live custom elements against :state(name) for each state name found
706
+ // in CSS, and stamp data-percy-custom-state on the matching clone element.
707
+ //
708
+ // Builds a percyId → cloneEl Map in one walk over the clone tree so the
709
+ // per-element lookup is O(1) — a naive per-element ctx.clone.querySelector
710
+ // scan is O(N × T) for N custom elements and a tree of size T.
711
+ function addCustomStateAttributes(ctx, stateNames) {
712
+ // ctx.clone is a DocumentFragment we constructed ourselves and shadow
713
+ // roots always have querySelectorAll, so the visitor doesn't need the
714
+ // defensive guard the live-DOM walk (below) uses.
715
+ const cloneByPercyId = new Map();
716
+ walkShadowDOM(ctx.clone, scope => {
717
+ for (const el of scope.querySelectorAll('[data-percy-element-id]')) {
718
+ cloneByPercyId.set(el.getAttribute('data-percy-element-id'), el);
719
+ }
720
+ });
721
+ walkShadowDOM(ctx.dom, scope => {
722
+ if (!scope.querySelectorAll) return;
723
+ for (const el of scope.querySelectorAll('*')) {
724
+ if (!isCustomElement(el)) continue;
725
+ const percyId = el.getAttribute('data-percy-element-id');
726
+ if (!percyId) continue;
727
+ const cloneEl = cloneByPercyId.get(percyId);
728
+ if (!cloneEl || cloneEl.hasAttribute('data-percy-custom-state')) continue;
729
+ const matchedStates = [];
730
+ for (const name of stateNames) {
731
+ if (elementInState(el, name)) matchedStates.push(name);
732
+ }
733
+ if (matchedStates.length > 0) {
734
+ cloneEl.setAttribute('data-percy-custom-state', matchedStates.join(' '));
735
+ }
736
+ }
737
+ });
738
+ }
739
+
508
740
  /* global XPathResult */
509
- const PSEDUO_ELEMENT_MARKER_ATTR = 'data-percy-pseudo-element-id';
741
+ const PSEUDO_ELEMENT_MARKER_ATTR = 'data-percy-pseudo-element-id';
510
742
  const POPOVER_OPEN_ATTR = 'data-percy-popover-open';
743
+ const FOCUS_ATTR = 'data-percy-focus';
744
+ const FOCUS_WITHIN_ATTR = 'data-percy-focus-within';
745
+ const CHECKED_ATTR = 'data-percy-checked';
746
+ const DISABLED_ATTR = 'data-percy-disabled';
747
+ const HOVER_ATTR = 'data-percy-hover';
748
+ const ACTIVE_ATTR = 'data-percy-active';
749
+ const ALL_INTERACTIVE_PSEUDO = [':focus', ':focus-within', ':checked', ':disabled', ':hover', ':active'];
750
+ const PSEUDO_TO_ATTR = {
751
+ ':focus': '[data-percy-focus]',
752
+ ':focus-within': '[data-percy-focus-within]',
753
+ ':checked': '[data-percy-checked]',
754
+ ':disabled': '[data-percy-disabled]',
755
+ ':hover': '[data-percy-hover]',
756
+ ':active': '[data-percy-active]'
757
+ };
758
+
759
+ // Boundary regex per pseudo-class. Lookahead `(?![-\w])` prevents :focus
760
+ // from matching the start of :focus-within / :focus-visible. Order matters:
761
+ // longer pseudos (:focus-within) are listed first so they win over :focus.
762
+ const PSEUDO_RES = [[':focus-within', /:focus-within(?![-\w])/g], [':focus', /:focus(?![-\w])/g], [':checked', /:checked(?![-\w])/g], [':disabled', /:disabled(?![-\w])/g], [':hover', /:hover(?![-\w])/g], [':active', /:active(?![-\w])/g]];
763
+ function selectorContainsPseudo(selectorText, pseudoList) {
764
+ return pseudoList.some(pc => {
765
+ const re = PSEUDO_RES.find(([p]) => p === pc)[1];
766
+ re.lastIndex = 0;
767
+ return re.test(selectorText);
768
+ });
769
+ }
770
+ function rewritePseudoSelector(selectorText) {
771
+ let out = selectorText;
772
+ for (const [pseudo, re] of PSEUDO_RES) out = out.replace(re, PSEUDO_TO_ATTR[pseudo]);
773
+ return out;
774
+ }
775
+
776
+ // Record a live-DOM mutation so cleanup can undo it. Callers must ensure
777
+ // ctx._liveMutations exists — markPseudoClassElements and getElementsToProcess
778
+ // both initialize it upfront.
779
+ function stampOnce(ctx, element, attr, value) {
780
+ if (element.hasAttribute(attr)) return;
781
+ element.setAttribute(attr, value);
782
+ ctx._liveMutations.push([element, attr]);
783
+ }
784
+
785
+ // Walk into shadow roots (including closed ones captured via CDP) to find
786
+ // the deepest focused element, so we can stamp it with FOCUS_ATTR.
787
+ function findDeepActiveElement(dom) {
788
+ let active = dom.activeElement;
789
+ let root = active && getShadowRoot(active);
790
+ while ((_root = root) !== null && _root !== void 0 && _root.activeElement) {
791
+ var _root;
792
+ active = root.activeElement;
793
+ root = getShadowRoot(active);
794
+ }
795
+ return active;
796
+ }
797
+
798
+ // Walk the focused element's ancestor chain across shadow root boundaries
799
+ // stamping FOCUS_WITHIN_ATTR on each. :focus-within rules in CSS will be
800
+ // rewritten to [data-percy-focus-within] and match these stamps.
801
+ function markFocusWithinAncestors(ctx, focused) {
802
+ let node = focused === null || focused === void 0 ? void 0 : focused.parentNode;
803
+ while (node) {
804
+ if (node.nodeType === 1 /* ELEMENT_NODE */) {
805
+ stampOnce(ctx, node, FOCUS_WITHIN_ATTR, 'true');
806
+ node = node.parentNode;
807
+ } else if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE — shadow root */) {
808
+ // Shadow roots always have a host per spec; if a future detached
809
+ // fragment ever lacked one, the next iteration's nodeType checks
810
+ // both fail and the else cascade nulls node anyway.
811
+ node = node.host;
812
+ } else {
813
+ node = null;
814
+ }
815
+ }
816
+ }
817
+ function markInteractiveStates(ctx) {
818
+ const focused = findDeepActiveElement(ctx.dom);
819
+ if (focused && focused !== ctx.dom.body && focused !== ctx.dom.documentElement) {
820
+ stampOnce(ctx, focused, FOCUS_ATTR, 'true');
821
+ markFocusWithinAncestors(ctx, focused);
822
+ }
823
+
824
+ // Single walk of ctx.dom + shadow roots collecting BOTH :checked and
825
+ // :disabled in one pass. Previously two separate queryShadowAll calls
826
+ // each walked the tree and re-traversed every [data-percy-shadow-host]
827
+ // — on pages with many shadow hosts this doubled the per-snapshot
828
+ // walk cost. Also tracks which states were observed so the CSS rule
829
+ // extractor can skip work for selectors that have no matched elements.
830
+ ctx._stampedInteractive = ctx._stampedInteractive || new Set();
831
+ // walkShadowDOM only invokes the visitor with Document/Element/ShadowRoot
832
+ // scopes — each has querySelectorAll, so no defensive guard is needed here.
833
+ walkShadowDOM(ctx.dom, scope => {
834
+ try {
835
+ for (const el of scope.querySelectorAll(':checked')) {
836
+ stampOnce(ctx, el, CHECKED_ATTR, 'true');
837
+ ctx._stampedInteractive.add('checked');
838
+ }
839
+ } catch (e) {/* selector unsupported in this scope */}
840
+ try {
841
+ for (const el of scope.querySelectorAll(':disabled')) {
842
+ stampOnce(ctx, el, DISABLED_ATTR, 'true');
843
+ ctx._stampedInteractive.add('disabled');
844
+ }
845
+ } catch (e) {/* selector unsupported in this scope */}
846
+ });
847
+ }
511
848
  function isPopoverOpen(ctx, element) {
512
849
  try {
513
850
  return element.matches(':popover-open');
@@ -516,54 +853,62 @@
516
853
  return false;
517
854
  }
518
855
  }
519
- function markElementIfNeeded(ctx, element, markWithId) {
520
- if (!markWithId) return;
521
- if (element.hasAttribute('popover') && isPopoverOpen(ctx, element) && !element.hasAttribute(POPOVER_OPEN_ATTR)) {
522
- element.setAttribute(POPOVER_OPEN_ATTR, 'true');
856
+ function markPopoverIfOpen(ctx, element) {
857
+ if (element.hasAttribute('popover') && isPopoverOpen(ctx, element)) {
858
+ stampOnce(ctx, element, POPOVER_OPEN_ATTR, 'true');
523
859
  }
524
- if (!element.getAttribute(PSEDUO_ELEMENT_MARKER_ATTR)) {
525
- element.setAttribute(PSEDUO_ELEMENT_MARKER_ATTR, uid());
860
+ }
861
+ function stampPseudoElementId(ctx, element) {
862
+ if (!element.getAttribute(PSEUDO_ELEMENT_MARKER_ATTR)) {
863
+ element.setAttribute(PSEUDO_ELEMENT_MARKER_ATTR, uid());
864
+ ctx._liveMutations.push([element, PSEUDO_ELEMENT_MARKER_ATTR]);
526
865
  }
527
866
  }
528
867
 
529
- /**
530
- * Get all elements matching the pseudoClassEnabledElements configuration
531
- * @param {Document} dom - The document to search
532
- * @param {Object} config - Configuration with id, className, and xpath arrays
533
- * @param {boolean} markWithId - Whether to mark elements with PSEDUO_ELEMENT_MARKER_ATTR
534
- * @returns {Array} Array of elements found
535
- */
868
+ // Configured elements get :hover/:active stamped unconditionally — opting in
869
+ // IS the request to capture those forced states. :focus/:checked/:disabled
870
+ // are already covered by the page-wide markInteractiveStates pass.
871
+ function markElementInteractiveStates(ctx, element) {
872
+ stampOnce(ctx, element, HOVER_ATTR, 'true');
873
+ stampOnce(ctx, element, ACTIVE_ATTR, 'true');
874
+ }
536
875
  function getElementsToProcess(ctx, config, markWithId = false) {
537
876
  const {
538
877
  dom
539
878
  } = ctx;
540
879
  const elements = [];
541
- if (config.id && Array.isArray(config.id)) {
880
+ const stamp = el => {
881
+ if (markWithId) {
882
+ markPopoverIfOpen(ctx, el);
883
+ markElementInteractiveStates(ctx, el);
884
+ stampPseudoElementId(ctx, el);
885
+ }
886
+ };
887
+ if (Array.isArray(config.id)) {
542
888
  for (const id of config.id) {
543
889
  const element = dom.getElementById(id);
544
890
  if (!element) {
545
891
  ctx.warnings.add(`No element found with ID: ${id} for pseudo-class serialization`);
546
892
  continue;
547
893
  }
548
- markElementIfNeeded(ctx, element, markWithId);
894
+ stamp(element);
549
895
  elements.push(element);
550
896
  }
551
897
  }
552
-
553
- // Process only first match per class name
554
- if (config.className && Array.isArray(config.className)) {
898
+ if (Array.isArray(config.className)) {
555
899
  for (const className of config.className) {
556
- const elementCollection = dom.getElementsByClassName(className);
557
- if (!elementCollection.length) {
900
+ const collection = dom.getElementsByClassName(className);
901
+ if (!collection.length) {
558
902
  ctx.warnings.add(`No element found with class name: ${className} for pseudo-class serialization`);
559
903
  continue;
560
904
  }
561
- const element = elementCollection[0];
562
- markElementIfNeeded(ctx, element, markWithId);
905
+ // Process only first match per class name (preserves prior behavior).
906
+ const element = collection[0];
907
+ stamp(element);
563
908
  elements.push(element);
564
909
  }
565
910
  }
566
- if (config.xpath && Array.isArray(config.xpath)) {
911
+ if (Array.isArray(config.xpath)) {
567
912
  for (const xpathExpression of config.xpath) {
568
913
  try {
569
914
  const element = dom.evaluate(xpathExpression, dom, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
@@ -571,14 +916,14 @@
571
916
  ctx.warnings.add(`No element found for XPath: ${xpathExpression} for pseudo-class serialization`);
572
917
  continue;
573
918
  }
574
- markElementIfNeeded(ctx, element, markWithId);
919
+ stamp(element);
575
920
  } catch (err) {
576
921
  ctx.warnings.add(`Invalid XPath expression "${xpathExpression}" for pseudo-class serialization. Error: ${err.message}`);
577
922
  console.warn(`Invalid XPath expression "${xpathExpression}". Error: ${err.message}`);
578
923
  }
579
924
  }
580
925
  }
581
- if (config.selectors && Array.isArray(config.selectors)) {
926
+ if (Array.isArray(config.selectors)) {
582
927
  for (const selector of config.selectors) {
583
928
  try {
584
929
  const matched = Array.from(dom.querySelectorAll(selector));
@@ -587,7 +932,7 @@
587
932
  continue;
588
933
  }
589
934
  matched.forEach(el => {
590
- markElementIfNeeded(ctx, el, markWithId);
935
+ stamp(el);
591
936
  elements.push(el);
592
937
  });
593
938
  } catch (err) {
@@ -599,67 +944,209 @@
599
944
  return elements;
600
945
  }
601
946
 
602
- /**
603
- * Mark pseudo-class enabled elements with data-percy-element-id before cloning
604
- * This must be called before the DOM is cloned
605
- * @param {Document} dom - The document to mark
606
- * @param {Object} config - Configuration with id and xpath arrays
607
- */
947
+ // Pre-clone marking pass. Runs on the live DOM before cloneNodeAndShadow so
948
+ // the data-attributes are copied through to the clone via cloneNode.
608
949
  function markPseudoClassElements(ctx, config) {
609
- if (!config) return;
610
- getElementsToProcess(ctx, config, true);
950
+ ctx._liveMutations = [];
951
+ markInteractiveStates(ctx);
952
+ if (config) getElementsToProcess(ctx, config, true);
611
953
  }
612
954
 
613
- /**
614
- * Convert CSSStyleDeclaration to CSS text with !important declarations
615
- * @param {CSSStyleDeclaration} styles - Computed style declaration
616
- * @returns {string} CSS text
617
- */
955
+ // Reverse every setAttribute we made on the live DOM during marking. Called
956
+ // at the end of serializeDOM so the customer's page is left clean — SDK
957
+ // mode runs in the customer's actual browser tab and leaks would persist
958
+ // past the snapshot.
959
+ function cleanupInteractiveStateMarkers(ctx) {
960
+ if (!ctx._liveMutations) return;
961
+ for (const [element, attr] of ctx._liveMutations) {
962
+ try {
963
+ element.removeAttribute(attr);
964
+ } catch (e) {
965
+ // Element detached or attribute already gone — fine
966
+ }
967
+ }
968
+ ctx._liveMutations = [];
969
+ }
618
970
  function stylesToCSSText(styles) {
619
- const cssProperties = [];
971
+ const decls = [];
620
972
  for (let i = 0; i < styles.length; i++) {
621
973
  const property = styles[i];
622
- const value = styles.getPropertyValue(property);
623
- cssProperties.push(`${property}: ${value} !important;`);
974
+ decls.push(`${property}: ${styles.getPropertyValue(property)} !important;`);
624
975
  }
625
- return cssProperties.join(' ');
976
+ return decls.join(' ');
626
977
  }
627
978
 
628
- /**
629
- * Process pseudo-class elements and add percy-pseudo-class CSS
630
- * @param {Object} ctx - Serialization context
631
- */
632
- function serializePseudoClasses(ctx) {
633
- if (!ctx.pseudoClassEnabledElements) {
634
- return;
979
+ // Walk a CSSRule list yielding every reachable style rule. Nested rules
980
+ // inside @media/@layer/@supports are emitted with the at-rule prelude
981
+ // preserved as a wrapper string; flat-emitting would drop the guard.
982
+ function walkCSSRules(ruleList) {
983
+ const result = [];
984
+ for (let i = 0; i < ruleList.length; i++) {
985
+ const rule = ruleList[i];
986
+ const hasNested = !!(rule.cssRules && rule.cssRules.length);
987
+ if (hasNested) {
988
+ var _rule$media;
989
+ const conditionText = rule.conditionText || ((_rule$media = rule.media) === null || _rule$media === void 0 ? void 0 : _rule$media.mediaText);
990
+ const atRulePrelude = conditionText && rule.cssText ? rule.cssText.split('{')[0].trim() : null;
991
+ for (const inner of walkCSSRules(rule.cssRules)) {
992
+ if (atRulePrelude && inner.selectorText) {
993
+ result.push({
994
+ selectorText: inner.selectorText,
995
+ style: inner.style,
996
+ wrapper: atRulePrelude
997
+ });
998
+ } else {
999
+ result.push(inner);
1000
+ }
1001
+ }
1002
+ } else if (rule.selectorText) {
1003
+ // Rules without nested cssRules and without selectorText (@font-face,
1004
+ // @charset, @counter-style, etc.) are skipped — they can't contain
1005
+ // interactive pseudos.
1006
+ result.push({
1007
+ selectorText: rule.selectorText,
1008
+ style: rule.style,
1009
+ wrapper: null
1010
+ });
1011
+ }
635
1012
  }
636
- const elements = ctx.dom.querySelectorAll(`[${PSEDUO_ELEMENT_MARKER_ATTR}]`);
637
- if (elements.length === 0) {
638
- return;
1013
+ return result;
1014
+ }
1015
+
1016
+ // Collect { sheet, owner } entries for every stylesheet in the document
1017
+ // and inside every shadow root. owner is the shadow host (or null for
1018
+ // document-level sheets) so we know which clone scope to inject into.
1019
+ function collectStyleSheets(doc) {
1020
+ const entries = [];
1021
+ walkShadowDOM(doc, scope => {
1022
+ let sheets;
1023
+ try {
1024
+ sheets = scope.styleSheets;
1025
+ } catch (e) {
1026
+ return;
1027
+ }
1028
+ if (!sheets) return;
1029
+ const owner = scope === doc ? null : scope.host;
1030
+ for (const sheet of sheets) entries.push({
1031
+ sheet,
1032
+ owner
1033
+ });
1034
+ });
1035
+ return entries;
1036
+ }
1037
+ function extractPseudoClassRules(ctx) {
1038
+ const sheetEntries = collectStyleSheets(ctx.dom);
1039
+ const rulesByOwner = new Map();
1040
+
1041
+ // Short-circuit per pseudo: when markInteractiveStates ran first (the
1042
+ // production path via markPseudoClassElements), `ctx._stampedInteractive`
1043
+ // records which interactive states were actually observed. Rules for
1044
+ // `:checked` / `:disabled` selectors that found no live elements can't
1045
+ // match anything in the clone after rewriting, so we drop them from the
1046
+ // filter and avoid the rewrite cost. When the marker is absent (unit
1047
+ // tests that call serializePseudoClasses directly), fall back to the
1048
+ // full pseudo list to preserve prior behavior. `:focus`, `:focus-within`,
1049
+ // `:hover`, `:active` are kept regardless either way.
1050
+ const activePseudos = ctx._stampedInteractive ? ALL_INTERACTIVE_PSEUDO.filter(p => {
1051
+ if (p === ':checked') return ctx._stampedInteractive.has('checked');
1052
+ if (p === ':disabled') return ctx._stampedInteractive.has('disabled');
1053
+ return true;
1054
+ }) : ALL_INTERACTIVE_PSEUDO;
1055
+ for (const {
1056
+ sheet,
1057
+ owner
1058
+ } of sheetEntries) {
1059
+ let rules;
1060
+ try {
1061
+ rules = sheet.cssRules;
1062
+ } catch (e) {
1063
+ // Cross-origin stylesheet — skip
1064
+ continue;
1065
+ }
1066
+ if (!rules) continue;
1067
+ for (const rule of walkCSSRules(rules)) {
1068
+ // Cheapest possible filter: a selector with no `:` can't contain any
1069
+ // interactive pseudo. Skips most rules on most stylesheets without
1070
+ // touching the regex bank.
1071
+ if (!rule.selectorText.includes(':')) continue;
1072
+ if (!selectorContainsPseudo(rule.selectorText, activePseudos)) continue;
1073
+ const rewrittenSelector = rewritePseudoSelector(rule.selectorText);
1074
+ const cssText = `${rewrittenSelector} { ${rule.style.cssText} }`;
1075
+ const wrapped = rule.wrapper ? `${rule.wrapper} { ${cssText} }` : cssText;
1076
+ if (!rulesByOwner.has(owner)) rulesByOwner.set(owner, []);
1077
+ rulesByOwner.get(owner).push(wrapped);
1078
+ }
1079
+ }
1080
+
1081
+ // Build a percyId → cloneEl index once for shadow-host injection — only
1082
+ // when there is at least one non-null owner in the collected rules.
1083
+ let cloneByPercyId = null;
1084
+ for (const owner of rulesByOwner.keys()) {
1085
+ if (owner !== null) {
1086
+ cloneByPercyId = new Map();
1087
+ for (const el of ctx.clone.querySelectorAll('[data-percy-element-id]')) {
1088
+ cloneByPercyId.set(el.getAttribute('data-percy-element-id'), el);
1089
+ }
1090
+ break;
1091
+ }
1092
+ }
1093
+ for (const [owner, rewrittenRules] of rulesByOwner) {
1094
+ const styleElement = ctx.clone.createElement ? ctx.clone.createElement('style') : ctx.dom.createElement('style');
1095
+ styleElement.setAttribute('data-percy-interactive-states', 'true');
1096
+ // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method
1097
+ styleElement.textContent = rewrittenRules.join('\n');
1098
+ if (owner === null) {
1099
+ const head = ctx.clone.head || ctx.clone.querySelector('head');
1100
+ if (head) head.appendChild(styleElement);
1101
+ } else {
1102
+ const percyId = owner.getAttribute('data-percy-element-id');
1103
+ const cloneHost = cloneByPercyId.get(percyId);
1104
+ if (cloneHost && cloneHost.shadowRoot) {
1105
+ cloneHost.shadowRoot.appendChild(styleElement);
1106
+ }
1107
+ }
1108
+ }
1109
+ }
1110
+ function serializePseudoClasses(ctx) {
1111
+ // Auto-detect path runs unconditionally so `:focus`/`:checked`/`:disabled`
1112
+ // rules are rewritten regardless of whether the user configured a
1113
+ // pseudoClassEnabledElements list (and regardless of whether that list
1114
+ // matched anything on this page).
1115
+ extractPseudoClassRules(ctx);
1116
+ if (!ctx.pseudoClassEnabledElements) return;
1117
+ const elements = ctx.dom.querySelectorAll(`[${PSEUDO_ELEMENT_MARKER_ATTR}]`);
1118
+ if (elements.length === 0) return;
1119
+
1120
+ // pseudoElementId → cloneEl index, built once. The previous shape did a
1121
+ // ctx.clone.querySelector per element which is O(N × T).
1122
+ const cloneByPseudoId = new Map();
1123
+ for (const el of ctx.clone.querySelectorAll(`[${PSEUDO_ELEMENT_MARKER_ATTR}]`)) {
1124
+ cloneByPseudoId.set(el.getAttribute(PSEUDO_ELEMENT_MARKER_ATTR), el);
639
1125
  }
640
1126
  const cssRules = [];
641
1127
  for (const element of elements) {
642
- const percyElementId = element.getAttribute(PSEDUO_ELEMENT_MARKER_ATTR);
643
- const cloneElement = ctx.clone.querySelector(`[${PSEDUO_ELEMENT_MARKER_ATTR}="${percyElementId}"]`);
1128
+ const percyElementId = element.getAttribute(PSEUDO_ELEMENT_MARKER_ATTR);
1129
+ const cloneElement = cloneByPseudoId.get(percyElementId);
644
1130
  if (!cloneElement) {
645
1131
  ctx.warnings.add(`Element not found for pseudo-class serialization with percy-element-id: ${percyElementId}`);
646
1132
  continue;
647
1133
  }
648
1134
  try {
649
- // Get all computed styles including pseudo-classes
650
- const computedStyles = window.getComputedStyle(element);
1135
+ // ctx.dom.defaultView is the iframe's window for nested-frame contexts;
1136
+ // fall back to the global window when ctx.dom is the top document or a
1137
+ // synthetic root that doesn't expose defaultView (e.g. tests).
1138
+ const win = ctx.dom.defaultView || window;
1139
+ const computedStyles = win.getComputedStyle(element);
651
1140
  const cssText = stylesToCSSText(computedStyles);
652
- const selector = `[${PSEDUO_ELEMENT_MARKER_ATTR}="${percyElementId}"]`;
653
- cssRules.push(`${selector} { ${cssText} }`);
1141
+ cssRules.push(`[${PSEUDO_ELEMENT_MARKER_ATTR}="${percyElementId}"] { ${cssText} }`);
654
1142
  } catch (err) {
655
1143
  console.warn('Could not get computed styles for element', element, err);
656
1144
  }
657
1145
  }
658
-
659
- // Inject CSS into cloned document
660
1146
  if (cssRules.length > 0) {
661
1147
  const styleElement = ctx.dom.createElement('style');
662
1148
  styleElement.setAttribute('data-percy-pseudo-class-styles', 'true');
1149
+ // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method
663
1150
  styleElement.textContent = cssRules.join('\n');
664
1151
  const head = ctx.clone.head || ctx.clone.querySelector('head');
665
1152
  if (head) {
@@ -790,13 +1277,16 @@
790
1277
  const ignoreTags = ['NOSCRIPT'];
791
1278
 
792
1279
  /**
793
- * if a custom element has attribute callback then cloneNode calls a callback that can
794
- * increase CPU load or some other change.
795
- * So we want to make sure that it is not called when doing serialization.
796
- */
1280
+ * Clone an element without triggering custom element lifecycle callbacks.
1281
+ * Custom elements with callbacks or closed shadow roots are cloned as proxy elements
1282
+ * to prevent constructors from running (which could call attachShadow, fetch data, etc).
1283
+ */
797
1284
  function cloneElementWithoutLifecycle(element) {
798
- if (!element.attributeChangedCallback || !element.tagName.includes('-')) {
799
- return element.cloneNode(); // Standard clone for non-custom elements
1285
+ let isCustom = isCustomElement(element);
1286
+ let hasClosedShadow = isCustom && hasClosedShadowRoot(element);
1287
+ let hasCallbacks = isCustom && element.attributeChangedCallback;
1288
+ if (!isCustom || !hasCallbacks && !hasClosedShadow) {
1289
+ return element.cloneNode();
800
1290
  }
801
1291
  const cloned = document.createElement('data-percy-custom-element-' + element.tagName);
802
1292
 
@@ -841,6 +1331,10 @@
841
1331
  markElement(node, disableShadowDOM, forceShadowAsLightDOM);
842
1332
  let clone = cloneElementWithoutLifecycle(node);
843
1333
 
1334
+ // Custom-element :state() is captured by the fallback path in
1335
+ // serialize-custom-states.js (live el.matches against state names
1336
+ // discovered in CSS) — no clone-time fast path remains.
1337
+
844
1338
  // Handle <style> tag specifically for media queries
845
1339
  if (node.nodeName === 'STYLE' && !enableJavaScript) {
846
1340
  var _node$textContent;
@@ -872,11 +1366,13 @@
872
1366
  Array.from(clone.children).forEach(child => clone.removeChild(child));
873
1367
  }
874
1368
 
875
- // clone shadow DOM
876
- if (node.shadowRoot && !disableShadowDOM) {
1369
+ // clone shadow DOM (including closed shadow roots captured via CDP
1370
+ // and stored on window.__percyClosedShadowRoots)
1371
+ let nodeShadowRoot = node.shadowRoot || getClosedShadowRoot(node);
1372
+ if (nodeShadowRoot && !disableShadowDOM) {
877
1373
  if (forceShadowAsLightDOM) {
878
1374
  // When forceShadowAsLightDOM is true, treat shadow content as normal DOM
879
- walkTree(node.shadowRoot.firstChild, clone);
1375
+ walkTree(nodeShadowRoot.firstChild, clone);
880
1376
  } else {
881
1377
  // create shadowRoot
882
1378
  if (clone.shadowRoot) {
@@ -889,7 +1385,7 @@
889
1385
  });
890
1386
  }
891
1387
  // clone dom elements
892
- walkTree(node.shadowRoot.firstChild, clone.shadowRoot);
1388
+ walkTree(nodeShadowRoot.firstChild, clone.shadowRoot);
893
1389
  }
894
1390
  }
895
1391
 
@@ -992,13 +1488,14 @@
992
1488
  for (const shadowHost of ctx.dom.querySelectorAll('[data-percy-shadow-host]')) {
993
1489
  let percyElementId = shadowHost.getAttribute('data-percy-element-id');
994
1490
  let cloneShadowHost = ctx.clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
995
- if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
1491
+ let origShadow = shadowHost.shadowRoot || getClosedShadowRoot(shadowHost);
1492
+ if (origShadow && cloneShadowHost.shadowRoot) {
996
1493
  // getHTML requires shadowRoot to be passed explicitly
997
1494
  // to serialize the shadow elements properly
998
1495
  ctx.shadowRootElements.push(cloneShadowHost.shadowRoot);
999
1496
  serializeElements({
1000
1497
  ...ctx,
1001
- dom: shadowHost.shadowRoot,
1498
+ dom: origShadow,
1002
1499
  clone: cloneShadowHost.shadowRoot
1003
1500
  });
1004
1501
  } else {
@@ -1026,7 +1523,6 @@
1026
1523
 
1027
1524
  // Serializes a document and returns the resulting DOM string.
1028
1525
  function serializeDOM(options) {
1029
- var _ctx$clone$body;
1030
1526
  let {
1031
1527
  dom = document,
1032
1528
  // allow snake_case or camelCase
@@ -1038,6 +1534,9 @@
1038
1534
  ignoreCanvasSerializationErrors = options === null || options === void 0 ? void 0 : options.ignore_canvas_serialization_errors,
1039
1535
  ignoreStyleSheetSerializationErrors = options === null || options === void 0 ? void 0 : options.ignore_style_sheet_serialization_errors,
1040
1536
  forceShadowAsLightDOM = options === null || options === void 0 ? void 0 : options.force_shadow_dom_as_light_dom,
1537
+ ignoreIframeSelectors = options === null || options === void 0 ? void 0 : options.ignore_iframe_selectors,
1538
+ maxIframeDepth = options === null || options === void 0 ? void 0 : options.max_iframe_depth,
1539
+ iframeDepth = (options === null || options === void 0 ? void 0 : options.iframe_depth) ?? 0,
1041
1540
  pseudoClassEnabledElements = options === null || options === void 0 ? void 0 : options.pseudo_class_enabled_elements
1042
1541
  } = options || {};
1043
1542
 
@@ -1053,53 +1552,67 @@
1053
1552
  ignoreCanvasSerializationErrors,
1054
1553
  ignoreStyleSheetSerializationErrors,
1055
1554
  forceShadowAsLightDOM,
1555
+ ignoreIframeSelectors,
1556
+ maxIframeDepth,
1557
+ iframeDepth,
1056
1558
  pseudoClassEnabledElements
1057
1559
  };
1058
1560
  ctx.dom = dom;
1059
- markPseudoClassElements(ctx, pseudoClassEnabledElements);
1060
- ctx.clone = cloneNodeAndShadow(ctx);
1061
- serializeElements(ctx);
1062
1561
 
1063
- // STEP 4: Process pseudo-class enabled elements
1064
- serializePseudoClasses(ctx);
1065
- if (domTransformation) {
1562
+ // markPseudoClassElements writes data-percy-* attributes onto the LIVE DOM.
1563
+ // Wrap it AND everything that follows in try/finally so cleanup runs even
1564
+ // if any step (mark, clone, serialize, transform, html) throws — otherwise
1565
+ // partially-stamped attributes leak into the customer's page (SDK mode
1566
+ // runs in the customer's tab). _liveMutations is appended to incrementally
1567
+ // by stampOnce, so cleanup finds whatever was stamped before the throw.
1568
+ try {
1569
+ var _ctx$clone$body;
1570
+ markPseudoClassElements(ctx, pseudoClassEnabledElements);
1571
+ ctx.clone = cloneNodeAndShadow(ctx);
1572
+ serializeElements(ctx);
1573
+ serializePseudoClasses(ctx);
1574
+ rewriteCustomStateCSS(ctx);
1575
+ if (domTransformation) {
1576
+ try {
1577
+ // eslint-disable-next-line no-eval
1578
+ if (typeof domTransformation === 'string') domTransformation = window.eval(domTransformation);
1579
+ domTransformation(ctx.clone.documentElement);
1580
+ } catch (err) {
1581
+ let errorMessage = `Could not transform the dom: ${err.message}`;
1582
+ ctx.warnings.add(errorMessage);
1583
+ console.error(errorMessage);
1584
+ }
1585
+ }
1586
+ if (reshuffleInvalidTags) {
1587
+ let clonedBody = ctx.clone.body;
1588
+ while (clonedBody.nextSibling) {
1589
+ let sibling = clonedBody.nextSibling;
1590
+ clonedBody.append(sibling);
1591
+ }
1592
+ } else if ((_ctx$clone$body = ctx.clone.body) !== null && _ctx$clone$body !== void 0 && _ctx$clone$body.nextSibling) {
1593
+ ctx.hints.add('DOM elements found outside </body>');
1594
+ }
1595
+ let cookies = '';
1596
+ // Collecting cookies fail for about://blank page
1066
1597
  try {
1067
- // eslint-disable-next-line no-eval
1068
- if (typeof domTransformation === 'string') domTransformation = window.eval(domTransformation);
1069
- domTransformation(ctx.clone.documentElement);
1070
- } catch (err) {
1071
- let errorMessage = `Could not transform the dom: ${err.message}`;
1598
+ cookies = dom.cookie;
1599
+ } catch (err) /* istanbul ignore next */ /* Tested this part in discovery.test.js with about:blank page */{
1600
+ const errorMessage = `Could not capture cookies: ${err.message}`;
1072
1601
  ctx.warnings.add(errorMessage);
1073
1602
  console.error(errorMessage);
1074
1603
  }
1604
+ let result = {
1605
+ html: serializeHTML(ctx),
1606
+ cookies: cookies,
1607
+ userAgent: navigator.userAgent,
1608
+ warnings: Array.from(ctx.warnings),
1609
+ resources: Array.from(ctx.resources),
1610
+ hints: Array.from(ctx.hints)
1611
+ };
1612
+ return stringifyResponse ? JSON.stringify(result) : result;
1613
+ } finally {
1614
+ cleanupInteractiveStateMarkers(ctx);
1075
1615
  }
1076
- if (reshuffleInvalidTags) {
1077
- let clonedBody = ctx.clone.body;
1078
- while (clonedBody.nextSibling) {
1079
- let sibling = clonedBody.nextSibling;
1080
- clonedBody.append(sibling);
1081
- }
1082
- } else if ((_ctx$clone$body = ctx.clone.body) !== null && _ctx$clone$body !== void 0 && _ctx$clone$body.nextSibling) {
1083
- ctx.hints.add('DOM elements found outside </body>');
1084
- }
1085
- let cookies = '';
1086
- // Collecting cookies fail for about://blank page
1087
- try {
1088
- cookies = dom.cookie;
1089
- } catch (err) /* istanbul ignore next */ /* Tested this part in discovery.test.js with about:blank page */{
1090
- const errorMessage = `Could not capture cookies: ${err.message}`;
1091
- ctx.warnings.add(errorMessage);
1092
- console.error(errorMessage);
1093
- }
1094
- let result = {
1095
- html: serializeHTML(ctx),
1096
- cookies: cookies,
1097
- userAgent: navigator.userAgent,
1098
- warnings: Array.from(ctx.warnings),
1099
- resources: Array.from(ctx.resources),
1100
- hints: Array.from(ctx.hints)
1101
- };
1102
- return stringifyResponse ? JSON.stringify(result) : result;
1103
1616
  }
1104
1617
 
1105
1618
  function getSrcsets(dom) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/dom",
3
- "version": "1.31.14-beta.2",
3
+ "version": "1.31.14-beta.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,5 +35,5 @@
35
35
  "devDependencies": {
36
36
  "interactor.js": "^2.0.0-beta.10"
37
37
  },
38
- "gitHead": "e4fce73023453b77cdef50aac1a9bd5eb70cd01a"
38
+ "gitHead": "a17d4a1453c6bef282fd3da38082b670e125a5be"
39
39
  }