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('
host
', { withShadow: false }); - let host = document.getElementById('throw-host'); - let shadow = host.attachShadow({ mode: 'open' }); - shadow.innerHTML = ''; - - // Override querySelectorAll on the shadow root to throw - let origQSA = shadow.querySelectorAll.bind(shadow); - shadow.querySelectorAll = function(sel) { - if (sel === ':checked') throw new Error('simulated querySelectorAll failure'); - return origQSA(sel); - }; - - ctx = { dom: document, warnings: new Set() }; - // This will traverse into shadow and call queryShadowAll(shadow, ':checked') which throws - expect(() => markPseudoClassElements(ctx, { id: ['throw-host'] })).not.toThrow(); - }); - }); - - describe('queryShadowAll with shadow hosts (line 254)', () => { - it('traverses shadow hosts with data-percy-shadow-host attribute', () => { - withExample('
host
', { withShadow: false }); - let host = document.getElementById('sh'); - let shadow = host.attachShadow({ mode: 'open' }); - shadow.innerHTML = ''; - - ctx = { - dom: document, - warnings: new Set() - }; - markPseudoClassElements(ctx, { id: ['sh'] }); - // The checkbox inside shadow should be found and marked - let chk = shadow.getElementById('shadow-chk'); - if (chk) { - expect(chk.hasAttribute('data-percy-checked')).toBe(true); - } + markPseudoClassElements(ctx, null); + expect(document.getElementById('dis-nat').hasAttribute('data-percy-disabled')).toBe(false); + expect(document.getElementById('chk-nat').hasAttribute('data-percy-checked')).toBe(false); }); }); describe('walkCSSRules nested @media (line 273)', () => { it('walks CSS rules inside @media blocks', () => { - // Use :checked inside @media — works cross-browser without .focus() + // Use :hover inside @media — hover rules are always extracted, + // no element stamping or .focus() needed cross-browser withExample( - '' + + '' + '', { withShadow: false } ); @@ -1122,7 +1086,7 @@ describe('serialize-pseudo-classes', () => { serializePseudoClasses(ctx); let interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); expect(interactiveStyle).not.toBeNull(); - expect(interactiveStyle.textContent).toContain('[data-percy-checked]'); + expect(interactiveStyle.textContent).toContain('[data-percy-hover]'); }); }); @@ -1562,16 +1526,14 @@ describe('serialize-pseudo-classes', () => { }); describe('extractPseudoClassRules with multiple stylesheets', () => { - it('appends rules from each stylesheet under the same owner key', () => { - // Two ' + - '' + + '' + '' ); ctx = { @@ -1589,10 +1551,111 @@ describe('serialize-pseudo-classes', () => { // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; expect(() => serializePseudoClasses(ctx)).not.toThrow(); + const interactiveStyles = ctx.clone.querySelectorAll('style[data-percy-interactive-states]'); + expect(interactiveStyles.length).toBe(2); + const combined = Array.from(interactiveStyles).map(s => s.textContent).join('\n'); + expect(combined).toContain('[data-percy-focus]'); + expect(combined).toContain('[data-percy-hover]'); + }); + }); + + describe('cascade position of injected rules (PER-10077)', () => { + it('inserts a sheet’s rewritten rules immediately after that sheet, not at end of head', () => { + // The root cause of PER-10077: copies were appended at the end of , + // so they jumped past later stylesheets and won equal-specificity + // !important ties they should lose. Anchored after the source sheet, the + // copy keeps its rank and the later sheet still wins. + withExample( + // sheet 1 carries TWO interactive rules so both are grouped into a + // single injected ' + + '' + + '' + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + markPseudoClassElements(ctx, null); + serializePseudoClasses(ctx); + + const injected = ctx.clone.querySelectorAll('style[data-percy-interactive-states]'); + expect(injected.length).toBe(1); + expect(injected[0].textContent).toContain('.pos-btn[data-percy-hover]'); + + const sheet1 = ctx.clone.querySelector('[data-percy-element-id="_pcpos_s1"]'); + const sheet2 = ctx.clone.querySelector('[data-percy-element-id="_pcpos_s2"]'); + // Final order is: sheet1, injected copy, sheet2 — so the copy keeps + // sheet1's rank and sheet2 still wins the equal-specificity tie. + expect(sheet1.nextElementSibling).toBe(injected[0]); + expect(injected[0].nextElementSibling).toBe(sheet2); + }); + + it('falls back to end of head when a stamped sheet has no clone anchor', () => { + // ownerNode carries an id but the clone does not contain that element + // (e.g. the sheet node was dropped) — the copy still lands in . + withExample(''); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // Clone intentionally does NOT mirror the source ' + + '' + + '' + + '' + + '' + + '' + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + markPseudoClassElements(ctx, null); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + serializePseudoClasses(ctx); const interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); - expect(interactiveStyle).not.toBeNull(); - expect(interactiveStyle.textContent).toContain('[data-percy-focus]'); - expect(interactiveStyle.textContent).toContain('[data-percy-checked]'); + const injected = interactiveStyle ? interactiveStyle.textContent : ''; + expect(injected).not.toContain('data-percy-disabled'); + expect(injected).not.toContain('data-percy-checked'); + expect(injected).not.toContain(':not(:disabled)'); + expect(injected).not.toContain(':checked'); }); }); @@ -1707,14 +1770,16 @@ describe('serialize-pseudo-classes', () => { .toBe('.x[data-percy-focus-within], .y:focus-visible'); }); - it('rewrites :not(:checked) correctly', () => { + it('does not rewrite :checked or :disabled — they serialize natively (PER-10077)', () => { expect(rewritePseudoSelector(':not(:checked)')) - .toBe(':not([data-percy-checked])'); + .toBe(':not(:checked)'); + expect(rewritePseudoSelector('.mat-mdc-raised-button:not(:disabled)')) + .toBe('.mat-mdc-raised-button:not(:disabled)'); }); - it('rewrites multiple pseudo-classes in a single selector', () => { + it('rewrites only focus-family pseudos in mixed selectors', () => { expect(rewritePseudoSelector('.a:focus.b:checked.c:disabled')) - .toBe('.a[data-percy-focus].b[data-percy-checked].c[data-percy-disabled]'); + .toBe('.a[data-percy-focus].b:checked.c:disabled'); }); it('returns selector unchanged when no pseudo-class is present', () => {