diff --git a/gradle.properties b/gradle.properties index d2927cceb..31468bc8b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.daemon=true -selenium.version=4.42.0 +selenium.version=4.50.0 # Please increment the value in a release appiumClient.version=10.1.1 diff --git a/src/main/java/io/appium/java_client/AppiumDriver.java b/src/main/java/io/appium/java_client/AppiumDriver.java index 44cf6ed17..176dce90c 100644 --- a/src/main/java/io/appium/java_client/AppiumDriver.java +++ b/src/main/java/io/appium/java_client/AppiumDriver.java @@ -34,6 +34,7 @@ import org.openqa.selenium.WebDriverException; import org.openqa.selenium.bidi.BiDi; import org.openqa.selenium.bidi.BiDiException; +import org.openqa.selenium.bidi.Handle; import org.openqa.selenium.bidi.HasBiDi; import org.openqa.selenium.remote.CapabilityType; import org.openqa.selenium.remote.CommandInfo; @@ -85,6 +86,8 @@ public class AppiumDriver extends RemoteWebDriver implements private URI biDiUri; private BiDi biDi; private boolean wasBiDiRequested = false; + // Used for log websockets when the command executor is not an AppiumCommandExecutor. + private HttpClient ownHttpClient; /** * Creates a new instance based on command {@code executor} and {@code capabilities}. @@ -98,7 +101,17 @@ public AppiumDriver(HttpCommandExecutor executor, Capabilities capabilities) { super(executor, capabilities); this.executeMethod = new AppiumExecutionMethod(this); super.setErrorHandler(ERROR_HANDLER); - this.remoteAddress = executor.getAddressOfRemoteServer(); + this.remoteAddress = getServerUrl(executor); + } + + // Selenium deprecated getAddressOfRemoteServer() without a replacement, so it is only used + // for executors that are not Appium's. + @SuppressWarnings("removal") + private static URL getServerUrl(HttpCommandExecutor executor) { + if (executor instanceof AppiumCommandExecutor) { + return ((AppiumCommandExecutor) executor).getAppiumClientConfig().baseUrl(); + } + return executor.getAddressOfRemoteServer(); } public AppiumDriver(AppiumClientConfig clientConfig, Capabilities capabilities) { @@ -174,7 +187,7 @@ public AppiumDriver(URL remoteSessionAddress, String platformName, String automa setCommandExecutor(executor); this.executeMethod = new AppiumExecutionMethod(this); super.setErrorHandler(ERROR_HANDLER); - this.remoteAddress = executor.getAddressOfRemoteServer(); + this.remoteAddress = executor.getAppiumClientConfig().baseUrl(); setSessionId(sessionAddress.getId()); } @@ -265,11 +278,15 @@ public AppiumDriver markExtensionAbsence(String extName) { return this; } + // Selenium deprecated getBiDi() and maybeGetBiDi(), but has no replacement yet for listeners + // scoped to a browsing context (e.g. NATIVE_CONTEXT), so Appium keeps them for its users. + @SuppressWarnings("removal") @Override public Optional maybeGetBiDi() { return Optional.ofNullable(this.biDi); } + @SuppressWarnings("removal") @Override @NonNull public BiDi getBiDi() { @@ -299,8 +316,36 @@ public BiDi getBiDi() { return this.biDi; } - protected HttpClient getHttpClient() { - return ((HttpCommandExecutor) getCommandExecutor()).client; + @Override + public Handle getHandle() { + return getBiDi().asHandle(); + } + + protected synchronized HttpClient getHttpClient() { + var executor = getCommandExecutor(); + if (executor instanceof AppiumCommandExecutor) { + return ((AppiumCommandExecutor) executor).getClient(); + } + // Selenium does not expose the client of other executors, so create one, as BiDi does. + // Keep a strong reference: StringWebSocketClient only holds the client weakly. + if (ownHttpClient == null) { + ownHttpClient = HttpClient.Factory.createDefault().createClient(remoteAddress); + } + return ownHttpClient; + } + + @Override + public void quit() { + try { + super.quit(); + } finally { + synchronized (this) { + if (ownHttpClient != null) { + ownHttpClient.close(); + ownHttpClient = null; + } + } + } } @Override diff --git a/src/main/java/io/appium/java_client/android/AndroidDriver.java b/src/main/java/io/appium/java_client/android/AndroidDriver.java index aa1d30159..584728fac 100644 --- a/src/main/java/io/appium/java_client/android/AndroidDriver.java +++ b/src/main/java/io/appium/java_client/android/AndroidDriver.java @@ -176,7 +176,7 @@ public AndroidDriver(HttpClient.Factory httpClientFactory, Capabilities capabili /** * Creates a new instance based on the given ClientConfig and {@code capabilities}. - * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}. + * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}. * For example: * *
@@ -200,7 +200,7 @@ public AndroidDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java b/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java
index d5051da0e..ea375d07e 100644
--- a/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java
+++ b/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java
@@ -16,10 +16,10 @@
 
 package io.appium.java_client.android;
 
+import io.appium.java_client.AppiumDriver;
 import io.appium.java_client.CommandExecutionHelper;
 import io.appium.java_client.ExecutesMethod;
 import io.appium.java_client.ws.StringWebSocketClient;
-import org.openqa.selenium.remote.HttpCommandExecutor;
 import org.openqa.selenium.remote.RemoteWebDriver;
 import org.openqa.selenium.remote.SessionId;
 
@@ -59,7 +59,7 @@ default void startLogcatBroadcast(String host) {
      */
     default void startLogcatBroadcast(String host, int port) {
         var remoteWebDriver = (RemoteWebDriver) this;
-        URL serverUrl = ((HttpCommandExecutor) remoteWebDriver.getCommandExecutor()).getAddressOfRemoteServer();
+        URL serverUrl = ((AppiumDriver) this).getRemoteAddress();
         var scheme = "https".equals(serverUrl.getProtocol()) ? "wss" : "ws";
         CommandExecutionHelper.executeScript(this, "mobile: startLogsBroadcast");
         SessionId sessionId = remoteWebDriver.getSessionId();
diff --git a/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java b/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java
index d7e34242e..9e2bb0ff6 100644
--- a/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java
+++ b/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java
@@ -90,7 +90,7 @@ public ChromiumDriver(URL remoteSessionAddress, String platformName) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -113,7 +113,7 @@ public ChromiumDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/gecko/GeckoDriver.java b/src/main/java/io/appium/java_client/gecko/GeckoDriver.java
index 07cb9e3e7..73f2a3459 100644
--- a/src/main/java/io/appium/java_client/gecko/GeckoDriver.java
+++ b/src/main/java/io/appium/java_client/gecko/GeckoDriver.java
@@ -91,7 +91,7 @@ public GeckoDriver(URL remoteSessionAddress, String platformName) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -114,7 +114,7 @@ public GeckoDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/ios/IOSDriver.java b/src/main/java/io/appium/java_client/ios/IOSDriver.java
index 0fd5cbf20..757b1b6da 100644
--- a/src/main/java/io/appium/java_client/ios/IOSDriver.java
+++ b/src/main/java/io/appium/java_client/ios/IOSDriver.java
@@ -168,7 +168,7 @@ public IOSDriver(HttpClient.Factory httpClientFactory, Capabilities capabilities
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -192,7 +192,7 @@ public IOSDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java b/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java
index 98a75158a..f3dc5481d 100644
--- a/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java
+++ b/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java
@@ -16,10 +16,10 @@
 
 package io.appium.java_client.ios;
 
+import io.appium.java_client.AppiumDriver;
 import io.appium.java_client.CommandExecutionHelper;
 import io.appium.java_client.ExecutesMethod;
 import io.appium.java_client.ws.StringWebSocketClient;
-import org.openqa.selenium.remote.HttpCommandExecutor;
 import org.openqa.selenium.remote.RemoteWebDriver;
 import org.openqa.selenium.remote.SessionId;
 
@@ -60,7 +60,7 @@ default void startSyslogBroadcast(String host) {
      */
     default void startSyslogBroadcast(String host, int port) {
         var remoteWebDriver = (RemoteWebDriver) this;
-        URL serverUrl = ((HttpCommandExecutor) remoteWebDriver.getCommandExecutor()).getAddressOfRemoteServer();
+        URL serverUrl = ((AppiumDriver) this).getRemoteAddress();
         var scheme = "https".equals(serverUrl.getProtocol()) ? "wss" : "ws";
         CommandExecutionHelper.executeScript(this, "mobile: startLogsBroadcast");
         SessionId sessionId = remoteWebDriver.getSessionId();
diff --git a/src/main/java/io/appium/java_client/mac/Mac2Driver.java b/src/main/java/io/appium/java_client/mac/Mac2Driver.java
index 46911c314..7c21dc7fd 100644
--- a/src/main/java/io/appium/java_client/mac/Mac2Driver.java
+++ b/src/main/java/io/appium/java_client/mac/Mac2Driver.java
@@ -101,7 +101,7 @@ public Mac2Driver(URL remoteSessionAddress) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -125,7 +125,7 @@ public Mac2Driver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
index 54b590201..125436cf9 100644
--- a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
+++ b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
@@ -60,6 +60,10 @@ public class AppiumCommandExecutor extends HttpCommandExecutor {
     private final Optional serviceOptional;
     @Getter
     private final AppiumClientConfig appiumClientConfig;
+    // HttpCommandExecutor no longer keeps the factory, but direct connect and BiDi need it.
+    private final Factory httpClientFactory;
+    // Lets direct connect point to a new server URL without changing HttpCommandExecutor.client.
+    private final SwitchableHttpClient switchableClient;
 
     /**
      * Create an AppiumCommandExecutor instance.
@@ -74,13 +78,31 @@ public AppiumCommandExecutor(
             @Nullable DriverService service,
             @Nullable Factory httpClientFactory,
             AppiumClientConfig appiumClientConfig) {
-        super(additionalCommands,
-                appiumClientConfig,
-                ofNullable(httpClientFactory).orElseGet(HttpCommandExecutor::getDefaultClientFactory)
-        );
+        this(ofNullable(httpClientFactory).orElseGet(Factory::createDefault),
+                additionalCommands, service, appiumClientConfig);
+    }
+
+    private AppiumCommandExecutor(
+            Factory httpClientFactory,
+            Map additionalCommands,
+            @Nullable DriverService service,
+            AppiumClientConfig appiumClientConfig) {
+        this(new SwitchableHttpClient(httpClientFactory.createClient(appiumClientConfig)),
+                httpClientFactory, additionalCommands, service, appiumClientConfig);
+    }
+
+    private AppiumCommandExecutor(
+            SwitchableHttpClient switchableClient,
+            Factory httpClientFactory,
+            Map additionalCommands,
+            @Nullable DriverService service,
+            AppiumClientConfig appiumClientConfig) {
+        super(switchableClient, additionalCommands, appiumClientConfig.baseUrl());
         serviceOptional = ofNullable(service);
 
         this.appiumClientConfig = appiumClientConfig;
+        this.httpClientFactory = httpClientFactory;
+        this.switchableClient = switchableClient;
     }
 
     public AppiumCommandExecutor(Map additionalCommands, DriverService service,
@@ -148,14 +170,14 @@ public void setResponseCodec(ResponseCodec codec) {
         this.responseCodec = codec;
     }
 
-    protected HttpClient getClient() {
-        return this.client;
+    public HttpClient getClient() {
+        return this.switchableClient;
     }
 
     /**
-     * Override the http client in the HttpCommandExecutor class with a new http client instance with the given URL.
-     * It uses the same http client factory and client config for the new http client instance
-     * if the constructor got them.
+     * Switch the http client of this executor to a new http client instance with the given URL.
+     * It uses the same http client factory and client config for the new http client instance.
+     * The previous http client is closed.
      *
      * @param serverUrl URL to use for subsequent HTTP requests. Before switching clients, the host is
      *                  resolved and the override is refused if any resolved address is loopback,
@@ -166,8 +188,7 @@ protected HttpClient getClient() {
      */
     protected void overrideServerUrl(URL serverUrl) {
         DirectConnectUrlSafety.requireSafeOverrideTarget(serverUrl);
-        HttpClient newClient = getHttpClientFactory().createClient(appiumClientConfig.baseUrl(serverUrl));
-        setPrivateFieldValue(HttpCommandExecutor.class, "client", newClient);
+        switchableClient.switchTo(getHttpClientFactory().createClient(appiumClientConfig.baseUrl(serverUrl)));
     }
 
     private Response createSession(Command command) throws IOException {
diff --git a/src/main/java/io/appium/java_client/remote/SwitchableHttpClient.java b/src/main/java/io/appium/java_client/remote/SwitchableHttpClient.java
new file mode 100644
index 000000000..7d64500fb
--- /dev/null
+++ b/src/main/java/io/appium/java_client/remote/SwitchableHttpClient.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.remote;
+
+import org.jspecify.annotations.NullMarked;
+import org.openqa.selenium.remote.http.HttpClient;
+import org.openqa.selenium.remote.http.HttpRequest;
+import org.openqa.selenium.remote.http.HttpResponse;
+import org.openqa.selenium.remote.http.WebSocket;
+
+import java.util.concurrent.CompletableFuture;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * An {@link HttpClient} that forwards every call to another client, which can be replaced.
+ * Direct connect uses it to point the executor to a new server URL without touching the
+ * client field inside {@link org.openqa.selenium.remote.HttpCommandExecutor}.
+ */
+@NullMarked
+final class SwitchableHttpClient implements HttpClient {
+
+    private volatile HttpClient delegate;
+
+    SwitchableHttpClient(HttpClient delegate) {
+        this.delegate = requireNonNull(delegate);
+    }
+
+    /**
+     * Sends all later calls to the given client and closes the previous one.
+     *
+     * @param newDelegate the client to use from now on
+     */
+    void switchTo(HttpClient newDelegate) {
+        HttpClient previous = this.delegate;
+        this.delegate = requireNonNull(newDelegate);
+        previous.close();
+    }
+
+    @Override
+    public HttpResponse execute(HttpRequest req) {
+        return delegate.execute(req);
+    }
+
+    @Override
+    public CompletableFuture executeAsync(HttpRequest req) {
+        return delegate.executeAsync(req);
+    }
+
+    @Override
+    public WebSocket openSocket(HttpRequest request, WebSocket.Listener listener) {
+        return delegate.openSocket(request, listener);
+    }
+
+    @Override
+    public void close() {
+        delegate.close();
+    }
+}
diff --git a/src/main/java/io/appium/java_client/safari/SafariDriver.java b/src/main/java/io/appium/java_client/safari/SafariDriver.java
index f8f10bb01..0a5d2e764 100644
--- a/src/main/java/io/appium/java_client/safari/SafariDriver.java
+++ b/src/main/java/io/appium/java_client/safari/SafariDriver.java
@@ -98,7 +98,7 @@ public SafariDriver(URL remoteSessionAddress) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -122,7 +122,7 @@ public SafariDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/windows/WindowsDriver.java b/src/main/java/io/appium/java_client/windows/WindowsDriver.java
index 7c6e68a7a..38c3f05e1 100644
--- a/src/main/java/io/appium/java_client/windows/WindowsDriver.java
+++ b/src/main/java/io/appium/java_client/windows/WindowsDriver.java
@@ -87,7 +87,7 @@ public WindowsDriver(URL remoteSessionAddress) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -111,7 +111,7 @@ public WindowsDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java b/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java
index b11bd6273..301df5c5d 100644
--- a/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java
+++ b/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java
@@ -4,12 +4,14 @@
 import io.appium.java_client.MobileCommand;
 import org.junit.jupiter.api.Test;
 import org.openqa.selenium.SessionNotCreatedException;
+import org.openqa.selenium.remote.http.HttpClient;
 
 import java.net.MalformedURLException;
 import java.net.URL;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 class AppiumCommandExecutorTest {
@@ -36,6 +38,23 @@ void getHttpClientFactory() {
         assertNotNull(createExecutor().getHttpClientFactory());
     }
 
+    @Test
+    void keepsTheHttpClientFactoryPassedToTheConstructor() throws MalformedURLException {
+        HttpClient.Factory factory = HttpClient.Factory.createDefault();
+        AppiumCommandExecutor executor = new AppiumCommandExecutor(
+                MobileCommand.commandRepository, new URL(APPIUM_URL), factory);
+
+        assertSame(factory, executor.getHttpClientFactory());
+    }
+
+    @Test
+    void usesADefaultHttpClientFactoryWhenNoneIsPassed() throws MalformedURLException {
+        AppiumCommandExecutor executor = new AppiumCommandExecutor(
+                MobileCommand.commandRepository, new URL(APPIUM_URL), (HttpClient.Factory) null);
+
+        assertNotNull(executor.getHttpClientFactory());
+    }
+
     @Test
     void overrideServerUrl() throws MalformedURLException {
         assertDoesNotThrow(() -> createExecutor().overrideServerUrl(new URL("https://203.0.113.1/wd/hub")));
diff --git a/src/test/java/io/appium/java_client/remote/SwitchableHttpClientTest.java b/src/test/java/io/appium/java_client/remote/SwitchableHttpClientTest.java
new file mode 100644
index 000000000..1376e959e
--- /dev/null
+++ b/src/test/java/io/appium/java_client/remote/SwitchableHttpClientTest.java
@@ -0,0 +1,79 @@
+package io.appium.java_client.remote;
+
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.remote.http.HttpClient;
+import org.openqa.selenium.remote.http.HttpMethod;
+import org.openqa.selenium.remote.http.HttpRequest;
+import org.openqa.selenium.remote.http.HttpResponse;
+import org.openqa.selenium.remote.http.WebSocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SwitchableHttpClientTest {
+
+    private static final HttpRequest REQUEST = new HttpRequest(HttpMethod.GET, "/status");
+
+    private static class FakeHttpClient implements HttpClient {
+        private final int status;
+        private boolean closed;
+
+        FakeHttpClient(int status) {
+            this.status = status;
+        }
+
+        @Override
+        public HttpResponse execute(HttpRequest req) {
+            return new HttpResponse().setStatus(status);
+        }
+
+        @Override
+        public WebSocket openSocket(HttpRequest request, WebSocket.Listener listener) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+        }
+    }
+
+    @Test
+    void sendsRequestsToTheCurrentClient() {
+        var client = new SwitchableHttpClient(new FakeHttpClient(200));
+
+        assertEquals(200, client.execute(REQUEST).getStatus());
+    }
+
+    @Test
+    void sendsRequestsToTheNewClientAfterSwitching() {
+        var client = new SwitchableHttpClient(new FakeHttpClient(200));
+
+        client.switchTo(new FakeHttpClient(201));
+
+        assertEquals(201, client.execute(REQUEST).getStatus());
+    }
+
+    @Test
+    void closesThePreviousClientWhenSwitching() {
+        var previous = new FakeHttpClient(200);
+        var next = new FakeHttpClient(201);
+        var client = new SwitchableHttpClient(previous);
+
+        client.switchTo(next);
+
+        assertTrue(previous.closed);
+        assertFalse(next.closed);
+    }
+
+    @Test
+    void closesTheCurrentClient() {
+        var current = new FakeHttpClient(200);
+        var client = new SwitchableHttpClient(current);
+
+        client.close();
+
+        assertTrue(current.closed);
+    }
+}