@@ -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