Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 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
f665576
test(dom): cover inaccessible-shadow fidelity branch and invalid cust…
aryanku-dev May 4, 2026
0ad3f4c
fix(core): istanbul-ignore unreachable preflight defensive paths
aryanku-dev May 4, 2026
be1cffd
Drop fidelity regions, centralize iframe utils, harden preflight loader
aryanku-dev May 5, 2026
97eab9a
Add iframe-utils tests for 100% coverage
aryanku-dev May 5, 2026
caa9f30
Cover inaccessible-shadow line with istanbul-ignore (path requires mo…
aryanku-dev May 5, 2026
ad73736
Broaden 'about:' to a generic prefix for browser-internal iframe URLs
aryanku-dev May 5, 2026
e0d7d9c
Hoist istanbul-ignore so the branch above the inaccessible-shadow loo…
aryanku-dev May 5, 2026
6f41649
Hoist istanbul-ignore to cover full preflight catch block + CDP catch…
aryanku-dev May 5, 2026
873233f
Place istanbul-ignore directly above each catch-block statement
aryanku-dev May 5, 2026
f8b650f
Refactor serialize-pseudo-classes: split state handling, dedupe shado…
aryanku-dev May 6, 2026
9996685
Silence semgrep insecure-document-method on intentional textContent w…
aryanku-dev May 6, 2026
a621626
Ignore DOM serialization helpers in semgrep — operate on parsed CSS, …
aryanku-dev May 6, 2026
c8d9b15
Hoist pseudo-class boundary regexes to literals (semgrep ReDoS rule)
aryanku-dev May 6, 2026
66c20e9
Cover at-rule prelude branches and defensive rewrite-equality guard w…
aryanku-dev May 6, 2026
db20c65
Mark at-rule prelude branch as defensive
aryanku-dev May 6, 2026
942c200
Use istanbul ignore next to cover both branches of at-rule fork
aryanku-dev May 6, 2026
0da8018
Address code review findings: drop dead code, fix correctness bugs
aryanku-dev May 6, 2026
6a9c247
Restore :hover/:active/:focus-within per scope; wrap cleanup in finally
aryanku-dev May 6, 2026
4b49c35
Move marking inside try/finally; add nested-iframe depth limit
aryanku-dev May 6, 2026
f917176
sdk-utils: re-expose iframe depth constants for external SDKs
aryanku-dev May 6, 2026
4504f84
test: bring @percy/dom coverage back to 100%
aryanku-dev May 6, 2026
5afd16d
fix(dom,core): close 5 remaining PR review gaps on top of refactor
aryanku-dev May 7, 2026
5101695
fix(core): pass handlePreflightInjectionError directly to .catch
aryanku-dev May 7, 2026
6259941
fix(core): send Page.enable and preflight addScript in parallel
aryanku-dev May 7, 2026
16be70a
test(dom): cover defensive ignores; remove 7 PR-introduced istanbul-i…
aryanku-dev May 7, 2026
6306e52
refactor: replace preflight injection with CDP closed-shadow discovery
aryanku-dev May 7, 2026
f719993
test: drop now-empty packages/core/test/unit/page.test.js
aryanku-dev May 7, 2026
f4272f9
refactor: parallelize CDP, cap shadow depth, drop dead internals path
aryanku-dev May 7, 2026
be9e9f7
test: drop closed-shadow parity test
aryanku-dev May 7, 2026
de1f3c8
perf(dom): replace per-element querySelector loops with indexed Maps
aryanku-dev May 7, 2026
fd104af
fix(core): close 100% coverage gap on page.js shadow-debug log
aryanku-dev May 7, 2026
1deb86d
test: move _logShadowDebug coverage into percy.test.js
aryanku-dev May 7, 2026
551f2dc
fix(core): make _logShadowDebug a prototype method, not class field
aryanku-dev May 7, 2026
8a958f2
test(regression): add closed shadow root cases to shadow-dom page
aryanku-dev May 7, 2026
df30686
fix: ce-review P1/P2 — per-realm WeakMap, depth fix, success count, perf
aryanku-dev May 7, 2026
032470a
refactor: simplify PR — drop dead tokenizers, dedupe closed-shadow, e…
aryanku-dev May 9, 2026
002bf06
fix(ci): move closed-shadow into @percy/sdk-utils, depend from @percy…
aryanku-dev May 9, 2026
90d5301
Merge branch 'master' into PER-7292
aryanku-dev May 9, 2026
29d9150
fix(core): use default import + destructure for @percy/sdk-utils
aryanku-dev May 9, 2026
a9c95c2
chore: align @percy/sdk-utils version pin with the workspace bump
aryanku-dev May 9, 2026
3ee837b
fix(lint): hoist sdkUtils destructure below all imports
aryanku-dev May 9, 2026
6ae3c4d
revert: keep closed-shadow duplicated in core and sdk-utils
aryanku-dev May 9, 2026
2397f65
test(dom): close 100% coverage on serialize-frames depth-limit + drop…
aryanku-dev May 9, 2026
d340a9f
fix(core): skip pre-snapshot CDP steps when the session is already cl…
aryanku-dev May 9, 2026
a34a392
refactor: address PR review — drop waitForCustomElementsTimeout optio…
aryanku-dev May 10, 2026
8d652e8
test(core): cover the customElements-wait try/catch branch
aryanku-dev May 10, 2026
b93bad3
test(core): cover the closedReason gate skip branch on page.snapshot
aryanku-dev May 10, 2026
244aa8a
test(core): make the closedReason gate test actually reach line 261
aryanku-dev May 10, 2026
6e5d392
fix(core): simplify customElements-wait catch log + cover non-Error path
aryanku-dev May 10, 2026
59d2559
fix(core): align customElements-wait catch with codebase conventions
aryanku-dev May 10, 2026
7027db1
fix(core): hoist istanbul-ignore onto the in-page serialize arrow
aryanku-dev May 10, 2026
2b4b7f5
fix(core): place istanbul-ignore on its own line above the serialize …
aryanku-dev May 10, 2026
3bbfa07
fix(core): extract serializeDomCapture to a top-level function for ig…
aryanku-dev May 10, 2026
fb1e9f9
fix(core): extract _logShadowDebug to a prototype method + cover it d…
aryanku-dev May 10, 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
197 changes: 197 additions & 0 deletions packages/core/src/closed-shadow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Closed-shadow capture helper. CLI-side only.
//
// External Percy SDK plugins (puppeteer-percy, playwright-percy,
// cypress-percy, selenium-chrome-percy) will get their own copy when
// SDK-side closed-shadow capture is added — that work is intentionally
// scoped to a separate change so this PR stays focused on the CLI path.
//
// Discovers closed shadow roots in the live page and exposes them to
// PercyDOM.serialize() via per-document `__percyClosedShadowRoots`
// WeakMaps that clone-dom.js reads through shadow-utils.getRuntime().
//
// Closed shadow roots are inaccessible from JavaScript
// (`element.shadowRoot === null`), but Chrome DevTools Protocol's DOM domain
// can pierce them. We get the full DOM tree with `pierce: true` (which also
// traverses iframe boundaries — closed shadow hosts inside iframes are
// captured by the same walk), collect every closed-shadow host/root pair,
// resolve both to JS object references via `DOM.resolveNode`, then call
// `Runtime.callFunctionOn` to write the mapping. The function body installs
// the WeakMap on the host's *own* `ownerDocument.defaultView` — so a host
// inside an iframe writes into the iframe's realm, where shadow-utils will
// later read it.
//
// Works for any caller that has a CDP session-like object exposing
// `send(method, params) => Promise`:
// - Puppeteer: `await page.target().createCDPSession()`
// - Playwright: `await context.newCDPSession(page)`
// - Selenium: `await driver.getDevTools()` (Chromium only)
// - Percy CLI: Percy's own session.send wrapper
//
// Side effect: temporarily enables and then disables the CDP `DOM` domain
// on the supplied session. Don't run concurrently with another `DOM`-domain
// consumer on the same session — the helper installs an in-flight guard
// against itself, but can't see other consumers.
//
// Limitation: captures the closed shadow roots present at the time of the
// call. Custom elements that lazy-attach a closed shadow root after this
// returns (e.g. inside `requestIdleCallback` or `IntersectionObserver`)
// won't be captured. The caller is responsible for waiting until the page
// is settled before invoking.
//
// Returns the number of closed shadow roots successfully exposed (0 if none,
// -1 on top-level error). Per-pair errors are swallowed and surfaced via the
// optional `log` callback — closed-shadow capture is best-effort and must
// never break a snapshot run.

const DEFAULT_LOG = () => {};

// Mirrors HARD_MAX_IFRAME_DEPTH from serialize-frames so every recursive
// walk in the capture pipeline shares the same ceiling. Counted only across
// shadow / iframe boundary crossings — not plain children — otherwise a
// normal deep DOM (html → body → div → … → custom-element) would burn
// through the budget before reaching any shadow host.
const MAX_SHADOW_DEPTH = 10;

// Bound concurrent CDP messages so we don't flood a session with hundreds
// of in-flight resolveNode/callFunctionOn calls when a page has many
// closed shadow hosts. Phase 1 (resolve) issues 2 calls per pair, so peak
// in-flight there is 2 * CDP_BATCH_SIZE; phase 2 (stamp) is 1 per pair so
// peak is exactly CDP_BATCH_SIZE. 8 chosen as a conservative default that
// keeps both phases well under typical CDP message-queue depths.
const CDP_BATCH_SIZE = 8;

// The function body that installs the WeakMap and writes the host→shadow
// pair. Runs inside Runtime.callFunctionOn with the host as `this`, so
// `this.ownerDocument.defaultView` is the host's *own* realm — the iframe's
// window when the host is inside an iframe.
//
// IMPORTANT: this is a string (required by Runtime.callFunctionOn) AND it
// is intentionally ES5 — it executes in the page's realm, which may be any
// browser/JS target the page itself targets. Don't "modernize" with arrow
// functions, let/const, or optional chaining.
const STAMP_FUNCTION =
'function(shadowRoot) {' +
' var w = this.ownerDocument && this.ownerDocument.defaultView;' +
' if (!w) return;' +
' if (!w.__percyClosedShadowRoots) w.__percyClosedShadowRoots = new WeakMap();' +
' w.__percyClosedShadowRoots.set(this, shadowRoot);' +
'}';

// Marker for the in-flight guard — prevents concurrent invocations on the
// same session from racing each other's DOM.enable / DOM.disable lifecycle.
// Module-local Symbol (not Symbol.for) so it can't collide with any other
// global registry entry.
const IN_FLIGHT = Symbol('percy.closedShadow.inFlight');

export async function exposeClosedShadowRoots(cdp, log = DEFAULT_LOG) {
if (!cdp || typeof cdp.send !== 'function') return -1;
if (cdp[IN_FLIGHT]) {
log('Skipping concurrent closed-shadow CDP discovery on the same session');
return -1;
}
cdp[IN_FLIGHT] = true;

let domEnabled = false;
try {
await cdp.send('DOM.enable');
domEnabled = true;

const { root } = await cdp.send('DOM.getDocument', {
depth: -1,
pierce: true
});

const closedPairs = [];
walkCDPNodes(root, closedPairs);

if (closedPairs.length === 0) {
return 0;
}

log(`Found ${closedPairs.length} closed shadow root(s), exposing via CDP`);

// Phase 1: resolve every backendNodeId → objectId in parallel batches.
const resolved = [];
for (let i = 0; i < closedPairs.length; i += CDP_BATCH_SIZE) {
const slice = closedPairs.slice(i, i + CDP_BATCH_SIZE);
const out = await Promise.all(slice.map(async pair => {
try {
const [hostRes, shadowRes] = await Promise.all([
cdp.send('DOM.resolveNode', { backendNodeId: pair.hostBackendNodeId }),
cdp.send('DOM.resolveNode', { backendNodeId: pair.shadowBackendNodeId })
]);
return { hostObj: hostRes.object, shadowObj: shadowRes.object, pair };
} catch (err) {
const msg = err && err.message ? err.message : err;
log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`);
return null;
}
}));
for (const entry of out) if (entry) resolved.push(entry);
}

// Phase 2: stamp the WeakMap (per-realm), also batched. Track real
// successes — earlier shapes returned closedPairs.length and overstated
// success when stamps failed.
let stamped = 0;
for (let i = 0; i < resolved.length; i += CDP_BATCH_SIZE) {
const slice = resolved.slice(i, i + CDP_BATCH_SIZE);
const results = await Promise.all(slice.map(({ hostObj, shadowObj, pair }) =>
cdp.send('Runtime.callFunctionOn', {
functionDeclaration: STAMP_FUNCTION,
objectId: hostObj.objectId,
arguments: [{ objectId: shadowObj.objectId }]
}).then(() => true).catch(err => {
const msg = err && err.message ? err.message : err;
log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`);
return false;
})
));
for (const ok of results) if (ok) stamped++;
}

return stamped;
} catch (err) {
log(`Could not expose closed shadow roots via CDP: ${err && err.message ? err.message : err}`);
return -1;
} finally {
if (domEnabled) {
await cdp.send('DOM.disable').catch(disableErr => {
log(`DOM.disable failed during closed-shadow cleanup: ${disableErr && disableErr.message ? disableErr.message : disableErr}`);
});
}
delete cdp[IN_FLIGHT];
}
}

// Walk a DOM.getDocument tree (with pierce: true) collecting every
// closed-shadow host/root pair we encounter. `pierce: true` traverses both
// shadow boundaries and iframe `contentDocument` boundaries, so a single
// walk reaches closed shadow hosts inside nested iframes. Recursion is
// bounded at MAX_SHADOW_DEPTH levels — counted only across shadow/iframe
// boundary crossings, not plain children — so a deep ordinary DOM doesn't
// exhaust the budget before reaching its shadow hosts. Exported for tests.
export function walkCDPNodes(node, pairs, depth = 0) {
if (!node || depth >= MAX_SHADOW_DEPTH) return;
if (node.shadowRoots) {
for (const sr of node.shadowRoots) {
if (sr.shadowRootType === 'closed') {
pairs.push({
hostBackendNodeId: node.backendNodeId,
shadowBackendNodeId: sr.backendNodeId
});
}
// crossing a shadow boundary — increment depth
walkCDPNodes(sr, pairs, depth + 1);
}
}
if (node.children) {
// plain children — same realm, same depth
for (const child of node.children) walkCDPNodes(child, pairs, depth);
}
// pierce: true surfaces iframe content documents on the iframe node;
// crossing into the iframe's realm — increment depth.
if (node.contentDocument) walkCDPNodes(node.contentDocument, pairs, depth + 1);
}

export default exposeClosedShadowRoots;
9 changes: 9 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,14 @@ export const configSchema = {
type: 'boolean',
default: false
},
ignoreIframeSelectors: {
type: 'array',
default: [],
items: {
type: 'string',
minLength: 1
}
},
pseudoClassEnabledElements: {
type: 'object',
additionalProperties: false,
Expand Down Expand Up @@ -511,6 +519,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
96 changes: 89 additions & 7 deletions packages/core/src/page.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'fs';
import logger from '@percy/logger';
import Network from './network.js';
import { exposeClosedShadowRoots } from './closed-shadow.js';
import { PERCY_DOM } from './api.js';
import {
hostname,
Expand All @@ -9,6 +10,50 @@ import {
serializeFunction
} from './utils.js';

// Internal ceiling on the customElements wait. Set high enough to cover
// lazy-defined element cascades on slow networks; the loop exits early
// when no more undefined elements remain.
//
// NOTE: pages that always have at least one never-registering custom
// element (e.g. a third-party widget whose loader is blocked) will pay
// the full timeout on every snapshot — accepted trade-off for now.
export const DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 1500;

// Body of the customElements wait. Runs in the browser via
// Runtime.callFunctionOn. Re-polls each tick so lazy-defined element
// cascades are awaited up to the deadline.
//
// IMPORTANT: this body is intentionally ES5 — it is evaluated in the
// page's realm and must work in any browser the page targets. Don't
// "modernize" with arrow functions, let/const, or optional chaining.
export const WAIT_FOR_CUSTOM_ELEMENTS_BODY = `
var deadline = Date.now() + (arguments[0] || 1500);
return new Promise(function(resolve) {
function tick() {
var undef = document.querySelectorAll(":not(:defined)");
if (!undef.length) return resolve();
if (Date.now() >= deadline) return resolve();
var names = {};
for (var i = 0; i < undef.length; i++) names[undef[i].localName] = true;
var promises = Object.keys(names).map(function(n) {
return window.customElements.whenDefined(n).catch(function(){});
});
Promise.race([
Promise.all(promises),
new Promise(function(r) { setTimeout(r, 100); })
]).then(tick);
}
tick();
});
`;

/* istanbul ignore next: runs in the page realm via Runtime.callFunctionOn,
not in the test process — there is no way to instrument it from here */
function serializeDomCapture(_, options) {
/* eslint-disable-next-line no-undef */
return { domSnapshot: PercyDOM.serialize(options), url: document.URL };
}

export class Page {
static TIMEOUT = undefined;

Expand Down Expand Up @@ -187,7 +232,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,21 +256,58 @@ export class Page {
// wait for any final network activity before capturing the dom snapshot
await this.network.idle();

// Pre-snapshot best-effort steps: waiting for lazy custom elements and
// discovering closed shadow roots via CDP. Both target a fully-loaded
// page; if the session has already terminated, skip them so the proper
// crash/close error surfaces from the downstream insertPercyDom +
// serialize evals (which gate on the same session).
//
// Ordering is load-bearing: closed-shadow capture must run AFTER the
// customElements wait so we catch shadows attached inside upgrade /
// connectedCallback hooks. Don't reorder or parallelise these.
if (!this.session.closedReason) {
// Best-effort: a flaky page should not break the snapshot.
try {
await this.eval(WAIT_FOR_CUSTOM_ELEMENTS_BODY, DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT);
} catch (err) {
/* istanbul ignore next: best-effort log; defensive against non-Error throws */
this.log.debug(`Custom elements wait failed: ${err.message ?? err}`, this.meta);
}

if (!disableShadowDOM) {
await exposeClosedShadowRoots(this.session, this._logShadowDebug.bind(this));
}
}

await this.insertPercyDom();

// serialize and capture a DOM snapshot
this.log.debug('Serialize DOM', this.meta);

/* istanbul ignore next: no instrumenting injected code */
let capture = await this.eval((_, options) => ({
/* eslint-disable-next-line no-undef */
domSnapshot: PercyDOM.serialize(options),
url: document.URL
}), { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements });
let capture = await this.eval(serializeDomCapture, {
enableJavaScript,
disableShadowDOM,
forceShadowAsLightDOM,
domTransformation,
reshuffleInvalidTags,
ignoreCanvasSerializationErrors,
ignoreStyleSheetSerializationErrors,
ignoreIframeSelectors,
pseudoClassEnabledElements
});

return { ...snapshot, ...capture };
}

// Logger for the closed-shadow CDP helper. Defined on the prototype (not
// a class-field arrow) so it's reachable from a unit test that constructs
// a Page via Object.create without invoking the constructor — gives us a
// direct way to cover the callback without simulating a closed shadow
// discovery flow at the integration level.
_logShadowDebug(msg) {
this.log.debug(msg, this.meta);
}

// Initialize newly attached pages and iframes with page options
_handleAttachedToTarget = event => {
let session = !event ? this.session
Expand Down
Loading
Loading