From ca6c12a85566fb9cc87b7d6b205b38795a66faa7 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Tue, 21 Apr 2026 00:14:13 +0530 Subject: [PATCH] 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 // -------------------------------------------------------------------------