@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.
- package/dist/bundle.js +1308 -121
- 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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
520
|
-
if (
|
|
521
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
557
|
-
if (!
|
|
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
|
-
|
|
562
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
950
|
+
ctx._liveMutations = [];
|
|
951
|
+
markInteractiveStates(ctx);
|
|
952
|
+
if (config) getElementsToProcess(ctx, config, true);
|
|
611
953
|
}
|
|
612
954
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
971
|
+
const decls = [];
|
|
620
972
|
for (let i = 0; i < styles.length; i++) {
|
|
621
973
|
const property = styles[i];
|
|
622
|
-
|
|
623
|
-
cssProperties.push(`${property}: ${value} !important;`);
|
|
974
|
+
decls.push(`${property}: ${styles.getPropertyValue(property)} !important;`);
|
|
624
975
|
}
|
|
625
|
-
return
|
|
976
|
+
return decls.join(' ');
|
|
626
977
|
}
|
|
627
978
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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(
|
|
643
|
-
const cloneElement =
|
|
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
|
-
//
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
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
|
-
|
|
799
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1064
|
-
|
|
1065
|
-
if (
|
|
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
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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 });
|