Skip to content

Commit bcc27d3

Browse files
aryanku-devclaude
andcommitted
feat: expose closed shadow DOM to PercyDOM via CDP for Chromium drivers
Closed shadow roots (`{mode: 'closed'}`) are invisible to JavaScript — `element.shadowRoot` is `null` and there is no API that returns the underlying ShadowRoot object. The PercyDOM serializer can pierce them through a window-bound `__percyClosedShadowRoots` WeakMap (host element → shadow root) populated before serialization, but Selenium has no way to obtain the closed shadow root from page script. Use Chrome DevTools Protocol to discover and resolve them: 1. `DOM.getDocument {depth: -1, pierce: true}` to walk the entire DOM tree including closed shadow subtrees. 2. For each closed shadow root, `DOM.resolveNode` on the host and the shadow root to obtain JS object handles. 3. `Runtime.callFunctionOn` to write the pair into the WeakMap. `contentDocument` nodes are skipped because their execution context is distinct and has no WeakMap. Non-Chromium drivers are detected with a single `instanceof ChromeDriver` check and silently fall through, so the SDK keeps working with Firefox/WebKit without changes. Mirrors percy/percy-playwright#609. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2cbec3f commit bcc27d3

1 file changed

Lines changed: 98 additions & 0 deletions

File tree

src/main/java/io/percy/selenium/Percy.java

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,11 @@ private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie>
976976
Map<String, Object> domSnapshot = (Map<String, Object>) jse.executeScript(buildSnapshotJS(options));
977977
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);
978978
mutableSnapshot.put("cookies", cookies);
979+
980+
// Expose closed shadow roots via CDP (Chromium only) so PercyDOM.serialize
981+
// can pierce them through the WeakMap it reads. Non-fatal — skip on errors.
982+
try { exposeClosedShadowRoots(driver); } catch (Exception ignore) {}
983+
979984
try {
980985
String pageUrl = driver.getCurrentUrl();
981986
String pageOrigin = getOrigin(pageUrl);
@@ -1044,6 +1049,99 @@ private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie>
10441049
return mutableSnapshot;
10451050
}
10461051

1052+
// Discover closed shadow roots via CDP and expose them on a window-bound
1053+
// WeakMap that PercyDOM.serialize reads to pierce closed shadow DOM. This is
1054+
// Chromium-only — wrapped in try/catch so other browsers (or a missing
1055+
// executeCdpCommand) fall through silently. Three CDP calls per pair:
1056+
// DOM.getDocument (depth=-1, pierce=true) to discover, then DOM.resolveNode
1057+
// for host + shadow, then Runtime.callFunctionOn to write the pair into
1058+
// the WeakMap on the page.
1059+
@SuppressWarnings("unchecked")
1060+
private void exposeClosedShadowRoots(WebDriver driver) {
1061+
if (!(driver instanceof ChromeDriver)) return;
1062+
ChromeDriver chrome;
1063+
try { chrome = (ChromeDriver) driver; } catch (ClassCastException e) { return; }
1064+
try {
1065+
chrome.executeCdpCommand("DOM.enable", new HashMap<>());
1066+
Map<String, Object> getDocParams = new HashMap<>();
1067+
getDocParams.put("depth", -1);
1068+
getDocParams.put("pierce", true);
1069+
Map<String, Object> doc = chrome.executeCdpCommand("DOM.getDocument", getDocParams);
1070+
if (doc == null) return;
1071+
Object rootObj = doc.get("root");
1072+
if (!(rootObj instanceof Map)) return;
1073+
List<Map<String, Object>> closedPairs = new ArrayList<>();
1074+
collectClosedShadowPairs((Map<String, Object>) rootObj, closedPairs);
1075+
if (closedPairs.isEmpty()) return;
1076+
1077+
log("Found " + closedPairs.size() + " closed shadow root(s), exposing via CDP", "debug");
1078+
1079+
((JavascriptExecutor) chrome).executeScript(
1080+
"window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap();"
1081+
);
1082+
1083+
for (Map<String, Object> pair : closedPairs) {
1084+
try {
1085+
Map<String, Object> hostParams = new HashMap<>();
1086+
hostParams.put("backendNodeId", pair.get("hostBackendNodeId"));
1087+
Map<String, Object> hostRes = chrome.executeCdpCommand("DOM.resolveNode", hostParams);
1088+
Map<String, Object> shadowParams = new HashMap<>();
1089+
shadowParams.put("backendNodeId", pair.get("shadowBackendNodeId"));
1090+
Map<String, Object> shadowRes = chrome.executeCdpCommand("DOM.resolveNode", shadowParams);
1091+
if (hostRes == null || shadowRes == null) continue;
1092+
Object hostObj = hostRes.get("object");
1093+
Object shadowObj = shadowRes.get("object");
1094+
if (!(hostObj instanceof Map) || !(shadowObj instanceof Map)) continue;
1095+
Object hostObjectId = ((Map<String, Object>) hostObj).get("objectId");
1096+
Object shadowObjectId = ((Map<String, Object>) shadowObj).get("objectId");
1097+
if (hostObjectId == null || shadowObjectId == null) continue;
1098+
Map<String, Object> callParams = new HashMap<>();
1099+
callParams.put("functionDeclaration",
1100+
"function(shadowRoot) { window.__percyClosedShadowRoots.set(this, shadowRoot); }");
1101+
callParams.put("objectId", hostObjectId);
1102+
List<Map<String, Object>> args = new ArrayList<>();
1103+
Map<String, Object> a = new HashMap<>();
1104+
a.put("objectId", shadowObjectId);
1105+
args.add(a);
1106+
callParams.put("arguments", args);
1107+
chrome.executeCdpCommand("Runtime.callFunctionOn", callParams);
1108+
} catch (Exception perPair) {
1109+
log("Failed to expose a closed shadow root: " + perPair.getMessage(), "debug");
1110+
}
1111+
}
1112+
} catch (Exception ex) {
1113+
log("Could not expose closed shadow roots via CDP: " + ex.getMessage(), "debug");
1114+
}
1115+
}
1116+
1117+
// Walk the CDP DOM tree looking for closed shadow roots. Skips nodes that
1118+
// are themselves child-frame documents — cross-frame closed shadow roots
1119+
// are not supported (different execution context, no WeakMap there).
1120+
@SuppressWarnings("unchecked")
1121+
private static void collectClosedShadowPairs(Map<String, Object> node, List<Map<String, Object>> out) {
1122+
if (node.containsKey("contentDocument")) return;
1123+
Object srs = node.get("shadowRoots");
1124+
if (srs instanceof List<?>) {
1125+
for (Object sr : (List<?>) srs) {
1126+
if (!(sr instanceof Map)) continue;
1127+
Map<String, Object> srMap = (Map<String, Object>) sr;
1128+
if ("closed".equals(srMap.get("shadowRootType"))) {
1129+
Map<String, Object> pair = new HashMap<>();
1130+
pair.put("hostBackendNodeId", node.get("backendNodeId"));
1131+
pair.put("shadowBackendNodeId", srMap.get("backendNodeId"));
1132+
out.add(pair);
1133+
}
1134+
collectClosedShadowPairs(srMap, out);
1135+
}
1136+
}
1137+
Object children = node.get("children");
1138+
if (children instanceof List<?>) {
1139+
for (Object child : (List<?>) children) {
1140+
if (child instanceof Map) collectClosedShadowPairs((Map<String, Object>) child, out);
1141+
}
1142+
}
1143+
}
1144+
10471145
private List<String> getElementIdFromElement(List<RemoteWebElement> elements) {
10481146
List<String> ignoredElementsArray = new ArrayList<>();
10491147
for (int index = 0; index < elements.size(); index++) {

0 commit comments

Comments
 (0)