diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 0fcf5abf9..1835b0f9b 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -337,6 +337,10 @@ export const configSchema = { minLength: 1 } }, + enablePseudoClassSerialization: { + type: 'boolean', + default: false + }, pseudoClassEnabledElements: { type: 'object', additionalProperties: false, @@ -571,6 +575,7 @@ export const snapshotSchema = { ignoreCanvasSerializationErrors: { $ref: '/config/snapshot#/properties/ignoreCanvasSerializationErrors' }, ignoreStyleSheetSerializationErrors: { $ref: '/config/snapshot#/properties/ignoreStyleSheetSerializationErrors' }, ignoreIframeSelectors: { $ref: '/config/snapshot#/properties/ignoreIframeSelectors' }, + enablePseudoClassSerialization: { $ref: '/config/snapshot#/properties/enablePseudoClassSerialization' }, pseudoClassEnabledElements: { $ref: '/config/snapshot#/properties/pseudoClassEnabledElements' }, discovery: { type: 'object', diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index 5a63c8011..ac030d9bc 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -80,6 +80,7 @@ function debugSnapshotOptions(snapshot) { debugProp(snapshot, 'ignoreCanvasSerializationErrors'); debugProp(snapshot, 'ignoreStyleSheetSerializationErrors'); debugProp(snapshot, 'pseudoClassEnabledElements', JSON.stringify); + debugProp(snapshot, 'enablePseudoClassSerialization'); debugProp(snapshot, 'discovery.autoConfigureAllowedHostnames'); if (Array.isArray(snapshot.domSnapshot)) { diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 1a5db7aac..97f8e7a59 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -249,7 +249,7 @@ export class Page { execute, ...snapshot }) { - let { name, width, enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, ignoreIframeSelectors, pseudoClassEnabledElements } = snapshot; + let { name, width, enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, ignoreIframeSelectors, pseudoClassEnabledElements, enablePseudoClassSerialization } = snapshot; this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta); // wait for any specified timeout @@ -329,7 +329,8 @@ export class Page { ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, ignoreIframeSelectors, - pseudoClassEnabledElements + pseudoClassEnabledElements, + enablePseudoClassSerialization }); // Attach readiness diagnostics onto the captured DOM snapshot so the backend/UI can surface diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 992d1b0ab..bb62dc6f4 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -89,7 +89,8 @@ describe('Percy', () => { ignoreCanvasSerializationErrors: false, ignoreStyleSheetSerializationErrors: false, forceShadowAsLightDOM: false, - ignoreIframeSelectors: [] + ignoreIframeSelectors: [], + enablePseudoClassSerialization: false }); }); @@ -116,7 +117,7 @@ describe('Percy', () => { }); // expect required arguments are passed to PercyDOM.serialize - expect(evalSpy.calls.allArgs()[4]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined, ignoreCanvasSerializationErrors: undefined, ignoreStyleSheetSerializationErrors: undefined, ignoreIframeSelectors: undefined, forceShadowAsLightDOM: undefined, pseudoClassEnabledElements: undefined }])); + expect(evalSpy.calls.allArgs()[4]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined, ignoreCanvasSerializationErrors: undefined, ignoreStyleSheetSerializationErrors: undefined, ignoreIframeSelectors: undefined, forceShadowAsLightDOM: undefined, pseudoClassEnabledElements: undefined, enablePseudoClassSerialization: undefined }])); expect(snapshot.url).toEqual('http://localhost:8000/'); expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({ diff --git a/packages/dom/src/serialize-dom.js b/packages/dom/src/serialize-dom.js index 1d025b505..c9cb0bb6c 100644 --- a/packages/dom/src/serialize-dom.js +++ b/packages/dom/src/serialize-dom.js @@ -100,9 +100,12 @@ export function serializeDOM(options) { ignoreIframeSelectors = options?.ignore_iframe_selectors, maxIframeDepth = options?.max_iframe_depth, iframeDepth = options?.iframe_depth ?? 0, - pseudoClassEnabledElements = options?.pseudo_class_enabled_elements + pseudoClassEnabledElements = options?.pseudo_class_enabled_elements, + enablePseudoClassSerialization = options?.enable_pseudo_class_serialization } = options || {}; + const pseudoClassSerialization = !!(enablePseudoClassSerialization || pseudoClassEnabledElements); + // keep certain records throughout serialization let ctx = { resources: new Set(), @@ -118,7 +121,8 @@ export function serializeDOM(options) { ignoreIframeSelectors, maxIframeDepth, iframeDepth, - pseudoClassEnabledElements + pseudoClassEnabledElements, + pseudoClassSerialization }; ctx.dom = dom; diff --git a/packages/dom/src/serialize-frames.js b/packages/dom/src/serialize-frames.js index 8e7114f15..595aaf15c 100644 --- a/packages/dom/src/serialize-frames.js +++ b/packages/dom/src/serialize-frames.js @@ -67,7 +67,7 @@ function clampIframeDepth(raw) { // is the current nesting level (0 at the top-level document, +1 per recursion). // The default fires for direct callers that don't set it (e.g. tests, or // any future caller that doesn't go through serializeDOM). -export function serializeFrames({ dom, clone, warnings, resources, enableJavaScript, disableShadowDOM, ignoreIframeSelectors, forceShadowAsLightDOM, maxIframeDepth, iframeDepth = 0 }) { +export function serializeFrames({ dom, clone, warnings, resources, enableJavaScript, disableShadowDOM, ignoreIframeSelectors, forceShadowAsLightDOM, maxIframeDepth, iframeDepth = 0, pseudoClassEnabledElements, pseudoClassSerialization }) { maxIframeDepth = clampIframeDepth(maxIframeDepth); for (let frame of dom.querySelectorAll('iframe')) { @@ -131,7 +131,9 @@ export function serializeFrames({ dom, clone, warnings, resources, enableJavaScr forceShadowAsLightDOM, ignoreIframeSelectors, maxIframeDepth, - iframeDepth: iframeDepth + 1 + iframeDepth: iframeDepth + 1, + pseudoClassEnabledElements, + enablePseudoClassSerialization: pseudoClassSerialization }); // append serialized warnings and resources diff --git a/packages/dom/src/serialize-pseudo-classes.js b/packages/dom/src/serialize-pseudo-classes.js index a06dde73a..06571965f 100644 --- a/packages/dom/src/serialize-pseudo-classes.js +++ b/packages/dom/src/serialize-pseudo-classes.js @@ -2,7 +2,14 @@ // Serializes pseudo-class state into Percy's clone via two paths: // -// 1. Auto-detect path (every snapshot). For :focus / :focus-within we +// Both paths below are gated on ctx.pseudoClassSerialization, which is off +// unless the snapshot sets enablePseudoClassSerialization or configures +// pseudoClassEnabledElements. Open-popover stamping and custom element +// :state() rewriting are NOT gated — the renderer requires +// data-percy-popover-open, and :state() is rewritten in place so it cannot +// reorder the cascade. +// +// 1. Auto-detect path (when enabled). 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 @@ -79,6 +86,47 @@ function selectorContainsPseudo(selectorText, pseudoList) { }); } +function splitSelectorList(selectorText) { + const parts = []; + let depth = 0; + let quote = null; + let start = 0; + + for (let i = 0; i < selectorText.length; i++) { + const char = selectorText[i]; + + if (quote) { + if (char === '\\') i++; + else if (char === quote) quote = null; + continue; + } + + if (char === '"' || char === "'") quote = char; + else if (char === '(' || char === '[') depth++; + else if (char === ')' || char === ']') depth--; + else if (char === ',' && depth === 0) { + parts.push(selectorText.slice(start, i)); + start = i + 1; + } + } + + parts.push(selectorText.slice(start)); + return parts; +} + +function rewritePseudoSelectorList(selectorText, pseudoList) { + const pseudoParts = []; + + for (const part of splitSelectorList(selectorText)) { + const trimmed = part.trim(); + if (trimmed && selectorContainsPseudo(trimmed, pseudoList)) { + pseudoParts.push(rewritePseudoSelector(trimmed)); + } + } + + return pseudoParts.length ? pseudoParts.join(', ') : null; +} + export function rewritePseudoSelector(selectorText) { let out = selectorText; for (const [pseudo, re] of PSEUDO_RES) out = out.replace(re, PSEUDO_TO_ATTR[pseudo]); @@ -288,8 +336,9 @@ export function getElementsToProcess(ctx, config, markWithId = false) { // the data-attributes are copied through to the clone via cloneNode. export function markPseudoClassElements(ctx, config) { ctx._liveMutations = []; - markInteractiveStates(ctx); markOpenPopovers(ctx); + if (!ctx.pseudoClassSerialization) return; + markInteractiveStates(ctx); if (config) getElementsToProcess(ctx, config, true); } @@ -426,9 +475,9 @@ function extractPseudoClassRules(ctx) { // interactive pseudo. Skips most rules on most stylesheets without // touching the regex bank. if (!rule.selectorText.includes(':')) continue; - if (!selectorContainsPseudo(rule.selectorText, ALL_INTERACTIVE_PSEUDO)) continue; - const rewrittenSelector = rewritePseudoSelector(rule.selectorText); + const rewrittenSelector = rewritePseudoSelectorList(rule.selectorText, ALL_INTERACTIVE_PSEUDO); + if (!rewrittenSelector) continue; const cssText = `${rewrittenSelector} { ${rule.style.cssText} }`; const wrapped = rule.wrapper ? `${rule.wrapper} { ${cssText} }` : cssText; @@ -490,10 +539,11 @@ function injectAtSheetPosition(scopeRoot, sheet, styleElement, fallbackTarget) { } export function serializePseudoClasses(ctx) { - // Auto-detect path runs unconditionally so `:focus`/`:focus-within` - // rules are rewritten regardless of whether the user configured a - // pseudoClassEnabledElements list (and regardless of whether that list - // matched anything on this page). + if (!ctx.pseudoClassSerialization) return; + + // Auto-detect path rewrites `:focus`/`:focus-within` rules whether or not + // a pseudoClassEnabledElements list was configured (and regardless of + // whether that list matched anything on this page). extractPseudoClassRules(ctx); if (!ctx.pseudoClassEnabledElements) return; diff --git a/packages/dom/test/serialize-dom.test.js b/packages/dom/test/serialize-dom.test.js index f30697df2..2c322f56e 100644 --- a/packages/dom/test/serialize-dom.test.js +++ b/packages/dom/test/serialize-dom.test.js @@ -799,7 +799,7 @@ describe('serializeDOM', () => { '', { withShadow: false } ); - let html = serializeDOM().html; + let html = serializeDOM({ enablePseudoClassSerialization: true }).html; let sourceIdx = html.indexOf('.cbtn:hover'); let copyIdx = html.indexOf('[data-percy-hover]'); let laterIdx = html.indexOf('.cbtn.later'); @@ -809,6 +809,80 @@ describe('serializeDOM', () => { }); }); + describe('interactive-state serialization opt-in gate (PER-10588)', () => { + const HOVER_PAGE = + '' + + ''; + + it('is off by default — no rewritten copy and no state stamps', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM().html; + expect(html).not.toContain('data-percy-interactive-states'); + expect(html).not.toContain('[data-percy-hover]'); + expect(html).not.toContain('data-percy-focus'); + }); + + it('is on when enablePseudoClassSerialization is set', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ enablePseudoClassSerialization: true }).html; + expect(html).toContain('data-percy-interactive-states'); + expect(html).toContain('[data-percy-hover]'); + }); + + it('accepts the snake_case option name', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ enable_pseudo_class_serialization: true }).html; + expect(html).toContain('[data-percy-hover]'); + }); + + it('is on when pseudoClassEnabledElements is configured, without the flag', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ pseudoClassEnabledElements: { id: ['gbtn'] } }).html; + expect(html).toContain('[data-percy-hover]'); + expect(html).toContain('data-percy-pseudo-element-id'); + }); + + it('stays on for configured elements even when the flag is materialized false', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ + enablePseudoClassSerialization: false, + pseudoClassEnabledElements: { id: ['gbtn'] } + }).html; + expect(html).toContain('[data-percy-hover]'); + }); + + it('still stamps open popovers while disabled — the renderer requires it', () => { + withExample( + '
hi
', + { withShadow: false } + ); + let popover = document.getElementById('gpop'); + if (typeof popover.showPopover !== 'function') return; + popover.showPopover(); + + let html = serializeDOM().html; + expect(html).not.toContain('data-percy-interactive-states'); + expect(html).toContain('data-percy-popover-open'); + }); + + it('still rewrites custom element :state() while disabled', () => { + withExample( + '
', + { withShadow: false } + ); + let html = serializeDOM().html; + expect(html).not.toContain('data-percy-interactive-states'); + expect(html).toContain('[data-percy-custom-state~="checked"]'); + }); + + it('leaves no data-percy-* state attributes on the live DOM either way', () => { + withExample(HOVER_PAGE, { withShadow: false }); + serializeDOM({ enablePseudoClassSerialization: true }); + expect(document.querySelector('[data-percy-hover]')).toBeNull(); + expect(document.querySelector('[data-percy-focus]')).toBeNull(); + }); + }); + 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 diff --git a/packages/dom/test/serialize-frames.test.js b/packages/dom/test/serialize-frames.test.js index e9b27c375..8ec0e902f 100644 --- a/packages/dom/test/serialize-frames.test.js +++ b/packages/dom/test/serialize-frames.test.js @@ -554,3 +554,56 @@ describe('serializeFrames', () => { }); }); }); + +describe('serializeFrames - pseudo-class option propagation', () => { + let $container; + + const srcdocOfFrame = (options) => { + let $ = parseDOM(serializeDOM(options).html, 'plain'); + let el = $('#frame-pseudo')[0]; + let raw = el?.getAttribute('srcdoc') || ''; + let decoder = document.createElement('textarea'); + decoder.innerHTML = raw; + return decoder.value; + }; + + beforeEach(async () => { + $container = document.createElement('div'); + $container.id = 'pseudo-frame-host'; + $container.innerHTML = ''; + document.body.appendChild($container); + + await when(() => { + let $frame = $container.querySelector('#frame-pseudo'); + assert($frame?.contentDocument?.querySelector('.ibtn'), '#frame-pseudo did not load in time'); + return $frame; + }, 5000); + }, 0); + + afterEach(() => { + $container?.remove(); + $container = null; + }); + + it('does not serialize iframe interactive states by default', () => { + let inner = srcdocOfFrame(); + expect(inner).not.toContain('data-percy-interactive-states'); + expect(inner).not.toContain('data-percy-hover'); + }); + + it('serializes iframe interactive states when the flag is enabled', () => { + let inner = srcdocOfFrame({ enablePseudoClassSerialization: true }); + expect(inner).toContain('data-percy-interactive-states'); + expect(inner).toContain('[data-percy-hover]'); + }); + + it('serializes iframe interactive states via the snake_case alias', () => { + let inner = srcdocOfFrame({ enable_pseudo_class_serialization: true }); + expect(inner).toContain('[data-percy-hover]'); + }); + + it('serializes iframe interactive states when pseudoClassEnabledElements is configured', () => { + let inner = srcdocOfFrame({ pseudoClassEnabledElements: { selectors: ['.ibtn'] } }); + expect(inner).toContain('[data-percy-hover]'); + }); +}); diff --git a/packages/dom/test/serialize-pseudo-classes.test.js b/packages/dom/test/serialize-pseudo-classes.test.js index d8240214f..82a3780e8 100644 --- a/packages/dom/test/serialize-pseudo-classes.test.js +++ b/packages/dom/test/serialize-pseudo-classes.test.js @@ -26,7 +26,8 @@ describe('serialize-pseudo-classes', () => { beforeEach(() => { ctx = { dom: document, - warnings: new Set() + warnings: new Set(), + pseudoClassSerialization: true }; withExample('
'); ctx.clone = document.implementation.createHTMLDocument('Clone'); @@ -179,7 +180,7 @@ describe('serialize-pseudo-classes', () => { // We'll temporarily patch getElementsToProcess to expose markWithId withExample('
'); const el = document.getElementById('foo'); - const ctx2 = { dom: document, warnings: new Set() }; + const ctx2 = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; getElementsToProcess(ctx2, { id: ['foo'] }); expect(el.hasAttribute('data-percy-pseudo-element-id')).toBe(false); }); @@ -191,7 +192,7 @@ describe('serialize-pseudo-classes', () => { }); it('throws an error if config is null', () => { - const ctx2 = { dom: document, warnings: new Set() }; + const ctx2 = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; expect(() => { getElementsToProcess(ctx2, null); }).toThrow(); @@ -476,7 +477,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -531,7 +533,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -562,7 +565,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -679,7 +683,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -709,6 +714,7 @@ describe('serialize-pseudo-classes', () => { resources: new Set(), hints: new Set(), shadowRootElements: [], + pseudoClassSerialization: true, pseudoClassEnabledElements: { id: ['has-test'] } }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -738,7 +744,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -763,7 +770,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -793,7 +801,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -836,7 +845,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Do NOT copy DOM to clone - so the clone element won't be found // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -873,7 +883,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -908,7 +919,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = '
test
'; @@ -930,7 +942,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -956,7 +969,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -976,7 +990,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1004,7 +1019,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Blur any active element to ensure nothing is focused document.activeElement?.blur(); @@ -1025,7 +1041,7 @@ describe('serialize-pseudo-classes', () => { let el = document.getElementById('has-percy-id'); el.setAttribute('data-percy-element-id', '_focus_branch_test'); withMockedFocus(el, () => { - ctx = { dom: document, warnings: new Set() }; + ctx = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; markPseudoClassElements(ctx, { id: ['has-percy-id'] }); }); expect(el.hasAttribute('data-percy-focus')).toBe(true); @@ -1078,7 +1094,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1135,7 +1152,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1179,7 +1197,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1234,7 +1253,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1281,7 +1301,7 @@ describe('serialize-pseudo-classes', () => { Object.defineProperty(shadow, 'activeElement', { get: () => deepInput, configurable: true }); Object.defineProperty(document, 'activeElement', { get: () => host, configurable: true }); try { - ctx = { dom: document, warnings: new Set() }; + ctx = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; markPseudoClassElements(ctx, null); // The traversal should reach deepInput and stamp [data-percy-focus] expect(deepInput.hasAttribute('data-percy-focus')).toBe(true); @@ -1325,7 +1345,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = '
'; @@ -1371,7 +1392,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Clone host exists but WITHOUT a shadow root attached // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -1447,7 +1469,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1485,6 +1508,7 @@ describe('serialize-pseudo-classes', () => { resources: new Set(), hints: new Set(), shadowRootElements: [], + pseudoClassSerialization: true, pseudoClassEnabledElements: { id: ['cm3-btn'] } }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -1499,6 +1523,59 @@ describe('serialize-pseudo-classes', () => { }); }); + describe('grouped selector lists (PER-10588)', () => { + function serializeWith(styleText, bodyHTML) { + withExample('' + bodyHTML); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [], + pseudoClassSerialization: true + }; + // 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); + let el = ctx.clone.querySelector('style[data-percy-interactive-states]'); + return el ? el.textContent : null; + } + + it('copies only the pseudo-bearing members of a grouped selector', () => { + let css = serializeWith( + '.card .title, .card .trigger:hover { color: rgb(0, 75, 111) }' + + '.card--blue .title { color: rgb(255, 255, 255) }', + '
t' + + 'go
' + ); + expect(css).toContain('.card .trigger[data-percy-hover]'); + expect(css).not.toContain('.card .title'); + }); + + it('does not split commas nested in :is()/:not() or attribute values', () => { + let css = serializeWith( + ':is(.a, .b) .lnk:focus, [data-k="x,y"] .lnk:focus { color: red }', + '
a
' + ); + expect(css).toContain(':is(.a, .b) .lnk[data-percy-focus]'); + expect(css).toContain('[data-k="x,y"] .lnk[data-percy-focus]'); + }); + + it('keeps every member when they all carry a pseudo', () => { + let css = serializeWith( + '.p:focus, .q:focus { color: red }', + '
pq
' + ); + expect(css).toContain('.p[data-percy-focus]'); + expect(css).toContain('.q[data-percy-focus]'); + }); + }); + describe('walkCSSRules charset / import / etc with no selectorText', () => { it('skips rules that have neither nested cssRules nor a selector', () => { // @charset has no cssRules and no selectorText — exercises the @@ -1514,7 +1591,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1543,7 +1621,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1579,7 +1658,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; @@ -1609,7 +1689,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Clone intentionally does NOT mirror the source