Skip to content

Commit c7ffcb1

Browse files
aryanku-devclaude
andcommitted
fix: address PR review findings in CORS iframe + shadow DOM capture
- Skip CORS frame when PercyDOM.serialize returns null instead of emitting a poisoned snapshot entry (mirror getSerializedDOM guard) [High] - Guard exposeClosedShadowRoots with isCdpSupported before CDP calls [Med] - Honour single-string ignoreIframeSelectors CLI config value [Med] - Pass ignore selectors as executeScript argument, not interpolated [Med] - Add post-switch absolute-URL cycle check for relative-src cycles [Med] - Remove unused CSS/XPath imports [Low] - Add tests for null-snapshot skip, resolved-URL cycle guard, single-string CLI Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4b7835d commit c7ffcb1

2 files changed

Lines changed: 133 additions & 8 deletions

File tree

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

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@
2929

3030
import java.util.stream.Collectors;
3131

32-
import javax.swing.text.html.CSS;
33-
import javax.xml.xpath.XPath;
34-
3532
/**
3633
* Percy client for visual testing.
3734
*/
@@ -803,6 +800,10 @@ private List<String> resolveIgnoreSelectors(Map<String, Object> options) {
803800
for (int i = 0; i < arr.length(); i++) out.add(arr.opt(i));
804801
return normalizeIgnoreSelectors(out);
805802
}
803+
// A single string value (e.g. "ignoreIframeSelectors": "iframe.ads")
804+
// isn't a JSONArray; normalize it the same way the options-map path does.
805+
String single = snap.optString("ignoreIframeSelectors", null);
806+
if (single != null) return normalizeIgnoreSelectors(single);
806807
}
807808
}
808809
return Collections.emptyList();
@@ -815,12 +816,14 @@ private boolean iframeMatchesIgnoreSelector(WebElement iframe, List<String> sele
815816
if (selectors == null || selectors.isEmpty()) return false;
816817
try {
817818
JavascriptExecutor jse = (JavascriptExecutor) driver;
818-
JSONArray sel = new JSONArray(selectors);
819-
String script = "var el = arguments[0]; var selectors = " + sel.toString() + ";"
819+
// Pass the selector list as a script argument rather than interpolating
820+
// it into the source — a selector containing a quote or control char
821+
// would otherwise break the script literal and throw.
822+
String script = "var el = arguments[0]; var selectors = arguments[1];"
820823
+ "for (var i = 0; i < selectors.length; i++) {"
821824
+ " try { if (el.matches(selectors[i])) return true; } catch (e) {}"
822825
+ "} return false;";
823-
Object res = jse.executeScript(script, iframe);
826+
Object res = jse.executeScript(script, iframe, selectors);
824827
return res instanceof Boolean && (Boolean) res;
825828
} catch (Exception e) {
826829
return false;
@@ -876,10 +879,16 @@ private Map<String, Object> serializeCurrentFrame(Map<String, Object> options) {
876879
Map<String, Object> iframeOptions = new HashMap<>(options == null ? Collections.emptyMap() : options);
877880
iframeOptions.put("enableJavaScript", true);
878881
JSONObject optionsJson = new JSONObject(iframeOptions);
879-
@SuppressWarnings("unchecked")
880-
Map<String, Object> snapshot = (Map<String, Object>) jse.executeScript(
882+
Object raw = jse.executeScript(
881883
"return PercyDOM.serialize(" + optionsJson.toString() + ")"
882884
);
885+
// PercyDOM.serialize yields null (or a non-object) when @percy/dom failed
886+
// to load inside this frame — e.g. a sandboxed CORS document. Return null
887+
// so the caller can skip the frame instead of letting a poisoned snapshot
888+
// through, mirroring the main-page guard in getSerializedDOM.
889+
if (!(raw instanceof Map)) return null;
890+
@SuppressWarnings("unchecked")
891+
Map<String, Object> snapshot = (Map<String, Object>) raw;
883892
return snapshot;
884893
}
885894

@@ -940,6 +949,17 @@ private List<Map<String, Object>> processFrameTree(
940949
return collected;
941950
}
942951

952+
// Absolute-URL cycle re-check. The pre-switch guard above compares the
953+
// raw `src` attribute, which can be relative; a cycle expressed as a
954+
// relative src at one level and an absolute URL at another would slip
955+
// past it. document.URL here is the resolved absolute URL, and the
956+
// ancestor set is seeded with each level's absolute reportedUrl, so
957+
// this catches the cycle reliably regardless of how src was written.
958+
if (ancestorUrls != null && postSwitchUrl != null && ancestorUrls.contains(postSwitchUrl)) {
959+
log("Skipping cyclic iframe (resolved URL " + postSwitchUrl + " appears in ancestor chain)", "debug");
960+
return collected;
961+
}
962+
943963
// Expose closed shadow roots inside this CORS frame's document
944964
// before serializing — mirrors the top-page behaviour so closed
945965
// shadow DOM inside cross-origin iframes is also captured.
@@ -956,6 +976,15 @@ private List<Map<String, Object>> processFrameTree(
956976
Map<String, Object> iframeSnapshot = serializeCurrentFrame(options);
957977
String reportedUrl = (postSwitchUrl != null) ? postSwitchUrl : frameSrc;
958978

979+
// serializeCurrentFrame returns null when PercyDOM.serialize did not
980+
// produce a DOM object (script failed to load in this frame). Skip the
981+
// frame rather than emitting an entry with a null snapshot, which the
982+
// CLI would otherwise have to defend against.
983+
if (iframeSnapshot == null) {
984+
log("PercyDOM.serialize returned null inside CORS iframe " + reportedUrl + "; skipping", "warn");
985+
return collected;
986+
}
987+
959988
Map<String, Object> iframeData = new HashMap<>();
960989
iframeData.put("percyElementId", percyElementId);
961990
Map<String, Object> entry = new HashMap<>();
@@ -1159,6 +1188,10 @@ private void exposeClosedShadowRoots(WebDriver driver) {
11591188
if (!(driver instanceof ChromeDriver)) return;
11601189
ChromeDriver chrome;
11611190
try { chrome = (ChromeDriver) driver; } catch (ClassCastException e) { return; }
1191+
// Reuse the same CDP availability guard as changeWindowDimensionAndWait so
1192+
// ChromeDriver builds/subclasses without executeCdpCommand bail cleanly
1193+
// rather than relying on the catch below to absorb a NoSuchMethodError.
1194+
if (!isCdpSupported(chrome)) return;
11621195
boolean domEnabled = false;
11631196
try {
11641197
chrome.executeCdpCommand("DOM.enable", new HashMap<>());

src/test/java/io/percy/selenium/IframeFeatureTest.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,21 @@ public void resolveIgnoreSelectorsCoercesOptionAndCliConfig() throws Exception {
108108
assertEquals(Arrays.asList("iframe.cli-only"), fromCli);
109109
}
110110

111+
@Test
112+
public void resolveIgnoreSelectorsAcceptsSingleStringCliConfig() throws Exception {
113+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
114+
Percy percy = new Percy(mockedDriver);
115+
// A scalar string (not an array) in CLI config must still be honoured.
116+
setField(percy, "cliConfig",
117+
new JSONObject().put("snapshot",
118+
new JSONObject().put("ignoreIframeSelectors", "iframe.ads")));
119+
120+
@SuppressWarnings("unchecked")
121+
List<String> fromCli = (List<String>) invokePrivate(percy, "resolveIgnoreSelectors", new Class[]{Map.class}, new HashMap<>());
122+
assertEquals(Arrays.asList("iframe.ads"), fromCli,
123+
"A single-string ignoreIframeSelectors CLI config must not be dropped");
124+
}
125+
111126
@Test
112127
public void skipsIframeMarkedWithDataPercyIgnore() throws Exception {
113128
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
@@ -215,6 +230,83 @@ public void processFrameTreeSkipsAfterSwitchWhenDocumentUrlIsUnsupported() throw
215230
assertTrue(result.isEmpty(), "Frame must be skipped when document.URL is unsupported after switch");
216231
}
217232

233+
@Test
234+
public void processFrameTreeSkipsWhenSerializeReturnsNull() throws Exception {
235+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
236+
Percy percy = spy(new Percy(mockedDriver));
237+
setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};");
238+
239+
WebElement iframe = mock(WebElement.class);
240+
when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame");
241+
when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-null");
242+
243+
TargetLocator targetLocator = mock(TargetLocator.class);
244+
when(mockedDriver.switchTo()).thenReturn(targetLocator);
245+
when(targetLocator.frame(iframe)).thenReturn(mockedDriver);
246+
when(targetLocator.defaultContent()).thenReturn(mockedDriver);
247+
when(targetLocator.parentFrame()).thenReturn(mockedDriver);
248+
249+
// dom.js inject -> null; document.URL -> supported; PercyDOM.serialize -> null
250+
// (e.g. @percy/dom failed to load in the frame). The frame must be skipped, not
251+
// emitted with a null snapshot.
252+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> {
253+
String script = invocation.getArgument(0);
254+
if (script.startsWith("return PercyDOM.serialize(")) return null;
255+
if (script.equals("return document.URL")) return "https://cdn.other.com/frame";
256+
return null;
257+
});
258+
259+
Map<String, Object> ctx = new HashMap<>();
260+
ctx.put("options", new HashMap<String, Object>());
261+
ctx.put("maxFrameDepth", 5);
262+
ctx.put("ignoreSelectors", java.util.Collections.<String>emptyList());
263+
264+
@SuppressWarnings("unchecked")
265+
List<Map<String, Object>> result = (List<Map<String, Object>>) invokePrivate(
266+
percy, "processFrameTree",
267+
new Class[]{WebElement.class, int.class, Set.class, Map.class},
268+
iframe, 1, new HashSet<String>(), ctx);
269+
assertTrue(result.isEmpty(), "Frame must be skipped when PercyDOM.serialize returns null");
270+
}
271+
272+
@Test
273+
public void processFrameTreeSkipsCyclicFrameByResolvedUrl() throws Exception {
274+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
275+
Percy percy = spy(new Percy(mockedDriver));
276+
setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};");
277+
278+
// Relative src slips past the pre-switch raw-src cycle guard; the post-switch
279+
// absolute document.URL matches an ancestor, so the cycle must still be caught.
280+
WebElement iframe = mock(WebElement.class);
281+
when(iframe.getAttribute("src")).thenReturn("/frame");
282+
when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-cyc");
283+
284+
TargetLocator targetLocator = mock(TargetLocator.class);
285+
when(mockedDriver.switchTo()).thenReturn(targetLocator);
286+
when(targetLocator.frame(iframe)).thenReturn(mockedDriver);
287+
when(targetLocator.defaultContent()).thenReturn(mockedDriver);
288+
when(targetLocator.parentFrame()).thenReturn(mockedDriver);
289+
290+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class)))
291+
.thenReturn(null)
292+
.thenReturn("https://cdn.other.com/frame");
293+
294+
Map<String, Object> ctx = new HashMap<>();
295+
ctx.put("options", new HashMap<String, Object>());
296+
ctx.put("maxFrameDepth", 5);
297+
ctx.put("ignoreSelectors", java.util.Collections.<String>emptyList());
298+
299+
Set<String> ancestors = new HashSet<>();
300+
ancestors.add("https://cdn.other.com/frame");
301+
302+
@SuppressWarnings("unchecked")
303+
List<Map<String, Object>> result = (List<Map<String, Object>>) invokePrivate(
304+
percy, "processFrameTree",
305+
new Class[]{WebElement.class, int.class, Set.class, Map.class},
306+
iframe, 1, ancestors, ctx);
307+
assertTrue(result.isEmpty(), "Cyclic frame must be skipped when its resolved URL is already an ancestor");
308+
}
309+
218310
@Test
219311
public void percyContextLostExceptionCarriesPartialCapture() throws Exception {
220312
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);

0 commit comments

Comments
 (0)