From ca6c12a85566fb9cc87b7d6b205b38795a66faa7 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Tue, 21 Apr 2026 00:14:13 +0530 Subject: [PATCH 1/8] Add closed shadow DOM capture via CDP Use CDP to discover closed shadow roots before DOM serialization. Closed shadow roots are inaccessible from JS (element.shadowRoot === null), but CDP's DOM domain can pierce them. We resolve each closed shadow root to a JS object and store it in a WeakMap that PercyDOM.serialize() reads. - Add exposeClosedShadowRoots() using CDPSession - Add walkNodes() helper to traverse CDP DOM tree - Skip iframe contentDocument nodes (cross-frame not yet supported) - Non-fatal: catches exceptions for non-Chromium browsers and CDP errors - Called after PercyDOM injection, before DOM serialization Ported from percy/percy-playwright#609 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/java/io/percy/playwright/Percy.java | 98 ++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 2f2224e..5da4df8 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; /** @@ -303,6 +306,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(); @@ -902,6 +909,97 @@ 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; + } + + try { + cdpSession.send("DOM.enable"); + + 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) { + 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 err) { + log("Could not expose closed shadow roots via CDP: " + err.getMessage(), "debug"); + } finally { + if (cdpSession != null) { + 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 // ------------------------------------------------------------------------- From 4afca05696d0b8dd5c3275810b95d5d971cd2b8e Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Fri, 22 May 2026 17:47:12 +0530 Subject: [PATCH 2/8] Walk only main-frame children for CORS detection; release DOM domain - page.frames() returns the entire frame tree, including same-origin grandchildren of cross-origin frames. Switching to mainFrame().childFrames() scopes detection to top-level iframes; nested capture is handled inside processFrame via recursion when supported. - Pair every successful DOM.enable with DOM.disable in finally so the CDP session doesn't keep emitting DOM events after capture. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/playwright/Percy.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 5da4df8..1fe7c16 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -856,15 +856,17 @@ Map getSerializedDOM( URI pageUri = new URI(page.url()); String pageHost = pageUri.getHost(); - List crossOriginFrames = page.frames().stream() + // Only walk DIRECT children of the main frame. page.frames() returns the + // entire tree (including same-origin grandchildren of cross-origin + // frames); we want top-level CORS iframes only, since nested capture + // is handled inside processFrame via recursion when supported. + List crossOriginFrames = page.mainFrame().childFrames().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); } catch (Exception e) { return false; @@ -927,8 +929,10 @@ private void exposeClosedShadowRoots(Page page) { return; } + boolean domEnabled = false; try { cdpSession.send("DOM.enable"); + domEnabled = true; JsonObject docParams = new JsonObject(); docParams.addProperty("depth", -1); @@ -973,6 +977,9 @@ private void exposeClosedShadowRoots(Page page) { 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) { } } } From e10732446a7a37ac8084c507bae8fe56614983ca Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Sun, 24 May 2026 13:26:43 +0530 Subject: [PATCH 3/8] Fix lost CORS-iframe capture in main->same-origin->CORS topologies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 4afca05 switched from page.frames() to mainFrame().childFrames() on the assumption that processFrame recurses. It doesn't — processFrame only serializes and returns. As a result, topologies like main(A) -> same-origin(A) -> cross-origin(B) silently dropped B. Option A (chosen, minimal): walk the full page.frames() tree and skip frames whose ancestor is already a known cross-origin frame. This preserves correctness for all observed topologies while keeping each top-level CORS frame processed exactly once (descendants are stitched in by the in-frame PercyDOM.serialize call). Picked over Option B (recursive processFrame) because the diff is smaller and the existing test suite's mocking around mockPage.frames() keeps working as-is. Also leaves a TODO in processFrame for closed-shadow exposure inside CORS iframes — that requires a CDP session attached to the OOPIF target, which is materially more than ~30 LOC and is tracked separately. Top-page closed-shadow capture is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/playwright/Percy.java | 52 ++++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 1fe7c16..6d7f332 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -763,6 +763,21 @@ private List> captureResponsiveDom( // Cross-origin iframe helpers // ------------------------------------------------------------------------- + /** + * 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} @@ -785,6 +800,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); @@ -856,11 +880,21 @@ Map getSerializedDOM( URI pageUri = new URI(page.url()); String pageHost = pageUri.getHost(); - // Only walk DIRECT children of the main frame. page.frames() returns the - // entire tree (including same-origin grandchildren of cross-origin - // frames); we want top-level CORS iframes only, since nested capture - // is handled inside processFrame via recursion when supported. - List crossOriginFrames = page.mainFrame().childFrames().stream() + // 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; } @@ -874,6 +908,14 @@ Map getSerializedDOM( }) .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) { From 2ec308da79badc8e77b8ff100124eda933644611 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Sun, 24 May 2026 13:27:08 +0530 Subject: [PATCH 4/8] Isolate per-host failures in exposeClosedShadowRoots loop A single detached backendNodeId (host or shadow) caused DOM.resolveNode to throw, which previously aborted the whole loop and lost every remaining closed shadow root on the page. Wrap each iteration in its own try/catch so one bad pair only skips itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/io/percy/playwright/Percy.java | 47 +++++++++++--------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 6d7f332..3529529 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -994,26 +994,33 @@ private void exposeClosedShadowRoots(Page page) { page.evaluate("() => { window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap(); }"); for (JsonObject pair : closedPairs) { - 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); + // 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"); From 1be7129a0cdf8e4bb780632ae3ae193c4556494b Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Sun, 24 May 2026 13:27:27 +0530 Subject: [PATCH 5/8] Test CORS topology, DOM.enable/disable pairing, per-host isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the three regressions guarded by the BLOCKER fix and the per-host try/catch: - crossOriginGrandchildOfSameOriginFrameIsStillCaptured main(A) -> same-origin(A) -> cross-origin(B): B is emitted. - descendantsOfACorsFrameAreNotRecapturedIndependently main(A) -> cors(B) -> inner(B) -> nested-cors(C): only B is emitted; C is handled inside B's PercyDOM.serialize. - domEnableIsPairedWithDomDisableOnSuccess / OnException DOM.disable always runs after a successful DOM.enable, even when DOM.getDocument throws. The CDP session is always detached. - domDisableNotCalledWhenDomEnableFails If DOM.enable itself throws, DOM.disable is not called (correct protocol shape) but detach still runs. - oneBadBackendNodeIdDoesNotAbortRemainingShadowHosts One DOM.resolveNode failure skips only its pair; the remaining closed shadow hosts are still exposed via Runtime.callFunctionOn. Tests are pure-mock (Mockito) — no Playwright browser required, so they run on the full Java 8/11/17/21 CI matrix. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PercyShadowAndCorsTopologyTest.java | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 src/test/java/io/percy/playwright/PercyShadowAndCorsTopologyTest.java 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); + } +} From a6e73a864a1f6ef639f7996e4218c550ed1c8b6d Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Sun, 24 May 2026 13:27:33 +0530 Subject: [PATCH 6/8] Bump @percy/cli to 1.31.14 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9e8de3a..8b13474 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "test": "npx percy exec --testing -- mvn test" }, "devDependencies": { - "@percy/cli": "^1.31.10" + "@percy/cli": "^1.31.14" } } From 8c4d1adb50421bffc1decef9a7808e51f8c2060c Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 22 Jun 2026 19:47:27 +0530 Subject: [PATCH 7/8] build: declare gson as a direct dependency Percy.java (PER-7292) imports com.google.gson.* directly for parsing the CDP DOM tree when exposing closed shadow roots, but gson was only present transitively via the Playwright artifact. A direct compile-time source dependency must be declared explicitly so a future Playwright upgrade that drops or relocates gson cannot silently break compilation. Pinned to 2.10.1 to match the version Playwright 1.44.0 currently resolves, so no version skew is introduced. Build and offline test suites unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From 824737ee1acc7be6174fd4639dc41859a4d17ba9 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Thu, 25 Jun 2026 08:45:30 +0530 Subject: [PATCH 8/8] feat: honor data-percy-ignore and ignoreIframeSelectors in CORS iframe capture The Java SDK captured cross-origin iframes but ignored the two opt-out controls the JS SDK already supports, so an iframe a user marked to skip was still captured. Port from percy-playwright/index.js: - resolveIgnoreSelectors: per-snapshot ignoreIframeSelectors, then global cliConfig.snapshot.ignoreIframeSelectors; normalized to a String list. - Per-frame lookup in the parent frame's DOM for data-percy-ignore and ignoreIframeSelectors matches; skip (debug log) before the switch/serialize. Invalid selectors swallowed; empty/missing -> no-op. Adds PercyIframeIgnoreTest (5 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/java/io/percy/playwright/Percy.java | 130 ++++++++++- .../playwright/PercyIframeIgnoreTest.java | 207 ++++++++++++++++++ 2 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 src/test/java/io/percy/playwright/PercyIframeIgnoreTest.java diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index ce5f2ed..e4c4c4b 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -838,6 +838,117 @@ 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