@percy/dom 1.31.14-beta.2 → 1.31.14-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.
Files changed (2) hide show
  1. package/dist/bundle.js +1308 -121
  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 {
@@ -1024,9 +1521,9 @@
1024
1521
  window.resizeCount = 0;
1025
1522
  }
1026
1523
 
1027
- // Serializes a document and returns the resulting DOM string.
1524
+ // Synchronous DOM serializer. For readiness gating, call `PercyDOM.waitForReady(config)`
1525
+ // before this — see readiness.js.
1028
1526
  function serializeDOM(options) {
1029
- var _ctx$clone$body;
1030
1527
  let {
1031
1528
  dom = document,
1032
1529
  // allow snake_case or camelCase
@@ -1038,6 +1535,9 @@
1038
1535
  ignoreCanvasSerializationErrors = options === null || options === void 0 ? void 0 : options.ignore_canvas_serialization_errors,
1039
1536
  ignoreStyleSheetSerializationErrors = options === null || options === void 0 ? void 0 : options.ignore_style_sheet_serialization_errors,
1040
1537
  forceShadowAsLightDOM = options === null || options === void 0 ? void 0 : options.force_shadow_dom_as_light_dom,
1538
+ ignoreIframeSelectors = options === null || options === void 0 ? void 0 : options.ignore_iframe_selectors,
1539
+ maxIframeDepth = options === null || options === void 0 ? void 0 : options.max_iframe_depth,
1540
+ iframeDepth = (options === null || options === void 0 ? void 0 : options.iframe_depth) ?? 0,
1041
1541
  pseudoClassEnabledElements = options === null || options === void 0 ? void 0 : options.pseudo_class_enabled_elements
1042
1542
  } = options || {};
1043
1543
 
@@ -1053,53 +1553,67 @@
1053
1553
  ignoreCanvasSerializationErrors,
1054
1554
  ignoreStyleSheetSerializationErrors,
1055
1555
  forceShadowAsLightDOM,
1556
+ ignoreIframeSelectors,
1557
+ maxIframeDepth,
1558
+ iframeDepth,
1056
1559
  pseudoClassEnabledElements
1057
1560
  };
1058
1561
  ctx.dom = dom;
1059
- markPseudoClassElements(ctx, pseudoClassEnabledElements);
1060
- ctx.clone = cloneNodeAndShadow(ctx);
1061
- serializeElements(ctx);
1062
1562
 
1063
- // STEP 4: Process pseudo-class enabled elements
1064
- serializePseudoClasses(ctx);
1065
- if (domTransformation) {
1563
+ // markPseudoClassElements writes data-percy-* attributes onto the LIVE DOM.
1564
+ // Wrap it AND everything that follows in try/finally so cleanup runs even
1565
+ // if any step (mark, clone, serialize, transform, html) throws — otherwise
1566
+ // partially-stamped attributes leak into the customer's page (SDK mode
1567
+ // runs in the customer's tab). _liveMutations is appended to incrementally
1568
+ // by stampOnce, so cleanup finds whatever was stamped before the throw.
1569
+ try {
1570
+ var _ctx$clone$body;
1571
+ markPseudoClassElements(ctx, pseudoClassEnabledElements);
1572
+ ctx.clone = cloneNodeAndShadow(ctx);
1573
+ serializeElements(ctx);
1574
+ serializePseudoClasses(ctx);
1575
+ rewriteCustomStateCSS(ctx);
1576
+ if (domTransformation) {
1577
+ try {
1578
+ // eslint-disable-next-line no-eval
1579
+ if (typeof domTransformation === 'string') domTransformation = window.eval(domTransformation);
1580
+ domTransformation(ctx.clone.documentElement);
1581
+ } catch (err) {
1582
+ let errorMessage = `Could not transform the dom: ${err.message}`;
1583
+ ctx.warnings.add(errorMessage);
1584
+ console.error(errorMessage);
1585
+ }
1586
+ }
1587
+ if (reshuffleInvalidTags) {
1588
+ let clonedBody = ctx.clone.body;
1589
+ while (clonedBody.nextSibling) {
1590
+ let sibling = clonedBody.nextSibling;
1591
+ clonedBody.append(sibling);
1592
+ }
1593
+ } else if ((_ctx$clone$body = ctx.clone.body) !== null && _ctx$clone$body !== void 0 && _ctx$clone$body.nextSibling) {
1594
+ ctx.hints.add('DOM elements found outside </body>');
1595
+ }
1596
+ let cookies = '';
1597
+ // Collecting cookies fail for about://blank page
1066
1598
  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}`;
1599
+ cookies = dom.cookie;
1600
+ } catch (err) /* istanbul ignore next */ /* Tested this part in discovery.test.js with about:blank page */{
1601
+ const errorMessage = `Could not capture cookies: ${err.message}`;
1072
1602
  ctx.warnings.add(errorMessage);
1073
1603
  console.error(errorMessage);
1074
1604
  }
1605
+ let result = {
1606
+ html: serializeHTML(ctx),
1607
+ cookies: cookies,
1608
+ userAgent: navigator.userAgent,
1609
+ warnings: Array.from(ctx.warnings),
1610
+ resources: Array.from(ctx.resources),
1611
+ hints: Array.from(ctx.hints)
1612
+ };
1613
+ return stringifyResponse ? JSON.stringify(result) : result;
1614
+ } finally {
1615
+ cleanupInteractiveStateMarkers(ctx);
1075
1616
  }
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
1617
  }
1104
1618
 
1105
1619
  function getSrcsets(dom) {
@@ -1148,10 +1662,683 @@
1148
1662
  return allImgTags;
1149
1663
  }
1150
1664
 
1665
+ /* eslint-disable no-undef */
1666
+ // Browser globals (performance, MutationObserver, document, window, getComputedStyle)
1667
+ // are available in the browser execution context where this code runs.
1668
+
1669
+ // Readiness check presets
1670
+ //
1671
+ // `js_idle_window_ms` is separate from `stability_window_ms` on purpose:
1672
+ // DOM stability and main-thread idleness measure different things. With
1673
+ // the `strict` preset we want a long DOM-stability window (1000ms) but
1674
+ // not necessarily 1000ms of no long tasks — that would cause unnecessary
1675
+ // timeouts on pages with normal JS activity. Both windows are
1676
+ // independently configurable but default to reasonable values per preset.
1677
+ const PRESETS = {
1678
+ balanced: {
1679
+ stability_window_ms: 300,
1680
+ js_idle_window_ms: 300,
1681
+ network_idle_window_ms: 200,
1682
+ timeout_ms: 10000,
1683
+ dom_stability: true,
1684
+ image_ready: true,
1685
+ font_ready: true,
1686
+ js_idle: true
1687
+ },
1688
+ strict: {
1689
+ stability_window_ms: 1000,
1690
+ js_idle_window_ms: 500,
1691
+ network_idle_window_ms: 500,
1692
+ timeout_ms: 30000,
1693
+ dom_stability: true,
1694
+ image_ready: true,
1695
+ font_ready: true,
1696
+ js_idle: true
1697
+ },
1698
+ fast: {
1699
+ stability_window_ms: 100,
1700
+ js_idle_window_ms: 100,
1701
+ network_idle_window_ms: 100,
1702
+ timeout_ms: 5000,
1703
+ dom_stability: true,
1704
+ image_ready: false,
1705
+ font_ready: true,
1706
+ js_idle: true
1707
+ }
1708
+ };
1709
+ const LAYOUT_ATTRIBUTES = new Set(['class', 'width', 'height', 'display', 'visibility', 'position', 'src']);
1710
+ const LAYOUT_STYLE_PROPS = /^(width|height|top|left|right|bottom|margin|padding|display|position|visibility|flex|grid|min-|max-|inset|gap|order|float|clear|overflow|z-index|columns)/;
1711
+
1712
+ // Exported for direct unit testing — logic is deterministic and does not
1713
+ // depend on browser timing, so it should not be covered only indirectly
1714
+ // through MutationObserver-driven integration tests.
1715
+ function isLayoutMutation(mutation) {
1716
+ if (mutation.type === 'childList') return true;
1717
+ if (mutation.type === 'attributes') {
1718
+ let attr = mutation.attributeName;
1719
+ if (attr.startsWith('data-') || attr.startsWith('aria-')) return false;
1720
+ if (attr === 'style') {
1721
+ let oldStyle = mutation.oldValue || '';
1722
+ let newStyle = mutation.target.getAttribute('style') || '';
1723
+ return hasLayoutStyleChange(oldStyle, newStyle);
1724
+ }
1725
+ // href is only layout-affecting on <link> elements (stylesheets).
1726
+ // On <a> tags changing href is a no-op for layout.
1727
+ if (attr === 'href') return mutation.target.tagName === 'LINK';
1728
+ if (LAYOUT_ATTRIBUTES.has(attr)) return true;
1729
+ }
1730
+ return false;
1731
+ }
1732
+ function hasLayoutStyleChange(oldStyle, newStyle) {
1733
+ if (oldStyle === newStyle) return false;
1734
+ let oldProps = parseStyleProps(oldStyle);
1735
+ let newProps = parseStyleProps(newStyle);
1736
+ let allKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]);
1737
+ for (let key of allKeys) {
1738
+ if (LAYOUT_STYLE_PROPS.test(key) && oldProps[key] !== newProps[key]) return true;
1739
+ }
1740
+ return false;
1741
+ }
1742
+ function parseStyleProps(styleStr) {
1743
+ let props = {};
1744
+ if (!styleStr) return props;
1745
+ for (let part of styleStr.split(';')) {
1746
+ let i = part.indexOf(':');
1747
+ if (i > 0) {
1748
+ let key = part.slice(0, i).trim().toLowerCase();
1749
+ if (key) props[key] = part.slice(i + 1).trim();
1750
+ }
1751
+ }
1752
+ return props;
1753
+ }
1754
+
1755
+ // Resolve a single ready/notPresent selector to a DOM Element. Accepts:
1756
+ // - CSS string: '.app-loaded'
1757
+ // - XPath string: '//div[@id="root"]' (sniffed by leading /, //, ./, (/, (./)
1758
+ // - Object form (explicit): { css: '.foo' } | { xpath: '//bar' }
1759
+ // Returns the matched Element, or null when no element matches, the
1760
+ // selector is malformed, or it resolves to a non-Element node.
1761
+ //
1762
+ // Exported for direct unit testing.
1763
+ const XPATH_SNIFF = /^\(?\.?\//;
1764
+ function resolveSelector(selector) {
1765
+ if (!selector) return null;
1766
+ let xpath = null;
1767
+ let css = null;
1768
+ if (typeof selector === 'object') {
1769
+ if (selector.xpath) xpath = selector.xpath;else if (selector.css) css = selector.css;else return null;
1770
+ } else if (typeof selector === 'string') {
1771
+ if (XPATH_SNIFF.test(selector)) xpath = selector;else css = selector;
1772
+ } else {
1773
+ return null;
1774
+ }
1775
+ try {
1776
+ let el = xpath ? document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue : document.querySelector(css);
1777
+ return el instanceof Element ? el : null;
1778
+ } catch (e) {
1779
+ // Malformed XPath or invalid CSS — treat as no-match so the selector
1780
+ // gate keeps polling rather than blowing up the entire readiness gate.
1781
+ return null;
1782
+ }
1783
+ }
1784
+
1785
+ // Subscribe to PerformanceObserver entries of a given type. Returns the
1786
+ // observer (for the caller to disconnect) or null when PerformanceObserver
1787
+ // (or the requested entry type) is unavailable, so callers can fall back.
1788
+ //
1789
+ // Used by checkNetworkIdle (`resource`) and checkJSIdle (`longtask`) to
1790
+ // avoid duplicating the try/observe/disconnect boilerplate.
1791
+ function observePerformance(type, onEntries) {
1792
+ try {
1793
+ let observer = new PerformanceObserver(list => onEntries(list.getEntries()));
1794
+ observer.observe({
1795
+ type,
1796
+ buffered: false
1797
+ });
1798
+ return observer;
1799
+ } catch (e) /* istanbul ignore next: PerformanceObserver is available in Chrome/Firefox; catch is for old browsers */{
1800
+ return null;
1801
+ }
1802
+ }
1803
+
1804
+ // --- Individual Checks ---
1805
+ // Each check accepts an `aborted` object ({ value: boolean }) so the orchestrator
1806
+ // can signal cancellation on timeout. Checks must clean up timers/observers on abort.
1807
+
1808
+ function checkDOMStability(stabilityWindowMs, aborted) {
1809
+ return new Promise(resolve => {
1810
+ let startTime = performance.now();
1811
+ let timer = null;
1812
+ let mutationCount = 0;
1813
+ let lastMutationType = null;
1814
+ let observer = new MutationObserver(mutations => {
1815
+ /* istanbul ignore next: abort disconnects the observer synchronously, defensive dead code in tests */
1816
+ if (aborted.value) return;
1817
+ let hasLayout = false;
1818
+ for (let m of mutations) {
1819
+ if (isLayoutMutation(m)) {
1820
+ hasLayout = true;
1821
+ mutationCount++;
1822
+ lastMutationType = m.type;
1823
+ }
1824
+ }
1825
+ /* istanbul ignore next: timer is always set before observer fires */
1826
+ if (hasLayout) {
1827
+ if (timer) clearTimeout(timer);
1828
+ timer = setTimeout(settle, stabilityWindowMs);
1829
+ }
1830
+ });
1831
+ function settle() {
1832
+ observer.disconnect();
1833
+ resolve({
1834
+ passed: true,
1835
+ duration_ms: Math.round(performance.now() - startTime),
1836
+ mutations_observed: mutationCount,
1837
+ last_mutation_type: lastMutationType
1838
+ });
1839
+ }
1840
+ observer.observe(document.documentElement, {
1841
+ childList: true,
1842
+ attributes: true,
1843
+ attributeOldValue: true,
1844
+ subtree: true,
1845
+ attributeFilter: [...LAYOUT_ATTRIBUTES, 'style', 'href']
1846
+ });
1847
+ timer = setTimeout(settle, stabilityWindowMs);
1848
+
1849
+ // Cleanup on abort
1850
+ aborted.onAbort(() => {
1851
+ /* istanbul ignore next: timer is always set at line 124 before abort can fire */
1852
+ if (timer) clearTimeout(timer);
1853
+ observer.disconnect();
1854
+ });
1855
+ });
1856
+ }
1857
+ function checkNetworkIdle(networkIdleWindowMs, aborted) {
1858
+ return new Promise(resolve => {
1859
+ let startTime = performance.now();
1860
+ let timer = null;
1861
+ let pollInterval = null;
1862
+ function settle() {
1863
+ /* istanbul ignore next: observer is only null on fallback path (itself ignored) */
1864
+ if (observer) observer.disconnect();
1865
+ /* istanbul ignore next: fallback polling path only used when PerformanceObserver is unavailable */
1866
+ if (pollInterval) clearInterval(pollInterval);
1867
+ resolve({
1868
+ passed: true,
1869
+ duration_ms: Math.round(performance.now() - startTime)
1870
+ });
1871
+ }
1872
+ function resetIdleTimer() {
1873
+ /* istanbul ignore next: timer is always set before any resource entry arrives */
1874
+ if (timer) clearTimeout(timer);
1875
+ timer = setTimeout(settle, networkIdleWindowMs);
1876
+ }
1877
+
1878
+ /* istanbul ignore next: observer callback body only runs if a network resource loads during the idle window */
1879
+ let observer = observePerformance('resource', entries => {
1880
+ if (aborted.value) return;
1881
+ if (entries.length > 0) resetIdleTimer();
1882
+ });
1883
+
1884
+ /* istanbul ignore next: PerformanceObserver fallback only triggers in older browsers */
1885
+ if (!observer) {
1886
+ let lastCount = performance.getEntriesByType('resource').length;
1887
+ pollInterval = setInterval(() => {
1888
+ if (aborted.value) {
1889
+ clearInterval(pollInterval);
1890
+ return;
1891
+ }
1892
+ let count = performance.getEntriesByType('resource').length;
1893
+ if (count !== lastCount) {
1894
+ lastCount = count;
1895
+ resetIdleTimer();
1896
+ }
1897
+ }, 50);
1898
+ }
1899
+
1900
+ // Start the initial idle window.
1901
+ timer = setTimeout(settle, networkIdleWindowMs);
1902
+ aborted.onAbort(() => {
1903
+ /* istanbul ignore next: observer is only null on fallback path (itself ignored) */
1904
+ if (observer) observer.disconnect();
1905
+ /* istanbul ignore next: pollInterval is only set on the fallback path */
1906
+ if (pollInterval) clearInterval(pollInterval);
1907
+ /* istanbul ignore next: timer is always set before abort can fire */
1908
+ if (timer) clearTimeout(timer);
1909
+ });
1910
+ });
1911
+ }
1912
+ function checkFontReady(aborted) {
1913
+ var _document$fonts;
1914
+ let start = performance.now();
1915
+ /* istanbul ignore next: cannot mock document.fonts API in browser tests */
1916
+ if (!((_document$fonts = document.fonts) !== null && _document$fonts !== void 0 && _document$fonts.ready)) return Promise.resolve({
1917
+ passed: true,
1918
+ duration_ms: 0,
1919
+ skipped: true
1920
+ });
1921
+ let fontTimer;
1922
+ let resolveAbort;
1923
+ // Resolve deterministically on abort so the race is settled by the orchestrator's timeout
1924
+ // path and doesn't get retroactively flipped to { passed: true } when document.fonts.ready
1925
+ // settles late. Important if we ever begin reading checks.font_ready post-timeout.
1926
+ let abortPromise = new Promise(r => {
1927
+ resolveAbort = r;
1928
+ });
1929
+ let result = Promise.race([document.fonts.ready.then(() => ({
1930
+ passed: true,
1931
+ duration_ms: Math.round(performance.now() - start)
1932
+ })), /* istanbul ignore next: font timeout requires 5s delay, impractical in tests */
1933
+ new Promise(r => {
1934
+ fontTimer = setTimeout(() => r({
1935
+ passed: false,
1936
+ duration_ms: 5000,
1937
+ timed_out: true
1938
+ }), 5000);
1939
+ }), abortPromise]);
1940
+ /* istanbul ignore next: abort path not deterministically testable */
1941
+ if (aborted) {
1942
+ aborted.onAbort(() => {
1943
+ if (fontTimer) clearTimeout(fontTimer);
1944
+ resolveAbort({
1945
+ passed: false,
1946
+ duration_ms: Math.round(performance.now() - start),
1947
+ aborted: true
1948
+ });
1949
+ });
1950
+ }
1951
+ return result;
1952
+ }
1953
+ function checkImageReady(aborted) {
1954
+ return new Promise(resolve => {
1955
+ let start = performance.now();
1956
+ let vh = window.innerHeight;
1957
+ function getIncomplete() {
1958
+ let imgs = document.querySelectorAll('img');
1959
+ let incomplete = [];
1960
+ for (let img of imgs) {
1961
+ let r = img.getBoundingClientRect();
1962
+ /* istanbul ignore else: test images are always placed in the viewport with non-zero dimensions */
1963
+ if (r.top < vh && r.bottom > 0 && r.width > 0 && r.height > 0) {
1964
+ if (!img.complete || img.naturalWidth === 0) incomplete.push(img);
1965
+ }
1966
+ }
1967
+ return incomplete;
1968
+ }
1969
+ let total = document.querySelectorAll('img').length;
1970
+ let incStart = getIncomplete().length;
1971
+ if (incStart === 0) {
1972
+ resolve({
1973
+ passed: true,
1974
+ duration_ms: 0,
1975
+ images_checked: total,
1976
+ images_incomplete_at_start: 0
1977
+ });
1978
+ return;
1979
+ }
1980
+ let interval = setInterval(() => {
1981
+ /* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
1982
+ if (aborted.value) {
1983
+ clearInterval(interval);
1984
+ return;
1985
+ }
1986
+ /* istanbul ignore next: requires network latency — images load synchronously in tests with data: URLs */
1987
+ if (getIncomplete().length === 0) {
1988
+ clearInterval(interval);
1989
+ resolve({
1990
+ passed: true,
1991
+ duration_ms: Math.round(performance.now() - start),
1992
+ images_checked: total,
1993
+ images_incomplete_at_start: incStart
1994
+ });
1995
+ }
1996
+ }, 100);
1997
+
1998
+ /* istanbul ignore next: abort-on-timeout path; only fires when images never load in time */
1999
+ aborted.onAbort(() => clearInterval(interval));
2000
+ });
2001
+ }
2002
+ function checkJSIdle(idleWindowMs, aborted) {
2003
+ // Three-tier JS idle detection — purely observational, no monkey-patching:
2004
+ // Tier 1: Long Task API (PerformanceObserver) — detects main-thread tasks >50ms
2005
+ // Tier 2: requestIdleCallback — confirms browser idle (fallback: setTimeout 200ms)
2006
+ // Tier 3: Double-requestAnimationFrame — ensures render/paint cycle is complete
2007
+ return new Promise(resolve => {
2008
+ let start = performance.now();
2009
+ let longTaskCount = 0;
2010
+ let idleTimer = null;
2011
+ let observer = null;
2012
+ let settled = false;
2013
+ let observing = false;
2014
+
2015
+ // Tier 1: Long Task API — reset idle timer on each observed long task.
2016
+ // observePerformance returns null on older browsers; we degrade to the
2017
+ // rIC/rAF-only path in that case.
2018
+ /* istanbul ignore next: longtask callback fires only on CPU-heavy >50ms tasks, not reliable in tests */
2019
+ observer = observePerformance('longtask', entries => {
2020
+ if (!observing || settled || aborted.value) return;
2021
+ for (let entry of entries) {
2022
+ if (entry.entryType === 'longtask') {
2023
+ longTaskCount++;
2024
+ if (idleTimer) clearTimeout(idleTimer);
2025
+ idleTimer = setTimeout(confirmIdle, idleWindowMs);
2026
+ }
2027
+ }
2028
+ });
2029
+ function cleanup() {
2030
+ settled = true;
2031
+ /* istanbul ignore next: defensive — observer is always set except when Long Task API fails (itself ignored) */
2032
+ if (observer) observer.disconnect();
2033
+ /* istanbul ignore next: defensive — idleTimer may be null between cleanup calls from multiple abort paths */
2034
+ if (idleTimer) clearTimeout(idleTimer);
2035
+ }
2036
+ function done(idleCallbackUsed) {
2037
+ /* istanbul ignore next: defensive — re-entry guard for race between done/cleanup/abort */
2038
+ if (settled || aborted.value) return;
2039
+ cleanup();
2040
+ resolve({
2041
+ passed: true,
2042
+ duration_ms: Math.round(performance.now() - start),
2043
+ long_tasks_observed: longTaskCount,
2044
+ idle_callback_used: idleCallbackUsed
2045
+ });
2046
+ }
2047
+
2048
+ // Tier 2: requestIdleCallback confirmation (or fallback)
2049
+ function confirmIdle() {
2050
+ /* istanbul ignore next: defensive re-entry guard — confirmIdle can be scheduled multiple times */
2051
+ if (settled || aborted.value) return;
2052
+ /* istanbul ignore else: rIC is available in modern Chrome/Firefox — fallback is for older browsers */
2053
+ if (typeof requestIdleCallback === 'function') {
2054
+ /* istanbul ignore next: rIC timeout only fires if requestIdleCallback takes longer than idleWindowMs * 2 — cleared by rIC callback in normal runs */
2055
+ let ricTimer = setTimeout(() => doubleRAF(false), idleWindowMs * 2);
2056
+ requestIdleCallback(() => {
2057
+ clearTimeout(ricTimer);
2058
+ doubleRAF(true);
2059
+ });
2060
+ aborted.onAbort(() => clearTimeout(ricTimer));
2061
+ } else {
2062
+ let fallbackTimer = setTimeout(() => doubleRAF(false), 200);
2063
+ aborted.onAbort(() => clearTimeout(fallbackTimer));
2064
+ }
2065
+ }
2066
+
2067
+ // Tier 3: Double-rAF render gate
2068
+ function doubleRAF(usedRIC) {
2069
+ /* istanbul ignore next: defensive re-entry guard — doubleRAF can be scheduled from multiple paths */
2070
+ if (settled || aborted.value) return;
2071
+ requestAnimationFrame(() => {
2072
+ requestAnimationFrame(() => {
2073
+ done(usedRIC);
2074
+ });
2075
+ });
2076
+ }
2077
+
2078
+ // Start: skip first frame to avoid detecting Percy's own insertPercyDom() setup,
2079
+ // then begin idle window
2080
+ requestAnimationFrame(() => {
2081
+ /* istanbul ignore next: abort only fires during timeout race, not on first rAF in tests */
2082
+ if (aborted.value) return;
2083
+ observing = true;
2084
+ idleTimer = setTimeout(confirmIdle, idleWindowMs);
2085
+ });
2086
+ aborted.onAbort(() => cleanup());
2087
+ });
2088
+ }
2089
+ function checkReadySelectors(selectors, aborted) {
2090
+ /* istanbul ignore next: orchestrator only calls this when selectors.length > 0; defensive for direct callers */
2091
+ if (!(selectors !== null && selectors !== void 0 && selectors.length)) return Promise.resolve({
2092
+ passed: true,
2093
+ duration_ms: 0,
2094
+ selectors: []
2095
+ });
2096
+ return new Promise(resolve => {
2097
+ let start = performance.now();
2098
+ function check() {
2099
+ for (let s of selectors) {
2100
+ let el = resolveSelector(s);
2101
+ if (!el) return false;
2102
+ if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed' && getComputedStyle(el).position !== 'sticky') return false;
2103
+ }
2104
+ return true;
2105
+ }
2106
+ if (check()) {
2107
+ resolve({
2108
+ passed: true,
2109
+ duration_ms: 0,
2110
+ selectors
2111
+ });
2112
+ return;
2113
+ }
2114
+ let interval = setInterval(() => {
2115
+ /* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
2116
+ if (aborted.value) {
2117
+ clearInterval(interval);
2118
+ return;
2119
+ }
2120
+ if (check()) {
2121
+ clearInterval(interval);
2122
+ resolve({
2123
+ passed: true,
2124
+ duration_ms: Math.round(performance.now() - start),
2125
+ selectors
2126
+ });
2127
+ }
2128
+ }, 100);
2129
+ aborted.onAbort(() => clearInterval(interval));
2130
+ });
2131
+ }
2132
+ function checkNotPresentSelectors(selectors, aborted) {
2133
+ /* istanbul ignore next: orchestrator only calls this when selectors.length > 0; defensive for direct callers */
2134
+ if (!(selectors !== null && selectors !== void 0 && selectors.length)) return Promise.resolve({
2135
+ passed: true,
2136
+ duration_ms: 0,
2137
+ selectors: []
2138
+ });
2139
+ return new Promise(resolve => {
2140
+ let start = performance.now();
2141
+ function check() {
2142
+ for (let s of selectors) {
2143
+ if (resolveSelector(s)) return false;
2144
+ }
2145
+ return true;
2146
+ }
2147
+ if (check()) {
2148
+ resolve({
2149
+ passed: true,
2150
+ duration_ms: 0,
2151
+ selectors
2152
+ });
2153
+ return;
2154
+ }
2155
+ let interval = setInterval(() => {
2156
+ /* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
2157
+ if (aborted.value) {
2158
+ clearInterval(interval);
2159
+ return;
2160
+ }
2161
+ if (check()) {
2162
+ clearInterval(interval);
2163
+ resolve({
2164
+ passed: true,
2165
+ duration_ms: Math.round(performance.now() - start),
2166
+ selectors
2167
+ });
2168
+ }
2169
+ }, 100);
2170
+
2171
+ /* istanbul ignore next: abort-on-timeout path; only fires when the excluded selector never disappears */
2172
+ aborted.onAbort(() => clearInterval(interval));
2173
+ });
2174
+ }
2175
+
2176
+ // --- Orchestrator ---
2177
+
2178
+ // Simple abort controller for browser context (no AbortController dependency).
2179
+ // Exported for direct unit testing.
2180
+ function createAbortHandle() {
2181
+ let callbacks = [];
2182
+ return {
2183
+ value: false,
2184
+ onAbort(fn) {
2185
+ callbacks.push(fn);
2186
+ },
2187
+ abort() {
2188
+ this.value = true;
2189
+ callbacks.forEach(fn => fn());
2190
+ callbacks = [];
2191
+ }
2192
+ };
2193
+ }
2194
+ async function runAllChecks(config, result, aborted) {
2195
+ var _config$ready_selecto, _config$not_present_s;
2196
+ let checks = [];
2197
+ let expected = [];
2198
+ // dom_stability: false is an explicit kill switch for the MutationObserver
2199
+ // check. Use it on heavy SPA pages where the observer itself can drive
2200
+ // CPU/memory pressure. Other checks (js_idle, image/font ready, selectors)
2201
+ // continue to run, so capture is still gated — just not on raw mutation rate.
2202
+ if (config.dom_stability !== false && config.stability_window_ms > 0) {
2203
+ expected.push('dom_stability');
2204
+ checks.push(checkDOMStability(config.stability_window_ms, aborted).then(r => {
2205
+ result.checks.dom_stability = r;
2206
+ }));
2207
+ }
2208
+ if (config.network_idle_window_ms > 0) {
2209
+ expected.push('network_idle');
2210
+ checks.push(checkNetworkIdle(config.network_idle_window_ms, aborted).then(r => {
2211
+ result.checks.network_idle = r;
2212
+ }));
2213
+ }
2214
+ if (config.font_ready !== false) {
2215
+ expected.push('font_ready');
2216
+ checks.push(checkFontReady(aborted).then(r => {
2217
+ result.checks.font_ready = r;
2218
+ }));
2219
+ }
2220
+ if (config.image_ready !== false) {
2221
+ expected.push('image_ready');
2222
+ checks.push(checkImageReady(aborted).then(r => {
2223
+ result.checks.image_ready = r;
2224
+ }));
2225
+ }
2226
+ if (config.js_idle !== false) {
2227
+ expected.push('js_idle');
2228
+ // Fall back to stability_window_ms if js_idle_window_ms is not set.
2229
+ // All built-in presets set js_idle_window_ms, so this fallback only
2230
+ // fires when a caller passes a custom config that predates the
2231
+ // dedicated option — preserves backward compatibility.
2232
+ /* istanbul ignore next: fallback only hit by pre-js_idle_window_ms configs; built-in presets always set it */
2233
+ let jsIdleWindow = config.js_idle_window_ms ?? config.stability_window_ms;
2234
+ checks.push(checkJSIdle(jsIdleWindow, aborted).then(r => {
2235
+ result.checks.js_idle = r;
2236
+ }));
2237
+ }
2238
+ if ((_config$ready_selecto = config.ready_selectors) !== null && _config$ready_selecto !== void 0 && _config$ready_selecto.length) {
2239
+ expected.push('ready_selectors');
2240
+ checks.push(checkReadySelectors(config.ready_selectors, aborted).then(r => {
2241
+ result.checks.ready_selectors = r;
2242
+ }));
2243
+ }
2244
+ if ((_config$not_present_s = config.not_present_selectors) !== null && _config$not_present_s !== void 0 && _config$not_present_s.length) {
2245
+ expected.push('not_present_selectors');
2246
+ checks.push(checkNotPresentSelectors(config.not_present_selectors, aborted).then(r => {
2247
+ result.checks.not_present_selectors = r;
2248
+ }));
2249
+ }
2250
+ result._expectedChecks = expected;
2251
+ await Promise.all(checks);
2252
+ }
2253
+
2254
+ // Normalize camelCase config keys (from .percy.yml / SDK options) to the
2255
+ // snake_case keys used internally. Accepts either naming.
2256
+ // Exported for direct unit testing.
2257
+ function normalizeOptions(options = {}) {
2258
+ return {
2259
+ preset: options.preset,
2260
+ stability_window_ms: options.stabilityWindowMs ?? options.stability_window_ms,
2261
+ js_idle_window_ms: options.jsIdleWindowMs ?? options.js_idle_window_ms,
2262
+ network_idle_window_ms: options.networkIdleWindowMs ?? options.network_idle_window_ms,
2263
+ timeout_ms: options.timeoutMs ?? options.timeout_ms,
2264
+ dom_stability: options.domStability ?? options.dom_stability,
2265
+ image_ready: options.imageReady ?? options.image_ready,
2266
+ font_ready: options.fontReady ?? options.font_ready,
2267
+ js_idle: options.jsIdle ?? options.js_idle,
2268
+ ready_selectors: options.readySelectors ?? options.ready_selectors,
2269
+ not_present_selectors: options.notPresentSelectors ?? options.not_present_selectors,
2270
+ max_timeout_ms: options.maxTimeoutMs ?? options.max_timeout_ms
2271
+ };
2272
+ }
2273
+ async function waitForReady(options = {}) {
2274
+ let presetName = options.preset || 'balanced';
2275
+ if (presetName === 'disabled') return {
2276
+ passed: true,
2277
+ timed_out: false,
2278
+ skipped: true,
2279
+ checks: {}
2280
+ };
2281
+ let preset = PRESETS[presetName] || PRESETS.balanced;
2282
+ // Normalize user options to snake_case, then merge. Only overrides
2283
+ // where user explicitly provided a value (undefined keys don't overwrite).
2284
+ let userOptions = normalizeOptions(options);
2285
+ let config = {
2286
+ ...preset
2287
+ };
2288
+ for (let key of Object.keys(userOptions)) {
2289
+ if (userOptions[key] !== undefined) config[key] = userOptions[key];
2290
+ }
2291
+ let effectiveTimeout = config.max_timeout_ms ? Math.min(config.timeout_ms, config.max_timeout_ms) : config.timeout_ms;
2292
+ let startTime = performance.now();
2293
+ let result = {
2294
+ passed: false,
2295
+ timed_out: false,
2296
+ preset: presetName,
2297
+ checks: {}
2298
+ };
2299
+ let settled = false;
2300
+ let aborted = createAbortHandle();
2301
+ try {
2302
+ await Promise.race([runAllChecks(config, result, aborted).then(() => {
2303
+ settled = true;
2304
+ }), new Promise(resolve => setTimeout(() => {
2305
+ if (!settled) {
2306
+ result.timed_out = true;
2307
+ // Abort all running checks — clears intervals, disconnects observers
2308
+ aborted.abort();
2309
+ }
2310
+ resolve();
2311
+ }, effectiveTimeout))]);
2312
+ } catch (error) {
2313
+ /* istanbul ignore next: safety net for unexpected errors in readiness checks */
2314
+ result.error = error.message || String(error);
2315
+ }
2316
+
2317
+ // Mark any checks that didn't complete before timeout as failed.
2318
+ // `_expectedChecks` is always set by runAllChecks, but coverage here
2319
+ // depends on whether any expected check was skipped due to timeout.
2320
+ /* istanbul ignore next: only falsy when the catch block above fires before runAllChecks sets _expectedChecks */
2321
+ if (result._expectedChecks) {
2322
+ for (let name of result._expectedChecks) {
2323
+ if (!result.checks[name]) {
2324
+ result.checks[name] = {
2325
+ passed: false,
2326
+ timed_out: true
2327
+ };
2328
+ }
2329
+ }
2330
+ delete result._expectedChecks;
2331
+ }
2332
+ result.total_duration_ms = Math.round(performance.now() - startTime);
2333
+ result.passed = !result.timed_out && !result.error && Object.values(result.checks).every(c => c.passed);
2334
+ return result;
2335
+ }
2336
+
1151
2337
  exports["default"] = serializeDOM;
1152
2338
  exports.loadAllSrcsetLinks = loadAllSrcsetLinks;
1153
2339
  exports.serialize = serializeDOM;
1154
2340
  exports.serializeDOM = serializeDOM;
2341
+ exports.waitForReady = waitForReady;
1155
2342
  exports.waitForResize = waitForResize;
1156
2343
 
1157
2344
  Object.defineProperty(exports, '__esModule', { value: true });