Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ export const configSchema = {
minLength: 1
}
},
enablePseudoClassSerialization: {
type: 'boolean',
default: false
},
pseudoClassEnabledElements: {
type: 'object',
additionalProperties: false,
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/core/test/percy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ describe('Percy', () => {
ignoreCanvasSerializationErrors: false,
ignoreStyleSheetSerializationErrors: false,
forceShadowAsLightDOM: false,
ignoreIframeSelectors: []
ignoreIframeSelectors: [],
enablePseudoClassSerialization: false
});
});

Expand All @@ -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({
Expand Down
8 changes: 6 additions & 2 deletions packages/dom/src/serialize-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] An empty pseudoClassEnabledElements object silently re-enables the page-wide path

Any object is truthy here, so pseudoClassEnabledElements: {} — valid against the schema — turns the full auto-detect path back on, including the rule-copying behind the four regressions this PR is narrowing exposure to. Same for a config whose selector lists are all empty: the user's intent there is closer to "nothing configured" than "enable everything".

Suggestion: treat a config with no non-empty member list as absent (check that at least one recognized key holds a non-empty array). A one-line test with {} would pin the intended behavior either way.

Reviewer: orchestrator (aggregation pass)


// keep certain records throughout serialization
let ctx = {
resources: new Set(),
Expand All @@ -118,7 +121,8 @@ export function serializeDOM(options) {
ignoreIframeSelectors,
maxIframeDepth,
iframeDepth,
pseudoClassEnabledElements
pseudoClassEnabledElements,
pseudoClassSerialization
};

ctx.dom = dom;
Expand Down
6 changes: 4 additions & 2 deletions packages/dom/src/serialize-frames.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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
Expand Down
66 changes: 58 additions & 8 deletions packages/dom/src/serialize-pseudo-classes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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++;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Backslash escapes are only honored inside quotes

This skip lives in the if (quote) branch, so a comma escaped outside a string is still treated as a list separator. \, is valid inside a CSS identifier, so a rule like .foo\,bar:hover, .baz splits mid-identifier and can emit a mangled selector into the injected <style>. Reachability is low — CSSOM usually re-escapes such identifiers when serializing selectorText — but the omission currently reads as an oversight rather than a decision.

Suggestion: hoist the escape skip out of the quote branch so \ consumes the next character in any state, and add a test with .foo\,bar:hover, .baz. If the case is genuinely unreachable via CSSOM, say so in a comment instead.

Reviewer: stack-code-reviewer

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]);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
76 changes: 75 additions & 1 deletion packages/dom/test/serialize-dom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ describe('serializeDOM', () => {
'<button class="cbtn later">go</button>',
{ 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');
Expand All @@ -809,6 +809,80 @@ describe('serializeDOM', () => {
});
});

describe('interactive-state serialization opt-in gate (PER-10588)', () => {
const HOVER_PAGE =
'<style>.gbtn:hover { color: red }</style>' +
'<button id="gbtn" class="gbtn">go</button>';

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(
'<div id="gpop" popover="manual">hi</div>',
{ 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(
'<style>.gstate:state(checked) { color: red }</style><div class="gstate"></div>',
{ 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 <link> stamping (PER-10077 cascade anchoring)', () => {
it('stamps only stylesheet <link>s with a percy element id', () => {
// The pseudo-class serializer anchors its rewritten rules after a sheet's
Expand Down
53 changes: 53 additions & 0 deletions packages/dom/test/serialize-frames.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<iframe id="frame-pseudo" srcdoc="<style>.ibtn:hover { color: rgb(1, 2, 3); }</style><div class=\'ibtn\'>x</div>"></iframe>';
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]');
});
});
Loading
Loading