Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@
<artifactId>playwright</artifactId>
<version>1.44.0</version>
</dependency>
<!-- Declared explicitly: Percy.java imports com.google.gson.* directly
for CDP DOM-tree parsing. It currently resolves transitively via
Playwright, but a direct source dependency must be declared so a
Playwright upgrade cannot silently break compilation. Version kept
in sync with the one Playwright 1.44.0 brings in. -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
Expand Down
290 changes: 286 additions & 4 deletions src/main/java/io/percy/playwright/Percy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;


/**
Expand Down Expand Up @@ -319,6 +322,10 @@ public JSONObject snapshot(String name, Map<String, Object> 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<Cookie> cookies = new ArrayList<>();
try {
cookies = page.context().cookies();
Expand Down Expand Up @@ -831,6 +838,132 @@ private List<Map<String, Object>> captureResponsiveDom(
// Cross-origin iframe helpers
// -------------------------------------------------------------------------

/**
* Resolves the list of iframe-ignore CSS selectors for this snapshot.
*
* <p>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<String>} 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.</p>
*/
@SuppressWarnings("unchecked")
List<String> resolveIgnoreSelectors(Map<String, Object> 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<String> 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<String> out = new ArrayList<>();
for (Object v : (List<Object>) 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 <iframe>} element from its PARENT
* frame's DOM (where the element lives), mirroring the JS SDK.
*
* <p>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).</p>
*/
@SuppressWarnings("unchecked")
private Map<String, Object> resolveIframeIgnoreFlags(Frame frame, List<String> ignoreSelectors) {
Map<String, Object> defaults = new HashMap<>();
defaults.put("dataPercyIgnore", false);
defaults.put("matchesIgnoreSelector", false);
try {
Frame parent = frame.parentFrame();
// For a top-level iframe the <iframe> 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<String, Object> 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<String, Object>) 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<Frame> 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}
Expand All @@ -853,6 +986,15 @@ private Map<String, Object> 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<String, Object> frameOptions = new HashMap<>(options);
Expand Down Expand Up @@ -932,22 +1074,59 @@ Map<String, Object> getSerializedDOM(
URI pageUri = new URI(page.url());
String pageHost = pageUri.getHost();

List<Frame> crossOriginFrames = page.frames().stream()
// Resolve iframe-ignore selectors once for this snapshot. Per-snapshot
// options take precedence over the global config (matches JS).
List<String> 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<Frame> 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<String, Object> 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<Frame> crossOriginSet = new HashSet<>(allCrossOrigin);
List<Frame> crossOriginFrames = new ArrayList<>();
for (Frame f : allCrossOrigin) {
if (!hasCrossOriginAncestor(f, crossOriginSet)) {
crossOriginFrames.add(f);
}
}

if (!crossOriginFrames.isEmpty()) {
List<Map<String, Object>> processedFrames = new ArrayList<>();
for (Frame frame : crossOriginFrames) {
Expand Down Expand Up @@ -985,6 +1164,109 @@ Map<String, Object> 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<JsonObject> 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<JsonObject> 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
// -------------------------------------------------------------------------
Expand Down
Loading
Loading