diff --git a/package.json b/package.json index 72d9dfe..3e790ec 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "test": "npx percy exec --testing -- mvn test" }, "devDependencies": { - "@percy/cli": "1.31.10" - } + "@percy/cli": "1.31.14" + }, + "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 453c4bf..49d5638 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"); @@ -604,6 +607,23 @@ 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; + 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 +632,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,86 +644,370 @@ private String getOrigin(String url) { } } - 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; + // 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; + 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) { + 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; + } + + // 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; + } + } + + // 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. + 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; + } + + // 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 + // to the parent frame on exit so the caller can continue iterating siblings. + // + // Bounded by `maxFrameDepth` to stop runaway recursion when pages link to + // each other. `ancestorUrls` tracks parent frame URLs — if the current + // frame's URL is already in the chain we treat it as a cycle and stop + // descending. Compares nested-frame origin against the IMMEDIATE PARENT + // origin, not the top page origin. + private List> processFrameTree( + WebElement frameElement, + int depth, + Set ancestorUrls, + Map ctx + ) { + @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"); - log("processFrame: data-percy-element-id=\"" + percyElementId + "\" for src=\"" + finalFrameUrl + "\"", "debug"); + + 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 frame " + finalFrameUrl + ": no matching percyElementId found", "debug"); - return null; + log("Skipping cross-origin iframe without data-percy-element-id: " + frameSrc, "debug"); + return collected; } - Map iframeSnapshot = null; + boolean switchedIn = false; try { + log("Processing cross-origin iframe (depth " + depth + "): " + frameSrc, "debug"); driver.switchTo().frame(frameElement); + switchedIn = true; + JavascriptExecutor jse = (JavascriptExecutor) driver; - // Inject Percy DOM into the cross-origin frame context jse.executeScript(domJs); - // 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() + ")" - ); + + // 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; + } + + // 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; + + Map iframeData = new HashMap<>(); + iframeData.put("percyElementId", percyElementId); + Map entry = new HashMap<>(); + entry.put("iframeData", iframeData); + entry.put("iframeSnapshot", iframeSnapshot); + 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(reportedUrl); + List childIframes; + try { + childIframes = driver.findElements(By.tagName("iframe")); + } catch (Exception e) { + 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; + 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; + } + if (iframeMatchesIgnoreSelector(child, ignoreSelectors)) { + log("Skipping iframe matching ignoreIframeSelectors: " + childSrc, "debug"); + continue; + } + String childOrigin; + try { + URI base = new URI(reportedUrl); + URI resolved = base.resolve(childSrc); + childOrigin = getOrigin(resolved.toString()); + } catch (Exception e) { + continue; + } + // Compare to the IMMEDIATE PARENT origin, not the page origin. + // 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); + 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) { + log("Skipping nested iframe \"" + childSrc + "\" due to error: " + e.getMessage(), "debug"); + } + } + } + return collected; + } catch (PercyContextLostException ctxLost) { + throw ctxLost; } 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); + log("Failed to process cross-origin iframe " + frameSrc + ": " + e.getMessage(), "warn"); + return collected; } finally { - try { - driver.switchTo().defaultContent(); - } catch (Exception err) { - throw new FatalIframeException( - "Could not exit iframe context after processing \"" + finalFrameUrl + "\". Driver may be unstable.", err - ); + 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) + ); + } + } } } - - 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; } 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); + + // 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 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); + 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) { - String frameSrc = frame.getAttribute("src"); - if (isUnsupportedIframeSrc(frameSrc)) { + 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; + } + if (iframeMatchesIgnoreSelector(frame, ignoreSelectors)) { + log("Skipping iframe matching ignoreIframeSelectors: " + frameSrc, "debug"); 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; - } + // Null-safe equality — see processFrameTree() for rationale. + if (Objects.equals(frameOrigin, 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 (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) { @@ -722,6 +1026,109 @@ 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; } + boolean domEnabled = false; + try { + chrome.executeCdpCommand("DOM.enable", new HashMap<>()); + domEnabled = true; + 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"); + } 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 */ } + } + } + } + + // 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++) { 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..90d5ba1 --- /dev/null +++ b/src/test/java/io/percy/selenium/IframeFeatureTest.java @@ -0,0 +1,569 @@ +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 processFrameTreeSkipsAfterSwitchWhenDocumentUrlIsUnsupported() 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); + when(targetLocator.parentFrame()).thenReturn(mockedDriver); + + // 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) + .thenReturn("about:blank"); + + 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 + 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")); + } + + @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