diff --git a/pom.xml b/pom.xml index 468d183..e563227 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,16 @@ playwright 1.44.0 + + + com.google.code.gson + gson + 2.10.1 + org.json json diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 007a6cb..e4c4c4b 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -21,6 +21,9 @@ import com.microsoft.playwright.*; import com.microsoft.playwright.options.Cookie; import com.microsoft.playwright.options.ViewportSize; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; /** @@ -319,6 +322,10 @@ public JSONObject snapshot(String name, Map options) { String percyDomScript = fetchPercyDOM(); page.evaluate(percyDomScript); + // Expose closed shadow roots via CDP before serialization so + // PercyDOM.serialize() can access them through the WeakMap + exposeClosedShadowRoots(page); + List cookies = new ArrayList<>(); try { cookies = page.context().cookies(); @@ -831,6 +838,132 @@ private List> captureResponsiveDom( // Cross-origin iframe helpers // ------------------------------------------------------------------------- + /** + * Resolves the list of iframe-ignore CSS selectors for this snapshot. + * + * Mirrors the JS SDK's {@code resolveIgnoreSelectors(options)}: per-snapshot + * {@code options.ignoreIframeSelectors} (falling back to {@code options.ignoreSelectors}) + * takes precedence; if absent, the global {@code cliConfig.snapshot.ignoreIframeSelectors} + * is used. The value is normalized to a {@code List} of non-empty strings. + * A single string is wrapped in a one-element list. Any other type (or a missing + * value) yields an empty list, which makes the filter a no-op. + */ + @SuppressWarnings("unchecked") + List resolveIgnoreSelectors(Map options) { + Object sel = null; + if (options != null) { + sel = options.get("ignoreIframeSelectors"); + if (sel == null) { sel = options.get("ignoreSelectors"); } + } + // Fall back to global config (cliConfig.snapshot.ignoreIframeSelectors) + if (sel == null && cliConfig != null && cliConfig.has("snapshot") + && !cliConfig.isNull("snapshot")) { + JSONObject snapshotConfig = cliConfig.optJSONObject("snapshot"); + if (snapshotConfig != null && snapshotConfig.has("ignoreIframeSelectors") + && !snapshotConfig.isNull("ignoreIframeSelectors")) { + Object configSel = snapshotConfig.get("ignoreIframeSelectors"); + if (configSel instanceof JSONArray) { + List out = new ArrayList<>(); + JSONArray arr = (JSONArray) configSel; + for (int i = 0; i < arr.length(); i++) { + Object v = arr.opt(i); + if (v instanceof String && !((String) v).isEmpty()) { out.add((String) v); } + } + return out; + } else if (configSel instanceof String) { + return Collections.singletonList((String) configSel); + } + return new ArrayList<>(); + } + } + + if (sel == null) { return new ArrayList<>(); } + if (sel instanceof List) { + List out = new ArrayList<>(); + for (Object v : (List) sel) { + if (v instanceof String && !((String) v).isEmpty()) { out.add((String) v); } + } + return out; + } + if (sel instanceof String) { + return Collections.singletonList((String) sel); + } + return new ArrayList<>(); + } + + /** + * Reads the {@code data-percy-ignore} attribute and ignore-selector match + * state of a cross-origin frame's {@code } element from its PARENT + * frame's DOM (where the element lives), mirroring the JS SDK. + * + * The lookup matches the iframe by exact {@code src} first, then by a + * trailing-slash-normalized comparison. Invalid selectors are swallowed + * (per the JS {@code try/catch} around {@code el.matches}). Returns a map + * with boolean keys {@code dataPercyIgnore} and {@code matchesIgnoreSelector}; + * on any failure both default to {@code false} (frame is captured). + */ + @SuppressWarnings("unchecked") + private Map resolveIframeIgnoreFlags(Frame frame, List ignoreSelectors) { + Map defaults = new HashMap<>(); + defaults.put("dataPercyIgnore", false); + defaults.put("matchesIgnoreSelector", false); + try { + Frame parent = frame.parentFrame(); + // For a top-level iframe the element lives in the main page; + // page.evaluate has the same signature so the lookup still works. + Object evalTarget = parent != null ? parent : page; + + String js = + "({ fUrl, selectors }) => {" + + " const norm = (s) => (s || '').replace(/\\/+$/, '');" + + " const target = norm(fUrl);" + + " const iframes = Array.from(document.querySelectorAll('iframe'));" + + " const el = iframes.find(i => i.src === fUrl) || iframes.find(i => norm(i.src) === target);" + + " if (!el) { return { dataPercyIgnore: false, matchesIgnoreSelector: false }; }" + + " let matches = false;" + + " if (selectors && selectors.length) {" + + " for (let j = 0; j < selectors.length; j++) {" + + " try { if (el.matches(selectors[j])) { matches = true; break; } } catch (e) { /* invalid */ }" + + " }" + + " }" + + " return {" + + " dataPercyIgnore: el.hasAttribute('data-percy-ignore')," + + " matchesIgnoreSelector: matches" + + " };" + + "}"; + + Map arg = new HashMap<>(); + arg.put("fUrl", frame.url()); + arg.put("selectors", ignoreSelectors); + + Object flags; + if (parent != null) { + flags = parent.evaluate(js, arg); + } else { + flags = page.evaluate(js, arg); + } + if (flags instanceof Map) { return (Map) flags; } + return defaults; + } catch (Exception e) { + return defaults; + } + } + + /** + * Returns {@code true} if any ancestor of {@code frame} is itself in the + * provided set of known cross-origin frames. Used to dedupe descendants of + * a CORS frame whose serialization is handled inside its top-level CORS + * ancestor via PercyDOM.serialize(). + */ + private boolean hasCrossOriginAncestor(Frame frame, Set crossOriginFrames) { + Frame parent = frame.parentFrame(); + while (parent != null) { + if (crossOriginFrames.contains(parent)) { return true; } + parent = parent.parentFrame(); + } + return false; + } + /** * Processes a single cross-origin {@link Frame}: injects the Percy DOM script, * serializes the frame, and retrieves the matching {@code data-percy-element-id} @@ -853,6 +986,15 @@ private Map processFrame( // Inject Percy DOM into the cross-origin frame frame.evaluate(percyDomScript); + // TODO(PER-7292 follow-up): expose closed shadow roots inside this + // cross-origin frame. The top-page exposure uses page-level + // CDPSession + DOM.getDocument(pierce:true). Doing the same here + // requires attaching a CDP session to the OOPIF target + // (Target.attachToTarget on the frame's target id) and re-running + // DOM.enable/DOM.getDocument/resolveNode against that session. + // That is materially more than ~30 LOC and warrants its own ticket; + // tracked separately. Top-page closed-shadow capture still works. + // enableJavaScript=true prevents standard iframe serialization so we can // handle cross-origin frames manually Map frameOptions = new HashMap<>(options); @@ -932,22 +1074,59 @@ Map getSerializedDOM( URI pageUri = new URI(page.url()); String pageHost = pageUri.getHost(); - List crossOriginFrames = page.frames().stream() + // Resolve iframe-ignore selectors once for this snapshot. Per-snapshot + // options take precedence over the global config (matches JS). + List ignoreSelectors = resolveIgnoreSelectors(options); + + // Option A (chosen): walk the full page.frames() tree but skip any + // frame whose nearest cross-origin ancestor frame is *already* in + // our result set. This guarantees: + // - topologies like main(A) -> same-origin(A) -> cross-origin(B) + // still capture B (which the prior mainFrame().childFrames() + // short-walk lost, since processFrame does NOT recurse into + // child frames — it only serializes the given frame). + // - each top-level CORS frame is processed exactly once; nested + // descendants of that CORS frame are handled by the in-frame + // PercyDOM.serialize() call (same-document tree), so we must + // not re-emit them here. + // Picked over Option B (recursive processFrame) because it is the + // minimal diff and preserves the existing test suite's frame + // mocking (mockPage.frames()). + List allCrossOrigin = page.frames().stream() .filter(f -> { String fUrl = f.url(); if ("about:blank".equals(fUrl) || fUrl.isEmpty()) { return false; } - // If the page has no host (e.g., file:, data:), skip CORS detection if (pageHost == null) { return false; } try { String frameHost = new URI(fUrl).getHost(); - // Treat frames with no host as non-cross-origin - return frameHost != null && !Objects.equals(frameHost, pageHost); + if (frameHost == null || Objects.equals(frameHost, pageHost)) { return false; } } catch (Exception e) { return false; } + // Honor data-percy-ignore and ignoreIframeSelectors on the + // iframe element (read from the parent frame's DOM) BEFORE + // the expensive switch/serialize. Mirrors the JS SDK. + Map ignoreFlags = resolveIframeIgnoreFlags(f, ignoreSelectors); + if (Boolean.TRUE.equals(ignoreFlags.get("dataPercyIgnore"))) { + log("Skipping iframe marked with data-percy-ignore: " + fUrl, "debug"); + return false; + } + if (Boolean.TRUE.equals(ignoreFlags.get("matchesIgnoreSelector"))) { + log("Skipping iframe matching ignoreIframeSelectors: " + fUrl, "debug"); + return false; + } + return true; }) .collect(Collectors.toList()); + Set crossOriginSet = new HashSet<>(allCrossOrigin); + List crossOriginFrames = new ArrayList<>(); + for (Frame f : allCrossOrigin) { + if (!hasCrossOriginAncestor(f, crossOriginSet)) { + crossOriginFrames.add(f); + } + } + if (!crossOriginFrames.isEmpty()) { List> processedFrames = new ArrayList<>(); for (Frame frame : crossOriginFrames) { @@ -985,6 +1164,109 @@ Map getSerializedDOM( return mutableSnapshot; } + // ------------------------------------------------------------------------- + // Closed Shadow DOM via CDP + // ------------------------------------------------------------------------- + + /** + * Use CDP to discover closed shadow roots and expose them to PercyDOM.serialize(). + * Closed shadow roots are inaccessible from JS (element.shadowRoot === null), + * but CDP's DOM domain can pierce them. + */ + private void exposeClosedShadowRoots(Page page) { + CDPSession cdpSession = null; + try { + cdpSession = page.context().newCDPSession(page); + } catch (Exception err) { + log("CDP session unavailable: " + err.getMessage(), "debug"); + return; + } + + boolean domEnabled = false; + try { + cdpSession.send("DOM.enable"); + domEnabled = true; + + JsonObject docParams = new JsonObject(); + docParams.addProperty("depth", -1); + docParams.addProperty("pierce", true); + JsonElement docResult = cdpSession.send("DOM.getDocument", docParams); + JsonObject root = docResult.getAsJsonObject().getAsJsonObject("root"); + + List closedPairs = new ArrayList<>(); + walkNodes(root, closedPairs); + + if (closedPairs.isEmpty()) { + return; + } + + log("Found " + closedPairs.size() + " closed shadow root(s), exposing via CDP", "debug"); + + page.evaluate("() => { window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap(); }"); + + for (JsonObject pair : closedPairs) { + // Wrap each per-host body so one bad backendNodeId (e.g. a node + // that was detached after DOM.getDocument but before + // DOM.resolveNode) doesn't abort exposure of the rest. + try { + JsonObject resolveHost = new JsonObject(); + resolveHost.addProperty("backendNodeId", pair.get("hostBackendNodeId").getAsInt()); + JsonElement hostResult = cdpSession.send("DOM.resolveNode", resolveHost); + String hostObjectId = hostResult.getAsJsonObject().getAsJsonObject("object").get("objectId").getAsString(); + + JsonObject resolveShadow = new JsonObject(); + resolveShadow.addProperty("backendNodeId", pair.get("shadowBackendNodeId").getAsInt()); + JsonElement shadowResult = cdpSession.send("DOM.resolveNode", resolveShadow); + String shadowObjectId = shadowResult.getAsJsonObject().getAsJsonObject("object").get("objectId").getAsString(); + + JsonObject callParams = new JsonObject(); + callParams.addProperty("functionDeclaration", + "function(shadowRoot) { window.__percyClosedShadowRoots.set(this, shadowRoot); }"); + callParams.addProperty("objectId", hostObjectId); + JsonArray args = new JsonArray(); + JsonObject arg = new JsonObject(); + arg.addProperty("objectId", shadowObjectId); + args.add(arg); + callParams.add("arguments", args); + cdpSession.send("Runtime.callFunctionOn", callParams); + } catch (Exception perPairErr) { + log("Skipping one closed shadow host: " + perPairErr.getMessage(), "debug"); + } + } + } catch (Exception err) { + log("Could not expose closed shadow roots via CDP: " + err.getMessage(), "debug"); + } finally { + if (cdpSession != null) { + if (domEnabled) { + try { cdpSession.send("DOM.disable"); } catch (Exception ignored) { /* defensive */ } + } + try { cdpSession.detach(); } catch (Exception ignored) { } + } + } + } + + private void walkNodes(JsonObject node, List closedPairs) { + if (node.has("contentDocument")) return; + + if (node.has("shadowRoots")) { + for (JsonElement srElem : node.getAsJsonArray("shadowRoots")) { + JsonObject sr = srElem.getAsJsonObject(); + if (sr.has("shadowRootType") && "closed".equals(sr.get("shadowRootType").getAsString())) { + JsonObject pair = new JsonObject(); + pair.addProperty("hostBackendNodeId", node.get("backendNodeId").getAsInt()); + pair.addProperty("shadowBackendNodeId", sr.get("backendNodeId").getAsInt()); + closedPairs.add(pair); + } + walkNodes(sr, closedPairs); + } + } + if (node.has("children")) { + for (JsonElement childElem : node.getAsJsonArray("children")) { + walkNodes(childElem.getAsJsonObject(), closedPairs); + } + } + } + // ------------------------------------------------------------------------- // Logging // ------------------------------------------------------------------------- diff --git a/src/test/java/io/percy/playwright/PercyIframeIgnoreTest.java b/src/test/java/io/percy/playwright/PercyIframeIgnoreTest.java new file mode 100644 index 0000000..702285e --- /dev/null +++ b/src/test/java/io/percy/playwright/PercyIframeIgnoreTest.java @@ -0,0 +1,207 @@ +package io.percy.playwright; + +import com.microsoft.playwright.Frame; +import com.microsoft.playwright.Page; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for the iframe-ignore controls ported from the JS SDK: + * - the {@code data-percy-ignore} attribute opt-out + * - the {@code ignoreIframeSelectors} option (per-snapshot AND global config) + * + * These mock {@link Page}/{@link Frame} so they run without a live browser. + */ +public class PercyIframeIgnoreTest { + + /** + * Wires up a main(A) -> cross-origin(B) topology and returns the cross-origin + * frame so the test can configure its ignore flags / serialization. + */ + private Frame buildCorsTopology(Page mockPage, Frame mainFrame, Frame corsFrame) { + Map mainDomMap = new HashMap<>(); + mainDomMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(mainDomMap); + + when(mockPage.url()).thenReturn("http://a.com/"); + when(mainFrame.url()).thenReturn("http://a.com/"); + when(corsFrame.url()).thenReturn("http://b.com/"); + + when(mainFrame.parentFrame()).thenReturn(null); + when(corsFrame.parentFrame()).thenReturn(mainFrame); + + when(mockPage.frames()).thenReturn(Arrays.asList(mainFrame, corsFrame)); + + // Cross-origin frame serialization: first evaluate (DOM injection) -> null, + // second evaluate (PercyDOM.serialize) -> snapshot map. + Map bSnapshot = new HashMap<>(); + bSnapshot.put("html", "B"); + when(corsFrame.evaluate(anyString())).thenReturn(null).thenReturn(bSnapshot); + + return corsFrame; + } + + /** Make the parent-frame ignore-flag lookup (evaluate with an arg) return the given flags. */ + private void stubIgnoreFlags(Frame mainFrame, boolean dataPercyIgnore, boolean matchesIgnoreSelector) { + Map flags = new HashMap<>(); + flags.put("dataPercyIgnore", dataPercyIgnore); + flags.put("matchesIgnoreSelector", matchesIgnoreSelector); + when(mainFrame.evaluate(anyString(), any())).thenReturn(flags); + } + + @Test + @SuppressWarnings("unchecked") + public void iframeWithDataPercyIgnoreIsSkipped() { + Page mockPage = mock(Page.class); + Frame mainFrame = mock(Frame.class); + Frame corsFrame = mock(Frame.class); + buildCorsTopology(mockPage, mainFrame, corsFrame); + + // iframe element in the parent (main) DOM carries data-percy-ignore + stubIgnoreFlags(mainFrame, true, false); + + Percy percy = new Percy(mockPage); + Map result = percy.getSerializedDOM( + new ArrayList<>(), "// percy dom", new HashMap<>()); + + assertNotNull(result); + assertNull(result.get("corsIframes"), + "iframe marked data-percy-ignore must not be captured"); + // The expensive serialize path must never run for the skipped frame + verify(corsFrame, never()).evaluate(anyString()); + } + + @Test + @SuppressWarnings("unchecked") + public void iframeMatchingPerSnapshotIgnoreSelectorIsSkipped() { + Page mockPage = mock(Page.class); + Frame mainFrame = mock(Frame.class); + Frame corsFrame = mock(Frame.class); + buildCorsTopology(mockPage, mainFrame, corsFrame); + + // The in-browser el.matches() resolved to true for the configured selector + stubIgnoreFlags(mainFrame, false, true); + + Map options = new HashMap<>(); + options.put("ignoreIframeSelectors", Collections.singletonList(".ad-frame")); + + Percy percy = new Percy(mockPage); + Map result = percy.getSerializedDOM( + new ArrayList<>(), "// percy dom", options); + + assertNotNull(result); + assertNull(result.get("corsIframes"), + "iframe matching per-snapshot ignoreIframeSelectors must not be captured"); + verify(corsFrame, never()).evaluate(anyString()); + } + + @Test + @SuppressWarnings("unchecked") + public void iframeMatchingGlobalConfigIgnoreSelectorIsSkipped() { + Page mockPage = mock(Page.class); + Frame mainFrame = mock(Frame.class); + Frame corsFrame = mock(Frame.class); + buildCorsTopology(mockPage, mainFrame, corsFrame); + + stubIgnoreFlags(mainFrame, false, true); + + Percy percy = new Percy(mockPage); + // Global config: percy.config.snapshot.ignoreIframeSelectors + JSONObject config = new JSONObject(); + JSONObject snapshot = new JSONObject(); + snapshot.put("ignoreIframeSelectors", new JSONArray(Collections.singletonList(".ad-frame"))); + config.put("snapshot", snapshot); + percy.cliConfig = config; + + Map result = percy.getSerializedDOM( + new ArrayList<>(), "// percy dom", new HashMap<>()); + + assertNotNull(result); + assertNull(result.get("corsIframes"), + "iframe matching global config ignoreIframeSelectors must not be captured"); + verify(corsFrame, never()).evaluate(anyString()); + } + + @Test + @SuppressWarnings("unchecked") + public void nonMatchingIframeIsStillCaptured() { + Page mockPage = mock(Page.class); + Frame mainFrame = mock(Frame.class); + Frame corsFrame = mock(Frame.class); + buildCorsTopology(mockPage, mainFrame, corsFrame); + + // Neither data-percy-ignore nor a selector match. + // mainFrame.evaluate(js, arg) is used both for the ignore-flag lookup AND + // the percyElementId lookup, so return ignore-flags first, then the id. + Map flags = new HashMap<>(); + flags.put("dataPercyIgnore", false); + flags.put("matchesIgnoreSelector", false); + Map idData = new HashMap<>(); + idData.put("percyElementId", "percy-elem-b"); + when(mainFrame.evaluate(anyString(), any())).thenReturn(flags); + when(mockPage.evaluate(anyString(), any())).thenReturn(idData); + + Percy percy = new Percy(mockPage); + Map result = percy.getSerializedDOM( + new ArrayList<>(), "// percy dom", new HashMap<>()); + + assertNotNull(result); + List> corsIframes = (List>) result.get("corsIframes"); + assertNotNull(corsIframes, "Non-ignored cross-origin iframe must still be captured"); + assertEquals(1, corsIframes.size()); + assertEquals("http://b.com/", corsIframes.get(0).get("frameUrl")); + } + + @Test + public void resolveIgnoreSelectorsNormalizesAndPrefersPerSnapshot() { + Page mockPage = mock(Page.class); + Percy percy = new Percy(mockPage); + + // Per-snapshot list with an empty string filtered out + Map options = new HashMap<>(); + options.put("ignoreIframeSelectors", Arrays.asList(".a", "", ".b")); + assertEquals(Arrays.asList(".a", ".b"), percy.resolveIgnoreSelectors(options)); + + // Single string is wrapped + Map single = new HashMap<>(); + single.put("ignoreIframeSelectors", ".only"); + assertEquals(Collections.singletonList(".only"), percy.resolveIgnoreSelectors(single)); + + // Missing -> empty (no-op) + assertTrue(percy.resolveIgnoreSelectors(new HashMap<>()).isEmpty()); + + // Non-array/non-string -> empty (no-op) + Map bad = new HashMap<>(); + bad.put("ignoreIframeSelectors", 42); + assertTrue(percy.resolveIgnoreSelectors(bad).isEmpty()); + + // Per-snapshot wins over global config + JSONObject config = new JSONObject(); + JSONObject snapshot = new JSONObject(); + snapshot.put("ignoreIframeSelectors", new JSONArray(Collections.singletonList(".global"))); + config.put("snapshot", snapshot); + percy.cliConfig = config; + Map per = new HashMap<>(); + per.put("ignoreIframeSelectors", Collections.singletonList(".per")); + assertEquals(Collections.singletonList(".per"), percy.resolveIgnoreSelectors(per)); + // and falls back to global when per-snapshot absent + assertEquals(Collections.singletonList(".global"), + percy.resolveIgnoreSelectors(new HashMap<>())); + } +} diff --git a/src/test/java/io/percy/playwright/PercyShadowAndCorsTopologyTest.java b/src/test/java/io/percy/playwright/PercyShadowAndCorsTopologyTest.java new file mode 100644 index 0000000..20abe7a --- /dev/null +++ b/src/test/java/io/percy/playwright/PercyShadowAndCorsTopologyTest.java @@ -0,0 +1,315 @@ +package io.percy.playwright; + +import com.google.gson.JsonObject; +import com.google.gson.JsonArray; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.CDPSession; +import com.microsoft.playwright.Frame; +import com.microsoft.playwright.Page; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for the BLOCKER fix and the per-pair try/catch in + * {@code exposeClosedShadowRoots}. + * + * These tests deliberately do NOT depend on a live Playwright browser; they + * mock {@link Page}/{@link Frame}/{@link CDPSession} so they run on every CI + * matrix entry (Java 8..21) without networking. + */ +public class PercyShadowAndCorsTopologyTest { + + // --------------------------------------------------------------------- + // BLOCKER fix: main(A) -> same-origin(A) -> cross-origin(B) must capture B + // --------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + public void crossOriginGrandchildOfSameOriginFrameIsStillCaptured() throws Exception { + Page mockPage = mock(Page.class); + Frame mainFrame = mock(Frame.class); + Frame sameOriginChild = mock(Frame.class); + Frame crossOriginGrandchild = mock(Frame.class); + + Map mainDomMap = new HashMap<>(); + mainDomMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(mainDomMap); + + Map iframeDataMap = new HashMap<>(); + iframeDataMap.put("percyElementId", "percy-elem-grandchild"); + when(mockPage.evaluate(anyString(), any())).thenReturn(iframeDataMap); + + when(mockPage.url()).thenReturn("http://example.com/page"); + + // Topology: main(example.com) -> sameOriginChild(example.com) -> + // crossOriginGrandchild(other.com). page.frames() returns the full tree. + when(mainFrame.url()).thenReturn("http://example.com/page"); + when(sameOriginChild.url()).thenReturn("http://example.com/inner"); + when(crossOriginGrandchild.url()).thenReturn("http://other.com/embed"); + + // parent chain + when(mainFrame.parentFrame()).thenReturn(null); + when(sameOriginChild.parentFrame()).thenReturn(mainFrame); + when(crossOriginGrandchild.parentFrame()).thenReturn(sameOriginChild); + + when(mockPage.frames()).thenReturn(Arrays.asList( + mainFrame, sameOriginChild, crossOriginGrandchild)); + + // Frame serialization mock for the grandchild + Map grandchildSnapshot = new HashMap<>(); + grandchildSnapshot.put("html", "cors grandchild"); + when(crossOriginGrandchild.evaluate(anyString())) + .thenReturn(null) + .thenReturn(grandchildSnapshot); + + Percy percyInstance = new Percy(mockPage); + + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + assertNotNull(result.get("corsIframes"), + "Cross-origin grandchild (main -> same-origin -> CORS) must still be captured"); + + List> corsIframes = (List>) result.get("corsIframes"); + assertEquals(1, corsIframes.size(), + "Exactly one CORS frame should be emitted for the grandchild"); + assertEquals("http://other.com/embed", corsIframes.get(0).get("frameUrl")); + } + + @Test + @SuppressWarnings("unchecked") + public void descendantsOfACorsFrameAreNotRecapturedIndependently() throws Exception { + // Topology: main(A) -> cors(B) -> same-origin-of-B(B) -> nested-cors(C) + // We want exactly one top-level CORS emission (B). C is inside B's + // document tree and is handled by PercyDOM.serialize() inside B; the + // outer walk must not double-emit it. + Page mockPage = mock(Page.class); + Frame mainFrame = mock(Frame.class); + Frame corsB = mock(Frame.class); + Frame innerOfB = mock(Frame.class); + Frame nestedCorsC = mock(Frame.class); + + Map mainDomMap = new HashMap<>(); + mainDomMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(mainDomMap); + + Map iframeDataMap = new HashMap<>(); + iframeDataMap.put("percyElementId", "percy-elem-b"); + when(mockPage.evaluate(anyString(), any())).thenReturn(iframeDataMap); + + when(mockPage.url()).thenReturn("http://a.com/"); + when(mainFrame.url()).thenReturn("http://a.com/"); + when(corsB.url()).thenReturn("http://b.com/"); + when(innerOfB.url()).thenReturn("http://b.com/inner"); + when(nestedCorsC.url()).thenReturn("http://c.com/"); + + when(mainFrame.parentFrame()).thenReturn(null); + when(corsB.parentFrame()).thenReturn(mainFrame); + when(innerOfB.parentFrame()).thenReturn(corsB); + when(nestedCorsC.parentFrame()).thenReturn(innerOfB); + + when(mockPage.frames()).thenReturn(Arrays.asList( + mainFrame, corsB, innerOfB, nestedCorsC)); + + Map bSnapshot = new HashMap<>(); + bSnapshot.put("html", "B"); + when(corsB.evaluate(anyString())) + .thenReturn(null) + .thenReturn(bSnapshot); + + Percy percyInstance = new Percy(mockPage); + + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + List> corsIframes = (List>) result.get("corsIframes"); + assertNotNull(corsIframes, "Outer CORS frame B must be captured"); + assertEquals(1, corsIframes.size(), + "Nested CORS C must not be processed independently; it lives inside B"); + assertEquals("http://b.com/", corsIframes.get(0).get("frameUrl")); + + // nestedCorsC.evaluate must never be called by the outer walk + verify(nestedCorsC, never()).evaluate(anyString()); + } + + // --------------------------------------------------------------------- + // DOM.enable / DOM.disable pairing + // --------------------------------------------------------------------- + + @Test + public void domEnableIsPairedWithDomDisableOnSuccess() throws Exception { + Page mockPage = mock(Page.class); + BrowserContext mockCtx = mock(BrowserContext.class); + CDPSession mockCdp = mock(CDPSession.class); + + when(mockPage.context()).thenReturn(mockCtx); + when(mockCtx.newCDPSession(mockPage)).thenReturn(mockCdp); + + // DOM.enable returns empty object + when(mockCdp.send("DOM.enable")).thenReturn(new JsonObject()); + // DOM.getDocument returns a tiny tree with no closed shadow roots + JsonObject docResult = new JsonObject(); + JsonObject root = new JsonObject(); + root.addProperty("nodeId", 1); + root.addProperty("backendNodeId", 1); + docResult.add("root", root); + when(mockCdp.send(anyString(), any())).thenReturn(docResult); + + invokeExposeClosedShadowRoots(new Percy(mockPage), mockPage); + + verify(mockCdp, times(1)).send("DOM.enable"); + verify(mockCdp, times(1)).send("DOM.disable"); + verify(mockCdp, times(1)).detach(); + } + + @Test + public void domEnableIsPairedWithDomDisableOnException() throws Exception { + Page mockPage = mock(Page.class); + BrowserContext mockCtx = mock(BrowserContext.class); + CDPSession mockCdp = mock(CDPSession.class); + + when(mockPage.context()).thenReturn(mockCtx); + when(mockCtx.newCDPSession(mockPage)).thenReturn(mockCdp); + + when(mockCdp.send("DOM.enable")).thenReturn(new JsonObject()); + // DOM.getDocument explodes — must still pair with DOM.disable + when(mockCdp.send(anyString(), any())) + .thenThrow(new RuntimeException("simulated CDP error")); + + invokeExposeClosedShadowRoots(new Percy(mockPage), mockPage); + + verify(mockCdp, times(1)).send("DOM.enable"); + verify(mockCdp, times(1)).send("DOM.disable"); + verify(mockCdp, times(1)).detach(); + } + + @Test + public void domDisableNotCalledWhenDomEnableFails() throws Exception { + // If DOM.enable itself throws, we must NOT call DOM.disable (it was + // never enabled). But the CDP session must still be detached. + Page mockPage = mock(Page.class); + BrowserContext mockCtx = mock(BrowserContext.class); + CDPSession mockCdp = mock(CDPSession.class); + + when(mockPage.context()).thenReturn(mockCtx); + when(mockCtx.newCDPSession(mockPage)).thenReturn(mockCdp); + + when(mockCdp.send("DOM.enable")).thenThrow(new RuntimeException("no DOM")); + + invokeExposeClosedShadowRoots(new Percy(mockPage), mockPage); + + verify(mockCdp, times(1)).send("DOM.enable"); + verify(mockCdp, never()).send("DOM.disable"); + verify(mockCdp, times(1)).detach(); + } + + // --------------------------------------------------------------------- + // Per-host loop isolation: one bad backendNodeId must not abort the rest + // --------------------------------------------------------------------- + + @Test + public void oneBadBackendNodeIdDoesNotAbortRemainingShadowHosts() throws Exception { + Page mockPage = mock(Page.class); + BrowserContext mockCtx = mock(BrowserContext.class); + CDPSession mockCdp = mock(CDPSession.class); + + when(mockPage.context()).thenReturn(mockCtx); + when(mockCtx.newCDPSession(mockPage)).thenReturn(mockCdp); + + // Build a fake DOM tree with two closed shadow roots: + // host#1 (host backendNodeId=100, shadow backendNodeId=101) -- bad: resolveNode throws + // host#2 (host backendNodeId=200, shadow backendNodeId=201) -- good + JsonObject root = new JsonObject(); + root.addProperty("backendNodeId", 1); + JsonArray rootChildren = new JsonArray(); + + rootChildren.add(buildHostWithClosedShadow(100, 101)); + rootChildren.add(buildHostWithClosedShadow(200, 201)); + root.add("children", rootChildren); + + JsonObject docResult = new JsonObject(); + docResult.add("root", root); + + AtomicInteger callFunctionOnCalls = new AtomicInteger(0); + AtomicReference resolveNodeError = + new AtomicReference<>(new RuntimeException("detached node")); + + when(mockCdp.send("DOM.enable")).thenReturn(new JsonObject()); + + when(mockCdp.send(anyString(), any())).thenAnswer(inv -> { + String method = inv.getArgument(0); + JsonObject params = inv.getArgument(1); + if ("DOM.getDocument".equals(method)) { + return docResult; + } + if ("DOM.resolveNode".equals(method)) { + int bid = params.get("backendNodeId").getAsInt(); + // Fail only for the bad host (100) and its shadow (101) + if (bid == 100 || bid == 101) { + throw resolveNodeError.get(); + } + JsonObject ok = new JsonObject(); + JsonObject obj = new JsonObject(); + obj.addProperty("objectId", "obj-" + bid); + ok.add("object", obj); + return ok; + } + if ("Runtime.callFunctionOn".equals(method)) { + callFunctionOnCalls.incrementAndGet(); + return new JsonObject(); + } + return new JsonObject(); + }); + + invokeExposeClosedShadowRoots(new Percy(mockPage), mockPage); + + // Exactly one shadow host (the good one) must have been exposed. + assertEquals(1, callFunctionOnCalls.get(), + "Bad host must be skipped, good host must still be exposed"); + verify(mockCdp, times(1)).send("DOM.disable"); + verify(mockCdp, times(1)).detach(); + } + + // --------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------- + + private static JsonObject buildHostWithClosedShadow(int hostBid, int shadowBid) { + JsonObject host = new JsonObject(); + host.addProperty("backendNodeId", hostBid); + JsonArray shadowRoots = new JsonArray(); + JsonObject sr = new JsonObject(); + sr.addProperty("backendNodeId", shadowBid); + sr.addProperty("shadowRootType", "closed"); + shadowRoots.add(sr); + host.add("shadowRoots", shadowRoots); + return host; + } + + private static void invokeExposeClosedShadowRoots(Percy percy, Page page) throws Exception { + Method m = Percy.class.getDeclaredMethod("exposeClosedShadowRoots", Page.class); + m.setAccessible(true); + m.invoke(percy, page); + } +}
Mirrors the JS SDK's {@code resolveIgnoreSelectors(options)}: per-snapshot + * {@code options.ignoreIframeSelectors} (falling back to {@code options.ignoreSelectors}) + * takes precedence; if absent, the global {@code cliConfig.snapshot.ignoreIframeSelectors} + * is used. The value is normalized to a {@code List} of non-empty strings. + * A single string is wrapped in a one-element list. Any other type (or a missing + * value) yields an empty list, which makes the filter a no-op.
The lookup matches the iframe by exact {@code src} first, then by a + * trailing-slash-normalized comparison. Invalid selectors are swallowed + * (per the JS {@code try/catch} around {@code el.matches}). Returns a map + * with boolean keys {@code dataPercyIgnore} and {@code matchesIgnoreSelector}; + * on any failure both default to {@code false} (frame is captured).
These mock {@link Page}/{@link Frame} so they run without a live browser.
These tests deliberately do NOT depend on a live Playwright browser; they + * mock {@link Page}/{@link Frame}/{@link CDPSession} so they run on every CI + * matrix entry (Java 8..21) without networking.