@percy/dom 1.31.14-beta.2 → 1.31.14-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle.js +633 -120
- 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 {
|
|
@@ -1026,7 +1523,6 @@
|
|
|
1026
1523
|
|
|
1027
1524
|
// Serializes a document and returns the resulting DOM string.
|
|
1028
1525
|
function serializeDOM(options) {
|
|
1029
|
-
var _ctx$clone$body;
|
|
1030
1526
|
let {
|
|
1031
1527
|
dom = document,
|
|
1032
1528
|
// allow snake_case or camelCase
|
|
@@ -1038,6 +1534,9 @@
|
|
|
1038
1534
|
ignoreCanvasSerializationErrors = options === null || options === void 0 ? void 0 : options.ignore_canvas_serialization_errors,
|
|
1039
1535
|
ignoreStyleSheetSerializationErrors = options === null || options === void 0 ? void 0 : options.ignore_style_sheet_serialization_errors,
|
|
1040
1536
|
forceShadowAsLightDOM = options === null || options === void 0 ? void 0 : options.force_shadow_dom_as_light_dom,
|
|
1537
|
+
ignoreIframeSelectors = options === null || options === void 0 ? void 0 : options.ignore_iframe_selectors,
|
|
1538
|
+
maxIframeDepth = options === null || options === void 0 ? void 0 : options.max_iframe_depth,
|
|
1539
|
+
iframeDepth = (options === null || options === void 0 ? void 0 : options.iframe_depth) ?? 0,
|
|
1041
1540
|
pseudoClassEnabledElements = options === null || options === void 0 ? void 0 : options.pseudo_class_enabled_elements
|
|
1042
1541
|
} = options || {};
|
|
1043
1542
|
|
|
@@ -1053,53 +1552,67 @@
|
|
|
1053
1552
|
ignoreCanvasSerializationErrors,
|
|
1054
1553
|
ignoreStyleSheetSerializationErrors,
|
|
1055
1554
|
forceShadowAsLightDOM,
|
|
1555
|
+
ignoreIframeSelectors,
|
|
1556
|
+
maxIframeDepth,
|
|
1557
|
+
iframeDepth,
|
|
1056
1558
|
pseudoClassEnabledElements
|
|
1057
1559
|
};
|
|
1058
1560
|
ctx.dom = dom;
|
|
1059
|
-
markPseudoClassElements(ctx, pseudoClassEnabledElements);
|
|
1060
|
-
ctx.clone = cloneNodeAndShadow(ctx);
|
|
1061
|
-
serializeElements(ctx);
|
|
1062
1561
|
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
if (
|
|
1562
|
+
// markPseudoClassElements writes data-percy-* attributes onto the LIVE DOM.
|
|
1563
|
+
// Wrap it AND everything that follows in try/finally so cleanup runs even
|
|
1564
|
+
// if any step (mark, clone, serialize, transform, html) throws — otherwise
|
|
1565
|
+
// partially-stamped attributes leak into the customer's page (SDK mode
|
|
1566
|
+
// runs in the customer's tab). _liveMutations is appended to incrementally
|
|
1567
|
+
// by stampOnce, so cleanup finds whatever was stamped before the throw.
|
|
1568
|
+
try {
|
|
1569
|
+
var _ctx$clone$body;
|
|
1570
|
+
markPseudoClassElements(ctx, pseudoClassEnabledElements);
|
|
1571
|
+
ctx.clone = cloneNodeAndShadow(ctx);
|
|
1572
|
+
serializeElements(ctx);
|
|
1573
|
+
serializePseudoClasses(ctx);
|
|
1574
|
+
rewriteCustomStateCSS(ctx);
|
|
1575
|
+
if (domTransformation) {
|
|
1576
|
+
try {
|
|
1577
|
+
// eslint-disable-next-line no-eval
|
|
1578
|
+
if (typeof domTransformation === 'string') domTransformation = window.eval(domTransformation);
|
|
1579
|
+
domTransformation(ctx.clone.documentElement);
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
let errorMessage = `Could not transform the dom: ${err.message}`;
|
|
1582
|
+
ctx.warnings.add(errorMessage);
|
|
1583
|
+
console.error(errorMessage);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
if (reshuffleInvalidTags) {
|
|
1587
|
+
let clonedBody = ctx.clone.body;
|
|
1588
|
+
while (clonedBody.nextSibling) {
|
|
1589
|
+
let sibling = clonedBody.nextSibling;
|
|
1590
|
+
clonedBody.append(sibling);
|
|
1591
|
+
}
|
|
1592
|
+
} else if ((_ctx$clone$body = ctx.clone.body) !== null && _ctx$clone$body !== void 0 && _ctx$clone$body.nextSibling) {
|
|
1593
|
+
ctx.hints.add('DOM elements found outside </body>');
|
|
1594
|
+
}
|
|
1595
|
+
let cookies = '';
|
|
1596
|
+
// Collecting cookies fail for about://blank page
|
|
1066
1597
|
try {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
} catch (err) {
|
|
1071
|
-
let errorMessage = `Could not transform the dom: ${err.message}`;
|
|
1598
|
+
cookies = dom.cookie;
|
|
1599
|
+
} catch (err) /* istanbul ignore next */ /* Tested this part in discovery.test.js with about:blank page */{
|
|
1600
|
+
const errorMessage = `Could not capture cookies: ${err.message}`;
|
|
1072
1601
|
ctx.warnings.add(errorMessage);
|
|
1073
1602
|
console.error(errorMessage);
|
|
1074
1603
|
}
|
|
1604
|
+
let result = {
|
|
1605
|
+
html: serializeHTML(ctx),
|
|
1606
|
+
cookies: cookies,
|
|
1607
|
+
userAgent: navigator.userAgent,
|
|
1608
|
+
warnings: Array.from(ctx.warnings),
|
|
1609
|
+
resources: Array.from(ctx.resources),
|
|
1610
|
+
hints: Array.from(ctx.hints)
|
|
1611
|
+
};
|
|
1612
|
+
return stringifyResponse ? JSON.stringify(result) : result;
|
|
1613
|
+
} finally {
|
|
1614
|
+
cleanupInteractiveStateMarkers(ctx);
|
|
1075
1615
|
}
|
|
1076
|
-
if (reshuffleInvalidTags) {
|
|
1077
|
-
let clonedBody = ctx.clone.body;
|
|
1078
|
-
while (clonedBody.nextSibling) {
|
|
1079
|
-
let sibling = clonedBody.nextSibling;
|
|
1080
|
-
clonedBody.append(sibling);
|
|
1081
|
-
}
|
|
1082
|
-
} else if ((_ctx$clone$body = ctx.clone.body) !== null && _ctx$clone$body !== void 0 && _ctx$clone$body.nextSibling) {
|
|
1083
|
-
ctx.hints.add('DOM elements found outside </body>');
|
|
1084
|
-
}
|
|
1085
|
-
let cookies = '';
|
|
1086
|
-
// Collecting cookies fail for about://blank page
|
|
1087
|
-
try {
|
|
1088
|
-
cookies = dom.cookie;
|
|
1089
|
-
} catch (err) /* istanbul ignore next */ /* Tested this part in discovery.test.js with about:blank page */{
|
|
1090
|
-
const errorMessage = `Could not capture cookies: ${err.message}`;
|
|
1091
|
-
ctx.warnings.add(errorMessage);
|
|
1092
|
-
console.error(errorMessage);
|
|
1093
|
-
}
|
|
1094
|
-
let result = {
|
|
1095
|
-
html: serializeHTML(ctx),
|
|
1096
|
-
cookies: cookies,
|
|
1097
|
-
userAgent: navigator.userAgent,
|
|
1098
|
-
warnings: Array.from(ctx.warnings),
|
|
1099
|
-
resources: Array.from(ctx.resources),
|
|
1100
|
-
hints: Array.from(ctx.hints)
|
|
1101
|
-
};
|
|
1102
|
-
return stringifyResponse ? JSON.stringify(result) : result;
|
|
1103
1616
|
}
|
|
1104
1617
|
|
|
1105
1618
|
function getSrcsets(dom) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/dom",
|
|
3
|
-
"version": "1.31.14-beta.
|
|
3
|
+
"version": "1.31.14-beta.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"interactor.js": "^2.0.0-beta.10"
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "a17d4a1453c6bef282fd3da38082b670e125a5be"
|
|
39
39
|
}
|