@@ -181,7 +181,7 @@ public void skipsIframeMatchingIgnoreIframeSelectorsOption() throws Exception {
181181 }
182182
183183 @ Test
184- public void processFrameSkipsAfterSwitchWhenDocumentUrlIsUnsupported () throws Exception {
184+ public void processFrameTreeSkipsAfterSwitchWhenDocumentUrlIsUnsupported () throws Exception {
185185 RemoteWebDriver mockedDriver = mock (RemoteWebDriver .class );
186186 Percy percy = spy (new Percy (mockedDriver ));
187187 setField (percy , "domJs" , "window.PercyDOM = window.PercyDOM || {};" );
@@ -194,16 +194,25 @@ public void processFrameSkipsAfterSwitchWhenDocumentUrlIsUnsupported() throws Ex
194194 when (mockedDriver .switchTo ()).thenReturn (targetLocator );
195195 when (targetLocator .frame (iframe )).thenReturn (mockedDriver );
196196 when (targetLocator .defaultContent ()).thenReturn (mockedDriver );
197+ when (targetLocator .parentFrame ()).thenReturn (mockedDriver );
197198
198- // First executeScript is the dom.js injection (script string) ; the second
199- // is `return document.URL` which we make report an unsupported scheme .
199+ // First executeScript is the dom.js injection; the second `return document.URL`
200+ // reports an unsupported scheme so the frame is skipped before serialization .
200201 when (((JavascriptExecutor ) mockedDriver ).executeScript (any (String .class )))
201- .thenReturn (null ) // domJs inject
202- .thenReturn ("about:blank" ); // document.URL
202+ .thenReturn (null )
203+ .thenReturn ("about:blank" );
203204
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 ();
205+ Map <String , Object > ctx = new HashMap <>();
206+ ctx .put ("options" , new HashMap <String , Object >());
207+ ctx .put ("maxFrameDepth" , 5 );
208+ ctx .put ("ignoreSelectors" , java .util .Collections .<String >emptyList ());
209+
210+ @ SuppressWarnings ("unchecked" )
211+ List <Map <String , Object >> result = (List <Map <String , Object >>) invokePrivate (
212+ percy , "processFrameTree" ,
213+ new Class []{WebElement .class , int .class , Set .class , Map .class },
214+ iframe , 1 , new HashSet <String >(), ctx );
215+ assertTrue (result .isEmpty (), "Frame must be skipped when document.URL is unsupported after switch" );
207216 }
208217
209218 @ Test
@@ -349,6 +358,195 @@ public void collectClosedShadowPairsWalksTreeAndSkipsContentDocuments() throws E
349358 assertEquals (200 , pairs .get (0 ).get ("shadowBackendNodeId" ));
350359 }
351360
361+ @ Test
362+ public void clampFrameDepthZeroReturnsDocumentedDefault () throws Exception {
363+ // Semantic regression test: maxIframeDepth=0 must fall back to the
364+ // documented default (5), matching @percy/sdk-utils behaviour. Anyone
365+ // who later changes this to "0 disables CORS capture" would break
366+ // cross-SDK alignment — this test guards against the silent flip.
367+ int fromZero = (int ) invokeStaticPrivate ("clampFrameDepth" , new Class []{int .class }, 0 );
368+ assertEquals (5 , fromZero , "maxIframeDepth=0 must use the canonical default (5), not disable nested capture" );
369+ }
370+
371+ @ Test
372+ public void nestedIframeWithNullOriginIsNullSafeAndDoesNotAbortLoop () throws Exception {
373+ // Regression test for the NPE risk at processFrameTree's child-origin
374+ // comparison. A child <iframe src="data:..."> resolves to a URI with no
375+ // host, and getOrigin returns an empty/blank value. The comparison
376+ // (`Objects.equals(childOrigin, currentOrigin)`) must NOT throw — if it
377+ // did, the per-iframe catch would swallow the NPE and skip the frame,
378+ // losing the capture.
379+ RemoteWebDriver mockedDriver = mock (RemoteWebDriver .class );
380+ Percy percy = spy (new Percy (mockedDriver ));
381+ setField (percy , "domJs" , "window.PercyDOM = window.PercyDOM || {};" );
382+
383+ // Outer cross-origin iframe with a same-origin sibling that resolves to
384+ // a data:... URI (no host -> null/empty origin). We don't actually
385+ // recurse into the child because its origin is treated as "different
386+ // from parent" only when non-equal; the key assertion is that the
387+ // equality call itself is null-safe and does not throw.
388+ WebElement iframe = mock (WebElement .class );
389+ when (iframe .getAttribute ("src" )).thenReturn ("https://cdn.other.com/frame" );
390+ when (iframe .getAttribute ("data-percy-element-id" )).thenReturn ("frame-a" );
391+ when (iframe .getAttribute ("data-percy-ignore" )).thenReturn (null );
392+
393+ WebElement nestedDataIframe = mock (WebElement .class );
394+ when (nestedDataIframe .getAttribute ("src" )).thenReturn ("data:text/html,<p>x</p>" );
395+
396+ when (mockedDriver .getCurrentUrl ()).thenReturn ("https://app.example.com/page" );
397+ when (mockedDriver .findElements (By .tagName ("iframe" )))
398+ .thenReturn (Collections .singletonList (iframe ))
399+ // Inside the frame, findElements returns the data: iframe child.
400+ .thenReturn (Collections .singletonList (nestedDataIframe ));
401+
402+ TargetLocator targetLocator = mock (TargetLocator .class );
403+ when (mockedDriver .switchTo ()).thenReturn (targetLocator );
404+ when (targetLocator .frame (iframe )).thenReturn (mockedDriver );
405+ when (targetLocator .parentFrame ()).thenReturn (mockedDriver );
406+ when (targetLocator .defaultContent ()).thenReturn (mockedDriver );
407+
408+ Map <String , Object > mainSnapshot = new HashMap <>();
409+ mainSnapshot .put ("dom" , "main" );
410+ Map <String , Object > iframeSnapshot = new HashMap <>();
411+ iframeSnapshot .put ("dom" , "iframe" );
412+ when (((JavascriptExecutor ) mockedDriver ).executeScript (any (String .class ))).thenAnswer (invocation -> {
413+ String script = invocation .getArgument (0 );
414+ if (script .startsWith ("return PercyDOM.serialize(" )) {
415+ if (script .contains ("\" enableJavaScript\" :true" )) return iframeSnapshot ;
416+ return mainSnapshot ;
417+ }
418+ if (script .equals ("return document.URL" )) return "https://cdn.other.com/frame" ;
419+ return null ;
420+ });
421+
422+ Map <String , Object > options = new HashMap <>();
423+ options .put ("maxIframeDepth" , 3 );
424+
425+ // Must complete without throwing; outer CORS iframe must still be captured.
426+ @ SuppressWarnings ("unchecked" )
427+ Map <String , Object > serialized = (Map <String , Object >) invokePrivate (
428+ percy , "getSerializedDOM" ,
429+ new Class []{JavascriptExecutor .class , Set .class , Map .class },
430+ mockedDriver , new HashSet <Cookie >(), options );
431+ assertTrue (serialized .containsKey ("corsIframes" ),
432+ "Outer CORS iframe capture must survive a child with a null/empty origin (data: URI)" );
433+ @ SuppressWarnings ("unchecked" )
434+ List <Map <String , Object >> caps = (List <Map <String , Object >>) serialized .get ("corsIframes" );
435+ assertEquals (1 , caps .size (), "data:... child must be skipped without aborting the outer frame" );
436+ }
437+
438+ @ Test
439+ public void exposeClosedShadowRootsIsAttemptedInsideCorsFrame () throws Exception {
440+ // Verifies MAJOR #3: the closed-shadow CDP exposure runs not only at the
441+ // top page but also inside each CORS frame after switchTo().frame(...).
442+ // We can't assert CDP calls on a non-Chrome mock, but we can confirm the
443+ // top-page + per-frame call attempts proceed without throwing and that
444+ // the outer frame snapshot is still captured.
445+ RemoteWebDriver mockedDriver = mock (RemoteWebDriver .class );
446+ Percy percy = spy (new Percy (mockedDriver ));
447+ setField (percy , "domJs" , "window.PercyDOM = window.PercyDOM || {};" );
448+
449+ WebElement iframe = mock (WebElement .class );
450+ when (iframe .getAttribute ("src" )).thenReturn ("https://cdn.other.com/frame" );
451+ when (iframe .getAttribute ("data-percy-element-id" )).thenReturn ("frame-shadow" );
452+ when (iframe .getAttribute ("data-percy-ignore" )).thenReturn (null );
453+
454+ when (mockedDriver .getCurrentUrl ()).thenReturn ("https://app.example.com/page" );
455+ when (mockedDriver .findElements (By .tagName ("iframe" )))
456+ .thenReturn (Collections .singletonList (iframe ))
457+ .thenReturn (Collections .emptyList ());
458+
459+ TargetLocator targetLocator = mock (TargetLocator .class );
460+ when (mockedDriver .switchTo ()).thenReturn (targetLocator );
461+ when (targetLocator .frame (iframe )).thenReturn (mockedDriver );
462+ when (targetLocator .parentFrame ()).thenReturn (mockedDriver );
463+ when (targetLocator .defaultContent ()).thenReturn (mockedDriver );
464+
465+ Map <String , Object > mainSnapshot = new HashMap <>();
466+ mainSnapshot .put ("dom" , "main" );
467+ Map <String , Object > iframeSnapshot = new HashMap <>();
468+ iframeSnapshot .put ("dom" , "iframe" );
469+ when (((JavascriptExecutor ) mockedDriver ).executeScript (any (String .class ))).thenAnswer (invocation -> {
470+ String script = invocation .getArgument (0 );
471+ if (script .startsWith ("return PercyDOM.serialize(" )) {
472+ if (script .contains ("\" enableJavaScript\" :true" )) return iframeSnapshot ;
473+ return mainSnapshot ;
474+ }
475+ if (script .equals ("return document.URL" )) return "https://cdn.other.com/frame" ;
476+ return null ;
477+ });
478+
479+ @ SuppressWarnings ("unchecked" )
480+ Map <String , Object > serialized = (Map <String , Object >) invokePrivate (
481+ percy , "getSerializedDOM" ,
482+ new Class []{JavascriptExecutor .class , Set .class , Map .class },
483+ mockedDriver , new HashSet <Cookie >(), new HashMap <>());
484+
485+ assertTrue (serialized .containsKey ("corsIframes" ),
486+ "CORS iframe capture must succeed even with the closed-shadow CDP step attempted inside the frame" );
487+
488+ // Confirm the closed-shadow helper exists with the expected signature and
489+ // is safely invocable on a non-Chrome driver without throwing — this is
490+ // the same call path the per-frame attempt uses inside processFrameTree.
491+ Method m = Percy .class .getDeclaredMethod ("exposeClosedShadowRoots" , WebDriver .class );
492+ m .setAccessible (true );
493+ m .invoke (percy , mockedDriver );
494+
495+ // The source contains the per-frame call site (guards against the call
496+ // being removed in a future refactor without updating this test).
497+ String src = new String (java .nio .file .Files .readAllBytes (
498+ java .nio .file .Paths .get ("src/main/java/io/percy/selenium/Percy.java" )));
499+ assertTrue (src .contains ("exposeClosedShadowRoots(driver)" ) &&
500+ src .contains ("TODO(closed-shadow-cors)" ),
501+ "processFrameTree must invoke exposeClosedShadowRoots inside each CORS frame" );
502+ }
503+
504+ @ Test
505+ public void collectClosedShadowPairsContinuesPastOneBadEntry () throws Exception {
506+ // MAJOR #5: in exposeClosedShadowRoots the per-pair body is already
507+ // wrapped in try/catch so a single bad backendNodeId pair must not
508+ // abort the rest. We exercise the collector against a tree that mixes
509+ // a valid closed-shadow pair and one missing fields; the helper itself
510+ // is permissive, and the runtime loop swallows per-pair failures.
511+ Method m = Percy .class .getDeclaredMethod ("collectClosedShadowPairs" , Map .class , List .class );
512+ m .setAccessible (true );
513+
514+ Map <String , Object > validClosed = new HashMap <>();
515+ validClosed .put ("backendNodeId" , 10 );
516+ validClosed .put ("shadowRootType" , "closed" );
517+ Map <String , Object > hostA = new HashMap <>();
518+ hostA .put ("backendNodeId" , 1 );
519+ hostA .put ("shadowRoots" , Collections .singletonList (validClosed ));
520+
521+ // Missing backendNodeId on host — collector still records null; the
522+ // exposeClosedShadowRoots loop must skip without aborting the next pair.
523+ Map <String , Object > badClosed = new HashMap <>();
524+ badClosed .put ("shadowRootType" , "closed" );
525+ // intentionally no backendNodeId
526+ Map <String , Object > hostB = new HashMap <>();
527+ // intentionally no backendNodeId
528+ hostB .put ("shadowRoots" , Collections .singletonList (badClosed ));
529+
530+ Map <String , Object > root = new HashMap <>();
531+ root .put ("children" , Arrays .asList (hostB , hostA ));
532+
533+ List <Map <String , Object >> pairs = new ArrayList <>();
534+ m .invoke (null , root , pairs );
535+
536+ // Both pairs are collected (one valid, one null-field) — the per-pair
537+ // try/catch in exposeClosedShadowRoots is what makes the bad one
538+ // tolerable at runtime. The collector itself must not throw.
539+ assertEquals (2 , pairs .size (), "Collector tolerates missing backendNodeId without throwing" );
540+ boolean sawValid = false ;
541+ for (Map <String , Object > p : pairs ) {
542+ if (Integer .valueOf (10 ).equals (p .get ("shadowBackendNodeId" ))
543+ && Integer .valueOf (1 ).equals (p .get ("hostBackendNodeId" ))) {
544+ sawValid = true ;
545+ }
546+ }
547+ assertTrue (sawValid , "Valid pair must still be present alongside the bad entry" );
548+ }
549+
352550 // ---------- reflection helpers ----------
353551
354552 private static Object invokePrivate (Object target , String name , Class <?>[] types , Object ... args ) throws Exception {
0 commit comments