Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
9 changes: 8 additions & 1 deletion packages/core/src/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ 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
let fidelityRegions = domSnapshot?.fidelityRegions || domSnapshot?.[0]?.fidelityRegions || [];

// 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 +239,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
42 changes: 40 additions & 2 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,21 @@ 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 preflightPath = path.join(pkgRoot, 'src', 'preflight.js');
try {
_preflightScript = await fs.promises.readFile(preflightPath, 'utf-8');
} catch {
_preflightScript = ''; // graceful fallback if file not found
}
}
return _preflightScript;
}

export class Page {
static TIMEOUT = undefined;

Expand Down Expand Up @@ -187,7 +203,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 +227,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() {
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, 5000); })
]);
});

await this.insertPercyDom();

// serialize and capture a DOM snapshot
Expand All @@ -221,7 +252,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 @@ -247,6 +278,13 @@ export class Page {
waitForDebuggerOnStart: false,
autoAttach: true,
flatten: true
}),
// inject preflight script to intercept closed shadow roots and ElementInternals
// before any page scripts run
getPreflightScript().then(script => {
if (script) {
return session.send('Page.addScriptToEvaluateOnNewDocument', { source: script });
}
}));
}

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
42 changes: 32 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,23 @@ 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) {
// CSS-escape the state value to prevent injection
states.push(state.replace(/["\\}\]]/g, '\\$&'));
}
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 +119,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 +137,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.call(this, init);
if (init && 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.call(this);
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