Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7f9d847
feat: add warnings for sandboxed iframes during DOM serialization
aryanku-dev Apr 11, 2026
faf5789
feat: increase DOM structures coverage — closed shadow roots, :state(…
aryanku-dev Apr 11, 2026
a7cb982
feat: add data-percy-ignore for iframes, customElements.whenDefined()…
aryanku-dev Apr 11, 2026
32cb384
fix: resolve eslint no-undef errors in preflight.js
aryanku-dev Apr 11, 2026
42cb9b8
fix: CI failures — Firefox focus tests and @percy/core ignoreIframeSe…
aryanku-dev Apr 11, 2026
30224bc
fix
aryanku-dev Apr 11, 2026
14c5de7
feat: capture fidelity regions with bounding rects for excluded ifram…
aryanku-dev Apr 11, 2026
7cbb2b3
feat: send fidelityRegions to API in snapshot creation payload
aryanku-dev Apr 11, 2026
cab957e
fix: add fidelity-regions to snapshot payload assertions in client an…
aryanku-dev Apr 11, 2026
32f5fce
fix: simplify fidelity-regions payload to fix client coverage branch
aryanku-dev Apr 11, 2026
f25a181
fix: focus capture inside shadow DOM and CSS rule injection into shad…
aryanku-dev Apr 14, 2026
25b87aa
test: add coverage for shadow DOM focus traversal and style injection
aryanku-dev Apr 15, 2026
88ad0bf
fix: improve shadow DOM style injection test to use CSSOM for reliabl…
aryanku-dev Apr 15, 2026
6d4b313
fix: remove dead code guard and improve shadow DOM style injection test
aryanku-dev Apr 15, 2026
4d75f34
coverage
aryanku-dev Apr 15, 2026
297afa2
coverage fix
aryanku-dev Apr 15, 2026
2846dfd
Address PR review comments: preflight fixes, false-positive shadow fl…
aryanku-dev Apr 20, 2026
d0d36f0
Merge branch 'master' into PER-7292
aryanku-dev Apr 20, 2026
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
3 changes: 2 additions & 1 deletion packages/cli-upload/test/upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ describe('percy upload', () => {
regions: null,
'enable-layout': false,
'th-test-case-execution-id': null,
browsers: null
browsers: null,
'fidelity-regions': null
},
relationships: {
resources: {
Expand Down
4 changes: 3 additions & 1 deletion packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ export class PercyClient {
regions,
algorithm,
algorithmConfiguration,
fidelityRegions,
resources = [],
meta
} = {}) {
Expand Down Expand Up @@ -490,7 +491,8 @@ export class PercyClient {
'enable-javascript': enableJavaScript || null,
'enable-layout': enableLayout || false,
'th-test-case-execution-id': thTestCaseExecutionId || null,
browsers: normalizeBrowsers(browsers) || null
browsers: normalizeBrowsers(browsers) || null,
'fidelity-regions': fidelityRegions || null
},
relationships: {
resources: {
Expand Down
15 changes: 10 additions & 5 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1076,7 +1076,8 @@ describe('PercyClient', () => {
'enable-javascript': true,
'enable-layout': true,
'th-test-case-execution-id': 'random-uuid',
browsers: null
browsers: null,
'fidelity-regions': null
},
relationships: {
resources: {
Expand Down Expand Up @@ -1168,7 +1169,8 @@ describe('PercyClient', () => {
'enable-javascript': true,
'enable-layout': true,
'th-test-case-execution-id': 'random-uuid',
browsers: ['chrome', 'firefox', 'safari_on_iphone']
browsers: ['chrome', 'firefox', 'safari_on_iphone'],
'fidelity-regions': null
},
relationships: {
resources: {
Expand Down Expand Up @@ -1219,7 +1221,8 @@ describe('PercyClient', () => {
'enable-layout': false,
regions: null,
'th-test-case-execution-id': null,
browsers: null
browsers: null,
'fidelity-regions': null
},
relationships: {
resources: {
Expand Down Expand Up @@ -1292,7 +1295,8 @@ describe('PercyClient', () => {
regions: null,
'enable-layout': false,
'th-test-case-execution-id': null,
browsers: null
browsers: null,
'fidelity-regions': null
},
relationships: {
resources: {
Expand Down Expand Up @@ -2070,7 +2074,8 @@ describe('PercyClient', () => {
regions: null,
'enable-layout': false,
'th-test-case-execution-id': null,
browsers: null
browsers: null,
'fidelity-regions': null
},
relationships: {
resources: {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,13 @@ export const configSchema = {
type: 'boolean',
default: false
},
ignoreIframeSelectors: {
type: 'array',
default: [],
items: {
type: 'string'
}
},
pseudoClassEnabledElements: {
type: 'object',
additionalProperties: false,
Expand Down Expand Up @@ -501,6 +508,7 @@ export const snapshotSchema = {
scopeOptions: { $ref: '/config/snapshot#/properties/scopeOptions' },
ignoreCanvasSerializationErrors: { $ref: '/config/snapshot#/properties/ignoreCanvasSerializationErrors' },
ignoreStyleSheetSerializationErrors: { $ref: '/config/snapshot#/properties/ignoreStyleSheetSerializationErrors' },
ignoreIframeSelectors: { $ref: '/config/snapshot#/properties/ignoreIframeSelectors' },
pseudoClassEnabledElements: { $ref: '/config/snapshot#/properties/pseudoClassEnabledElements' },
discovery: {
type: 'object',
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) {
let log = logger('core:snapshot');
resources = [...(resources?.values() ?? [])];

// log fidelity warnings from dom serialization
let domWarnings = domSnapshot?.warnings?.filter(w => w.startsWith('[fidelity]')) || [];
for (let w of domWarnings) log.info(w);

// extract fidelity regions for API upload
// Only the first domSnapshot's fidelity regions are used — for responsive captures
// with multiple widths, regions are width-independent (same DOM structure), so
// the first entry is representative
let fidelityRegions = domSnapshot?.fidelityRegions || domSnapshot?.[0]?.fidelityRegions || [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reading domSnapshot?.[0]?.fidelityRegions suggests multi-root DOMs are expected, but only domSnapshot[0] is consulted — fidelity regions captured by subsequent roots are silently dropped. If responsive snapshot capture produces multiple domSnapshots (per-width), you'll be under-reporting. Either merge regions across all entries or add a comment explaining why only the first matters.

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.

Added a comment explaining that only the first domSnapshot's fidelity regions are used because for responsive captures with multiple widths, regions are width-independent (same DOM structure) so the first entry is representative.


// find any root resource matching the provided dom snapshot
// since root resources are stored as array
let roots = resources.find(r => Array.isArray(r));
Expand Down Expand Up @@ -232,7 +242,7 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) {
}
}

return { ...snapshot, resources };
return { ...snapshot, resources, fidelityRegions };
}

// Triggers the capture of resource requests for a page by iterating over snapshot widths to resize
Expand Down
61 changes: 58 additions & 3 deletions packages/core/src/page.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'fs';
import path from 'path';
import logger from '@percy/logger';
import Network from './network.js';
import { PERCY_DOM } from './api.js';
Expand All @@ -9,6 +10,31 @@ import {
serializeFunction
} from './utils.js';

// Cached preflight script for closed shadow root and ElementInternals interception
let _preflightScript = null;
async function getPreflightScript() {
if (!_preflightScript) {
let pkgRoot = path.resolve(path.dirname(PERCY_DOM), '..');
let candidates = [
path.join(pkgRoot, 'src', 'preflight.js'),
path.join(pkgRoot, 'dist', 'preflight.js'),
path.join(path.dirname(PERCY_DOM), 'preflight.js')
];
for (let candidate of candidates) {
try {
_preflightScript = await fs.promises.readFile(candidate, 'utf-8');
break;
} catch {
// try next candidate
}
}
if (!_preflightScript) {
_preflightScript = ''; // graceful fallback if file not found in any location
}
}
return _preflightScript;
}

export class Page {
static TIMEOUT = undefined;

Expand Down Expand Up @@ -187,7 +213,7 @@ export class Page {
execute,
...snapshot
}) {
let { name, width, enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements } = snapshot;
let { name, width, enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, ignoreIframeSelectors, pseudoClassEnabledElements } = snapshot;
this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta);

// wait for any specified timeout
Expand All @@ -211,6 +237,21 @@ export class Page {
// wait for any final network activity before capturing the dom snapshot
await this.network.idle();

// wait for custom elements to be defined before capturing
/* istanbul ignore next: no instrumenting injected code */
await this.eval(function() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adds up to a 5-second hard wait to every snapshot whenever the page has a single :not(:defined) element. Many production sites legitimately reference custom-element tags that never register (third-party widgets, blocked lazy loaders, typos). That means the common case eats +5s latency with no way to opt out. Please either (a) make the timeout configurable via the snapshot schema, (b) use a much shorter default (e.g. 500ms) since customElements.whenDefined tends to resolve synchronously for already-defined tags, or (c) resolve early if network is idle and the undefined tags have no pending loader.

This will regress P50 build latency for SDK consumers.

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.

Fixed. Reduced timeout from 5000ms to 500ms. customElements.whenDefined resolves synchronously for already-defined tags, so 500ms is sufficient for the common case while avoiding the latency regression for pages with never-registered custom elements.

let undefinedEls = document.querySelectorAll(':not(:defined)');
if (!undefinedEls.length) return Promise.resolve();
return Promise.race([
Promise.all(
Array.from(undefinedEls).map(function(el) {
return window.customElements.whenDefined(el.localName);
})
),
new Promise(function(r) { setTimeout(r, 500); })
]);
});

await this.insertPercyDom();

// serialize and capture a DOM snapshot
Expand All @@ -221,7 +262,7 @@ export class Page {
/* eslint-disable-next-line no-undef */
domSnapshot: PercyDOM.serialize(options),
url: document.URL
}), { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements });
}), { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, ignoreIframeSelectors, pseudoClassEnabledElements });

return { ...snapshot, ...capture };
}
Expand All @@ -238,8 +279,22 @@ export class Page {
if (session.isDocument) {
session.on('Target.attachedToTarget', this._handleAttachedToTarget);

// Chain preflight injection after Page.enable to ensure the Page domain
// is ready before addScriptToEvaluateOnNewDocument
let pageEnablePromise = session.send('Page.enable');
commands.push(
session.send('Page.enable'),
pageEnablePromise.then(() => {
return getPreflightScript().then(script => {
if (script) {
return session.send('Page.addScriptToEvaluateOnNewDocument', { source: script })
.catch(err => {
if (!err.message?.includes('closed') && !err.message?.includes('destroyed')) {
logger('core:page').debug('Preflight script injection failed:', err.message);
}
});
}
});
}),
session.send('Page.setLifecycleEventsEnabled', { enabled: true }),
session.send('Security.setIgnoreCertificateErrors', { ignore: true }),
session.send('Emulation.setScriptExecutionDisabled', { value: !this.enableJavaScript }),
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 @@ -83,7 +83,8 @@ describe('Percy', () => {
responsiveSnapshotCapture: false,
ignoreCanvasSerializationErrors: false,
ignoreStyleSheetSerializationErrors: false,
forceShadowAsLightDOM: false
forceShadowAsLightDOM: false,
ignoreIframeSelectors: []
});
});

Expand All @@ -110,7 +111,7 @@ describe('Percy', () => {
});

// expect required arguments are passed to PercyDOM.serialize
expect(evalSpy.calls.allArgs()[3]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined, ignoreCanvasSerializationErrors: undefined, ignoreStyleSheetSerializationErrors: 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 }]));

expect(snapshot.url).toEqual('http://localhost:8000/');
expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({
Expand Down
43 changes: 33 additions & 10 deletions packages/dom/src/clone-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ import { handleErrors } from './utils';
const ignoreTags = ['NOSCRIPT'];

/**
* if a custom element has attribute callback then cloneNode calls a callback that can
* increase CPU load or some other change.
* So we want to make sure that it is not called when doing serialization.
*/
* Clone an element without triggering custom element lifecycle callbacks.
* Custom elements with callbacks or closed shadow roots are cloned as proxy elements
* to prevent constructors from running (which could call attachShadow, fetch data, etc).
*/
function cloneElementWithoutLifecycle(element) {
if (!(element.attributeChangedCallback) || !element.tagName.includes('-')) {
return element.cloneNode(); // Standard clone for non-custom elements
let isCustomElement = element.tagName?.includes('-');
let hasClosedShadow = isCustomElement && window.__percyClosedShadowRoots?.has(element);
let hasCallbacks = isCustomElement && element.attributeChangedCallback;

if (!isCustomElement || (!hasCallbacks && !hasClosedShadow)) {
return element.cloneNode();
}

const cloned = document.createElement('data-percy-custom-element-' + element.tagName);
Expand Down Expand Up @@ -65,6 +69,24 @@ export function cloneNodeAndShadow(ctx) {

let clone = cloneElementWithoutLifecycle(node);

// After cloning and before shadow DOM handling, detect custom states
let percyInternals = window.__percyInternals?.get(node);
if (percyInternals?.states?.size > 0) {
let states = [];
try {
for (let state of percyInternals.states) {
// Skip invalid state values (spec requires <dashed-ident>)
if (!/^[-\w]+$/.test(state)) continue;
states.push(state);
}
if (states.length > 0) {
clone.setAttribute('data-percy-custom-state', states.join(' '));
}
} catch (e) {
// graceful no-op if states not iterable
}
}

// Handle <style> tag specifically for media queries
if (node.nodeName === 'STYLE' && !enableJavaScript) {
let cssText = node.textContent?.trim() || '';
Expand Down Expand Up @@ -98,11 +120,12 @@ export function cloneNodeAndShadow(ctx) {
Array.from(clone.children).forEach((child) => clone.removeChild(child));
}

// clone shadow DOM
if (node.shadowRoot && !disableShadowDOM) {
// clone shadow DOM (including closed shadow roots intercepted by preflight)
let nodeShadowRoot = node.shadowRoot || window.__percyClosedShadowRoots?.get(node);
if (nodeShadowRoot && !disableShadowDOM) {
if (forceShadowAsLightDOM) {
// When forceShadowAsLightDOM is true, treat shadow content as normal DOM
walkTree(node.shadowRoot.firstChild, clone);
walkTree(nodeShadowRoot.firstChild, clone);
} else {
// create shadowRoot
if (clone.shadowRoot) {
Expand All @@ -115,7 +138,7 @@ export function cloneNodeAndShadow(ctx) {
});
}
// clone dom elements
walkTree(node.shadowRoot.firstChild, clone.shadowRoot);
walkTree(nodeShadowRoot.firstChild, clone.shadowRoot);
}
}

Expand Down
32 changes: 32 additions & 0 deletions packages/dom/src/preflight.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Percy Pre-flight Script
// Injected before page scripts to intercept closed shadow roots and ElementInternals.
// This enables Percy to capture content inside closed shadow DOM and custom element states.

(function() {
if (window.__percyPreflightActive) return;
window.__percyPreflightActive = true;

// --- Intercept closed shadow roots ---
let closedShadowRoots = new WeakMap();
let origAttachShadow = window.Element.prototype.attachShadow;
window.Element.prototype.attachShadow = function(init) {
let root = origAttachShadow.apply(this, arguments);
if (init?.mode === 'closed') {
closedShadowRoots.set(this, root);
}
return root;
};
window.__percyClosedShadowRoots = closedShadowRoots;

// --- Intercept ElementInternals for :state() capture ---
if (typeof window.HTMLElement.prototype.attachInternals === 'function') {
let internalsMap = new WeakMap();
let origAttachInternals = window.HTMLElement.prototype.attachInternals;
window.HTMLElement.prototype.attachInternals = function() {
let internals = origAttachInternals.apply(this, arguments);
internalsMap.set(this, internals);
return internals;
};
window.__percyInternals = internalsMap;
}
})();
5 changes: 3 additions & 2 deletions packages/dom/src/prepare-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ export function markElement(domElement, disableShadowDOM, forceShadowAsLightDOM)
}
}

// add special marker for shadow host
if (!disableShadowDOM && domElement.shadowRoot) {
// add special marker for shadow host (including closed shadow roots intercepted by preflight)
let shadowRoot = domElement.shadowRoot || window.__percyClosedShadowRoots?.get(domElement);
if (!disableShadowDOM && shadowRoot) {
// When forceShadowAsLightDOM is true, don't mark as shadow host
if (!forceShadowAsLightDOM) {
domElement.setAttribute('data-percy-shadow-host', '');
Expand Down
Loading
Loading