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