diff --git a/packages/core/test/utils.test.js b/packages/core/test/utils.test.js index 083da6671..8bb490bfe 100644 --- a/packages/core/test/utils.test.js +++ b/packages/core/test/utils.test.js @@ -924,10 +924,11 @@ describe('utils', () => { expect(isMetadataIP(undefined)).toBeNull(); }); - it('falls back to the raw value for a malformed IPv6 connected address', () => { - // A colon-bearing address that is not a valid IPv6 literal makes the - // URL-based canonicalization throw; canonicalHost catches and returns the - // input unchanged, and a non-metadata value is then allowed through. + it('allows a colon-containing address that is not a valid IPv6 literal', () => { + // canonicalHost wraps any colon-containing host in new URL('http://[..]/') + // to normalize IPv6. A malformed address (colon present but not valid + // IPv6) makes the URL parser throw; the catch falls back to the bare + // host, which is not a metadata target, so the connection is allowed. expect(isMetadataIP('gg::1')).toBeNull(); }); }); diff --git a/packages/dom/src/prepare-dom.js b/packages/dom/src/prepare-dom.js index d868afab5..fe10a496a 100644 --- a/packages/dom/src/prepare-dom.js +++ b/packages/dom/src/prepare-dom.js @@ -10,8 +10,16 @@ export function markElement(domElement, disableShadowDOM, forceShadowAsLightDOM) // Mark elements that are to be serialized later with a data attribute. // Custom elements with ElementInternals or closed shadow roots also get // stamped so the post-clone state-fallback can locate their clones. + let tagName = domElement.tagName?.toLowerCase(); + // Stylesheet s are stamped so the pseudo-class serializer can locate + // the sheet's clone element and anchor its rewritten interactive-state rules + // immediately after it — preserving the sheet's original cascade position + // instead of appending at the end of
(PER-10077). + let isStylesheetLink = tagName === 'link' && + /(^|\s)stylesheet(\s|$)/i.test(domElement.getAttribute('rel') || ''); if ( - ['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes(domElement.tagName?.toLowerCase()) || + ['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes(tagName) || + isStylesheetLink || isCustomElement(domElement) ) { if (!domElement.getAttribute('data-percy-element-id')) { diff --git a/packages/dom/src/serialize-pseudo-classes.js b/packages/dom/src/serialize-pseudo-classes.js index 023804afa..a06dde73a 100644 --- a/packages/dom/src/serialize-pseudo-classes.js +++ b/packages/dom/src/serialize-pseudo-classes.js @@ -2,11 +2,29 @@ // Serializes pseudo-class state into Percy's clone via two paths: // -// 1. Auto-detect path (every snapshot). For :focus / :focus-within / -// :checked / :disabled we stamp the live DOM with the corresponding -// data-percy-* attribute and rewrite matching CSS rules to use those -// attribute selectors. :focus-within stamps the focused element's -// ancestor chain across shadow boundaries. +// 1. Auto-detect path (every snapshot). For :focus / :focus-within we +// stamp the live DOM with the corresponding data-percy-* attribute +// and rewrite matching CSS rules to use those attribute selectors. +// :focus-within stamps the focused element's ancestor chain across +// shadow boundaries. +// +// Each rewritten rule is injected into a ' + + '' + + '', + { withShadow: false } + ); + let html = serializeDOM().html; + let sourceIdx = html.indexOf('.cbtn:hover'); + let copyIdx = html.indexOf('[data-percy-hover]'); + let laterIdx = html.indexOf('.cbtn.later'); + expect(copyIdx).toBeGreaterThan(-1); + expect(copyIdx).toBeGreaterThan(sourceIdx); // after its own sheet + expect(copyIdx).toBeLessThan(laterIdx); // before the later sheet + }); + }); + + describe('stylesheet stamping (PER-10077 cascade anchoring)', () => { + it('stamps only stylesheet s with a percy element id', () => { + // The pseudo-class serializer anchors its rewritten rules after a sheet's + // clone element, found via data-percy-element-id — so stylesheet links + // need one. Non-stylesheet links (preload, icon, …) do not. + withExample( + '' + + '' + + '', + { withShadow: false } + ); + let $ = parseDOM(serializeDOM().html, 'plain'); + expect($('link[rel="stylesheet"]')[0].getAttribute('data-percy-element-id')).toBeTruthy(); + expect($('link[rel="preload"]')[0].getAttribute('data-percy-element-id')).toBeNull(); + expect($('link:not([rel])')[0].getAttribute('data-percy-element-id')).toBeNull(); + }); }); describe(':state() CSS rewriting', () => { diff --git a/packages/dom/test/serialize-pseudo-classes.test.js b/packages/dom/test/serialize-pseudo-classes.test.js index ad8cded93..d8240214f 100644 --- a/packages/dom/test/serialize-pseudo-classes.test.js +++ b/packages/dom/test/serialize-pseudo-classes.test.js @@ -752,7 +752,7 @@ describe('serialize-pseudo-classes', () => { describe('extractPseudoClassRules clone.createElement fallback and head fallback (lines 399-406)', () => { it('uses ctx.dom.createElement when ctx.clone.createElement is falsy (line 401)', () => { withExample( - '' + + '' + '', { withShadow: false } ); @@ -782,7 +782,7 @@ describe('serialize-pseudo-classes', () => { it('uses ctx.clone.querySelector(head) when ctx.clone.head is falsy (line 405)', () => { withExample( - '' + + '' + '', { withShadow: false } ); @@ -967,7 +967,7 @@ describe('serialize-pseudo-classes', () => { describe('extractPseudoClassRules no head fallback (line 406)', () => { it('does not inject styles when clone has no head at all', () => { - withExample('', { withShadow: false }); + withExample('', { withShadow: false }); ctx = { dom: document, @@ -1012,9 +1012,10 @@ describe('serialize-pseudo-classes', () => { // unfocused should NOT have data-percy-focus let el = document.getElementById('unfocused'); expect(el.hasAttribute('data-percy-focus')).toBe(false); - // but :checked should still be detected on chk2 + // :checked is never stamped — the state serializes natively via the + // `checked` attribute (serialize-inputs), so no marker is needed let chk = document.getElementById('chk2'); - expect(chk.hasAttribute('data-percy-checked')).toBe(true); + expect(chk.hasAttribute('data-percy-checked')).toBe(false); }); }); @@ -1031,66 +1032,29 @@ describe('serialize-pseudo-classes', () => { }); }); - describe('markInteractiveStates disabled already marked branch', () => { - it('does not re-mark already disabled element', () => { - withExample('', { withShadow: false }); - let el = document.getElementById('dis-pre'); - el.setAttribute('data-percy-disabled', 'true'); + describe('markInteractiveStates does not stamp natively-serializable states', () => { + it('leaves :checked and :disabled elements unstamped (PER-10077)', () => { + // `disabled` is a reflected content attribute and serialize-inputs + // syncs `checked` to an attribute on the clone — both pseudo-classes + // match natively in the renderer, so no marker attribute is stamped + // and no CSS rules are rewritten for them. + withExample('', { withShadow: false }); ctx = { dom: document, warnings: new Set() }; - markPseudoClassElements(ctx, { id: ['dis-pre'] }); - // Should still have the attribute (not removed) - expect(el.getAttribute('data-percy-disabled')).toBe('true'); - }); - }); - - describe('queryShadowAll catch branch (line 253)', () => { - it('returns empty array when querySelectorAll throws', () => { - withExample('