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', () => {
'go ',
{ 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 =
+ '' +
+ 'go ';
+
+ 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) }',
+ ''
+ );
+ 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 }',
+ ''
+ );
+ 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 }',
+ 'p q
'
+ );
+ 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