From 9bd103934278a791a7b69e725cf7772a4eb55314 Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Mon, 19 Oct 2026 14:52:10 +0530 Subject: [PATCH 1/3] PPLT-6242: Resolve hub URL without deprecated getAddressOfRemoteServer Selenium 4.50 deprecates HttpCommandExecutor.getAddressOfRemoteServer() for removal. Read the hub URL from RemoteWebDriver.getClientConfig() .baseUri() when available and fall back to the old accessor, called reflectively, then to the remoteServer field, so older Selenium versions keep working and the SDK keeps loading once the method is removed. Test against selenium-java 4.50.0, with a JDK 8 profile that keeps the Java 8 CI job on 4.5.3. Drop the leaked compile-scope selenium-firefox-driver 4.5.3 (selenium-java already provides it) and add jsr305 as provided, since Selenium no longer brings javax.annotation. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 26 +++++- .../io/percy/selenium/DriverMetadata.java | 71 +++++++++++++--- .../java/io/percy/selenium/CacheTest.java | 81 +++++++++++++++++++ .../percy/selenium/PercyDriverPathTest.java | 4 + 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/pom.xml b/pom.xml index 3acf979..39c7d29 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ UTF-8 5.9.2 + 4.50.0 @@ -45,13 +46,16 @@ org.seleniumhq.selenium selenium-java - 4.5.3 + ${selenium.version} provided + - org.seleniumhq.selenium - selenium-firefox-driver - 4.5.3 + com.google.code.findbugs + jsr305 + 3.0.2 + provided org.json @@ -103,6 +107,20 @@ + + + + java8 + + 1.8 + + + 4.5.3 + + + + central diff --git a/src/main/java/io/percy/selenium/DriverMetadata.java b/src/main/java/io/percy/selenium/DriverMetadata.java index 8ab08c4..56faab0 100644 --- a/src/main/java/io/percy/selenium/DriverMetadata.java +++ b/src/main/java/io/percy/selenium/DriverMetadata.java @@ -4,6 +4,8 @@ import org.openqa.selenium.WebDriver; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -49,24 +51,67 @@ public ConcurrentHashMap getCapabilities() { public String getCommandExecutorUrl() { String key = "commandExecutorUrl_" + this.sessionId; if (Cache.CACHE_MAP.get(key) == null) { - CommandExecutor executor = ((RemoteWebDriver) driver).getCommandExecutor(); + String remoteWebAddress = getClientConfigBaseUri(); + if (remoteWebAddress == null) { + CommandExecutor executor = ((RemoteWebDriver) driver).getCommandExecutor(); - // Get HttpCommandExecutor From TracedCommandExecutor - if (executor.getClass().toString().contains("TracedCommandExecutor")) { - Class className = executor.getClass(); - try { - Field field = className.getDeclaredField("delegate"); - // make private field accessible - field.setAccessible(true); - executor = (HttpCommandExecutor) field.get(executor); - } catch (Exception e) { - Percy.log(e.toString()); - return e.toString(); + // Get HttpCommandExecutor From TracedCommandExecutor + if (executor.getClass().toString().contains("TracedCommandExecutor")) { + Class className = executor.getClass(); + try { + Field field = className.getDeclaredField("delegate"); + // make private field accessible + field.setAccessible(true); + executor = (HttpCommandExecutor) field.get(executor); + } catch (Exception e) { + Percy.log(e.toString()); + return e.toString(); + } } + remoteWebAddress = getAddressOfRemoteServer(executor).toString(); } - String remoteWebAddress = ((HttpCommandExecutor) executor).getAddressOfRemoteServer().toString(); Cache.CACHE_MAP.put(key, remoteWebAddress); } return (String) Cache.CACHE_MAP.get(key); } + + // RemoteWebDriver.getClientConfig() is the non-deprecated source of the hub URL in Selenium + // 4.50+, but it is absent from older Selenium versions, so it is looked up reflectively. Its + // baseUri is null when the driver was built from a CommandExecutor (e.g. local drivers). + private String getClientConfigBaseUri() { + try { + Object clientConfig = driver.getClass().getMethod("getClientConfig").invoke(driver); + if (clientConfig == null) { + return null; + } + Object baseUri = clientConfig.getClass().getMethod("baseUri").invoke(clientConfig); + return baseUri == null ? null : baseUri.toString(); + } catch (Exception e) { + return null; + } + } + + // HttpCommandExecutor.getAddressOfRemoteServer() is deprecated for removal since Selenium 4.50. + // Calling it reflectively keeps this class loadable once it is gone; the private field it + // returns is the last resort. + static URL getAddressOfRemoteServer(CommandExecutor executor) { + try { + Method method = HttpCommandExecutor.class.getMethod("getAddressOfRemoteServer"); + return (URL) method.invoke(executor); + } catch (NoSuchMethodException e) { + return readRemoteServerField(executor); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Could not resolve the remote server address", e); + } + } + + static URL readRemoteServerField(CommandExecutor executor) { + try { + Field field = HttpCommandExecutor.class.getDeclaredField("remoteServer"); + field.setAccessible(true); + return (URL) field.get(executor); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Could not resolve the remote server address", e); + } + } } diff --git a/src/test/java/io/percy/selenium/CacheTest.java b/src/test/java/io/percy/selenium/CacheTest.java index 5c74b3d..f5e0f9b 100644 --- a/src/test/java/io/percy/selenium/CacheTest.java +++ b/src/test/java/io/percy/selenium/CacheTest.java @@ -6,10 +6,12 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import org.openqa.selenium.WebDriver; import org.openqa.selenium.remote.*; +import org.openqa.selenium.remote.http.ClientConfig; import static org.mockito.Mockito.*; import java.net.URL; import java.util.concurrent.ConcurrentHashMap; @@ -125,4 +127,83 @@ public void testCommandExecutorUrlReturnsErrorWhenDelegateFieldMissing() { assertNotNull(result); assertTrue(result.contains("NoSuchFieldException") || result.contains("delegate")); } + + // ------------------------------------------------------------------ + // getCommandExecutorUrl: ClientConfig path (Selenium 4.50+) and the + // reflective getAddressOfRemoteServer fallbacks. + // ------------------------------------------------------------------ + + private static boolean hasClientConfigAccessor() { + try { + RemoteWebDriver.class.getMethod("getClientConfig"); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } + + // getClientConfig() only exists on Selenium 4.50+, so it is stubbed through a default answer + // to keep this file compiling against the Java 8 profile's older Selenium. + private static RemoteWebDriver driverWithClientConfig(String sessionId, ClientConfig config) { + RemoteWebDriver driver = mock(RemoteWebDriver.class, invocation -> + invocation.getMethod().getName().equals("getClientConfig") + ? config + : RETURNS_DEFAULTS.answer(invocation)); + when(driver.getSessionId()).thenReturn(new SessionId(sessionId)); + return driver; + } + + @Test + public void testCommandExecutorUrlPrefersClientConfigBaseUri() throws Exception { + assumeTrue(hasClientConfigAccessor()); + Cache.CACHE_MAP.clear(); + ClientConfig config = ClientConfig.defaultConfig().baseUrl(new URL("https://hub-cloud.browserstack.com/wd/hub")); + RemoteWebDriver driver = driverWithClientConfig("client-config-1", config); + HttpCommandExecutor executor = mock(HttpCommandExecutor.class); + when(driver.getCommandExecutor()).thenReturn(executor); + + String url = new DriverMetadata(driver).getCommandExecutorUrl(); + + assertEquals("https://hub-cloud.browserstack.com/wd/hub", url); + verifyNoInteractions(executor); + } + + @Test + public void testCommandExecutorUrlFallsBackToExecutorWithoutBaseUri() throws Exception { + assumeTrue(hasClientConfigAccessor()); + Cache.CACHE_MAP.clear(); + RemoteWebDriver driver = driverWithClientConfig("client-config-2", ClientConfig.defaultConfig()); + HttpCommandExecutor executor = new HttpCommandExecutor(new URL("http://localhost:9515")); + when(driver.getCommandExecutor()).thenReturn(executor); + + assertEquals("http://localhost:9515", new DriverMetadata(driver).getCommandExecutorUrl()); + } + + @Test + public void testGetAddressOfRemoteServerOnRealExecutor() throws Exception { + HttpCommandExecutor executor = new HttpCommandExecutor(new URL("https://hub.example.com/wd/hub")); + assertEquals(new URL("https://hub.example.com/wd/hub"), DriverMetadata.getAddressOfRemoteServer(executor)); + } + + @Test + public void testReadRemoteServerFieldMatchesAccessor() throws Exception { + HttpCommandExecutor executor = new HttpCommandExecutor(new URL("https://hub.example.com/wd/hub")); + assertEquals(new URL("https://hub.example.com/wd/hub"), DriverMetadata.readRemoteServerField(executor)); + } + + @Test + public void testGetAddressOfRemoteServerWrapsInvocationFailure() { + HttpCommandExecutor executor = mock(HttpCommandExecutor.class, invocation -> { + throw new IllegalStateException("boom"); + }); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> DriverMetadata.getAddressOfRemoteServer(executor)); + assertEquals("boom", e.getCause().getCause().getMessage()); + } + + @Test + public void testReadRemoteServerFieldRejectsOtherExecutors() { + assertThrows(RuntimeException.class, + () -> DriverMetadata.readRemoteServerField(new BrokenTracedCommandExecutor())); + } } diff --git a/src/test/java/io/percy/selenium/PercyDriverPathTest.java b/src/test/java/io/percy/selenium/PercyDriverPathTest.java index 814d41a..f994e42 100644 --- a/src/test/java/io/percy/selenium/PercyDriverPathTest.java +++ b/src/test/java/io/percy/selenium/PercyDriverPathTest.java @@ -608,6 +608,8 @@ public void captureResponsiveDomTolueratesResizeWaitTimeout() throws Exception { when(driverOptions.window()).thenReturn(driverWindow); when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); when(driver.executeCdpCommand(anyString(), anyMap())).thenReturn(new HashMap()); + // Selenium 4.50 WebDriverWait reads the capabilities when building its timeout message. + when(driver.getCapabilities()).thenReturn(new DesiredCapabilities()); when(driver.getCurrentUrl()).thenReturn("https://example.com"); when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); @@ -707,6 +709,8 @@ public void captureResponsiveDomHandlesNonNumericSleepAndNullResizeCount() throw when(driverOptions.window()).thenReturn(driverWindow); when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); when(driver.executeCdpCommand(anyString(), anyMap())).thenReturn(new HashMap()); + // Selenium 4.50 WebDriverWait reads the capabilities when building its timeout message. + when(driver.getCapabilities()).thenReturn(new DesiredCapabilities()); when(driver.getCurrentUrl()).thenReturn("https://example.com"); when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); From 9b3bc81ed94cf080cda87f369f297bfb0ce996e8 Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Tue, 27 Oct 2026 19:39:00 +0530 Subject: [PATCH 2/3] PPLT-6242: Exact responsive widths and closed shadow roots on every browser Responsive capture now sets an exact viewport instead of resizing the window: Chromium via CDP (HasCdp, or Augmenter on remote sessions such as Grid and Automate), then BiDi browsingContext.setViewport when the session already has webSocketUrl (reached reflectively, so the Selenium 4.5.3 floor still loads), then a window resize corrected once by the measured browser border. Restore clears the emulation instead of re-applying it at the window size, which used to leave the viewport frozen. exactResponsiveWidth (per snapshot, then .percy.yml) set to false keeps the previous mechanism. ClosedShadowRoots.expose runs before every serialize (unless disableShadowDOM) and fills window.__percyClosedShadowRoots via CDP, BiDi script.callFunction (includeShadowTree: all), or WebDriver Get Element Shadow Root on local sessions (capped by PERCY_CLOSED_SHADOW_MAX_HOSTS, opt in on remote with PERCY_CLOSED_SHADOW_CLASSIC=true). Co-Authored-By: Claude Opus 5.5 --- .../io/percy/selenium/BrowserSupport.java | 175 +++++++++++ .../io/percy/selenium/ClosedShadowRoots.java | 290 ++++++++++++++++++ src/main/java/io/percy/selenium/Percy.java | 183 +++++++++-- .../io/percy/selenium/BrowserSupportTest.java | 160 ++++++++++ .../percy/selenium/ClosedShadowRootsTest.java | 241 +++++++++++++++ .../io/percy/selenium/ExactViewportTest.java | 261 ++++++++++++++++ .../percy/selenium/PercyDriverPathTest.java | 47 +-- .../io/percy/selenium/PercyLogicTest.java | 7 +- src/test/java/io/percy/selenium/SdkTest.java | 31 +- 9 files changed, 1329 insertions(+), 66 deletions(-) create mode 100644 src/main/java/io/percy/selenium/BrowserSupport.java create mode 100644 src/main/java/io/percy/selenium/ClosedShadowRoots.java create mode 100644 src/test/java/io/percy/selenium/BrowserSupportTest.java create mode 100644 src/test/java/io/percy/selenium/ClosedShadowRootsTest.java create mode 100644 src/test/java/io/percy/selenium/ExactViewportTest.java diff --git a/src/main/java/io/percy/selenium/BrowserSupport.java b/src/main/java/io/percy/selenium/BrowserSupport.java new file mode 100644 index 0000000..1e8f72c --- /dev/null +++ b/src/main/java/io/percy/selenium/BrowserSupport.java @@ -0,0 +1,175 @@ +package io.percy.selenium; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.*; +import java.util.function.Function; + +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.HasCapabilities; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chromium.HasCdp; +import org.openqa.selenium.remote.Augmenter; +import org.openqa.selenium.remote.RemoteWebDriver; + +/** + * Feature detection for the browser protocols the SDK can drive: Chromium CDP, WebDriver BiDi and + * plain WebDriver classic. Everything newer than the Selenium 4.5.3 compile floor is reached + * reflectively so the SDK keeps loading on older bindings. + */ +final class BrowserSupport { + interface Cdp { + Map execute(String command, Map params); + } + + interface BiDi { + Map send(String method, Map params); + } + + private static final Set CHROMIUM_NAMES = new HashSet<>(Arrays.asList( + "chrome", "chromium", "msedge", "microsoftedge", "chrome-headless-shell")); + private static final Set LOCAL_HOSTS = new HashSet<>(Arrays.asList( + "localhost", "127.0.0.1", "::1", "[::1]")); + + // Augmenting a remote session builds a new proxy class (and, with BiDi, a new WebSocket), so + // do it once per driver. + private static final Map AUGMENTED = Collections.synchronizedMap(new WeakHashMap<>()); + + // Test seams. + static Function augmenter = d -> new Augmenter().augment(d); + static Function bidiFactory = BrowserSupport::reflectiveBiDi; + + private BrowserSupport() {} + + static Capabilities capabilities(WebDriver driver) { + try { + if (driver instanceof HasCapabilities) { + return ((HasCapabilities) driver).getCapabilities(); + } + } catch (Exception ignored) { + } + return null; + } + + static String browserName(WebDriver driver) { + Capabilities caps = capabilities(driver); + String name = caps == null ? null : caps.getBrowserName(); + return name == null ? "" : name.toLowerCase(Locale.ROOT); + } + + static boolean isChromium(WebDriver driver) { + String name = browserName(driver); + if (CHROMIUM_NAMES.contains(name)) { + return true; + } + return driver instanceof HasCdp && !name.contains("firefox") && !name.contains("safari"); + } + + static boolean hasWebSocketUrl(WebDriver driver) { + Capabilities caps = capabilities(driver); + return caps != null && caps.getCapability("webSocketUrl") instanceof String; + } + + static WebDriver augmented(WebDriver driver) { + WebDriver cached = AUGMENTED.get(driver); + if (cached != null) { + return cached; + } + WebDriver result = driver; + if (driver instanceof RemoteWebDriver) { + try { + WebDriver aug = augmenter.apply(driver); + if (aug != null) { + result = aug; + } + } catch (Throwable e) { + Percy.log("Could not augment remote driver: " + e, "debug"); + } + } + AUGMENTED.put(driver, result); + return result; + } + + /** CDP for Chromium sessions, local or remote; null when the session can't take CDP. */ + static Cdp cdp(WebDriver driver) { + if (!isChromium(driver)) { + return null; + } + WebDriver target = driver instanceof HasCdp ? driver : augmented(driver); + if (!(target instanceof HasCdp)) { + return null; + } + HasCdp hasCdp = (HasCdp) target; + return hasCdp::executeCdpCommand; + } + + /** BiDi only when the user already started the session with webSocketUrl; never requested here. */ + static BiDi bidi(WebDriver driver) { + if (!hasWebSocketUrl(driver)) { + return null; + } + try { + return bidiFactory.apply(driver); + } catch (Throwable e) { + Percy.log("BiDi unavailable: " + e, "debug"); + return null; + } + } + + @SuppressWarnings("unchecked") + private static BiDi reflectiveBiDi(WebDriver driver) { + try { + // setViewport landed in Selenium 4.15; older bindings lack a usable raw BiDi command API too. + Class.forName("org.openqa.selenium.bidi.browsingcontext.BrowsingContext") + .getMethod("setViewport", int.class, int.class); + Class hasBiDi = Class.forName("org.openqa.selenium.bidi.HasBiDi"); + Class commandClass = Class.forName("org.openqa.selenium.bidi.Command"); + WebDriver target = hasBiDi.isInstance(driver) ? driver : augmented(driver); + if (!hasBiDi.isInstance(target)) { + return null; + } + Object bidi = hasBiDi.getMethod("getBiDi").invoke(target); + Constructor ctor = commandClass.getConstructor(String.class, Map.class, Type.class); + Method send = bidi.getClass().getMethod("send", commandClass); + return (method, params) -> { + try { + Object result = send.invoke(bidi, ctor.newInstance(method, params, Map.class)); + return result instanceof Map ? (Map) result : Collections.emptyMap(); + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + throw new RuntimeException(cause.getMessage(), cause); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + }; + } catch (ReflectiveOperationException | LinkageError e) { + return null; + } + } + + /** True for driver-service sessions and for remote sessions whose hub is on this machine. */ + static boolean isLocal(WebDriver driver) { + if (!(driver instanceof RemoteWebDriver)) { + return false; + } + try { + String url = new DriverMetadata(driver).getCommandExecutorUrl(); + String host = new URI(url).getHost(); + return host != null && LOCAL_HOSTS.contains(host.toLowerCase(Locale.ROOT)); + } catch (Exception e) { + return false; + } + } + + static String sessionKey(WebDriver driver) { + try { + if (driver instanceof RemoteWebDriver && ((RemoteWebDriver) driver).getSessionId() != null) { + return ((RemoteWebDriver) driver).getSessionId().toString(); + } + } catch (Exception ignored) { + } + return "driver@" + System.identityHashCode(driver); + } +} diff --git a/src/main/java/io/percy/selenium/ClosedShadowRoots.java b/src/main/java/io/percy/selenium/ClosedShadowRoots.java new file mode 100644 index 0000000..2359d91 --- /dev/null +++ b/src/main/java/io/percy/selenium/ClosedShadowRoots.java @@ -0,0 +1,290 @@ +package io.percy.selenium; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +/** + * Fills {@code window.__percyClosedShadowRoots} (host element -> closed ShadowRoot), which + * PercyDOM.serialize reads to capture closed shadow trees. Strategies, first available wins: + * Chromium CDP, then WebDriver BiDi (only if the session already has webSocketUrl), then + * WebDriver classic Get Element Shadow Root (local sessions only, capped). + */ +final class ClosedShadowRoots { + static final String CLASSIC_REMOTE_SKIP_MESSAGE = + "closed shadow roots skipped on remote session; start the session with webSocketUrl: true or set PERCY_CLOSED_SHADOW_CLASSIC=true"; + + static boolean forceClassic = Boolean.parseBoolean( + System.getenv().getOrDefault("PERCY_CLOSED_SHADOW_CLASSIC", "false").trim().toLowerCase()); + static int maxHosts = parseMaxHosts(System.getenv("PERCY_CLOSED_SHADOW_MAX_HOSTS")); + + private static final Set REMOTE_SKIP_LOGGED = ConcurrentHashMap.newKeySet(); + + private static final String SET_JS = + "function(shadowRoot) { window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap();" + + " window.__percyClosedShadowRoots.set(this, shadowRoot); }"; + + // Custom elements whose shadowRoot reads null are the only hosts that can hide a closed root; + // open roots are walked so closed roots nested inside them are found too. + private static final String CANDIDATES_JS = + "const root = arguments[0] || document; const out = [];" + + " const walk = r => { for (const e of r.querySelectorAll('*')) {" + + " if (e.shadowRoot) walk(e.shadowRoot); else if (e.localName.includes('-')) out.push(e); } };" + + " walk(root); return out;"; + + private static final String CLASSIC_SET_JS = + "(window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap()).set(arguments[0], arguments[1]);"; + + private static final String BIDI_LIST_FN = + "(...roots) => { const out = []; const walk = r => { for (const e of r.querySelectorAll('*')) {" + + " if (e.shadowRoot) walk(e.shadowRoot); else if (e.localName.includes('-')) out.push(e); } };" + + " for (const r of (roots.length ? roots : [document])) walk(r); return out; }"; + + private static final String BIDI_SET_FN = + "(pairs) => { const m = window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap();" + + " for (const [h, s] of pairs) m.set(h, s); return pairs.length; }"; + + private static final int BIDI_MAX_DEPTH = 10; + + private ClosedShadowRoots() {} + + static int parseMaxHosts(String value) { + try { + int parsed = Integer.parseInt(value == null ? "" : value.trim()); + return parsed > 0 ? parsed : 200; + } catch (NumberFormatException e) { + return 200; + } + } + + /** Never throws: closed-root exposure is best effort and must not fail a snapshot. */ + static void expose(WebDriver driver) { + try { + BrowserSupport.Cdp cdp = BrowserSupport.cdp(driver); + if (cdp != null && tryCdp(driver, cdp)) { + return; + } + BrowserSupport.BiDi bidi = BrowserSupport.bidi(driver); + if (bidi != null && tryBiDi(driver, bidi)) { + return; + } + if (!forceClassic && !BrowserSupport.isLocal(driver)) { + if (REMOTE_SKIP_LOGGED.add(BrowserSupport.sessionKey(driver))) { + Percy.log(CLASSIC_REMOTE_SKIP_MESSAGE, "info"); + } + return; + } + exposeClassic(driver); + } catch (Throwable e) { + Percy.log("Could not expose closed shadow roots: " + e.getMessage(), "warn"); + } + } + + @SuppressWarnings("unchecked") + static boolean tryCdp(WebDriver driver, BrowserSupport.Cdp cdp) { + try { + cdp.execute("DOM.enable", new HashMap<>()); + Map params = new HashMap<>(); + params.put("depth", -1); + params.put("pierce", true); + Map doc = cdp.execute("DOM.getDocument", params); + Object root = doc == null ? null : doc.get("root"); + if (!(root instanceof Map)) { + Percy.log("CDP DOM.getDocument returned no root; skipping closed shadow root capture", "debug"); + return true; + } + List pairs = new ArrayList<>(); + collectCdpPairs((Map) root, pairs); + if (pairs.isEmpty()) { + return true; + } + Percy.log("Found " + pairs.size() + " closed shadow root(s), exposing via CDP", "debug"); + for (Object[] pair : pairs) { + String hostId = resolveObjectId(cdp, pair[0]); + String shadowId = resolveObjectId(cdp, pair[1]); + if (hostId == null || shadowId == null) { + continue; + } + Map arg = new HashMap<>(); + arg.put("objectId", shadowId); + Map call = new HashMap<>(); + call.put("functionDeclaration", SET_JS); + call.put("objectId", hostId); + call.put("arguments", Collections.singletonList(arg)); + cdp.execute("Runtime.callFunctionOn", call); + } + return true; + } catch (Exception e) { + Percy.log("Could not expose closed shadow roots via CDP: " + e.getMessage(), "debug"); + return false; + } + } + + @SuppressWarnings("unchecked") + private static void collectCdpPairs(Map node, List pairs) { + // Closed roots inside child frame documents live in another execution context. + if (node.get("contentDocument") != null) { + return; + } + Object shadowRoots = node.get("shadowRoots"); + if (shadowRoots instanceof List) { + for (Object sr : (List) shadowRoots) { + if (!(sr instanceof Map)) continue; + Map shadow = (Map) sr; + if ("closed".equals(shadow.get("shadowRootType"))) { + pairs.add(new Object[] { node.get("backendNodeId"), shadow.get("backendNodeId") }); + } + collectCdpPairs(shadow, pairs); + } + } + Object children = node.get("children"); + if (children instanceof List) { + for (Object child : (List) children) { + if (child instanceof Map) collectCdpPairs((Map) child, pairs); + } + } + } + + @SuppressWarnings("unchecked") + private static String resolveObjectId(BrowserSupport.Cdp cdp, Object backendNodeId) { + if (backendNodeId == null) return null; + Map params = new HashMap<>(); + params.put("backendNodeId", backendNodeId); + Map resp = cdp.execute("DOM.resolveNode", params); + Object obj = resp == null ? null : resp.get("object"); + Object id = obj instanceof Map ? ((Map) obj).get("objectId") : null; + return id == null ? null : id.toString(); + } + + @SuppressWarnings("unchecked") + static boolean tryBiDi(WebDriver driver, BrowserSupport.BiDi bidi) { + try { + String context = driver.getWindowHandle(); + Map serialization = new HashMap<>(); + serialization.put("maxDomDepth", 0); + serialization.put("includeShadowTree", "all"); + + List roots = new ArrayList<>(); + int exposed = 0; + for (int depth = 0; depth < BIDI_MAX_DEPTH; depth++) { + Map params = callParams(context, BIDI_LIST_FN, roots); + params.put("serializationOptions", serialization); + params.put("resultOwnership", "none"); + Map res = bidi.send("script.callFunction", params); + List pairs = closedPairsFromBiDi(res); + if (pairs.isEmpty()) { + break; + } + List pairValues = new ArrayList<>(); + roots = new ArrayList<>(); + for (Object[] pair : pairs) { + pairValues.add(arrayValue(Arrays.asList(sharedRef(pair[0]), sharedRef(pair[1])))); + roots.add(sharedRef(pair[1])); + } + bidi.send("script.callFunction", + callParams(context, BIDI_SET_FN, Collections.singletonList(arrayValue(pairValues)))); + exposed += pairs.size(); + } + if (exposed > 0) { + Percy.log("Exposed " + exposed + " closed shadow root(s) via BiDi", "debug"); + } + return true; + } catch (Exception e) { + Percy.log("Could not expose closed shadow roots via BiDi: " + e.getMessage(), "debug"); + return false; + } + } + + private static Map callParams(String context, String fn, List args) { + Map target = new HashMap<>(); + target.put("context", context); + Map params = new HashMap<>(); + params.put("functionDeclaration", fn); + params.put("awaitPromise", false); + params.put("target", target); + params.put("arguments", args); + return params; + } + + private static Map sharedRef(Object sharedId) { + Map ref = new HashMap<>(); + ref.put("sharedId", sharedId); + return ref; + } + + private static Map arrayValue(List values) { + Map arr = new HashMap<>(); + arr.put("type", "array"); + arr.put("value", values); + return arr; + } + + @SuppressWarnings("unchecked") + private static List closedPairsFromBiDi(Map response) { + List pairs = new ArrayList<>(); + Object result = response == null ? null : response.get("result"); + Object items = result instanceof Map ? ((Map) result).get("value") : null; + if (!(items instanceof List)) { + return pairs; + } + for (Object item : (List) items) { + if (!(item instanceof Map)) continue; + Map node = (Map) item; + Object value = node.get("value"); + Object shadow = value instanceof Map ? ((Map) value).get("shadowRoot") : null; + if (!(shadow instanceof Map)) continue; + Map shadowNode = (Map) shadow; + Object shadowValue = shadowNode.get("value"); + Object mode = shadowValue instanceof Map ? ((Map) shadowValue).get("mode") : null; + if ("closed".equals(mode) && node.get("sharedId") != null && shadowNode.get("sharedId") != null) { + pairs.add(new Object[] { node.get("sharedId"), shadowNode.get("sharedId") }); + } + } + return pairs; + } + + @SuppressWarnings("unchecked") + static int exposeClassic(WebDriver driver) { + JavascriptExecutor jse = (JavascriptExecutor) driver; + Deque queue = new ArrayDeque<>(); + int checked = 0; + int exposed = 0; + boolean first = true; + while (first || !queue.isEmpty()) { + SearchContext scope = first ? null : queue.pop(); + first = false; + Object listed = scope == null ? jse.executeScript(CANDIDATES_JS) : jse.executeScript(CANDIDATES_JS, scope); + if (!(listed instanceof List)) { + continue; + } + for (Object candidate : (List) listed) { + if (!(candidate instanceof WebElement)) continue; + if (checked >= maxHosts) { + Percy.log("Closed shadow root host cap of " + maxHosts + + " reached; set PERCY_CLOSED_SHADOW_MAX_HOSTS to raise it", "debug"); + return exposed; + } + checked++; + SearchContext root; + try { + root = ((WebElement) candidate).getShadowRoot(); + } catch (Exception e) { + continue; + } + // Safari answers null instead of a no-such-shadow-root error for closed roots. + if (root == null) continue; + jse.executeScript(CLASSIC_SET_JS, candidate, root); + exposed++; + queue.push(root); + } + } + if (exposed > 0) { + Percy.log("Exposed " + exposed + " closed shadow root(s) via WebDriver classic", "debug"); + } + return exposed; + } +} diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 1ea82a3..cb3e309 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -23,9 +23,8 @@ import org.openqa.selenium.*; import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chromium.HasCdp; import org.openqa.selenium.remote.*; -import org.openqa.selenium.support.ui.ExpectedCondition; -import org.openqa.selenium.support.ui.WebDriverWait; import java.util.stream.Collectors; @@ -57,6 +56,8 @@ public class Percy { private static boolean PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT = Boolean.parseBoolean(System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", "false").toLowerCase()); private static final int WIDTHS_CONFIG_TIMEOUT_MS = 30000; + static long RESIZE_WAIT_MS = 1000; + private static final Set WIDTH_MISMATCH_WARNED = ConcurrentHashMap.newKeySet(); // for logging private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]"; @@ -874,6 +875,9 @@ Map getSerializedDOM(JavascriptExecutor jse, Set cookies // Readiness gate before serialize. Graceful on old CLI. Object readinessDiagnostics = waitForReady(jse, options); + if (!isDisableShadowDom(options)) { + ClosedShadowRoots.expose(driver); + } Map domSnapshot = (Map) jse.executeScript(buildSnapshotJS(options)); Map mutableSnapshot = new HashMap<>(domSnapshot); mutableSnapshot.put("cookies", cookies); @@ -927,6 +931,11 @@ Map getSerializedDOM(JavascriptExecutor jse, Set cookies return mutableSnapshot; } + private boolean isDisableShadowDom(Map options) { + Object value = options == null ? null : options.get("disableShadowDOM"); + return value != null && "true".equalsIgnoreCase(value.toString().trim()); + } + private List getElementIdFromElement(List elements) { List ignoredElementsArray = new ArrayList<>(); for (int index = 0; index < elements.size(); index++) { @@ -946,8 +955,61 @@ private static boolean isCdpSupported(ChromeDriver chromeDriver) { } } - // Change window dimensions and wait for the resize event - private static void changeWindowDimensionAndWait(WebDriver driver, int width, int height, int resizeCount) { + enum ViewportMechanism { CDP, BIDI, WINDOW } + + // Per-snapshot option, then .percy.yml snapshot config from the healthcheck, then on. + boolean resolveExactResponsiveWidth(Map options) { + Object value = options == null ? null : options.get("exactResponsiveWidth"); + if (value == null && cliConfig != null) { + JSONObject snapshotConfig = cliConfig.optJSONObject("snapshot"); + if (snapshotConfig != null && snapshotConfig.has("exactResponsiveWidth") && !snapshotConfig.isNull("exactResponsiveWidth")) { + value = snapshotConfig.get("exactResponsiveWidth"); + } + } + if (value == null) { + return true; + } + return !"false".equalsIgnoreCase(value.toString().trim()); + } + + ViewportMechanism setViewport(WebDriver driver, int width, int height, boolean exact) { + if (!exact) { + return setLegacyViewport(driver, width, height); + } + BrowserSupport.Cdp cdp = BrowserSupport.cdp(driver); + if (cdp != null) { + try { + Map params = new HashMap<>(); + params.put("width", width); + params.put("height", height); + params.put("deviceScaleFactor", 1); + params.put("mobile", false); + cdp.execute("Emulation.setDeviceMetricsOverride", params); + waitForInnerWidth(driver, width); + return ViewportMechanism.CDP; + } catch (Exception e) { + log("Resizing using CDP failed for width " + width + ": " + e.getMessage(), "debug"); + } + } + BrowserSupport.BiDi bidi = BrowserSupport.bidi(driver); + if (bidi != null) { + try { + Map viewport = new HashMap<>(); + viewport.put("width", width); + viewport.put("height", height); + bidi.send("browsingContext.setViewport", bidiViewportParams(driver, viewport, 1)); + waitForInnerWidth(driver, width); + return ViewportMechanism.BIDI; + } catch (Exception e) { + log("Resizing using BiDi failed for width " + width + ": " + e.getMessage(), "debug"); + } + } + setWindowWidthExactly(driver, width, height); + return ViewportMechanism.WINDOW; + } + + // exactResponsiveWidth: false keeps the pre-2.3 mechanism (CDP only on a local ChromeDriver). + private ViewportMechanism setLegacyViewport(WebDriver driver, int width, int height) { try { if (driver instanceof ChromeDriver && isCdpSupported((ChromeDriver) driver)) { Map commandParams = new HashMap<>(); @@ -955,28 +1017,100 @@ private static void changeWindowDimensionAndWait(WebDriver driver, int width, in commandParams.put("height", height); commandParams.put("deviceScaleFactor", 1); commandParams.put("mobile", false); - ((ChromeDriver) driver).executeCdpCommand("Emulation.setDeviceMetricsOverride", commandParams); - } else { - driver.manage().window().setSize(new Dimension(width, height)); + waitForInnerWidth(driver, width); + return ViewportMechanism.CDP; } } catch (Exception e) { log("Resizing using CDP failed, falling back to driver for width " + width + ": " + e.getMessage(), "debug"); - driver.manage().window().setSize(new Dimension(width, height)); } - // Wait for window resize event using WebDriverWait - try { - WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(1)); - wait.until((ExpectedCondition) d -> { - Object resizeCountObj = ((JavascriptExecutor) d).executeScript("return window.resizeCount"); - if (resizeCountObj == null) { - return false; + driver.manage().window().setSize(new Dimension(width, height)); + waitForInnerWidth(driver, width); + return ViewportMechanism.WINDOW; + } + + // Window resizes include browser chrome (Edge's border, Windows frames), so correct once by + // the measured difference. OS minimum window widths (~500px) still can't be beaten here. + private void setWindowWidthExactly(WebDriver driver, int width, int height) { + driver.manage().window().setSize(new Dimension(width, height)); + Long innerWidth = waitForInnerWidth(driver, width); + if (innerWidth != null && innerWidth != width) { + int corrected = width + (width - innerWidth.intValue()); + driver.manage().window().setSize(new Dimension(corrected, height)); + innerWidth = waitForInnerWidth(driver, width); + } + if (innerWidth != null && innerWidth != width) { + String browser = BrowserSupport.browserName(driver); + log("Viewport width is " + innerWidth + " instead of " + width + " on " + browser + " after window resize", "debug"); + if (WIDTH_MISMATCH_WARNED.add(BrowserSupport.sessionKey(driver))) { + log("Could not set an exact viewport width of " + width + "px on " + (browser.isEmpty() ? "this browser" : browser) + + " (got " + innerWidth + "px). Start the session with webSocketUrl: true for exact responsive widths.", "warn"); + } + } + } + + void clearViewport(WebDriver driver, Set used, int windowWidth, int windowHeight) { + if (used.contains(ViewportMechanism.CDP)) { + try { + BrowserSupport.Cdp cdp = driver instanceof HasCdp + ? ((HasCdp) driver)::executeCdpCommand : BrowserSupport.cdp(driver); + if (cdp != null) { + cdp.execute("Emulation.clearDeviceMetricsOverride", new HashMap<>()); } - return (resizeCountObj instanceof Number) && ((Number) resizeCountObj).longValue() == resizeCount; - }); - } catch (WebDriverException e) { - log("Timed out waiting for window resize event for width " + width, "debug"); + } catch (Exception e) { + log("Could not clear CDP device metrics override: " + e.getMessage(), "debug"); + } + } + if (used.contains(ViewportMechanism.BIDI)) { + try { + BrowserSupport.BiDi bidi = BrowserSupport.bidi(driver); + if (bidi != null) { + bidi.send("browsingContext.setViewport", bidiViewportParams(driver, null, null)); + } + } catch (Exception e) { + log("Could not reset BiDi viewport: " + e.getMessage(), "debug"); + } + } + if (used.contains(ViewportMechanism.WINDOW)) { + driver.manage().window().setSize(new Dimension(windowWidth, windowHeight)); + } + } + + private static Map bidiViewportParams(WebDriver driver, Map viewport, Integer devicePixelRatio) { + Map params = new HashMap<>(); + params.put("context", driver.getWindowHandle()); + params.put("viewport", viewport); + params.put("devicePixelRatio", devicePixelRatio); + return params; + } + + private static Long readInnerWidth(WebDriver driver) { + try { + Object value = ((JavascriptExecutor) driver).executeScript("return window.innerWidth"); + return value instanceof Number ? ((Number) value).longValue() : null; + } catch (Exception e) { + return null; + } + } + + // Polls window.innerWidth (not a resize-event counter) so the check reflects what the + // serializer will see. Returns the last width read, or null if it couldn't be read. + private static Long waitForInnerWidth(WebDriver driver, int width) { + long deadline = System.currentTimeMillis() + RESIZE_WAIT_MS; + Long innerWidth = readInnerWidth(driver); + while ((innerWidth == null || innerWidth != width) && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + innerWidth = readInnerWidth(driver); + } + if (innerWidth == null || innerWidth != width) { + log("Timed out waiting for viewport width " + width + " (innerWidth " + innerWidth + ")", "debug"); } + return innerWidth; } private List extractResponsiveWidths(Map options) { @@ -1051,7 +1185,8 @@ public List> captureResponsiveDom(WebDriver driver, Set used = EnumSet.noneOf(ViewportMechanism.class); JavascriptExecutor jse = (JavascriptExecutor) driver; jse.executeScript("PercyDOM.waitForResize()"); int targetHeight = resolveResponsiveTargetHeight(options, currentHeight); @@ -1067,9 +1202,10 @@ public List> captureResponsiveDom(WebDriver driver, Set> captureResponsiveDom(WebDriver driver, Set> captureResponsiveDom(WebDriver driver, Set originalAugmenter; + private Function originalBiDi; + + @BeforeEach + public void saveHooks() { + originalAugmenter = BrowserSupport.augmenter; + originalBiDi = BrowserSupport.bidiFactory; + BrowserSupport.augmenter = d -> d; + } + + @AfterEach + public void restoreHooks() { + BrowserSupport.augmenter = originalAugmenter; + BrowserSupport.bidiFactory = originalBiDi; + } + + private static DesiredCapabilities firefoxWithBiDi() { + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName("firefox"); + caps.setCapability("webSocketUrl", "ws://localhost/session/x"); + return caps; + } + + // The SDK only uses BiDi from Selenium 4.15 (setViewport), so the Java 8 (4.5.3) job skips these. + private static Class bidiClass(String name) { + try { + Class.forName("org.openqa.selenium.bidi.browsingcontext.BrowsingContext") + .getMethod("setViewport", int.class, int.class); + return Class.forName("org.openqa.selenium.bidi." + name); + } catch (ReflectiveOperationException e) { + Assumptions.assumeTrue(false, "BiDi not in this Selenium version"); + return null; + } + } + + @Test + @SuppressWarnings("unchecked") + public void reflectiveBiDiSendsRawCommands() throws Exception { + Class hasBiDi = bidiClass("HasBiDi"); + Class biDiClass = bidiClass("BiDi"); + Class commandClass = bidiClass("Command"); + RemoteWebDriver driver = (RemoteWebDriver) mock(RemoteWebDriver.class, withSettings().extraInterfaces(hasBiDi)); + when(driver.getCapabilities()).thenReturn(firefoxWithBiDi()); + Object bidi = mock(biDiClass); + hasBiDi.getMethod("getBiDi").invoke(doReturn(bidi).when(driver)); + Map reply = new HashMap<>(); + reply.put("ok", true); + List sent = new ArrayList<>(); + biDiClass.getMethod("send", commandClass).invoke(doAnswer(inv -> { + sent.add(inv.getArgument(0)); + return reply; + }).when(bidi), (Object) any()); + + BrowserSupport.BiDi transport = BrowserSupport.bidi(driver); + assertNotNull(transport); + Map params = new HashMap<>(); + params.put("context", "c"); + assertEquals(reply, transport.send("browsingContext.setViewport", params)); + assertEquals("browsingContext.setViewport", commandClass.getMethod("getMethod").invoke(sent.get(0))); + assertEquals(params, commandClass.getMethod("getParams").invoke(sent.get(0))); + + biDiClass.getMethod("send", commandClass).invoke( + doThrow(new IllegalStateException("closed")).when(bidi), (Object) any()); + RuntimeException e = assertThrows(RuntimeException.class, () -> transport.send("x", new HashMap<>())); + assertEquals("closed", e.getMessage()); + } + + @Test + public void reflectiveBiDiIsNullWhenDriverCannotSpeakBiDi() { + bidiClass("HasBiDi"); + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenReturn(firefoxWithBiDi()); + assertNull(BrowserSupport.bidi(driver)); + } + + @Test + public void bidiFactoryFailureIsSwallowed() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenReturn(firefoxWithBiDi()); + BrowserSupport.bidiFactory = d -> { throw new IllegalStateException("no ws"); }; + assertNull(BrowserSupport.bidi(driver)); + } + + @Test + public void augmentationFailureFallsBackToTheDriver() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName("chrome"); + when(driver.getCapabilities()).thenReturn(caps); + BrowserSupport.augmenter = d -> { throw new IllegalStateException("no augmenter"); }; + assertNull(BrowserSupport.cdp(driver)); + assertSame(driver, BrowserSupport.augmented(driver)); + } + + @Test + public void capabilitiesFailureMeansUnknownBrowser() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenThrow(new IllegalStateException("gone")); + assertEquals("", BrowserSupport.browserName(driver)); + assertFalse(BrowserSupport.hasWebSocketUrl(driver)); + assertEquals("", BrowserSupport.browserName(mock(WebDriver.class))); + } + + private static RemoteWebDriver driverAt(String sessionId, String url) throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getSessionId()).thenReturn(new SessionId(sessionId)); + HttpCommandExecutor executor = mock(HttpCommandExecutor.class); + when(executor.getAddressOfRemoteServer()).thenReturn(new URL(url)); + when(driver.getCommandExecutor()).thenReturn(executor); + return driver; + } + + @Test + public void localDetectionUsesTheCommandExecutorHost() throws Exception { + assertTrue(BrowserSupport.isLocal(driverAt("local-1", "http://localhost:9515"))); + assertTrue(BrowserSupport.isLocal(driverAt("local-2", "http://127.0.0.1:4444/wd/hub"))); + assertFalse(BrowserSupport.isLocal(driverAt("remote-1", "https://hub-cloud.browserstack.com/wd/hub"))); + assertFalse(BrowserSupport.isLocal(mock(RemoteWebDriver.class))); + assertFalse(BrowserSupport.isLocal(mock(WebDriver.class))); + assertEquals("local-1", BrowserSupport.sessionKey(driverAt("local-1", "http://localhost:1"))); + assertTrue(BrowserSupport.sessionKey(mock(WebDriver.class)).startsWith("driver@")); + } + + @Test + public void getSerializedDomSkipsClosedRootsWhenShadowDomDisabled() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = new Percy(driver); + Map snap = new HashMap<>(); + snap.put("dom", "x"); + when(driver.executeScript(anyString())).thenReturn(snap); + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + Map options = new HashMap<>(); + options.put("disableShadowDOM", true); + percy.getSerializedDOM((JavascriptExecutor) driver, new HashSet(), options); + verify(driver, never()).getCapabilities(); + } +} diff --git a/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java b/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java new file mode 100644 index 0000000..53ff1c6 --- /dev/null +++ b/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java @@ -0,0 +1,241 @@ +package io.percy.selenium; + +import java.util.*; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.NoSuchShadowRootException; +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.remote.DesiredCapabilities; +import org.openqa.selenium.remote.RemoteWebDriver; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class ClosedShadowRootsTest { + private Function originalAugmenter; + private Function originalBiDi; + private boolean originalForce; + private int originalMax; + + @BeforeEach + public void saveHooks() { + originalAugmenter = BrowserSupport.augmenter; + originalBiDi = BrowserSupport.bidiFactory; + originalForce = ClosedShadowRoots.forceClassic; + originalMax = ClosedShadowRoots.maxHosts; + BrowserSupport.augmenter = d -> d; + BrowserSupport.bidiFactory = d -> null; + } + + @AfterEach + public void restoreHooks() { + BrowserSupport.augmenter = originalAugmenter; + BrowserSupport.bidiFactory = originalBiDi; + ClosedShadowRoots.forceClassic = originalForce; + ClosedShadowRoots.maxHosts = originalMax; + } + + private static Map node(Object backendNodeId, Object... kv) { + Map n = new HashMap<>(); + n.put("backendNodeId", backendNodeId); + for (int i = 0; i < kv.length; i += 2) n.put((String) kv[i], kv[i + 1]); + return n; + } + + @Test + public void cdpExposesClosedAndNestedRootsButSkipsFrames() { + ChromeDriver driver = mock(ChromeDriver.class); + Map nestedClosed = node(31L, "shadowRootType", "closed"); + Map innerHost = node(30L, "shadowRoots", Collections.singletonList(nestedClosed)); + Map closedRoot = node(21L, "shadowRootType", "closed", "children", Collections.singletonList(innerHost)); + Map openRoot = node(11L, "shadowRootType", "open"); + Map host = node(20L, "shadowRoots", Collections.singletonList(closedRoot)); + Map openHost = node(10L, "shadowRoots", Collections.singletonList(openRoot)); + Map frameClosed = node(41L, "shadowRootType", "closed"); + Map frame = node(40L, "contentDocument", node(42L), "shadowRoots", Collections.singletonList(frameClosed)); + Map root = node(1L, "children", Arrays.asList(openHost, host, frame)); + Map doc = new HashMap<>(); + doc.put("root", root); + + when(driver.executeCdpCommand(eq("DOM.getDocument"), anyMap())).thenReturn(doc); + when(driver.executeCdpCommand(eq("DOM.resolveNode"), anyMap())).thenAnswer(inv -> { + Object id = ((Map) inv.getArgument(1)).get("backendNodeId"); + Map obj = new HashMap<>(); + obj.put("objectId", "obj-" + id); + Map resp = new HashMap<>(); + resp.put("object", obj); + return resp; + }); + + ClosedShadowRoots.expose(driver); + + List calledOn = new ArrayList<>(); + verify(driver, times(2)).executeCdpCommand(eq("Runtime.callFunctionOn"), argThat(m -> { + calledOn.add((String) m.get("objectId")); + return true; + })); + assertTrue(calledOn.contains("obj-20")); + assertTrue(calledOn.contains("obj-30")); + assertFalse(calledOn.contains("obj-40")); + } + + @Test + @SuppressWarnings("unchecked") + public void bidiExposesRootsForFirefoxWithWebSocketUrl() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName("firefox"); + caps.setCapability("webSocketUrl", "ws://localhost/x"); + when(driver.getCapabilities()).thenReturn(caps); + when(driver.getWindowHandle()).thenReturn("ctx"); + + List> calls = new ArrayList<>(); + BrowserSupport.bidiFactory = d -> (method, params) -> { + calls.add(params); + Map resp = new HashMap<>(); + if (calls.size() == 1) { + Map shadowValue = new HashMap<>(); + shadowValue.put("mode", "closed"); + Map shadow = new HashMap<>(); + shadow.put("sharedId", "root-1"); + shadow.put("value", shadowValue); + Map value = new HashMap<>(); + value.put("shadowRoot", shadow); + Map host = new HashMap<>(); + host.put("sharedId", "host-1"); + host.put("value", value); + Map result = new HashMap<>(); + result.put("value", Collections.singletonList(host)); + resp.put("result", result); + } else { + Map result = new HashMap<>(); + result.put("value", Collections.emptyList()); + resp.put("result", result); + } + return resp; + }; + + ClosedShadowRoots.expose(driver); + + // list, set, then a second list scoped to the new closed root that finds nothing. + assertEquals(3, calls.size()); + Map serialization = (Map) calls.get(0).get("serializationOptions"); + assertEquals("all", serialization.get("includeShadowTree")); + assertEquals("ctx", ((Map) calls.get(0).get("target")).get("context")); + List setArgs = (List) calls.get(1).get("arguments"); + assertEquals("array", ((Map) setArgs.get(0)).get("type")); + List scopedRoots = (List) calls.get(2).get("arguments"); + assertEquals("root-1", ((Map) scopedRoots.get(0)).get("sharedId")); + verify(driver, never()).executeScript(anyString(), any()); + } + + @Test + public void classicSkipsNullAndMissingRootsAndRecurses() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + ClosedShadowRoots.forceClassic = true; + WebElement closedHost = mock(WebElement.class); + WebElement safariLike = mock(WebElement.class); + WebElement noRoot = mock(WebElement.class); + SearchContext root = mock(SearchContext.class); + when(closedHost.getShadowRoot()).thenReturn(root); + when(safariLike.getShadowRoot()).thenReturn(null); + when(noRoot.getShadowRoot()).thenThrow(new NoSuchShadowRootException("none")); + when(driver.executeScript(contains("querySelectorAll"))).thenReturn(Arrays.asList(closedHost, safariLike, noRoot)); + when(driver.executeScript(contains("querySelectorAll"), eq(root))).thenReturn(Collections.emptyList()); + + ClosedShadowRoots.expose(driver); + + verify(driver).executeScript(contains("__percyClosedShadowRoots"), eq(closedHost), eq(root)); + verify(driver, times(1)).executeScript(contains("__percyClosedShadowRoots"), any(), any()); + verify(driver).executeScript(contains("querySelectorAll"), eq(root)); + } + + @Test + public void classicStopsAtHostCap() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + ClosedShadowRoots.forceClassic = true; + ClosedShadowRoots.maxHosts = 2; + List hosts = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + WebElement el = mock(WebElement.class); + when(el.getShadowRoot()).thenThrow(new NoSuchShadowRootException("none")); + hosts.add(el); + } + when(driver.executeScript(contains("querySelectorAll"))).thenReturn(hosts); + + ClosedShadowRoots.expose(driver); + + verify(hosts.get(0)).getShadowRoot(); + verify(hosts.get(1)).getShadowRoot(); + verify(hosts.get(2), never()).getShadowRoot(); + } + + @Test + public void remoteSessionWithoutCdpOrBiDiSkipsClassic() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName("firefox"); + when(driver.getCapabilities()).thenReturn(caps); + ClosedShadowRoots.forceClassic = false; + + ClosedShadowRoots.expose(driver); + + verify(driver, never()).executeScript(anyString()); + } + + @Test + public void failuresNeverEscape() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + ClosedShadowRoots.forceClassic = true; + when(driver.executeScript(anyString())).thenThrow(new RuntimeException("boom")); + assertDoesNotThrow(() -> ClosedShadowRoots.expose(driver)); + } + + @Test + public void cdpWithoutClosedRootsOrUnresolvableNodesDoesNothing() { + ChromeDriver driver = mock(ChromeDriver.class); + Map doc = new HashMap<>(); + doc.put("root", node(1L, "children", Collections.singletonList(node(2L)))); + when(driver.executeCdpCommand(eq("DOM.getDocument"), anyMap())).thenReturn(doc); + ClosedShadowRoots.expose(driver); + verify(driver, never()).executeCdpCommand(eq("DOM.resolveNode"), anyMap()); + + Map host = node(20L, "shadowRoots", + Collections.singletonList(node(21L, "shadowRootType", "closed"))); + doc.put("root", node(1L, "children", Collections.singletonList(host))); + when(driver.executeCdpCommand(eq("DOM.resolveNode"), anyMap())).thenReturn(new HashMap()); + ClosedShadowRoots.expose(driver); + verify(driver, never()).executeCdpCommand(eq("Runtime.callFunctionOn"), anyMap()); + } + + @Test + public void bidiFailureFallsThroughToClassic() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName("firefox"); + caps.setCapability("webSocketUrl", "ws://localhost/x"); + when(driver.getCapabilities()).thenReturn(caps); + BrowserSupport.bidiFactory = d -> (method, params) -> { throw new IllegalStateException("no script module"); }; + ClosedShadowRoots.forceClassic = true; + when(driver.executeScript(contains("querySelectorAll"))).thenReturn("not a list"); + + ClosedShadowRoots.expose(driver); + + verify(driver).executeScript(contains("querySelectorAll")); + } + + @Test + public void maxHostsParsing() { + assertEquals(200, ClosedShadowRoots.parseMaxHosts(null)); + assertEquals(200, ClosedShadowRoots.parseMaxHosts("abc")); + assertEquals(200, ClosedShadowRoots.parseMaxHosts("0")); + assertEquals(50, ClosedShadowRoots.parseMaxHosts(" 50 ")); + } +} diff --git a/src/test/java/io/percy/selenium/ExactViewportTest.java b/src/test/java/io/percy/selenium/ExactViewportTest.java new file mode 100644 index 0000000..163a4a5 --- /dev/null +++ b/src/test/java/io/percy/selenium/ExactViewportTest.java @@ -0,0 +1,261 @@ +package io.percy.selenium; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.Dimension; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.remote.DesiredCapabilities; +import org.openqa.selenium.remote.RemoteWebDriver; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class ExactViewportTest { + private Function originalAugmenter; + private Function originalBiDi; + + @BeforeEach + public void saveHooks() { + originalAugmenter = BrowserSupport.augmenter; + originalBiDi = BrowserSupport.bidiFactory; + BrowserSupport.augmenter = d -> d; + BrowserSupport.bidiFactory = d -> null; + Percy.RESIZE_WAIT_MS = 0; + } + + @AfterEach + public void restoreHooks() { + BrowserSupport.augmenter = originalAugmenter; + BrowserSupport.bidiFactory = originalBiDi; + Percy.RESIZE_WAIT_MS = 1000; + } + + static DesiredCapabilities caps(String browserName, boolean webSocket) { + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName(browserName); + if (webSocket) { + caps.setCapability("webSocketUrl", "ws://localhost:1234/session/abc"); + } + return caps; + } + + /** A remote driver whose window drives innerWidth, minus a fixed browser border. */ + static class FakeWindow { + final WebDriver.Window window = mock(WebDriver.Window.class); + final AtomicInteger innerWidth = new AtomicInteger(1024); + + FakeWindow(RemoteWebDriver driver, int border, int minWidth) { + WebDriver.Options options = mock(WebDriver.Options.class); + when(driver.manage()).thenReturn(options); + when(options.window()).thenReturn(window); + doAnswer(inv -> { + int outer = Math.max(((Dimension) inv.getArgument(0)).getWidth(), minWidth); + innerWidth.set(outer - border); + return null; + }).when(window).setSize(any(Dimension.class)); + when(driver.executeScript("return window.innerWidth")).thenAnswer(inv -> (long) innerWidth.get()); + } + } + + private static Percy percy(WebDriver driver, JSONObject snapshotConfig) throws Exception { + Percy percy = new Percy(driver); + Field field = Percy.class.getDeclaredField("cliConfig"); + field.setAccessible(true); + field.set(percy, new JSONObject().put("snapshot", snapshotConfig)); + return percy; + } + + @Test + public void chromiumDetectionMatrix() { + String[][] cases = { + {"chrome", "true"}, {"MicrosoftEdge", "true"}, {"msedge", "true"}, {"chromium", "true"}, + {"chrome-headless-shell", "true"}, {"firefox", "false"}, {"safari", "false"}, {"", "false"} + }; + for (String[] c : cases) { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenReturn(caps(c[0], false)); + assertEquals(Boolean.parseBoolean(c[1]), BrowserSupport.isChromium(driver), c[0]); + } + // A local Chromium driver is CDP-capable even without a recognised browserName. + assertTrue(BrowserSupport.isChromium(mock(ChromeDriver.class))); + } + + @Test + public void bidiRequiresWebSocketUrlCapability() { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + BrowserSupport.BiDi fake = (m, p) -> Collections.emptyMap(); + BrowserSupport.bidiFactory = d -> fake; + when(driver.getCapabilities()).thenReturn(caps("firefox", false)); + assertNull(BrowserSupport.bidi(driver)); + when(driver.getCapabilities()).thenReturn(caps("firefox", true)); + assertSame(fake, BrowserSupport.bidi(driver)); + } + + @Test + public void remoteChromeUsesAugmentedCdpAndClearsOnRestore() throws Exception { + RemoteWebDriver remote = mock(RemoteWebDriver.class); + when(remote.getCapabilities()).thenReturn(caps("chrome", false)); + ChromeDriver augmented = mock(ChromeDriver.class); + BrowserSupport.augmenter = d -> augmented; + FakeWindow fw = new FakeWindow(remote, 0, 500); + when(augmented.executeCdpCommand(eq("Emulation.setDeviceMetricsOverride"), anyMap())).thenAnswer(inv -> { + fw.innerWidth.set((Integer) ((Map) inv.getArgument(1)).get("width")); + return new HashMap(); + }); + + Percy percy = percy(remote, new JSONObject()); + assertEquals(Percy.ViewportMechanism.CDP, percy.setViewport(remote, 375, 800, true)); + assertEquals(375, fw.innerWidth.get()); + + Map expected = new HashMap<>(); + expected.put("width", 375); + expected.put("height", 800); + expected.put("deviceScaleFactor", 1); + expected.put("mobile", false); + verify(augmented).executeCdpCommand("Emulation.setDeviceMetricsOverride", expected); + + percy.clearViewport(remote, EnumSet.of(Percy.ViewportMechanism.CDP), 1024, 768); + verify(augmented).executeCdpCommand(eq("Emulation.clearDeviceMetricsOverride"), anyMap()); + verify(fw.window, never()).setSize(any(Dimension.class)); + } + + @Test + public void firefoxWithBiDiUsesSetViewportAndResetsWithNullViewport() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenReturn(caps("firefox", true)); + when(driver.getWindowHandle()).thenReturn("ctx-1"); + FakeWindow fw = new FakeWindow(driver, 0, 500); + List> sent = new ArrayList<>(); + BrowserSupport.bidiFactory = d -> (method, params) -> { + assertEquals("browsingContext.setViewport", method); + sent.add(params); + Object viewport = params.get("viewport"); + if (viewport instanceof Map) fw.innerWidth.set((Integer) ((Map) viewport).get("width")); + return Collections.emptyMap(); + }; + + Percy percy = percy(driver, new JSONObject()); + assertEquals(Percy.ViewportMechanism.BIDI, percy.setViewport(driver, 375, 800, true)); + assertEquals("ctx-1", sent.get(0).get("context")); + assertEquals(1, sent.get(0).get("devicePixelRatio")); + assertEquals(375, ((Map) sent.get(0).get("viewport")).get("width")); + + percy.clearViewport(driver, EnumSet.of(Percy.ViewportMechanism.BIDI), 1024, 768); + Map reset = sent.get(1); + assertTrue(reset.containsKey("viewport")); + assertNull(reset.get("viewport")); + assertNull(reset.get("devicePixelRatio")); + verify(fw.window, never()).setSize(any(Dimension.class)); + } + + @Test + public void windowFallbackCorrectsBrowserBorderOnce() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenReturn(caps("MicrosoftEdge", false)); + // Edge without CDP (augmentation unavailable) and without BiDi: 8px border. + FakeWindow fw = new FakeWindow(driver, 8, 500); + + Percy percy = percy(driver, new JSONObject()); + assertEquals(Percy.ViewportMechanism.WINDOW, percy.setViewport(driver, 1280, 800, true)); + verify(fw.window).setSize(new Dimension(1280, 800)); + verify(fw.window).setSize(new Dimension(1288, 800)); + assertEquals(1280, fw.innerWidth.get()); + + percy.clearViewport(driver, EnumSet.of(Percy.ViewportMechanism.WINDOW), 1024, 768); + verify(fw.window).setSize(new Dimension(1024, 768)); + } + + @Test + public void windowFallbackGivesUpBelowMinimumWindowWidth() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenReturn(caps("firefox", false)); + FakeWindow fw = new FakeWindow(driver, 0, 500); + + Percy percy = percy(driver, new JSONObject()); + assertEquals(Percy.ViewportMechanism.WINDOW, percy.setViewport(driver, 375, 800, true)); + // One correction attempt only, then it logs and moves on. + verify(fw.window, times(2)).setSize(any(Dimension.class)); + assertEquals(500, fw.innerWidth.get()); + } + + @Test + public void bidiFailureFallsBackToWindowAndRestoreFailuresAreSwallowed() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getCapabilities()).thenReturn(caps("firefox", true)); + when(driver.getWindowHandle()).thenReturn("ctx-1"); + FakeWindow fw = new FakeWindow(driver, 0, 100); + BrowserSupport.bidiFactory = d -> (method, params) -> { throw new IllegalStateException("unsupported"); }; + + Percy percy = percy(driver, new JSONObject()); + assertEquals(Percy.ViewportMechanism.WINDOW, percy.setViewport(driver, 375, 800, true)); + assertEquals(375, fw.innerWidth.get()); + assertDoesNotThrow(() -> percy.clearViewport(driver, EnumSet.of(Percy.ViewportMechanism.BIDI), 1024, 768)); + + ChromeDriver chrome = mock(ChromeDriver.class); + when(chrome.executeCdpCommand(anyString(), anyMap())).thenThrow(new IllegalStateException("closed")); + assertDoesNotThrow(() -> percy.clearViewport(chrome, EnumSet.of(Percy.ViewportMechanism.CDP), 1024, 768)); + } + + @Test + public void legacyCdpFailureFallsBackToWindow() throws Exception { + ChromeDriver chrome = mock(ChromeDriver.class); + WebDriver.Options options = mock(WebDriver.Options.class); + WebDriver.Window window = mock(WebDriver.Window.class); + when(chrome.manage()).thenReturn(options); + when(options.window()).thenReturn(window); + when(chrome.executeCdpCommand(anyString(), anyMap())).thenThrow(new IllegalStateException("no cdp")); + when(chrome.executeScript("return window.innerWidth")).thenThrow(new IllegalStateException("no js")); + + Percy percy = percy(chrome, new JSONObject()); + assertEquals(Percy.ViewportMechanism.WINDOW, percy.setViewport(chrome, 1280, 800, false)); + verify(window).setSize(new Dimension(1280, 800)); + } + + @Test + public void exactResponsiveWidthPrecedence() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = percy(driver, new JSONObject()); + assertTrue(percy.resolveExactResponsiveWidth(new HashMap<>())); + + Percy configOff = percy(driver, new JSONObject().put("exactResponsiveWidth", false)); + assertFalse(configOff.resolveExactResponsiveWidth(new HashMap<>())); + + Map perSnapshotOn = new HashMap<>(); + perSnapshotOn.put("exactResponsiveWidth", true); + assertTrue(configOff.resolveExactResponsiveWidth(perSnapshotOn)); + + Map perSnapshotOff = new HashMap<>(); + perSnapshotOff.put("exactResponsiveWidth", "false"); + assertFalse(percy.resolveExactResponsiveWidth(perSnapshotOff)); + } + + @Test + public void legacyModeKeepsWindowResizeForEdgeAndCdpForLocalChrome() throws Exception { + RemoteWebDriver edge = mock(RemoteWebDriver.class); + when(edge.getCapabilities()).thenReturn(caps("MicrosoftEdge", false)); + ChromeDriver augmented = mock(ChromeDriver.class); + BrowserSupport.augmenter = d -> augmented; + FakeWindow fw = new FakeWindow(edge, 8, 500); + + Percy percy = percy(edge, new JSONObject()); + assertEquals(Percy.ViewportMechanism.WINDOW, percy.setViewport(edge, 1280, 800, false)); + verify(fw.window, times(1)).setSize(new Dimension(1280, 800)); + verifyNoInteractions(augmented); + + ChromeDriver chrome = mock(ChromeDriver.class); + when(chrome.executeScript("return window.innerWidth")).thenReturn(375L); + Percy chromePercy = percy(chrome, new JSONObject()); + assertEquals(Percy.ViewportMechanism.CDP, chromePercy.setViewport(chrome, 375, 800, false)); + chromePercy.clearViewport(chrome, EnumSet.of(Percy.ViewportMechanism.CDP), 1024, 768); + verify(chrome).executeCdpCommand(eq("Emulation.clearDeviceMetricsOverride"), anyMap()); + } +} diff --git a/src/test/java/io/percy/selenium/PercyDriverPathTest.java b/src/test/java/io/percy/selenium/PercyDriverPathTest.java index f994e42..682d3d1 100644 --- a/src/test/java/io/percy/selenium/PercyDriverPathTest.java +++ b/src/test/java/io/percy/selenium/PercyDriverPathTest.java @@ -491,18 +491,21 @@ public void captureResponsiveDomUsesCdpResizePathForChromeDriver() throws Except when(driverOptions.window()).thenReturn(driverWindow); when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); - // The CDP resize bumps an internal counter that executeScript reports - // back as window.resizeCount so WebDriverWait resolves immediately. - AtomicInteger resizeCount = new AtomicInteger(0); + // The CDP override drives what executeScript reports as window.innerWidth. + AtomicInteger innerWidth = new AtomicInteger(1024); when(driver.executeCdpCommand(eq("Emulation.setDeviceMetricsOverride"), anyMap())) - .thenAnswer(inv -> { resizeCount.incrementAndGet(); return new HashMap(); }); + .thenAnswer(inv -> { + Map params = inv.getArgument(1); + innerWidth.set((Integer) params.get("width")); + return new HashMap(); + }); when(driver.getCurrentUrl()).thenReturn("https://example.com"); when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); when(driver.executeScript(anyString())).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { - return (long) resizeCount.get(); + if (script.equals("return window.innerWidth")) { + return (long) innerWidth.get(); } if (script.startsWith("return PercyDOM.serialize(")) { Map snap = new HashMap(); @@ -520,8 +523,9 @@ public void captureResponsiveDomUsesCdpResizePathForChromeDriver() throws Except assertEquals(2, snapshots.size()); assertEquals(375, snapshots.get(0).get("width")); assertEquals(1280, snapshots.get(1).get("width")); - // CDP used for every resize (2 widths + final restore). - verify(driver, times(3)).executeCdpCommand(eq("Emulation.setDeviceMetricsOverride"), anyMap()); + // CDP for each width, then the override is cleared rather than re-applied at the window size. + verify(driver, times(2)).executeCdpCommand(eq("Emulation.setDeviceMetricsOverride"), anyMap()); + verify(driver, times(1)).executeCdpCommand(eq("Emulation.clearDeviceMetricsOverride"), anyMap()); verify(driverWindow, never()).setSize(any(Dimension.class)); } finally { setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); @@ -548,19 +552,19 @@ public void captureResponsiveDomFallsBackToSetSizeWhenCdpThrows() throws Excepti when(driverOptions.window()).thenReturn(driverWindow); when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); - AtomicInteger resizeCount = new AtomicInteger(0); + AtomicInteger innerWidth = new AtomicInteger(1024); // CDP throws -> the catch logs and falls back to window().setSize(). when(driver.executeCdpCommand(anyString(), anyMap())) .thenThrow(new WebDriverException("cdp unsupported")); - doAnswer(inv -> { resizeCount.incrementAndGet(); return null; }) + doAnswer(inv -> { innerWidth.set(((Dimension) inv.getArgument(0)).getWidth()); return null; }) .when(driverWindow).setSize(any(Dimension.class)); when(driver.getCurrentUrl()).thenReturn("https://example.com"); when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); when(driver.executeScript(anyString())).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { - return (long) resizeCount.get(); + if (script.equals("return window.innerWidth")) { + return (long) innerWidth.get(); } if (script.startsWith("return PercyDOM.serialize(")) { Map snap = new HashMap(); @@ -577,7 +581,8 @@ public void captureResponsiveDomFallsBackToSetSizeWhenCdpThrows() throws Excepti assertEquals(1, snapshots.size()); // Fallback setSize used for resize + final restore. - verify(driverWindow, atLeast(2)).setSize(any(Dimension.class)); + verify(driverWindow).setSize(new Dimension(375, 768)); + verify(driverWindow).setSize(new Dimension(1024, 768)); } finally { setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); server.stop(0); @@ -586,8 +591,7 @@ public void captureResponsiveDomFallsBackToSetSizeWhenCdpThrows() throws Excepti @Test public void captureResponsiveDomTolueratesResizeWaitTimeout() throws Exception { - // window.resizeCount never matches -> WebDriverWait times out and the - // WebDriverException catch (lines ~893-894) logs but capture continues. + // window.innerWidth never matches -> the wait times out, logs, and capture continues. HttpServer server = startWidthsConfigServer( "{\"widths\":[{\"width\":375}]}", HttpURLConnection.HTTP_OK); String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); @@ -615,8 +619,8 @@ public void captureResponsiveDomTolueratesResizeWaitTimeout() throws Exception { when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); when(driver.executeScript(anyString())).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { - // Never matches the expected resizeCount -> forces a wait timeout. + if (script.equals("return window.innerWidth")) { + // Never matches the requested width -> forces a wait timeout. return 999999L; } if (script.startsWith("return PercyDOM.serialize(")) { @@ -683,12 +687,11 @@ public void getSerializedDomSwallowsOuterIframeDiscoveryFailure() throws Excepti // captureResponsiveDom: a non-numeric RESPONSIVE_CAPTURE_SLEEP_TIME makes // Integer.parseInt throw inside the sleep block; the shared // InterruptedException|NumberFormatException catch (line ~1003) swallows it - // and capture proceeds. Also drives the resizeCount==null wait branch - // (line ~889) by returning null for window.resizeCount. + // and capture proceeds. Also drives the null innerWidth wait branch. // ------------------------------------------------------------------ @Test - public void captureResponsiveDomHandlesNonNumericSleepAndNullResizeCount() throws Exception { + public void captureResponsiveDomHandlesNonNumericSleepAndNullInnerWidth() throws Exception { HttpServer server = startWidthsConfigServer( "{\"widths\":[{\"width\":375}]}", HttpURLConnection.HTTP_OK); String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); @@ -716,8 +719,8 @@ public void captureResponsiveDomHandlesNonNumericSleepAndNullResizeCount() throw when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); when(driver.executeScript(anyString())).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { - // null -> the wait lambda's `resizeCountObj == null` branch returns false. + if (script.equals("return window.innerWidth")) { + // null -> the innerWidth poll treats the width as unknown and times out. return null; } if (script.startsWith("return PercyDOM.serialize(")) { diff --git a/src/test/java/io/percy/selenium/PercyLogicTest.java b/src/test/java/io/percy/selenium/PercyLogicTest.java index d52f32d..f858a1a 100644 --- a/src/test/java/io/percy/selenium/PercyLogicTest.java +++ b/src/test/java/io/percy/selenium/PercyLogicTest.java @@ -634,19 +634,20 @@ public void captureResponsiveDomResizesPerWidthAndRestores() throws Exception { setField(mockedPercy, "domJs", "/* percy */"); setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); - AtomicInteger changes = new AtomicInteger(0); + AtomicInteger changes = new AtomicInteger(1024); WebDriver.Options driverOptions = mock(WebDriver.Options.class); WebDriver.Window driverWindow = mock(WebDriver.Window.class); when(mockedDriver.manage()).thenReturn(driverOptions); when(driverOptions.window()).thenReturn(driverWindow); when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); - doAnswer(inv -> { changes.incrementAndGet(); return null; }).when(driverWindow).setSize(any(Dimension.class)); + doAnswer(inv -> { changes.set(((Dimension) inv.getArgument(0)).getWidth()); return null; }) + .when(driverWindow).setSize(any(Dimension.class)); when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); when(((JavascriptExecutor) mockedDriver).executeScript(anyString())).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { + if (script.equals("return window.innerWidth")) { return (long) changes.get(); } if (script.startsWith("return PercyDOM.serialize(")) { diff --git a/src/test/java/io/percy/selenium/SdkTest.java b/src/test/java/io/percy/selenium/SdkTest.java index eeda302..d03177b 100644 --- a/src/test/java/io/percy/selenium/SdkTest.java +++ b/src/test/java/io/percy/selenium/SdkTest.java @@ -933,21 +933,21 @@ public void captureResponsiveDomResizesToCorrectWidthAndHeight() throws Exceptio setField(mockedPercy, "domJs", "/* percy dom */"); setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); - // Track actual setSize calls so window.resizeCount can echo the right value. - AtomicInteger dimensionChangeCount = new AtomicInteger(0); + // Track setSize calls so window.innerWidth echoes the last requested width. + AtomicInteger dimensionChangeCount = new AtomicInteger(1024); WebDriver.Options driverOptions = mock(WebDriver.Options.class); WebDriver.Window driverWindow = mock(WebDriver.Window.class); when(mockedDriver.manage()).thenReturn(driverOptions); when(driverOptions.window()).thenReturn(driverWindow); when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); - doAnswer(inv -> { dimensionChangeCount.incrementAndGet(); return null; }) + doAnswer(inv -> { dimensionChangeCount.set(((Dimension) inv.getArgument(0)).getWidth()); return null; }) .when(driverWindow).setSize(any(Dimension.class)); when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { + if (script.equals("return window.innerWidth")) { return (long) dimensionChangeCount.get(); } if (script.startsWith("return PercyDOM.serialize(")) { @@ -1010,20 +1010,20 @@ public void captureResponsiveDomSkipsResizeWhenDimensionsUnchanged() throws Exce setField(mockedPercy, "domJs", "/* percy dom */"); setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); - AtomicInteger dimensionChangeCount = new AtomicInteger(0); + AtomicInteger dimensionChangeCount = new AtomicInteger(1024); WebDriver.Options driverOptions = mock(WebDriver.Options.class); WebDriver.Window driverWindow = mock(WebDriver.Window.class); when(mockedDriver.manage()).thenReturn(driverOptions); when(driverOptions.window()).thenReturn(driverWindow); when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); - doAnswer(inv -> { dimensionChangeCount.incrementAndGet(); return null; }) + doAnswer(inv -> { dimensionChangeCount.set(((Dimension) inv.getArgument(0)).getWidth()); return null; }) .when(driverWindow).setSize(any(Dimension.class)); when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { + if (script.equals("return window.innerWidth")) { return (long) dimensionChangeCount.get(); } if (script.startsWith("return PercyDOM.serialize(")) { @@ -1038,12 +1038,8 @@ public void captureResponsiveDomSkipsResizeWhenDimensionsUnchanged() throws Exce options.put("widths", Arrays.asList(1024)); mockedPercy.captureResponsiveDom(mockedDriver, new HashSet<>(), options); - // Only the final restore call should fire; no per-width resize. - ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Dimension.class); - verify(driverWindow, times(1)).setSize(sizeCaptor.capture()); - Dimension restoreSize = sizeCaptor.getValue(); - assertEquals(1024, restoreSize.getWidth()); - assertEquals(768, restoreSize.getHeight()); + // No per-width resize, so there is nothing to restore either. + verify(driverWindow, never()).setSize(any(Dimension.class)); } finally { setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); server.stop(0); @@ -1081,12 +1077,13 @@ public void captureResponsiveDomRefreshesDriverForEachWidthWhenReloadFlagSet() t when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); - // After each reload resizeCount resets to 0, so the next changeWindowDimensionAndWait - // call uses resizeCount=1. Return 1L so the wait resolves without timing out. + AtomicInteger innerWidth = new AtomicInteger(1024); + doAnswer(inv -> { innerWidth.set(((Dimension) inv.getArgument(0)).getWidth()); return null; }) + .when(driverWindow).setSize(any(Dimension.class)); when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { String script = invocation.getArgument(0); - if (script.equals("return window.resizeCount")) { - return 1L; + if (script.equals("return window.innerWidth")) { + return (long) innerWidth.get(); } if (script.startsWith("return PercyDOM.serialize(")) { Map snap = new HashMap<>(); From 2d6ae8ea8998a72201987ff1f48ac67984f04792 Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Wed, 28 Oct 2026 12:00:09 +0530 Subject: [PATCH 3/3] PPLT-6242: Send remote Edge CDP to ms/cdp/execute The Augmenter's HasCdp always posts to goog/cdp/execute: selenium-chrome and selenium-edge register the same executeCdpCommand name and goog wins. msedgedriver rejects goog, so on remote Edge (e.g. BrowserStack Automate) exact widths fell to BiDi or the window path and closed shadow roots fell to BiDi or classic. Remote Edge now registers its own ms/cdp/execute command on the session's HttpCommandExecutor, keeping the augmented goog command as a fallback for hubs that only proxy goog. Also says in the debug log that a failed CDP/BiDi closed-shadow-root strategy moves on to the next one, and covers that fall-through (CDP failure -> BiDi, and -> classic gating without BiDi) in tests. Co-Authored-By: Claude Opus 5.5 --- .../io/percy/selenium/BrowserSupport.java | 101 ++++++++++- .../io/percy/selenium/ClosedShadowRoots.java | 4 +- .../io/percy/selenium/BrowserSupportTest.java | 164 ++++++++++++++++++ .../percy/selenium/ClosedShadowRootsTest.java | 53 ++++++ 4 files changed, 319 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/percy/selenium/BrowserSupport.java b/src/main/java/io/percy/selenium/BrowserSupport.java index 1e8f72c..226d82f 100644 --- a/src/main/java/io/percy/selenium/BrowserSupport.java +++ b/src/main/java/io/percy/selenium/BrowserSupport.java @@ -1,6 +1,7 @@ package io.percy.selenium; import java.lang.reflect.Constructor; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Type; import java.net.URI; @@ -12,7 +13,12 @@ import org.openqa.selenium.WebDriver; import org.openqa.selenium.chromium.HasCdp; import org.openqa.selenium.remote.Augmenter; +import org.openqa.selenium.remote.CommandExecutor; +import org.openqa.selenium.remote.CommandInfo; +import org.openqa.selenium.remote.HttpCommandExecutor; +import org.openqa.selenium.remote.RemoteExecuteMethod; import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.remote.http.HttpMethod; /** * Feature detection for the browser protocols the SDK can drive: Chromium CDP, WebDriver BiDi and @@ -36,10 +42,14 @@ interface BiDi { // Augmenting a remote session builds a new proxy class (and, with BiDi, a new WebSocket), so // do it once per driver. private static final Map AUGMENTED = Collections.synchronizedMap(new WeakHashMap<>()); + private static final Map EDGE_CDP = Collections.synchronizedMap(new WeakHashMap<>()); + + static final String MS_CDP_COMMAND = "percyMsCdpExecute"; // Test seams. static Function augmenter = d -> new Augmenter().augment(d); static Function bidiFactory = BrowserSupport::reflectiveBiDi; + static Function msCdpFactory = BrowserSupport::msCdp; private BrowserSupport() {} @@ -97,7 +107,17 @@ static Cdp cdp(WebDriver driver) { if (!isChromium(driver)) { return null; } - WebDriver target = driver instanceof HasCdp ? driver : augmented(driver); + if (driver instanceof HasCdp) { + return ((HasCdp) driver)::executeCdpCommand; + } + if (driver instanceof RemoteWebDriver && browserName(driver).contains("edge")) { + return EDGE_CDP.computeIfAbsent(driver, d -> edgeCdp((RemoteWebDriver) d)); + } + return augmentedCdp(driver); + } + + private static Cdp augmentedCdp(WebDriver driver) { + WebDriver target = augmented(driver); if (!(target instanceof HasCdp)) { return null; } @@ -105,6 +125,85 @@ static Cdp cdp(WebDriver driver) { return hasCdp::executeCdpCommand; } + // Augmenter's HasCdp posts to goog/cdp/execute whatever the browser (selenium-chrome and + // selenium-edge both register the same command name, and goog wins), and msedgedriver rejects + // goog. So remote Edge goes to ms/cdp/execute first; goog stays as a fallback for hubs that + // only proxy goog, and is kept for the session once it works. + private static Cdp edgeCdp(RemoteWebDriver driver) { + Cdp ms; + try { + ms = msCdpFactory.apply(driver); + } catch (Throwable e) { + Percy.log("Could not register the ms/cdp/execute command: " + e, "debug"); + ms = null; + } + if (ms == null) { + return augmentedCdp(driver); + } + final Cdp msCdp = ms; + final Cdp[] googCdp = new Cdp[1]; + return (command, params) -> { + if (googCdp[0] != null) { + return googCdp[0].execute(command, params); + } + try { + return msCdp.execute(command, params); + } catch (RuntimeException msError) { + Cdp goog = augmentedCdp(driver); + if (goog == null) { + throw msError; + } + Map result; + try { + result = goog.execute(command, params); + } catch (RuntimeException googError) { + throw msError; + } + googCdp[0] = goog; + return result; + } + }; + } + + @SuppressWarnings("unchecked") + static Cdp msCdp(RemoteWebDriver driver) { + CommandExecutor executor = httpExecutor(driver.getCommandExecutor()); + if (!(executor instanceof HttpCommandExecutor)) { + return null; + } + try { + // defineCommand is protected, but it is the only way to add an endpoint to a live session. + Method define = HttpCommandExecutor.class.getDeclaredMethod("defineCommand", String.class, CommandInfo.class); + define.setAccessible(true); + define.invoke(executor, MS_CDP_COMMAND, new CommandInfo("/session/:sessionId/ms/cdp/execute", HttpMethod.POST)); + } catch (ReflectiveOperationException e) { + Percy.log("Could not register the ms/cdp/execute command: " + e, "debug"); + return null; + } + RemoteExecuteMethod execute = new RemoteExecuteMethod(driver); + return (command, params) -> { + Map body = new HashMap<>(); + body.put("cmd", command); + body.put("params", params); + Object result = execute.execute(MS_CDP_COMMAND, body); + return result instanceof Map ? (Map) result : Collections.emptyMap(); + }; + } + + // RemoteWebDriver wraps its HttpCommandExecutor in a TracedCommandExecutor. + static CommandExecutor httpExecutor(CommandExecutor executor) { + if (executor != null && executor.getClass().getName().endsWith("TracedCommandExecutor")) { + try { + Field field = executor.getClass().getDeclaredField("delegate"); + field.setAccessible(true); + return (CommandExecutor) field.get(executor); + } catch (ReflectiveOperationException e) { + return executor; + } + } + return executor; + } + /** BiDi only when the user already started the session with webSocketUrl; never requested here. */ static BiDi bidi(WebDriver driver) { if (!hasWebSocketUrl(driver)) { diff --git a/src/main/java/io/percy/selenium/ClosedShadowRoots.java b/src/main/java/io/percy/selenium/ClosedShadowRoots.java index 2359d91..376d64c 100644 --- a/src/main/java/io/percy/selenium/ClosedShadowRoots.java +++ b/src/main/java/io/percy/selenium/ClosedShadowRoots.java @@ -119,7 +119,7 @@ static boolean tryCdp(WebDriver driver, BrowserSupport.Cdp cdp) { } return true; } catch (Exception e) { - Percy.log("Could not expose closed shadow roots via CDP: " + e.getMessage(), "debug"); + Percy.log("Could not expose closed shadow roots via CDP, trying the next strategy: " + e.getMessage(), "debug"); return false; } } @@ -194,7 +194,7 @@ static boolean tryBiDi(WebDriver driver, BrowserSupport.BiDi bidi) { } return true; } catch (Exception e) { - Percy.log("Could not expose closed shadow roots via BiDi: " + e.getMessage(), "debug"); + Percy.log("Could not expose closed shadow roots via BiDi, trying the next strategy: " + e.getMessage(), "debug"); return false; } } diff --git a/src/test/java/io/percy/selenium/BrowserSupportTest.java b/src/test/java/io/percy/selenium/BrowserSupportTest.java index 2b38709..1d94d68 100644 --- a/src/test/java/io/percy/selenium/BrowserSupportTest.java +++ b/src/test/java/io/percy/selenium/BrowserSupportTest.java @@ -1,6 +1,11 @@ package io.percy.selenium; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.Function; @@ -10,12 +15,17 @@ import org.junit.jupiter.api.Test; import org.openqa.selenium.Cookie; import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.UnsupportedCommandException; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.remote.CommandExecutor; import org.openqa.selenium.WebDriver; import org.openqa.selenium.remote.DesiredCapabilities; import org.openqa.selenium.remote.HttpCommandExecutor; import org.openqa.selenium.remote.RemoteWebDriver; import org.openqa.selenium.remote.SessionId; +import com.sun.net.httpserver.HttpServer; + import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -23,11 +33,13 @@ public class BrowserSupportTest { private Function originalAugmenter; private Function originalBiDi; + private Function originalMsCdp; @BeforeEach public void saveHooks() { originalAugmenter = BrowserSupport.augmenter; originalBiDi = BrowserSupport.bidiFactory; + originalMsCdp = BrowserSupport.msCdpFactory; BrowserSupport.augmenter = d -> d; } @@ -35,6 +47,158 @@ public void saveHooks() { public void restoreHooks() { BrowserSupport.augmenter = originalAugmenter; BrowserSupport.bidiFactory = originalBiDi; + BrowserSupport.msCdpFactory = originalMsCdp; + } + + private static RemoteWebDriver remote(String browserName) { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName(browserName); + when(driver.getCapabilities()).thenReturn(caps); + return driver; + } + + private static Map reply(String key) { + Map r = new HashMap<>(); + r.put(key, true); + return r; + } + + @Test + public void remoteEdgeUsesTheMsVendorEndpoint() { + RemoteWebDriver driver = remote("MicrosoftEdge"); + List ms = new ArrayList<>(); + BrowserSupport.msCdpFactory = d -> (cmd, params) -> { ms.add(cmd); return reply("ms"); }; + BrowserSupport.augmenter = d -> { throw new AssertionError("goog path must not be used"); }; + + BrowserSupport.Cdp cdp = BrowserSupport.cdp(driver); + assertEquals(reply("ms"), cdp.execute("DOM.enable", new HashMap<>())); + assertSame(cdp, BrowserSupport.cdp(driver)); + cdp.execute("DOM.getDocument", new HashMap<>()); + assertEquals(Arrays.asList("DOM.enable", "DOM.getDocument"), ms); + } + + @Test + public void remoteChromeAndMsedgeNamesPickTheirVendor() { + BrowserSupport.msCdpFactory = d -> { throw new AssertionError("ms path must not be used for Chrome"); }; + ChromeDriver augmented = mock(ChromeDriver.class); + BrowserSupport.augmenter = d -> augmented; + BrowserSupport.cdp(remote("chrome")).execute("DOM.enable", new HashMap<>()); + verify(augmented).executeCdpCommand(eq("DOM.enable"), anyMap()); + + List ms = new ArrayList<>(); + BrowserSupport.msCdpFactory = d -> (cmd, params) -> { ms.add(cmd); return reply("ms"); }; + BrowserSupport.cdp(remote("msedge")).execute("DOM.enable", new HashMap<>()); + assertEquals(Collections.singletonList("DOM.enable"), ms); + } + + @Test + public void remoteEdgeFallsBackToGoogOnceMsIsRejected() { + RemoteWebDriver driver = remote("MicrosoftEdge"); + int[] msCalls = {0}; + BrowserSupport.msCdpFactory = d -> (cmd, params) -> { + msCalls[0]++; + throw new UnsupportedCommandException("unknown command: ms/cdp/execute"); + }; + ChromeDriver augmented = mock(ChromeDriver.class); + when(augmented.executeCdpCommand(anyString(), anyMap())).thenReturn(reply("goog")); + BrowserSupport.augmenter = d -> augmented; + + BrowserSupport.Cdp cdp = BrowserSupport.cdp(driver); + assertEquals(reply("goog"), cdp.execute("DOM.enable", new HashMap<>())); + assertEquals(reply("goog"), cdp.execute("DOM.getDocument", new HashMap<>())); + assertEquals(1, msCalls[0]); + verify(augmented, times(2)).executeCdpCommand(anyString(), anyMap()); + } + + @Test + public void remoteEdgeReportsTheMsErrorWhenGoogFailsToo() { + RemoteWebDriver driver = remote("MicrosoftEdge"); + UnsupportedCommandException msError = new UnsupportedCommandException("ms rejected"); + BrowserSupport.msCdpFactory = d -> (cmd, params) -> { throw msError; }; + ChromeDriver augmented = mock(ChromeDriver.class); + when(augmented.executeCdpCommand(anyString(), anyMap())).thenThrow(new UnsupportedCommandException("goog rejected")); + BrowserSupport.augmenter = d -> augmented; + + BrowserSupport.Cdp cdp = BrowserSupport.cdp(driver); + assertSame(msError, assertThrows(UnsupportedCommandException.class, () -> cdp.execute("DOM.enable", new HashMap<>()))); + + RemoteWebDriver noGoog = remote("MicrosoftEdge"); + BrowserSupport.augmenter = d -> d; + BrowserSupport.Cdp msOnly = BrowserSupport.cdp(noGoog); + assertSame(msError, assertThrows(UnsupportedCommandException.class, () -> msOnly.execute("DOM.enable", new HashMap<>()))); + } + + @Test + public void remoteEdgeWithoutMsEndpointUsesAugmenter() { + ChromeDriver augmented = mock(ChromeDriver.class); + BrowserSupport.augmenter = d -> augmented; + BrowserSupport.msCdpFactory = d -> null; + BrowserSupport.cdp(remote("MicrosoftEdge")).execute("DOM.enable", new HashMap<>()); + BrowserSupport.msCdpFactory = d -> { throw new IllegalStateException("no executor"); }; + BrowserSupport.cdp(remote("MicrosoftEdge")).execute("DOM.enable", new HashMap<>()); + verify(augmented, times(2)).executeCdpCommand(eq("DOM.enable"), anyMap()); + } + + @Test + public void msCdpNeedsAnHttpCommandExecutor() { + RemoteWebDriver driver = remote("MicrosoftEdge"); + when(driver.getCommandExecutor()).thenReturn(mock(CommandExecutor.class)); + assertNull(BrowserSupport.msCdp(driver)); + } + + // Real RemoteWebDriver against a stub hub, so the command really goes out as + // POST /session/{id}/ms/cdp/execute through Selenium's own executor. + @Test + @SuppressWarnings("unchecked") + public void msCdpPostsToTheMsEndpointOfALiveSession() throws Exception { + List posts = new ArrayList<>(); + HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + hub.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + String body = new String(readAll(exchange.getRequestBody()), StandardCharsets.UTF_8); + String reply; + if (path.equals("/session")) { + reply = "{\"value\":{\"sessionId\":\"s-1\",\"capabilities\":{\"browserName\":\"MicrosoftEdge\"}}}"; + } else if (path.endsWith("/ms/cdp/execute")) { + posts.add(exchange.getRequestMethod() + " " + path + " " + body.replaceAll("\\s", "")); + reply = "{\"value\":{\"root\":{\"nodeId\":1}}}"; + } else { + reply = "{\"value\":null}"; + } + byte[] bytes = reply.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + hub.start(); + RemoteWebDriver driver = null; + try { + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName("MicrosoftEdge"); + driver = new RemoteWebDriver(new URL("http://127.0.0.1:" + hub.getAddress().getPort()), caps); + BrowserSupport.augmenter = d -> { throw new AssertionError("goog path must not be used"); }; + Map params = new HashMap<>(); + params.put("depth", -1); + Map result = BrowserSupport.cdp(driver).execute("DOM.getDocument", params); + + assertEquals(1L, ((Number) ((Map) result.get("root")).get("nodeId")).longValue()); + assertEquals(1, posts.size()); + assertTrue(posts.get(0).startsWith("POST /session/s-1/ms/cdp/execute "), posts.get(0)); + assertTrue(posts.get(0).contains("\"cmd\":\"DOM.getDocument\""), posts.get(0)); + assertTrue(posts.get(0).contains("\"depth\":-1"), posts.get(0)); + } finally { + if (driver != null) driver.quit(); + hub.stop(0); + } + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + for (int n; (n = in.read(buf)) > 0; ) out.write(buf, 0, n); + return out.toByteArray(); } private static DesiredCapabilities firefoxWithBiDi() { diff --git a/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java b/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java index 53ff1c6..2b31585 100644 --- a/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java +++ b/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import org.openqa.selenium.NoSuchShadowRootException; import org.openqa.selenium.SearchContext; +import org.openqa.selenium.UnsupportedCommandException; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; @@ -21,6 +22,7 @@ public class ClosedShadowRootsTest { private Function originalAugmenter; private Function originalBiDi; + private Function originalMsCdp; private boolean originalForce; private int originalMax; @@ -28,6 +30,7 @@ public class ClosedShadowRootsTest { public void saveHooks() { originalAugmenter = BrowserSupport.augmenter; originalBiDi = BrowserSupport.bidiFactory; + originalMsCdp = BrowserSupport.msCdpFactory; originalForce = ClosedShadowRoots.forceClassic; originalMax = ClosedShadowRoots.maxHosts; BrowserSupport.augmenter = d -> d; @@ -38,6 +41,7 @@ public void saveHooks() { public void restoreHooks() { BrowserSupport.augmenter = originalAugmenter; BrowserSupport.bidiFactory = originalBiDi; + BrowserSupport.msCdpFactory = originalMsCdp; ClosedShadowRoots.forceClassic = originalForce; ClosedShadowRoots.maxHosts = originalMax; } @@ -231,6 +235,55 @@ public void bidiFailureFallsThroughToClassic() { verify(driver).executeScript(contains("querySelectorAll")); } + // Remote Edge on a hub that rejects CDP (the Automate failure mode): CDP is available but fails. + private static RemoteWebDriver remoteEdgeWithFailingCdp(boolean webSocketUrl) { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + DesiredCapabilities caps = new DesiredCapabilities(); + caps.setBrowserName("MicrosoftEdge"); + if (webSocketUrl) caps.setCapability("webSocketUrl", "wss://hub/session/x/se/bidi"); + when(driver.getCapabilities()).thenReturn(caps); + when(driver.getWindowHandle()).thenReturn("ctx"); + BrowserSupport.msCdpFactory = d -> (cmd, params) -> { throw new UnsupportedCommandException("unknown command"); }; + return driver; + } + + @Test + public void cdpFailureFallsThroughToBiDi() { + RemoteWebDriver driver = remoteEdgeWithFailingCdp(true); + List sent = new ArrayList<>(); + BrowserSupport.bidiFactory = d -> (method, params) -> { + sent.add(method); + return new HashMap<>(); + }; + ClosedShadowRoots.forceClassic = true; + + ClosedShadowRoots.expose(driver); + + assertEquals(Collections.singletonList("script.callFunction"), sent); + verify(driver, never()).executeScript(anyString()); + } + + @Test + public void cdpFailureWithoutBiDiSkipsClassicOnRemoteSessions() { + RemoteWebDriver driver = remoteEdgeWithFailingCdp(false); + ClosedShadowRoots.forceClassic = false; + + ClosedShadowRoots.expose(driver); + + verify(driver, never()).executeScript(anyString()); + } + + @Test + public void cdpFailureWithoutBiDiRunsClassicWhenAllowed() { + RemoteWebDriver driver = remoteEdgeWithFailingCdp(false); + ClosedShadowRoots.forceClassic = true; + when(driver.executeScript(contains("querySelectorAll"))).thenReturn(new ArrayList<>()); + + ClosedShadowRoots.expose(driver); + + verify(driver).executeScript(contains("querySelectorAll")); + } + @Test public void maxHostsParsing() { assertEquals(200, ClosedShadowRoots.parseMaxHosts(null));