Skip to content

Commit f6e435a

Browse files
aryanku-devclaude
andcommitted
chore: cover nested CORS iframe and closed shadow DOM behaviours
Add JUnit + Mockito unit tests for the new helper methods and the nested cross-origin iframe capture flow: - `clampFrameDepth` bounds + defaults - `normalizeIgnoreSelectors` accepts List<String> / String / null - `resolveMaxFrameDepth` precedence (option > cliConfig > default) - `resolveIgnoreSelectors` precedence - `data-percy-ignore` iframes are skipped without `switchTo` - `ignoreIframeSelectors` matches are skipped without `switchTo` - `processFrame` bails after switch when document.URL is unsupported - `PercyContextLostException.partialCapture` round-trips - `getSerializedDOM` recovers partial captures on context loss - `exposeClosedShadowRoots` is a no-op for non-Chrome drivers - `collectClosedShadowPairs` walks the CDP tree and skips iframes Tests live in a separate `IframeFeatureTest` class to avoid being blocked by `SdkTest`'s `@BeforeAll` Firefox initialisation in environments without a Firefox binary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bcc27d3 commit f6e435a

1 file changed

Lines changed: 371 additions & 0 deletions

File tree

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
package io.percy.selenium;
2+
3+
import java.lang.reflect.Field;
4+
import java.lang.reflect.Method;
5+
import java.util.ArrayList;
6+
import java.util.Arrays;
7+
import java.util.Collections;
8+
import java.util.HashMap;
9+
import java.util.HashSet;
10+
import java.util.List;
11+
import java.util.Map;
12+
import java.util.Set;
13+
14+
import org.json.JSONArray;
15+
import org.json.JSONObject;
16+
import org.junit.jupiter.api.Test;
17+
import org.openqa.selenium.By;
18+
import org.openqa.selenium.Cookie;
19+
import org.openqa.selenium.JavascriptExecutor;
20+
import org.openqa.selenium.NoSuchFrameException;
21+
import org.openqa.selenium.WebDriver;
22+
import org.openqa.selenium.WebDriver.TargetLocator;
23+
import org.openqa.selenium.WebElement;
24+
import org.openqa.selenium.remote.RemoteWebDriver;
25+
26+
import static org.junit.jupiter.api.Assertions.*;
27+
import static org.mockito.ArgumentMatchers.any;
28+
import static org.mockito.Mockito.*;
29+
30+
/**
31+
* Unit tests for the nested cross-origin iframe capture, data-percy-ignore,
32+
* ignoreIframeSelectors, post-switch URL re-check, PercyContextLostException,
33+
* and frame-depth helpers.
34+
*
35+
* Kept in a separate class so its tests run even if the Firefox-based
36+
* @BeforeAll in SdkTest is unavailable in the current environment.
37+
*/
38+
public class IframeFeatureTest {
39+
40+
@Test
41+
public void clampFrameDepthBoundsValuesAndDefaults() throws Exception {
42+
int defDepth = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, 0);
43+
assertEquals(5, defDepth, "Non-positive depth clamps to default");
44+
45+
int negDepth = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, -3);
46+
assertEquals(5, negDepth, "Negative depth clamps to default");
47+
48+
int hugeDepth = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, 9999);
49+
assertEquals(10, hugeDepth, "Huge depth clamps to cap (10)");
50+
51+
int passThrough = (int) invokeStaticPrivate("clampFrameDepth", new Class[]{int.class}, 3);
52+
assertEquals(3, passThrough, "In-range depth passes through");
53+
}
54+
55+
@Test
56+
public void normalizeIgnoreSelectorsAcceptsListAndStringInputs() throws Exception {
57+
@SuppressWarnings("unchecked")
58+
List<String> fromList = (List<String>) invokeStaticPrivate(
59+
"normalizeIgnoreSelectors", new Class[]{Object.class}, Arrays.asList("iframe.foo", " ", null, "iframe[data-x]"));
60+
assertEquals(Arrays.asList("iframe.foo", "iframe[data-x]"), fromList);
61+
62+
@SuppressWarnings("unchecked")
63+
List<String> fromString = (List<String>) invokeStaticPrivate(
64+
"normalizeIgnoreSelectors", new Class[]{Object.class}, " iframe.single ");
65+
assertEquals(Arrays.asList("iframe.single"), fromString);
66+
67+
@SuppressWarnings("unchecked")
68+
List<String> fromNull = (List<String>) invokeStaticPrivate(
69+
"normalizeIgnoreSelectors", new Class[]{Object.class}, new Object[]{null});
70+
assertTrue(fromNull.isEmpty());
71+
}
72+
73+
@Test
74+
public void resolveMaxFrameDepthPrefersOptionThenCliConfigThenDefault() throws Exception {
75+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
76+
Percy percy = new Percy(mockedDriver);
77+
setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject().put("maxIframeDepth", 4)));
78+
79+
Map<String, Object> withOption = new HashMap<>();
80+
withOption.put("maxIframeDepth", 7);
81+
int fromOption = (int) invokePrivate(percy, "resolveMaxFrameDepth", new Class[]{Map.class}, withOption);
82+
assertEquals(7, fromOption);
83+
84+
int fromCli = (int) invokePrivate(percy, "resolveMaxFrameDepth", new Class[]{Map.class}, new HashMap<>());
85+
assertEquals(4, fromCli);
86+
87+
setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));
88+
int def = (int) invokePrivate(percy, "resolveMaxFrameDepth", new Class[]{Map.class}, new HashMap<>());
89+
assertEquals(5, def);
90+
}
91+
92+
@Test
93+
public void resolveIgnoreSelectorsCoercesOptionAndCliConfig() throws Exception {
94+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
95+
Percy percy = new Percy(mockedDriver);
96+
setField(percy, "cliConfig",
97+
new JSONObject().put("snapshot",
98+
new JSONObject().put("ignoreIframeSelectors", new JSONArray(Arrays.asList("iframe.cli-only")))));
99+
100+
Map<String, Object> withOption = new HashMap<>();
101+
withOption.put("ignoreIframeSelectors", Arrays.asList("iframe.from-opt"));
102+
@SuppressWarnings("unchecked")
103+
List<String> fromOption = (List<String>) invokePrivate(percy, "resolveIgnoreSelectors", new Class[]{Map.class}, withOption);
104+
assertEquals(Arrays.asList("iframe.from-opt"), fromOption);
105+
106+
@SuppressWarnings("unchecked")
107+
List<String> fromCli = (List<String>) invokePrivate(percy, "resolveIgnoreSelectors", new Class[]{Map.class}, new HashMap<>());
108+
assertEquals(Arrays.asList("iframe.cli-only"), fromCli);
109+
}
110+
111+
@Test
112+
public void skipsIframeMarkedWithDataPercyIgnore() throws Exception {
113+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
114+
Percy percy = spy(new Percy(mockedDriver));
115+
setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};");
116+
117+
WebElement iframe = mock(WebElement.class);
118+
when(iframe.getAttribute("src")).thenReturn("https://ads.example.com/frame");
119+
when(iframe.getAttribute("data-percy-ignore")).thenReturn("");
120+
121+
when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page");
122+
when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe));
123+
124+
Map<String, Object> mainSnapshot = new HashMap<>();
125+
mainSnapshot.put("dom", "main");
126+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(mainSnapshot);
127+
128+
@SuppressWarnings("unchecked")
129+
Map<String, Object> serialized = (Map<String, Object>) invokePrivate(
130+
percy, "getSerializedDOM",
131+
new Class[]{JavascriptExecutor.class, Set.class, Map.class},
132+
mockedDriver, new HashSet<Cookie>(), new HashMap<>());
133+
134+
assertFalse(serialized.containsKey("corsIframes"),
135+
"Frames flagged with data-percy-ignore must be omitted from corsIframes");
136+
// Ensure we never switched into the ignored frame.
137+
verify(mockedDriver, never()).switchTo();
138+
}
139+
140+
@Test
141+
public void skipsIframeMatchingIgnoreIframeSelectorsOption() throws Exception {
142+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
143+
Percy percy = spy(new Percy(mockedDriver));
144+
setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};");
145+
146+
WebElement iframe = mock(WebElement.class);
147+
when(iframe.getAttribute("src")).thenReturn("https://ads.example.com/banner");
148+
when(iframe.getAttribute("data-percy-ignore")).thenReturn(null);
149+
150+
when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page");
151+
when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe));
152+
153+
Map<String, Object> mainSnapshot = new HashMap<>();
154+
mainSnapshot.put("dom", "main");
155+
// Single stub that branches by script content: the selector-match script
156+
// contains `el.matches(selectors` and must return true so the iframe is
157+
// recognised as matched and skipped; all others return the main DOM.
158+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class), any())).thenAnswer(inv -> {
159+
String script = inv.getArgument(0);
160+
if (script.contains("el.matches(selectors")) return Boolean.TRUE;
161+
return mainSnapshot;
162+
});
163+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(inv -> {
164+
String script = inv.getArgument(0);
165+
if (script.contains("el.matches(selectors")) return Boolean.TRUE;
166+
return mainSnapshot;
167+
});
168+
169+
Map<String, Object> options = new HashMap<>();
170+
options.put("ignoreIframeSelectors", Arrays.asList("iframe.ads"));
171+
172+
@SuppressWarnings("unchecked")
173+
Map<String, Object> serialized = (Map<String, Object>) invokePrivate(
174+
percy, "getSerializedDOM",
175+
new Class[]{JavascriptExecutor.class, Set.class, Map.class},
176+
mockedDriver, new HashSet<Cookie>(), options);
177+
178+
assertFalse(serialized.containsKey("corsIframes"),
179+
"Frames matching ignoreIframeSelectors must be omitted from corsIframes");
180+
verify(mockedDriver, never()).switchTo();
181+
}
182+
183+
@Test
184+
public void processFrameSkipsAfterSwitchWhenDocumentUrlIsUnsupported() throws Exception {
185+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
186+
Percy percy = spy(new Percy(mockedDriver));
187+
setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};");
188+
189+
WebElement iframe = mock(WebElement.class);
190+
when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame");
191+
when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-xyz");
192+
193+
TargetLocator targetLocator = mock(TargetLocator.class);
194+
when(mockedDriver.switchTo()).thenReturn(targetLocator);
195+
when(targetLocator.frame(iframe)).thenReturn(mockedDriver);
196+
when(targetLocator.defaultContent()).thenReturn(mockedDriver);
197+
198+
// First executeScript is the dom.js injection (script string); the second
199+
// is `return document.URL` which we make report an unsupported scheme.
200+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class)))
201+
.thenReturn(null) // domJs inject
202+
.thenReturn("about:blank"); // document.URL
203+
204+
Object result = invokePrivate(percy, "processFrame", new Class[]{WebElement.class, Map.class}, iframe, new HashMap<>());
205+
assertNull(result, "Frame must be skipped when document.URL is unsupported after switch");
206+
verify(targetLocator).defaultContent();
207+
}
208+
209+
@Test
210+
public void percyContextLostExceptionCarriesPartialCapture() throws Exception {
211+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
212+
Percy percy = spy(new Percy(mockedDriver));
213+
214+
List<Map<String, Object>> partial = new ArrayList<>();
215+
Map<String, Object> entry = new HashMap<>();
216+
entry.put("frameUrl", "https://cdn.example.com/a");
217+
partial.add(entry);
218+
219+
Class<?> exceptionClass = Class.forName("io.percy.selenium.Percy$PercyContextLostException");
220+
java.lang.reflect.Constructor<?> ctor = exceptionClass.getDeclaredConstructor(String.class, Throwable.class, List.class);
221+
ctor.setAccessible(true);
222+
RuntimeException ex = (RuntimeException) ctor.newInstance("lost", new RuntimeException("inner"), partial);
223+
224+
Field f = exceptionClass.getField("partialCapture");
225+
@SuppressWarnings("unchecked")
226+
List<Map<String, Object>> carried = (List<Map<String, Object>>) f.get(ex);
227+
assertEquals(1, carried.size());
228+
assertEquals("https://cdn.example.com/a", carried.get(0).get("frameUrl"));
229+
}
230+
231+
@Test
232+
public void getSerializedDomRecoversPartialCaptureOnContextLost() throws Exception {
233+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
234+
Percy percy = spy(new Percy(mockedDriver));
235+
setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};");
236+
237+
WebElement iframe = mock(WebElement.class);
238+
when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame");
239+
when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-a");
240+
when(iframe.getAttribute("data-percy-ignore")).thenReturn(null);
241+
242+
when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page");
243+
when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe));
244+
245+
TargetLocator targetLocator = mock(TargetLocator.class);
246+
when(mockedDriver.switchTo()).thenReturn(targetLocator);
247+
when(targetLocator.frame(iframe)).thenReturn(mockedDriver);
248+
when(targetLocator.defaultContent()).thenReturn(mockedDriver);
249+
// Simulate driver failing to step back to parent after recursion.
250+
when(targetLocator.parentFrame()).thenThrow(new NoSuchFrameException("driver lost frame"));
251+
252+
Map<String, Object> mainSnapshot = new HashMap<>();
253+
mainSnapshot.put("dom", "main");
254+
Map<String, Object> iframeSnapshot = new HashMap<>();
255+
iframeSnapshot.put("dom", "iframe");
256+
257+
// Three executeScript phases per call:
258+
// 1. main page serialize (no enableJavaScript:true in payload)
259+
// 2. domJs inject inside frame
260+
// 3. document.URL inside frame -> String
261+
// 4. PercyDOM.serialize({...enableJavaScript:true}) inside frame
262+
// 5. nested findElements / recursion path triggers parentFrame which throws
263+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> {
264+
String script = invocation.getArgument(0);
265+
if (script.startsWith("return PercyDOM.serialize(")) {
266+
if (script.contains("\"enableJavaScript\":true")) return iframeSnapshot;
267+
return mainSnapshot;
268+
}
269+
if (script.equals("return document.URL")) return "https://cdn.other.com/frame";
270+
return null;
271+
});
272+
273+
Map<String, Object> options = new HashMap<>();
274+
// Force at least one recursion attempt by setting maxIframeDepth>1.
275+
options.put("maxIframeDepth", 2);
276+
277+
@SuppressWarnings("unchecked")
278+
Map<String, Object> serialized = (Map<String, Object>) invokePrivate(
279+
percy, "getSerializedDOM",
280+
new Class[]{JavascriptExecutor.class, Set.class, Map.class},
281+
mockedDriver, new HashSet<Cookie>(), options);
282+
283+
// Even though parentFrame() failed during recursion, the top frame's snapshot
284+
// should still appear in corsIframes (partial capture recovered).
285+
assertTrue(serialized.containsKey("corsIframes"),
286+
"Partial capture from PercyContextLostException must be preserved");
287+
@SuppressWarnings("unchecked")
288+
List<Map<String, Object>> caps = (List<Map<String, Object>>) serialized.get("corsIframes");
289+
assertEquals(1, caps.size());
290+
assertEquals("https://cdn.other.com/frame", caps.get(0).get("frameUrl"));
291+
}
292+
293+
@Test
294+
public void exposeClosedShadowRootsIsNoopForNonChromeDrivers() throws Exception {
295+
WebDriver firefoxLike = mock(WebDriver.class);
296+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
297+
Percy percy = new Percy(mockedDriver);
298+
299+
// Should not throw and should not attempt CDP on a non-Chrome driver.
300+
invokePrivate(percy, "exposeClosedShadowRoots", new Class[]{WebDriver.class}, firefoxLike);
301+
verifyNoInteractions(firefoxLike);
302+
}
303+
304+
@Test
305+
public void collectClosedShadowPairsWalksTreeAndSkipsContentDocuments() throws Exception {
306+
Method m = Percy.class.getDeclaredMethod(
307+
"collectClosedShadowPairs", Map.class, List.class);
308+
m.setAccessible(true);
309+
310+
// host -> shadowRoot (closed)
311+
Map<String, Object> closedShadow = new HashMap<>();
312+
closedShadow.put("backendNodeId", 200);
313+
closedShadow.put("shadowRootType", "closed");
314+
315+
Map<String, Object> host = new HashMap<>();
316+
host.put("backendNodeId", 100);
317+
host.put("shadowRoots", Collections.singletonList(closedShadow));
318+
319+
// open shadow on a sibling — must NOT be collected
320+
Map<String, Object> openShadow = new HashMap<>();
321+
openShadow.put("backendNodeId", 201);
322+
openShadow.put("shadowRootType", "open");
323+
324+
Map<String, Object> openHost = new HashMap<>();
325+
openHost.put("backendNodeId", 101);
326+
openHost.put("shadowRoots", Collections.singletonList(openShadow));
327+
328+
// iframe node — has contentDocument so its subtree must be skipped entirely.
329+
Map<String, Object> nestedClosed = new HashMap<>();
330+
nestedClosed.put("backendNodeId", 300);
331+
nestedClosed.put("shadowRootType", "closed");
332+
Map<String, Object> hostInIframe = new HashMap<>();
333+
hostInIframe.put("backendNodeId", 301);
334+
hostInIframe.put("shadowRoots", Collections.singletonList(nestedClosed));
335+
Map<String, Object> iframeDoc = new HashMap<>();
336+
iframeDoc.put("children", Collections.singletonList(hostInIframe));
337+
Map<String, Object> iframeNode = new HashMap<>();
338+
iframeNode.put("backendNodeId", 99);
339+
iframeNode.put("contentDocument", iframeDoc);
340+
341+
Map<String, Object> root = new HashMap<>();
342+
root.put("children", Arrays.asList(host, openHost, iframeNode));
343+
344+
List<Map<String, Object>> pairs = new ArrayList<>();
345+
m.invoke(null, root, pairs);
346+
347+
assertEquals(1, pairs.size(), "Only the closed shadow root outside any iframe should be collected");
348+
assertEquals(100, pairs.get(0).get("hostBackendNodeId"));
349+
assertEquals(200, pairs.get(0).get("shadowBackendNodeId"));
350+
}
351+
352+
// ---------- reflection helpers ----------
353+
354+
private static Object invokePrivate(Object target, String name, Class<?>[] types, Object... args) throws Exception {
355+
Method m = Percy.class.getDeclaredMethod(name, types);
356+
m.setAccessible(true);
357+
return m.invoke(target, args);
358+
}
359+
360+
private static Object invokeStaticPrivate(String name, Class<?>[] types, Object... args) throws Exception {
361+
Method m = Percy.class.getDeclaredMethod(name, types);
362+
m.setAccessible(true);
363+
return m.invoke(null, args);
364+
}
365+
366+
private static void setField(Object target, String name, Object value) throws Exception {
367+
Field f = Percy.class.getDeclaredField(name);
368+
f.setAccessible(true);
369+
f.set(target, value);
370+
}
371+
}

0 commit comments

Comments
 (0)