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/BrowserSupport.java b/src/main/java/io/percy/selenium/BrowserSupport.java new file mode 100644 index 0000000..226d82f --- /dev/null +++ b/src/main/java/io/percy/selenium/BrowserSupport.java @@ -0,0 +1,274 @@ +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; +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.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 + * 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<>()); + 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() {} + + 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; + } + 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; + } + HasCdp hasCdp = (HasCdp) target; + 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)) { + 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..376d64c --- /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, trying the next strategy: " + 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, trying the next strategy: " + 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/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/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; + private Function originalMsCdp; + + @BeforeEach + public void saveHooks() { + originalAugmenter = BrowserSupport.augmenter; + originalBiDi = BrowserSupport.bidiFactory; + originalMsCdp = BrowserSupport.msCdpFactory; + BrowserSupport.augmenter = d -> d; + } + + @AfterEach + 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() { + 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/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/ClosedShadowRootsTest.java b/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java new file mode 100644 index 0000000..2b31585 --- /dev/null +++ b/src/test/java/io/percy/selenium/ClosedShadowRootsTest.java @@ -0,0 +1,294 @@ +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.UnsupportedCommandException; +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 Function originalMsCdp; + private boolean originalForce; + private int originalMax; + + @BeforeEach + public void saveHooks() { + originalAugmenter = BrowserSupport.augmenter; + originalBiDi = BrowserSupport.bidiFactory; + originalMsCdp = BrowserSupport.msCdpFactory; + originalForce = ClosedShadowRoots.forceClassic; + originalMax = ClosedShadowRoots.maxHosts; + BrowserSupport.augmenter = d -> d; + BrowserSupport.bidiFactory = d -> null; + } + + @AfterEach + public void restoreHooks() { + BrowserSupport.augmenter = originalAugmenter; + BrowserSupport.bidiFactory = originalBiDi; + BrowserSupport.msCdpFactory = originalMsCdp; + 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")); + } + + // 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)); + 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 814d41a..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"); @@ -608,13 +612,15 @@ 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()); 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(")) { @@ -681,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"); @@ -707,13 +712,15 @@ 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()); 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<>();