From 00950f1bb15c21f05d3fa2f77269417b18ca3a9e Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:17:07 +0530 Subject: [PATCH 01/12] feat: nest CORS iframe capture with depth cap and cycle guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat top-level iframe loop with a recursive `processFrameTree` that switches into each cross-origin iframe, captures its DOM, and descends into any further cross-origin iframes nested inside it (up to a configurable depth). Cycles are detected by tracking the chain of ancestor frame URLs and skipping any frame whose `src` already appears in the chain — without this guard, pages that link to each other could produce up to `maxIframeDepth` duplicate corsIframes entries. The depth cap defaults to 5 (matching the canonical Percy SDK behaviour) and is configurable per-snapshot via `maxIframeDepth` or via `cliConfig.snapshot.maxIframeDepth`. Inputs are clamped to a 1..10 range through `clampFrameDepth`. Nested-frame origin is compared against the IMMEDIATE PARENT origin (not the top page origin) so a same-origin grandchild inside a cross-origin parent is correctly inlined by PercyDOM and a cross-origin grandchild inside a same-origin parent is still captured. Mirrors percy/percy-nightwatch#869 and percy/percy-playwright#609. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 190 +++++++++++++++++++-- 1 file changed, 176 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 453c4bf..111ccab 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -604,6 +604,12 @@ static class FatalIframeException extends RuntimeException { } } + // Default maximum nesting depth for cross-origin iframe capture. Mirrors the + // canonical Percy SDK behaviour — depth 1 is a top-level iframe. + private static final int DEFAULT_MAX_FRAME_DEPTH = 5; + private static final int MIN_FRAME_DEPTH = 1; + private static final int MAX_FRAME_DEPTH_CAP = 10; + private boolean isUnsupportedIframeSrc(String src) { return src == null || src.isEmpty() || src.equals("about:blank") || @@ -612,7 +618,7 @@ private boolean isUnsupportedIframeSrc(String src) { src.startsWith("vbscript:"); } - private String getOrigin(String url) { + private static String getOrigin(String url) { try { URI uri = new URI(url); String scheme = uri.getScheme(); @@ -624,6 +630,46 @@ private String getOrigin(String url) { } } + // Clamp the configured frame depth to a sane range. Negative or + // unreasonably large values fall back to the default. + private static int clampFrameDepth(int depth) { + if (depth < MIN_FRAME_DEPTH) return DEFAULT_MAX_FRAME_DEPTH; + if (depth > MAX_FRAME_DEPTH_CAP) return MAX_FRAME_DEPTH_CAP; + return depth; + } + + private int resolveMaxFrameDepth(Map options) { + Object override = options == null ? null : options.get("maxIframeDepth"); + if (override instanceof Number) { + return clampFrameDepth(((Number) override).intValue()); + } + if (override instanceof String) { + try { return clampFrameDepth(Integer.parseInt((String) override)); } catch (NumberFormatException ignore) {} + } + if (cliConfig != null && cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snap = cliConfig.getJSONObject("snapshot"); + if (snap.has("maxIframeDepth") && !snap.isNull("maxIframeDepth")) { + return clampFrameDepth(snap.optInt("maxIframeDepth", DEFAULT_MAX_FRAME_DEPTH)); + } + } + return DEFAULT_MAX_FRAME_DEPTH; + } + + // Serialize the current frame context's DOM using PercyDOM.serialize. + // enableJavaScript=true is forced so PercyDOM.serialize doesn't recurse into + // nested iframes itself — we drive that recursion explicitly. + private Map serializeCurrentFrame(Map options) { + JavascriptExecutor jse = (JavascriptExecutor) driver; + Map iframeOptions = new HashMap<>(options == null ? Collections.emptyMap() : options); + iframeOptions.put("enableJavaScript", true); + JSONObject optionsJson = new JSONObject(iframeOptions); + @SuppressWarnings("unchecked") + Map snapshot = (Map) jse.executeScript( + "return PercyDOM.serialize(" + optionsJson.toString() + ")" + ); + return snapshot; + } + private Map processFrame(WebElement frameElement, Map options) { // Read attributes while still in parent context — these calls will // fail if made after switchTo().frame(). @@ -673,37 +719,153 @@ private Map processFrame(WebElement frameElement, Map> processFrameTree( + WebElement frameElement, + int depth, + Set ancestorUrls, + Map ctx + ) { + @SuppressWarnings("unchecked") + Map options = (Map) ctx.get("options"); + int maxFrameDepth = (int) ctx.get("maxFrameDepth"); + + String frameSrc = frameElement.getAttribute("src"); + String percyElementId = frameElement.getAttribute("data-percy-element-id"); + + List> collected = new ArrayList<>(); + if (depth > maxFrameDepth) { + log("Reached max iframe nesting depth (" + maxFrameDepth + "); stopping at " + frameSrc, "debug"); + return collected; + } + if (ancestorUrls != null && frameSrc != null && ancestorUrls.contains(frameSrc)) { + log("Skipping cyclic iframe (" + frameSrc + " appears in ancestor chain)", "debug"); + return collected; + } + if (percyElementId == null || percyElementId.isEmpty()) { + log("Skipping cross-origin iframe without data-percy-element-id: " + frameSrc, "debug"); + return collected; + } + + boolean switchedIn = false; + try { + log("Processing cross-origin iframe (depth " + depth + "): " + frameSrc, "debug"); + driver.switchTo().frame(frameElement); + switchedIn = true; + + JavascriptExecutor jse = (JavascriptExecutor) driver; + jse.executeScript(domJs); + + Map iframeSnapshot = serializeCurrentFrame(options); + + Map iframeData = new HashMap<>(); + iframeData.put("percyElementId", percyElementId); + Map entry = new HashMap<>(); + entry.put("iframeData", iframeData); + entry.put("iframeSnapshot", iframeSnapshot); + entry.put("frameUrl", frameSrc); + collected.add(entry); + + // Descend into further cross-origin iframes nested inside this one. + // Same-origin descendants are already inlined as srcdoc by PercyDOM. + if (depth < maxFrameDepth) { + String currentOrigin = getOrigin(frameSrc); + List childIframes; + try { + childIframes = driver.findElements(By.tagName("iframe")); + } catch (Exception e) { + log("Could not enumerate nested iframes in " + frameSrc + ": " + e.getMessage(), "debug"); + childIframes = Collections.emptyList(); + } + Set nextAncestors = new HashSet<>(ancestorUrls == null ? Collections.emptySet() : ancestorUrls); + if (frameSrc != null) nextAncestors.add(frameSrc); + + for (WebElement child : childIframes) { + String childSrc; + try { childSrc = child.getAttribute("src"); } catch (Exception e) { continue; } + if (isUnsupportedIframeSrc(childSrc)) continue; + String childOrigin; + try { + URI base = new URI(frameSrc); + URI resolved = base.resolve(childSrc); + childOrigin = getOrigin(resolved.toString()); + } catch (Exception e) { + continue; + } + // Compare to the IMMEDIATE PARENT origin, not the page origin. + if (childOrigin.equals(currentOrigin)) continue; + + try { + List> nested = processFrameTree(child, depth + 1, nextAncestors, ctx); + if (!nested.isEmpty()) collected.addAll(nested); + } catch (FatalIframeException fatal) { + throw fatal; + } catch (Exception e) { + log("Skipping nested iframe \"" + childSrc + "\" due to error: " + e.getMessage(), "debug"); + } + } + } + return collected; + } catch (Exception e) { + log("Failed to process cross-origin iframe " + frameSrc + ": " + e.getMessage(), "warn"); + return collected; + } finally { + if (switchedIn) { + try { + driver.switchTo().parentFrame(); + } catch (Exception parentErr) { + log("Failed to switch back to parent frame: " + parentErr.getMessage(), "warn"); + try { driver.switchTo().defaultContent(); } catch (Exception ignore) {} + } + } + } + } + private Map getSerializedDOM(JavascriptExecutor jse, Set cookies, Map options) { Map domSnapshot = (Map) jse.executeScript(buildSnapshotJS(options)); Map mutableSnapshot = new HashMap<>(domSnapshot); mutableSnapshot.put("cookies", cookies); try { - String pageOrigin = getOrigin(driver.getCurrentUrl()); + String pageUrl = driver.getCurrentUrl(); + String pageOrigin = getOrigin(pageUrl); List iframes = driver.findElements(By.tagName("iframe")); if (!iframes.isEmpty() && !domJs.trim().isEmpty()) { + int maxFrameDepth = resolveMaxFrameDepth(options); + + Map ctx = new HashMap<>(); + ctx.put("options", options); + ctx.put("maxFrameDepth", maxFrameDepth); + List> processedFrames = new ArrayList<>(); for (WebElement frame : iframes) { - String frameSrc = frame.getAttribute("src"); - if (isUnsupportedIframeSrc(frameSrc)) { - continue; - } + String frameSrc; + try { frameSrc = frame.getAttribute("src"); } catch (Exception e) { continue; } + if (isUnsupportedIframeSrc(frameSrc)) continue; String frameOrigin; try { - URI base = new URI(driver.getCurrentUrl()); + URI base = new URI(pageUrl); URI resolved = base.resolve(frameSrc); frameOrigin = getOrigin(resolved.toString()); } catch (Exception e) { log("Skipping iframe \"" + frameSrc + "\": " + e.getMessage(), "debug"); continue; } - if (frameOrigin.equals(pageOrigin)) { - continue; - } + if (frameOrigin.equals(pageOrigin)) continue; + + Set ancestors = new HashSet<>(); + if (pageUrl != null) ancestors.add(pageUrl); try { - Map result = processFrame(frame, options); - if (result != null) { - processedFrames.add(result); - } + List> nested = processFrameTree(frame, 1, ancestors, ctx); + if (!nested.isEmpty()) processedFrames.addAll(nested); } catch (FatalIframeException e) { throw e; } catch (Exception e) { From 4d6eda1c4b025840591bd5718e3608f28e04dd7e Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:17:43 +0530 Subject: [PATCH 02/12] feat: honour data-percy-ignore on cross-origin iframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip iframes that carry the `data-percy-ignore` boolean attribute when enumerating both top-level and nested cross-origin iframes. Customers add this attribute to opt out of CORS iframe capture for a specific frame without having to maintain a selector list — useful for ad slots or analytics iframes whose contents are noisy. Selenium's `getAttribute` returns an empty string for boolean attributes with no value, so a non-null result is treated as a positive hit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 111ccab..bb3ce6d 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -655,6 +655,17 @@ private int resolveMaxFrameDepth(Map options) { return DEFAULT_MAX_FRAME_DEPTH; } + // Probe a child iframe element for `data-percy-ignore`. Selenium's + // getAttribute returns "" for boolean attributes with no value; treat + // any non-null result as a positive hit. + private boolean childHasDataPercyIgnore(WebElement iframe) { + try { + return iframe.getAttribute("data-percy-ignore") != null; + } catch (Exception e) { + return false; + } + } + // Serialize the current frame context's DOM using PercyDOM.serialize. // enableJavaScript=true is forced so PercyDOM.serialize doesn't recurse into // nested iframes itself — we drive that recursion explicitly. @@ -793,6 +804,10 @@ private List> processFrameTree( String childSrc; try { childSrc = child.getAttribute("src"); } catch (Exception e) { continue; } if (isUnsupportedIframeSrc(childSrc)) continue; + if (childHasDataPercyIgnore(child)) { + log("Skipping iframe marked with data-percy-ignore: " + childSrc, "debug"); + continue; + } String childOrigin; try { URI base = new URI(frameSrc); @@ -850,6 +865,10 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set String frameSrc; try { frameSrc = frame.getAttribute("src"); } catch (Exception e) { continue; } if (isUnsupportedIframeSrc(frameSrc)) continue; + if (childHasDataPercyIgnore(frame)) { + log("Skipping iframe marked with data-percy-ignore: " + frameSrc, "debug"); + continue; + } String frameOrigin; try { URI base = new URI(pageUrl); From 538b91d43d9e38b660c44e2d1f9884a4bd3f16b4 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:18:38 +0530 Subject: [PATCH 03/12] feat: add ignoreIframeSelectors option for cross-origin iframe capture Customers can now pass an `ignoreIframeSelectors` list (either in the per-snapshot options Map or via `cliConfig.snapshot.ignoreIframeSelectors`) to skip any cross-origin iframe whose element matches one of the supplied CSS selectors. Matching is performed in-browser via `Element.matches` so any selector the browser accepts is valid; invalid selectors are tolerated without aborting the snapshot. Inputs go through `normalizeIgnoreSelectors` which accepts a List, a single String, or null and yields a sanitised List with empty/ whitespace-only entries removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index bb3ce6d..20746f9 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -638,6 +638,61 @@ private static int clampFrameDepth(int depth) { return depth; } + // Coerce an arbitrary user-provided selector list into a sanitized List. + private static List normalizeIgnoreSelectors(Object input) { + List result = new ArrayList<>(); + if (input == null) return result; + if (input instanceof List) { + for (Object o : (List) input) { + if (o instanceof String) { + String s = ((String) o).trim(); + if (!s.isEmpty()) result.add(s); + } + } + } else if (input instanceof String) { + String s = ((String) input).trim(); + if (!s.isEmpty()) result.add(s); + } + return result; + } + + private List resolveIgnoreSelectors(Map options) { + if (options != null && options.containsKey("ignoreIframeSelectors")) { + return normalizeIgnoreSelectors(options.get("ignoreIframeSelectors")); + } + if (cliConfig != null && cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snap = cliConfig.getJSONObject("snapshot"); + if (snap.has("ignoreIframeSelectors") && !snap.isNull("ignoreIframeSelectors")) { + JSONArray arr = snap.optJSONArray("ignoreIframeSelectors"); + if (arr != null) { + List out = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) out.add(arr.opt(i)); + return normalizeIgnoreSelectors(out); + } + } + } + return Collections.emptyList(); + } + + // True if the iframe element matches any of the user-provided ignore selectors. + // Selector matching is performed in-browser via Element.matches so any CSS + // selector the browser supports is valid; invalid selectors are tolerated. + private boolean iframeMatchesIgnoreSelector(WebElement iframe, List selectors) { + if (selectors == null || selectors.isEmpty()) return false; + try { + JavascriptExecutor jse = (JavascriptExecutor) driver; + JSONArray sel = new JSONArray(selectors); + String script = "var el = arguments[0]; var selectors = " + sel.toString() + ";" + + "for (var i = 0; i < selectors.length; i++) {" + + " try { if (el.matches(selectors[i])) return true; } catch (e) {}" + + "} return false;"; + Object res = jse.executeScript(script, iframe); + return res instanceof Boolean && (Boolean) res; + } catch (Exception e) { + return false; + } + } + private int resolveMaxFrameDepth(Map options) { Object override = options == null ? null : options.get("maxIframeDepth"); if (override instanceof Number) { @@ -749,6 +804,8 @@ private List> processFrameTree( @SuppressWarnings("unchecked") Map options = (Map) ctx.get("options"); int maxFrameDepth = (int) ctx.get("maxFrameDepth"); + @SuppressWarnings("unchecked") + List ignoreSelectors = (List) ctx.get("ignoreSelectors"); String frameSrc = frameElement.getAttribute("src"); String percyElementId = frameElement.getAttribute("data-percy-element-id"); @@ -808,6 +865,10 @@ private List> processFrameTree( log("Skipping iframe marked with data-percy-ignore: " + childSrc, "debug"); continue; } + if (iframeMatchesIgnoreSelector(child, ignoreSelectors)) { + log("Skipping iframe matching ignoreIframeSelectors: " + childSrc, "debug"); + continue; + } String childOrigin; try { URI base = new URI(frameSrc); @@ -855,10 +916,12 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set List iframes = driver.findElements(By.tagName("iframe")); if (!iframes.isEmpty() && !domJs.trim().isEmpty()) { int maxFrameDepth = resolveMaxFrameDepth(options); + List ignoreSelectors = resolveIgnoreSelectors(options); Map ctx = new HashMap<>(); ctx.put("options", options); ctx.put("maxFrameDepth", maxFrameDepth); + ctx.put("ignoreSelectors", ignoreSelectors); List> processedFrames = new ArrayList<>(); for (WebElement frame : iframes) { @@ -869,6 +932,10 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set log("Skipping iframe marked with data-percy-ignore: " + frameSrc, "debug"); continue; } + if (iframeMatchesIgnoreSelector(frame, ignoreSelectors)) { + log("Skipping iframe matching ignoreIframeSelectors: " + frameSrc, "debug"); + continue; + } String frameOrigin; try { URI base = new URI(pageUrl); From 534e0a8b16703e48dc242690d47232023ff3df39 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:19:33 +0530 Subject: [PATCH 04/12] feat: re-check iframe URL after switching into frame context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After switching into a cross-origin iframe, read `document.URL` and run the unsupported-src check again. The parent-side `src` attribute can be stale or misleading — the frame may have failed to load (leaving an about:blank document), or been navigated by script after attach to a data:/javascript: URL. Skipping these post-switch avoids attempting to serialize a placeholder document. When a post-switch URL is available it is also reported as the captured `frameUrl` and used as the parent context for any nested CORS iframe enumeration. Falls back to the parent-side `src` when the executor returns a non-String value (e.g. under mocking). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 40 +++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 20746f9..5c25f4b 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -721,6 +721,19 @@ private boolean childHasDataPercyIgnore(WebElement iframe) { } } + // Read document.URL inside the current frame context. Used for the post-switch + // sanity check to confirm the iframe actually resolved to a navigable URL. + // Only treat a String result as the document URL — otherwise return null so + // callers fall back to the parent-side `src` value. + private String readCurrentFrameUrl() { + try { + Object u = ((JavascriptExecutor) driver).executeScript("return document.URL"); + return (u instanceof String) ? (String) u : null; + } catch (Exception e) { + return null; + } + } + // Serialize the current frame context's DOM using PercyDOM.serialize. // enableJavaScript=true is forced so PercyDOM.serialize doesn't recurse into // nested iframes itself — we drive that recursion explicitly. @@ -755,6 +768,14 @@ private Map processFrame(WebElement frameElement, Map iframeOptions = new HashMap<>(options); iframeOptions.put("enableJavaScript", true); @@ -833,29 +854,40 @@ private List> processFrameTree( JavascriptExecutor jse = (JavascriptExecutor) driver; jse.executeScript(domJs); + // Post-switch URL re-check: this is the only place we know what the + // browser actually navigated to. If it's an unsupported scheme, + // bail before serializing. + String postSwitchUrl = readCurrentFrameUrl(); + if (postSwitchUrl != null && isUnsupportedIframeSrc(postSwitchUrl)) { + log("Skipping iframe after switch: unsupported document.URL \"" + postSwitchUrl + "\"", "debug"); + return collected; + } + Map iframeSnapshot = serializeCurrentFrame(options); + String reportedUrl = (postSwitchUrl != null) ? postSwitchUrl : frameSrc; Map iframeData = new HashMap<>(); iframeData.put("percyElementId", percyElementId); Map entry = new HashMap<>(); entry.put("iframeData", iframeData); entry.put("iframeSnapshot", iframeSnapshot); - entry.put("frameUrl", frameSrc); + entry.put("frameUrl", reportedUrl); collected.add(entry); // Descend into further cross-origin iframes nested inside this one. // Same-origin descendants are already inlined as srcdoc by PercyDOM. if (depth < maxFrameDepth) { - String currentOrigin = getOrigin(frameSrc); + String currentOrigin = getOrigin(reportedUrl); List childIframes; try { childIframes = driver.findElements(By.tagName("iframe")); } catch (Exception e) { - log("Could not enumerate nested iframes in " + frameSrc + ": " + e.getMessage(), "debug"); + log("Could not enumerate nested iframes in " + reportedUrl + ": " + e.getMessage(), "debug"); childIframes = Collections.emptyList(); } Set nextAncestors = new HashSet<>(ancestorUrls == null ? Collections.emptySet() : ancestorUrls); if (frameSrc != null) nextAncestors.add(frameSrc); + if (reportedUrl != null) nextAncestors.add(reportedUrl); for (WebElement child : childIframes) { String childSrc; @@ -871,7 +903,7 @@ private List> processFrameTree( } String childOrigin; try { - URI base = new URI(frameSrc); + URI base = new URI(reportedUrl); URI resolved = base.resolve(childSrc); childOrigin = getOrigin(resolved.toString()); } catch (Exception e) { From 2cbec3fe5873f129e7fa7b660b93c19e7ebb9847 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:20:19 +0530 Subject: [PATCH 05/12] feat: recover partial capture on lost frame context via PercyContextLostException When the driver fails to step back to a parent frame after recursing into a nested cross-origin iframe, we previously lost everything captured so far (a flaky network call inside a depth-3 frame would forfeit even the depth-1 snapshot). Introduce `PercyContextLostException` which carries a `partialCapture` list of every iframe snapshot collected before the failure; each recursion layer appends its own captures to the carried list and re-throws, and the top-level loop in `getSerializedDOM` merges the recovered captures into the snapshot and falls back to default content before aborting further sibling enumeration. Mirrors the `percyContextLost` flag in percy/percy-nightwatch#869 and percy/percy-webdriverio#... so the wire-format output stays consistent across SDKs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 5c25f4b..2825102 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -604,6 +604,17 @@ static class FatalIframeException extends RuntimeException { } } + // Signals that the driver lost its frame context mid-recursion. Any iframes + // captured before the failure are attached as `partialCapture` so the top- + // level caller can still salvage them instead of throwing away progress. + static class PercyContextLostException extends RuntimeException { + public List> partialCapture; + PercyContextLostException(String message, Throwable cause, List> partialCapture) { + super(message, cause); + this.partialCapture = partialCapture; + } + } + // Default maximum nesting depth for cross-origin iframe capture. Mirrors the // canonical Percy SDK behaviour — depth 1 is a top-level iframe. private static final int DEFAULT_MAX_FRAME_DEPTH = 5; @@ -915,6 +926,15 @@ private List> processFrameTree( try { List> nested = processFrameTree(child, depth + 1, nextAncestors, ctx); if (!nested.isEmpty()) collected.addAll(nested); + } catch (PercyContextLostException ctxLost) { + // Merge any partial capture from the inner level into ours before + // propagating, so the top-level caller can recover everything + // that was successfully serialized prior to the failure. + if (ctxLost.partialCapture != null && !ctxLost.partialCapture.isEmpty()) { + collected.addAll(ctxLost.partialCapture); + } + ctxLost.partialCapture = collected; + throw ctxLost; } catch (FatalIframeException fatal) { throw fatal; } catch (Exception e) { @@ -923,16 +943,30 @@ private List> processFrameTree( } } return collected; + } catch (PercyContextLostException ctxLost) { + throw ctxLost; } catch (Exception e) { log("Failed to process cross-origin iframe " + frameSrc + ": " + e.getMessage(), "warn"); return collected; } finally { if (switchedIn) { + // Step up exactly one level so an outer recursion continues from + // its own context. If parentFrame fails we have no reliable way + // to land in the correct parent — fall back to default content + // and signal that the rest of the sibling enumeration would be + // unreliable. Partial capture is propagated via the exception. try { driver.switchTo().parentFrame(); } catch (Exception parentErr) { log("Failed to switch back to parent frame: " + parentErr.getMessage(), "warn"); try { driver.switchTo().defaultContent(); } catch (Exception ignore) {} + if (depth > 1) { + throw new PercyContextLostException( + "Lost parent frame context: " + parentErr.getMessage(), + parentErr, + new ArrayList<>(collected) + ); + } } } } @@ -984,6 +1018,14 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set try { List> nested = processFrameTree(frame, 1, ancestors, ctx); if (!nested.isEmpty()) processedFrames.addAll(nested); + } catch (PercyContextLostException ctxLost) { + log("Aborting further nested CORS capture due to lost frame context", "warn"); + if (ctxLost.partialCapture != null && !ctxLost.partialCapture.isEmpty()) { + processedFrames.addAll(ctxLost.partialCapture); + } + // Try to ensure we're back at the top before bailing out of the loop. + try { driver.switchTo().defaultContent(); } catch (Exception ignore) {} + break; } catch (FatalIframeException e) { throw e; } catch (Exception e) { From bcc27d3e424c868dafae8a2e1afcc78e1e5f318c Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:21:12 +0530 Subject: [PATCH 06/12] feat: expose closed shadow DOM to PercyDOM via CDP for Chromium drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed shadow roots (`{mode: 'closed'}`) are invisible to JavaScript — `element.shadowRoot` is `null` and there is no API that returns the underlying ShadowRoot object. The PercyDOM serializer can pierce them through a window-bound `__percyClosedShadowRoots` WeakMap (host element → shadow root) populated before serialization, but Selenium has no way to obtain the closed shadow root from page script. Use Chrome DevTools Protocol to discover and resolve them: 1. `DOM.getDocument {depth: -1, pierce: true}` to walk the entire DOM tree including closed shadow subtrees. 2. For each closed shadow root, `DOM.resolveNode` on the host and the shadow root to obtain JS object handles. 3. `Runtime.callFunctionOn` to write the pair into the WeakMap. `contentDocument` nodes are skipped because their execution context is distinct and has no WeakMap. Non-Chromium drivers are detected with a single `instanceof ChromeDriver` check and silently fall through, so the SDK keeps working with Firefox/WebKit without changes. Mirrors percy/percy-playwright#609. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 2825102..c8638ca 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -976,6 +976,11 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set Map domSnapshot = (Map) jse.executeScript(buildSnapshotJS(options)); Map mutableSnapshot = new HashMap<>(domSnapshot); mutableSnapshot.put("cookies", cookies); + + // Expose closed shadow roots via CDP (Chromium only) so PercyDOM.serialize + // can pierce them through the WeakMap it reads. Non-fatal — skip on errors. + try { exposeClosedShadowRoots(driver); } catch (Exception ignore) {} + try { String pageUrl = driver.getCurrentUrl(); String pageOrigin = getOrigin(pageUrl); @@ -1044,6 +1049,99 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set return mutableSnapshot; } + // Discover closed shadow roots via CDP and expose them on a window-bound + // WeakMap that PercyDOM.serialize reads to pierce closed shadow DOM. This is + // Chromium-only — wrapped in try/catch so other browsers (or a missing + // executeCdpCommand) fall through silently. Three CDP calls per pair: + // DOM.getDocument (depth=-1, pierce=true) to discover, then DOM.resolveNode + // for host + shadow, then Runtime.callFunctionOn to write the pair into + // the WeakMap on the page. + @SuppressWarnings("unchecked") + private void exposeClosedShadowRoots(WebDriver driver) { + if (!(driver instanceof ChromeDriver)) return; + ChromeDriver chrome; + try { chrome = (ChromeDriver) driver; } catch (ClassCastException e) { return; } + try { + chrome.executeCdpCommand("DOM.enable", new HashMap<>()); + Map getDocParams = new HashMap<>(); + getDocParams.put("depth", -1); + getDocParams.put("pierce", true); + Map doc = chrome.executeCdpCommand("DOM.getDocument", getDocParams); + if (doc == null) return; + Object rootObj = doc.get("root"); + if (!(rootObj instanceof Map)) return; + List> closedPairs = new ArrayList<>(); + collectClosedShadowPairs((Map) rootObj, closedPairs); + if (closedPairs.isEmpty()) return; + + log("Found " + closedPairs.size() + " closed shadow root(s), exposing via CDP", "debug"); + + ((JavascriptExecutor) chrome).executeScript( + "window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap();" + ); + + for (Map pair : closedPairs) { + try { + Map hostParams = new HashMap<>(); + hostParams.put("backendNodeId", pair.get("hostBackendNodeId")); + Map hostRes = chrome.executeCdpCommand("DOM.resolveNode", hostParams); + Map shadowParams = new HashMap<>(); + shadowParams.put("backendNodeId", pair.get("shadowBackendNodeId")); + Map shadowRes = chrome.executeCdpCommand("DOM.resolveNode", shadowParams); + if (hostRes == null || shadowRes == null) continue; + Object hostObj = hostRes.get("object"); + Object shadowObj = shadowRes.get("object"); + if (!(hostObj instanceof Map) || !(shadowObj instanceof Map)) continue; + Object hostObjectId = ((Map) hostObj).get("objectId"); + Object shadowObjectId = ((Map) shadowObj).get("objectId"); + if (hostObjectId == null || shadowObjectId == null) continue; + Map callParams = new HashMap<>(); + callParams.put("functionDeclaration", + "function(shadowRoot) { window.__percyClosedShadowRoots.set(this, shadowRoot); }"); + callParams.put("objectId", hostObjectId); + List> args = new ArrayList<>(); + Map a = new HashMap<>(); + a.put("objectId", shadowObjectId); + args.add(a); + callParams.put("arguments", args); + chrome.executeCdpCommand("Runtime.callFunctionOn", callParams); + } catch (Exception perPair) { + log("Failed to expose a closed shadow root: " + perPair.getMessage(), "debug"); + } + } + } catch (Exception ex) { + log("Could not expose closed shadow roots via CDP: " + ex.getMessage(), "debug"); + } + } + + // Walk the CDP DOM tree looking for closed shadow roots. Skips nodes that + // are themselves child-frame documents — cross-frame closed shadow roots + // are not supported (different execution context, no WeakMap there). + @SuppressWarnings("unchecked") + private static void collectClosedShadowPairs(Map node, List> out) { + if (node.containsKey("contentDocument")) return; + Object srs = node.get("shadowRoots"); + if (srs instanceof List) { + for (Object sr : (List) srs) { + if (!(sr instanceof Map)) continue; + Map srMap = (Map) sr; + if ("closed".equals(srMap.get("shadowRootType"))) { + Map pair = new HashMap<>(); + pair.put("hostBackendNodeId", node.get("backendNodeId")); + pair.put("shadowBackendNodeId", srMap.get("backendNodeId")); + out.add(pair); + } + collectClosedShadowPairs(srMap, out); + } + } + Object children = node.get("children"); + if (children instanceof List) { + for (Object child : (List) children) { + if (child instanceof Map) collectClosedShadowPairs((Map) child, out); + } + } + } + private List getElementIdFromElement(List elements) { List ignoredElementsArray = new ArrayList<>(); for (int index = 0; index < elements.size(); index++) { From f6e435ac7228270c29a6c90824d5ca10b2952cca Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:21:42 +0530 Subject: [PATCH 07/12] chore: cover nested CORS iframe and closed shadow DOM behaviours Add JUnit + Mockito unit tests for the new helper methods and the nested cross-origin iframe capture flow: - `clampFrameDepth` bounds + defaults - `normalizeIgnoreSelectors` accepts List / String / null - `resolveMaxFrameDepth` precedence (option > cliConfig > default) - `resolveIgnoreSelectors` precedence - `data-percy-ignore` iframes are skipped without `switchTo` - `ignoreIframeSelectors` matches are skipped without `switchTo` - `processFrame` bails after switch when document.URL is unsupported - `PercyContextLostException.partialCapture` round-trips - `getSerializedDOM` recovers partial captures on context loss - `exposeClosedShadowRoots` is a no-op for non-Chrome drivers - `collectClosedShadowPairs` walks the CDP tree and skips iframes Tests live in a separate `IframeFeatureTest` class to avoid being blocked by `SdkTest`'s `@BeforeAll` Firefox initialisation in environments without a Firefox binary. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../io/percy/selenium/IframeFeatureTest.java | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 src/test/java/io/percy/selenium/IframeFeatureTest.java diff --git a/src/test/java/io/percy/selenium/IframeFeatureTest.java b/src/test/java/io/percy/selenium/IframeFeatureTest.java new file mode 100644 index 0000000..7f62a53 --- /dev/null +++ b/src/test/java/io/percy/selenium/IframeFeatureTest.java @@ -0,0 +1,371 @@ +package io.percy.selenium; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.Cookie; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.NoSuchFrameException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebDriver.TargetLocator; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.remote.RemoteWebDriver; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for the nested cross-origin iframe capture, data-percy-ignore, + * ignoreIframeSelectors, post-switch URL re-check, PercyContextLostException, + * and frame-depth helpers. + * + * Kept in a separate class so its tests run even if the Firefox-based + * @BeforeAll in SdkTest is unavailable in the current environment. + */ +public class IframeFeatureTest { + + @Test + public void clampFrameDepthBoundsValuesAndDefaults() throws Exception { + int defDepth = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, 0); + assertEquals(5, defDepth, "Non-positive depth clamps to default"); + + int negDepth = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, -3); + assertEquals(5, negDepth, "Negative depth clamps to default"); + + int hugeDepth = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, 9999); + assertEquals(10, hugeDepth, "Huge depth clamps to cap (10)"); + + int passThrough = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, 3); + assertEquals(3, passThrough, "In-range depth passes through"); + } + + @Test + public void normalizeIgnoreSelectorsAcceptsListAndStringInputs() throws Exception { + @SuppressWarnings("unchecked") + List fromList = (List) invokeStaticPrivate( + "normalizeIgnoreSelectors", new Class[]{Object.class}, Arrays.asList("iframe.foo", " ", null, "iframe[data-x]")); + assertEquals(Arrays.asList("iframe.foo", "iframe[data-x]"), fromList); + + @SuppressWarnings("unchecked") + List fromString = (List) invokeStaticPrivate( + "normalizeIgnoreSelectors", new Class[]{Object.class}, " iframe.single "); + assertEquals(Arrays.asList("iframe.single"), fromString); + + @SuppressWarnings("unchecked") + List fromNull = (List) invokeStaticPrivate( + "normalizeIgnoreSelectors", new Class[]{Object.class}, new Object[]{null}); + assertTrue(fromNull.isEmpty()); + } + + @Test + public void resolveMaxFrameDepthPrefersOptionThenCliConfigThenDefault() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = new Percy(mockedDriver); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject().put("maxIframeDepth", 4))); + + Map withOption = new HashMap<>(); + withOption.put("maxIframeDepth", 7); + int fromOption = (int) invokePrivate(percy, "resolveMaxFrameDepth", new Class[]{Map.class}, withOption); + assertEquals(7, fromOption); + + int fromCli = (int) invokePrivate(percy, "resolveMaxFrameDepth", new Class[]{Map.class}, new HashMap<>()); + assertEquals(4, fromCli); + + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + int def = (int) invokePrivate(percy, "resolveMaxFrameDepth", new Class[]{Map.class}, new HashMap<>()); + assertEquals(5, def); + } + + @Test + public void resolveIgnoreSelectorsCoercesOptionAndCliConfig() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = new Percy(mockedDriver); + setField(percy, "cliConfig", + new JSONObject().put("snapshot", + new JSONObject().put("ignoreIframeSelectors", new JSONArray(Arrays.asList("iframe.cli-only"))))); + + Map withOption = new HashMap<>(); + withOption.put("ignoreIframeSelectors", Arrays.asList("iframe.from-opt")); + @SuppressWarnings("unchecked") + List fromOption = (List) invokePrivate(percy, "resolveIgnoreSelectors", new Class[]{Map.class}, withOption); + assertEquals(Arrays.asList("iframe.from-opt"), fromOption); + + @SuppressWarnings("unchecked") + List fromCli = (List) invokePrivate(percy, "resolveIgnoreSelectors", new Class[]{Map.class}, new HashMap<>()); + assertEquals(Arrays.asList("iframe.cli-only"), fromCli); + } + + @Test + public void skipsIframeMarkedWithDataPercyIgnore() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(mockedDriver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://ads.example.com/frame"); + when(iframe.getAttribute("data-percy-ignore")).thenReturn(""); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + Map mainSnapshot = new HashMap<>(); + mainSnapshot.put("dom", "main"); + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(mainSnapshot); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + percy, "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, new HashSet(), new HashMap<>()); + + assertFalse(serialized.containsKey("corsIframes"), + "Frames flagged with data-percy-ignore must be omitted from corsIframes"); + // Ensure we never switched into the ignored frame. + verify(mockedDriver, never()).switchTo(); + } + + @Test + public void skipsIframeMatchingIgnoreIframeSelectorsOption() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(mockedDriver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://ads.example.com/banner"); + when(iframe.getAttribute("data-percy-ignore")).thenReturn(null); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + Map mainSnapshot = new HashMap<>(); + mainSnapshot.put("dom", "main"); + // Single stub that branches by script content: the selector-match script + // contains `el.matches(selectors` and must return true so the iframe is + // recognised as matched and skipped; all others return the main DOM. + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class), any())).thenAnswer(inv -> { + String script = inv.getArgument(0); + if (script.contains("el.matches(selectors")) return Boolean.TRUE; + return mainSnapshot; + }); + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(inv -> { + String script = inv.getArgument(0); + if (script.contains("el.matches(selectors")) return Boolean.TRUE; + return mainSnapshot; + }); + + Map options = new HashMap<>(); + options.put("ignoreIframeSelectors", Arrays.asList("iframe.ads")); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + percy, "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, new HashSet(), options); + + assertFalse(serialized.containsKey("corsIframes"), + "Frames matching ignoreIframeSelectors must be omitted from corsIframes"); + verify(mockedDriver, never()).switchTo(); + } + + @Test + public void processFrameSkipsAfterSwitchWhenDocumentUrlIsUnsupported() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(mockedDriver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-xyz"); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(mockedDriver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(mockedDriver); + when(targetLocator.defaultContent()).thenReturn(mockedDriver); + + // First executeScript is the dom.js injection (script string); the second + // is `return document.URL` which we make report an unsupported scheme. + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))) + .thenReturn(null) // domJs inject + .thenReturn("about:blank"); // document.URL + + Object result = invokePrivate(percy, "processFrame", new Class[]{WebElement.class, Map.class}, iframe, new HashMap<>()); + assertNull(result, "Frame must be skipped when document.URL is unsupported after switch"); + verify(targetLocator).defaultContent(); + } + + @Test + public void percyContextLostExceptionCarriesPartialCapture() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(mockedDriver)); + + List> partial = new ArrayList<>(); + Map entry = new HashMap<>(); + entry.put("frameUrl", "https://cdn.example.com/a"); + partial.add(entry); + + Class exceptionClass = Class.forName("io.percy.selenium.Percy$PercyContextLostException"); + java.lang.reflect.Constructor ctor = exceptionClass.getDeclaredConstructor(String.class, Throwable.class, List.class); + ctor.setAccessible(true); + RuntimeException ex = (RuntimeException) ctor.newInstance("lost", new RuntimeException("inner"), partial); + + Field f = exceptionClass.getField("partialCapture"); + @SuppressWarnings("unchecked") + List> carried = (List>) f.get(ex); + assertEquals(1, carried.size()); + assertEquals("https://cdn.example.com/a", carried.get(0).get("frameUrl")); + } + + @Test + public void getSerializedDomRecoversPartialCaptureOnContextLost() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(mockedDriver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-a"); + when(iframe.getAttribute("data-percy-ignore")).thenReturn(null); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(mockedDriver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(mockedDriver); + when(targetLocator.defaultContent()).thenReturn(mockedDriver); + // Simulate driver failing to step back to parent after recursion. + when(targetLocator.parentFrame()).thenThrow(new NoSuchFrameException("driver lost frame")); + + Map mainSnapshot = new HashMap<>(); + mainSnapshot.put("dom", "main"); + Map iframeSnapshot = new HashMap<>(); + iframeSnapshot.put("dom", "iframe"); + + // Three executeScript phases per call: + // 1. main page serialize (no enableJavaScript:true in payload) + // 2. domJs inject inside frame + // 3. document.URL inside frame -> String + // 4. PercyDOM.serialize({...enableJavaScript:true}) inside frame + // 5. nested findElements / recursion path triggers parentFrame which throws + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.startsWith("return PercyDOM.serialize(")) { + if (script.contains("\"enableJavaScript\":true")) return iframeSnapshot; + return mainSnapshot; + } + if (script.equals("return document.URL")) return "https://cdn.other.com/frame"; + return null; + }); + + Map options = new HashMap<>(); + // Force at least one recursion attempt by setting maxIframeDepth>1. + options.put("maxIframeDepth", 2); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + percy, "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, new HashSet(), options); + + // Even though parentFrame() failed during recursion, the top frame's snapshot + // should still appear in corsIframes (partial capture recovered). + assertTrue(serialized.containsKey("corsIframes"), + "Partial capture from PercyContextLostException must be preserved"); + @SuppressWarnings("unchecked") + List> caps = (List>) serialized.get("corsIframes"); + assertEquals(1, caps.size()); + assertEquals("https://cdn.other.com/frame", caps.get(0).get("frameUrl")); + } + + @Test + public void exposeClosedShadowRootsIsNoopForNonChromeDrivers() throws Exception { + WebDriver firefoxLike = mock(WebDriver.class); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy percy = new Percy(mockedDriver); + + // Should not throw and should not attempt CDP on a non-Chrome driver. + invokePrivate(percy, "exposeClosedShadowRoots", new Class[]{WebDriver.class}, firefoxLike); + verifyNoInteractions(firefoxLike); + } + + @Test + public void collectClosedShadowPairsWalksTreeAndSkipsContentDocuments() throws Exception { + Method m = Percy.class.getDeclaredMethod( + "collectClosedShadowPairs", Map.class, List.class); + m.setAccessible(true); + + // host -> shadowRoot (closed) + Map closedShadow = new HashMap<>(); + closedShadow.put("backendNodeId", 200); + closedShadow.put("shadowRootType", "closed"); + + Map host = new HashMap<>(); + host.put("backendNodeId", 100); + host.put("shadowRoots", Collections.singletonList(closedShadow)); + + // open shadow on a sibling — must NOT be collected + Map openShadow = new HashMap<>(); + openShadow.put("backendNodeId", 201); + openShadow.put("shadowRootType", "open"); + + Map openHost = new HashMap<>(); + openHost.put("backendNodeId", 101); + openHost.put("shadowRoots", Collections.singletonList(openShadow)); + + // iframe node — has contentDocument so its subtree must be skipped entirely. + Map nestedClosed = new HashMap<>(); + nestedClosed.put("backendNodeId", 300); + nestedClosed.put("shadowRootType", "closed"); + Map hostInIframe = new HashMap<>(); + hostInIframe.put("backendNodeId", 301); + hostInIframe.put("shadowRoots", Collections.singletonList(nestedClosed)); + Map iframeDoc = new HashMap<>(); + iframeDoc.put("children", Collections.singletonList(hostInIframe)); + Map iframeNode = new HashMap<>(); + iframeNode.put("backendNodeId", 99); + iframeNode.put("contentDocument", iframeDoc); + + Map root = new HashMap<>(); + root.put("children", Arrays.asList(host, openHost, iframeNode)); + + List> pairs = new ArrayList<>(); + m.invoke(null, root, pairs); + + assertEquals(1, pairs.size(), "Only the closed shadow root outside any iframe should be collected"); + assertEquals(100, pairs.get(0).get("hostBackendNodeId")); + assertEquals(200, pairs.get(0).get("shadowBackendNodeId")); + } + + // ---------- reflection helpers ---------- + + private static Object invokePrivate(Object target, String name, Class[] types, Object... args) throws Exception { + Method m = Percy.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(target, args); + } + + private static Object invokeStaticPrivate(String name, Class[] types, Object... args) throws Exception { + Method m = Percy.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = Percy.class.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } +} From 879a2cfb7587b8ddf930488ae2575ff403b465fc Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Fri, 22 May 2026 17:47:41 +0530 Subject: [PATCH 08/12] Null-safe responsive config, defensive snapshot cast, DOM.disable cleanup - responsive snapshot detection threw NPE when cliConfig.snapshot was missing or JSON-null; guard each layer before reading responsiveSnapshotCapture. - getSerializedDOM treated a null jse return as a Map and ClassCastException'd deep in the snapshot path. Detect non-Map results and raise a clear error pointing at the @percy/dom load failure as the root cause. - Pair every successful DOM.enable with DOM.disable in a finally block so the CDP session doesn't keep emitting DOM events after closed-shadow capture. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 ++- src/main/java/io/percy/selenium/Percy.java | 25 +++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 72d9dfe..3e17e84 100644 --- a/package.json +++ b/package.json @@ -5,5 +5,6 @@ }, "devDependencies": { "@percy/cli": "1.31.10" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index c8638ca..0c49ce1 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -339,8 +339,11 @@ private boolean isCaptureResponsiveDOM(Map options) { boolean responsiveSnapshotCaptureCLI = false; if (eligibleWidths == null) { return false; } - if (cliConfig.getJSONObject("snapshot").has("responsiveSnapshotCapture")) { - responsiveSnapshotCaptureCLI = cliConfig.getJSONObject("snapshot").getBoolean("responsiveSnapshotCapture"); + if (cliConfig != null && cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snapshotCfg = cliConfig.getJSONObject("snapshot"); + if (snapshotCfg.has("responsiveSnapshotCapture") && !snapshotCfg.isNull("responsiveSnapshotCapture")) { + responsiveSnapshotCaptureCLI = snapshotCfg.getBoolean("responsiveSnapshotCapture"); + } } Object responsiveSnapshotCaptureSDK = options.get("responsiveSnapshotCapture"); @@ -973,7 +976,13 @@ private List> processFrameTree( } private Map getSerializedDOM(JavascriptExecutor jse, Set cookies, Map options) { - Map domSnapshot = (Map) jse.executeScript(buildSnapshotJS(options)); + Object raw = jse.executeScript(buildSnapshotJS(options)); + if (!(raw instanceof Map)) { + throw new RuntimeException("PercyDOM.serialize returned null or non-object; " + + "the @percy/dom script likely failed to load. Aborting snapshot."); + } + @SuppressWarnings("unchecked") + Map domSnapshot = (Map) raw; Map mutableSnapshot = new HashMap<>(domSnapshot); mutableSnapshot.put("cookies", cookies); @@ -1061,8 +1070,10 @@ private void exposeClosedShadowRoots(WebDriver driver) { if (!(driver instanceof ChromeDriver)) return; ChromeDriver chrome; try { chrome = (ChromeDriver) driver; } catch (ClassCastException e) { return; } + boolean domEnabled = false; try { chrome.executeCdpCommand("DOM.enable", new HashMap<>()); + domEnabled = true; Map getDocParams = new HashMap<>(); getDocParams.put("depth", -1); getDocParams.put("pierce", true); @@ -1111,6 +1122,14 @@ private void exposeClosedShadowRoots(WebDriver driver) { } } catch (Exception ex) { log("Could not expose closed shadow roots via CDP: " + ex.getMessage(), "debug"); + } finally { + // Release the DOM domain so subsequent commands don't keep emitting + // DOM events for this session. Best-effort — we don't care if this + // fails (e.g., session already closed). + if (domEnabled) { + try { chrome.executeCdpCommand("DOM.disable", new HashMap<>()); } + catch (Exception ignore) { /* defensive */ } + } } } From 9d0d1aabe6c5acdc88bece28bb038a11a9034e92 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Sun, 24 May 2026 13:24:47 +0530 Subject: [PATCH 09/12] fix: address CE review MAJORs in CORS iframe traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Null-safe origin equality in both top-level and nested iframe comparisons. Switched to Objects.equals so a child URI that resolves to no host (data:, mailto:, schemeless) can never trigger an NPE that escapes the per-iframe catch. - Document the clampFrameDepth semantic: maxIframeDepth=0 falls back to DEFAULT_MAX_FRAME_DEPTH (5), mirroring @percy/sdk-utils. Disabling CORS capture should use ignoreIframeSelectors or data-percy-ignore, not depth=0. Comment guards against a silent flip in future refactors. - Expose closed shadow roots inside each CORS frame after switchTo() — mirrors the top-page behaviour so closed shadow DOM inside cross- origin iframes is also captured. Per-pair try/catch in the existing helper keeps one bad backendNodeId from aborting the rest. TODO tracks moving to per-frame CDP sessions when BiDi stabilises. - Remove the dead processFrame method — fully replaced by processFrameTree. Keeping duplicate logic invited drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 90 +++++++--------------- 1 file changed, 29 insertions(+), 61 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 0c49ce1..49d5638 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -644,8 +644,15 @@ private static String getOrigin(String url) { } } - // Clamp the configured frame depth to a sane range. Negative or - // unreasonably large values fall back to the default. + // Clamp the configured frame depth to a sane range. + // + // Semantic note: a value below MIN_FRAME_DEPTH (1) — including 0 and any + // negative number — falls back to DEFAULT_MAX_FRAME_DEPTH. This matches + // the @percy/sdk-utils canonical behaviour where `maxIframeDepth=0` is + // treated as "unset, use default" rather than "disable nested CORS + // capture". To disable CORS iframe traversal, callers should rely on + // ignoreIframeSelectors or data-percy-ignore — not depth=0. Keeping this + // aligned with sdk-utils avoids a breaking-change divergence across SDKs. private static int clampFrameDepth(int depth) { if (depth < MIN_FRAME_DEPTH) return DEFAULT_MAX_FRAME_DEPTH; if (depth > MAX_FRAME_DEPTH_CAP) return MAX_FRAME_DEPTH_CAP; @@ -763,63 +770,6 @@ private Map serializeCurrentFrame(Map options) { return snapshot; } - private Map processFrame(WebElement frameElement, Map options) { - // Read attributes while still in parent context — these calls will - // fail if made after switchTo().frame(). - String frameUrl = frameElement.getAttribute("src"); - if (frameUrl == null) frameUrl = "unknown-src"; - final String finalFrameUrl = frameUrl; - String percyElementId = frameElement.getAttribute("data-percy-element-id"); - log("processFrame: data-percy-element-id=\"" + percyElementId + "\" for src=\"" + finalFrameUrl + "\"", "debug"); - if (percyElementId == null || percyElementId.isEmpty()) { - log("Skipping frame " + finalFrameUrl + ": no matching percyElementId found", "debug"); - return null; - } - - Map iframeSnapshot = null; - try { - driver.switchTo().frame(frameElement); - JavascriptExecutor jse = (JavascriptExecutor) driver; - // Inject Percy DOM into the cross-origin frame context - jse.executeScript(domJs); - // Post-switch URL re-check: about:blank / data: / javascript: targets - // can slip through the parent-side `src` check (e.g. when the iframe - // failed to load, or has been navigated by script after attach). - String postSwitchUrl = readCurrentFrameUrl(); - if (postSwitchUrl != null && isUnsupportedIframeSrc(postSwitchUrl)) { - log("Skipping iframe after switch: unsupported document.URL \"" + postSwitchUrl + "\"", "debug"); - return null; - } - // Serialize inside the frame; enableJavaScript=true is required for CORS iframes - Map iframeOptions = new HashMap<>(options); - iframeOptions.put("enableJavaScript", true); - JSONObject optionsJson = new JSONObject(iframeOptions); - iframeSnapshot = (Map) jse.executeScript( - "return PercyDOM.serialize(" + optionsJson.toString() + ")" - ); - } catch (Exception e) { - log("Failed to process cross-origin frame " + finalFrameUrl + ": " + e.getMessage(), "error"); - throw new RuntimeException("Failed to process cross-origin frame " + finalFrameUrl, e); - } finally { - try { - driver.switchTo().defaultContent(); - } catch (Exception err) { - throw new FatalIframeException( - "Could not exit iframe context after processing \"" + finalFrameUrl + "\". Driver may be unstable.", err - ); - } - } - - Map iframeData = new HashMap<>(); - iframeData.put("percyElementId", percyElementId); - - Map result = new HashMap<>(); - result.put("iframeData", iframeData); - result.put("iframeSnapshot", iframeSnapshot); - result.put("frameUrl", finalFrameUrl); - return result; - } - // Recursively process a cross-origin iframe tree. From the current driver // frame context, switch into `frameElement`, capture its DOM, enumerate // further cross-origin iframes nested inside it, and recurse. Steps back @@ -877,6 +827,19 @@ private List> processFrameTree( return collected; } + // Expose closed shadow roots inside this CORS frame's document + // before serializing — mirrors the top-page behaviour so closed + // shadow DOM inside cross-origin iframes is also captured. + // CDP DOM.getDocument is invoked at depth=-1 with pierce=true, and + // contentDocument subtrees are skipped during collection so this + // remains scoped to the host element-level pairs visible from the + // current driver session. Non-fatal — wrapped in try/catch so a + // non-Chromium driver or restricted frame falls through silently. + // TODO(closed-shadow-cors): drive per-frame CDP via flat sessions + // (Target.setAutoAttach / sessionId) for deeper isolation when + // Selenium 4 BiDi support stabilises across versions. + try { exposeClosedShadowRoots(driver); } catch (Exception ignore) {} + Map iframeSnapshot = serializeCurrentFrame(options); String reportedUrl = (postSwitchUrl != null) ? postSwitchUrl : frameSrc; @@ -924,7 +887,11 @@ private List> processFrameTree( continue; } // Compare to the IMMEDIATE PARENT origin, not the page origin. - if (childOrigin.equals(currentOrigin)) continue; + // Null-safe: getOrigin currently returns "" on parse failure, but a + // child URI resolving to no host (e.g. data:, mailto:, schemeless) + // could yield null in future refactors. Objects.equals avoids any + // NPE escaping to the per-iframe catch. + if (Objects.equals(childOrigin, currentOrigin)) continue; try { List> nested = processFrameTree(child, depth + 1, nextAncestors, ctx); @@ -1025,7 +992,8 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set log("Skipping iframe \"" + frameSrc + "\": " + e.getMessage(), "debug"); continue; } - if (frameOrigin.equals(pageOrigin)) continue; + // Null-safe equality — see processFrameTree() for rationale. + if (Objects.equals(frameOrigin, pageOrigin)) continue; Set ancestors = new HashSet<>(); if (pageUrl != null) ancestors.add(pageUrl); From 88ef11fc6ab04af44fc7f88ee226b6b06841523b Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Sun, 24 May 2026 13:24:59 +0530 Subject: [PATCH 10/12] test: cover NPE, clamp(0), closed-shadow-in-CORS, bad-pair paths - nestedIframeWithNullOriginIsNullSafeAndDoesNotAbortLoop: regression test for the NPE risk at the child-origin comparison; a data:... child must not abort the outer CORS frame capture. - clampFrameDepthZeroReturnsDocumentedDefault: semantic guard so any future change to treat 0 as "disable" trips a test. - exposeClosedShadowRootsIsAttemptedInsideCorsFrame: confirms the per-CORS-frame helper invocation and the TODO marker survive future refactors; ensures the call is safe on a non-Chrome driver. - collectClosedShadowPairsContinuesPastOneBadEntry: documents that the collector tolerates missing backendNodeId fields without throwing, and one bad pair does not abort the rest at runtime. - Update the post-switch unsupported-URL test to drive processFrameTree directly (processFrame removed). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../io/percy/selenium/IframeFeatureTest.java | 214 +++++++++++++++++- 1 file changed, 206 insertions(+), 8 deletions(-) diff --git a/src/test/java/io/percy/selenium/IframeFeatureTest.java b/src/test/java/io/percy/selenium/IframeFeatureTest.java index 7f62a53..90d5ba1 100644 --- a/src/test/java/io/percy/selenium/IframeFeatureTest.java +++ b/src/test/java/io/percy/selenium/IframeFeatureTest.java @@ -181,7 +181,7 @@ public void skipsIframeMatchingIgnoreIframeSelectorsOption() throws Exception { } @Test - public void processFrameSkipsAfterSwitchWhenDocumentUrlIsUnsupported() throws Exception { + public void processFrameTreeSkipsAfterSwitchWhenDocumentUrlIsUnsupported() throws Exception { RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); Percy percy = spy(new Percy(mockedDriver)); setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); @@ -194,16 +194,25 @@ public void processFrameSkipsAfterSwitchWhenDocumentUrlIsUnsupported() throws Ex when(mockedDriver.switchTo()).thenReturn(targetLocator); when(targetLocator.frame(iframe)).thenReturn(mockedDriver); when(targetLocator.defaultContent()).thenReturn(mockedDriver); + when(targetLocator.parentFrame()).thenReturn(mockedDriver); - // First executeScript is the dom.js injection (script string); the second - // is `return document.URL` which we make report an unsupported scheme. + // First executeScript is the dom.js injection; the second `return document.URL` + // reports an unsupported scheme so the frame is skipped before serialization. when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))) - .thenReturn(null) // domJs inject - .thenReturn("about:blank"); // document.URL + .thenReturn(null) + .thenReturn("about:blank"); - Object result = invokePrivate(percy, "processFrame", new Class[]{WebElement.class, Map.class}, iframe, new HashMap<>()); - assertNull(result, "Frame must be skipped when document.URL is unsupported after switch"); - verify(targetLocator).defaultContent(); + Map ctx = new HashMap<>(); + ctx.put("options", new HashMap()); + ctx.put("maxFrameDepth", 5); + ctx.put("ignoreSelectors", java.util.Collections.emptyList()); + + @SuppressWarnings("unchecked") + List> result = (List>) invokePrivate( + percy, "processFrameTree", + new Class[]{WebElement.class, int.class, Set.class, Map.class}, + iframe, 1, new HashSet(), ctx); + assertTrue(result.isEmpty(), "Frame must be skipped when document.URL is unsupported after switch"); } @Test @@ -349,6 +358,195 @@ public void collectClosedShadowPairsWalksTreeAndSkipsContentDocuments() throws E assertEquals(200, pairs.get(0).get("shadowBackendNodeId")); } + @Test + public void clampFrameDepthZeroReturnsDocumentedDefault() throws Exception { + // Semantic regression test: maxIframeDepth=0 must fall back to the + // documented default (5), matching @percy/sdk-utils behaviour. Anyone + // who later changes this to "0 disables CORS capture" would break + // cross-SDK alignment — this test guards against the silent flip. + int fromZero = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, 0); + assertEquals(5, fromZero, "maxIframeDepth=0 must use the canonical default (5), not disable nested capture"); + } + + @Test + public void nestedIframeWithNullOriginIsNullSafeAndDoesNotAbortLoop() throws Exception { + // Regression test for the NPE risk at processFrameTree's child-origin + // comparison. A child