From 6d010bef8baf015cf3010007511d752d2fd461f3 Mon Sep 17 00:00:00 2001 From: Nacy Dev Date: Thu, 12 Nov 2026 23:24:57 +0700 Subject: [PATCH] fix: update executor and driver server URL state on directConnect --- .../selenium/SeleniumBridgeTest.java | 21 +++ .../io/appium/java_client/AppiumDriver.java | 18 +- .../remote/AppiumCommandExecutor.java | 29 +-- .../java_client/remote/DirectConnect.java | 46 ++++- .../internal/DirectConnectTest.java | 66 +++++++ .../remote/AppiumCommandExecutorTest.java | 167 +++++++++++++++++- .../remote/DriverWireScenariosTest.java | 5 + 7 files changed, 326 insertions(+), 26 deletions(-) diff --git a/selenium-bridge/src/test/java/io/appium/java_client/selenium/SeleniumBridgeTest.java b/selenium-bridge/src/test/java/io/appium/java_client/selenium/SeleniumBridgeTest.java index 20ac611a9..1dd6e9afc 100644 --- a/selenium-bridge/src/test/java/io/appium/java_client/selenium/SeleniumBridgeTest.java +++ b/selenium-bridge/src/test/java/io/appium/java_client/selenium/SeleniumBridgeTest.java @@ -18,6 +18,7 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; +import io.appium.java_client.AppiumClientConfig; import io.appium.java_client.android.AndroidDriver; import io.appium.java_client.android.options.UiAutomator2Options; import org.junit.jupiter.api.AfterEach; @@ -37,6 +38,7 @@ import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; +import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.List; @@ -261,4 +263,23 @@ void failsToProvideBiDiWithoutTheWebSocketUrlCapability() throws IOException { assertThrows(BiDiException.class, seleniumDriver::getHandle); } + + @Test + void propagatesDirectConnectServerUrlToSeleniumClientConfig() throws IOException { + extraCapabilities = "\"appium:directConnectProtocol\":\"https\"," + + "\"appium:directConnectHost\":\"203.0.113.1\"," + + "\"appium:directConnectPort\":4443," + + "\"appium:directConnectPath\":\"/wd/hub\","; + var clientConfig = AppiumClientConfig.defaultConfig() + .baseUri(URI.create("http://127.0.0.1:" + server.getAddress().getPort())) + .directConnect(true); + var appiumDriver = new AndroidDriver(clientConfig, + new UiAutomator2Options().setDeviceName("Android Emulator")); + + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(appiumDriver); + + assertEquals(URI.create("https://203.0.113.1:4443/wd/hub"), + seleniumDriver.getClientConfig().baseUri()); + seleniumDriver.closeBiDi(); + } } diff --git a/src/main/java/io/appium/java_client/AppiumDriver.java b/src/main/java/io/appium/java_client/AppiumDriver.java index ccdfffd13..74f8ed5ee 100644 --- a/src/main/java/io/appium/java_client/AppiumDriver.java +++ b/src/main/java/io/appium/java_client/AppiumDriver.java @@ -31,7 +31,7 @@ import io.appium.java_client.remote.options.BaseOptions; import io.appium.java_client.service.local.AppiumDriverLocalService; import io.appium.java_client.service.local.AppiumServiceBuilder; -import lombok.Getter; +import org.jspecify.annotations.Nullable; import org.openqa.selenium.Capabilities; import org.openqa.selenium.ImmutableCapabilities; import org.openqa.selenium.OutputType; @@ -64,8 +64,7 @@ public class AppiumDriver extends AppiumRemoteWebDriver implements private static final ErrorHandler ERROR_HANDLER = new ErrorHandler(new ErrorCodesMobile()); // frequently used command parameters - @Getter - private final URL remoteAddress; + private final @Nullable URL remoteAddress; private final ExecuteMethod executeMethod; /** @@ -221,6 +220,19 @@ public AppiumDriver(URL remoteSessionAddress, String platformName, String automa setSessionId(sessionAddress.getId()); } + /** + * Gets the remote server address to which commands are sent. + * + * @return the remote server URL, or {@code null} if not configured + */ + @Nullable + public URL getRemoteAddress() { + var executor = getCommandExecutor(); + return executor instanceof AppiumCommandExecutor + ? ((AppiumCommandExecutor) executor).getAddressOfRemoteServer() + : remoteAddress; + } + @Override public ExecuteMethod getExecuteMethod() { return executeMethod; 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 aea8263c9..da2031695 100644 --- a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java +++ b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java @@ -55,12 +55,12 @@ public class AppiumCommandExecutor implements CommandExecutor, Closeable { private final Optional serviceOptional; @Getter - private final AppiumClientConfig appiumClientConfig; - private final @Nullable URL remoteServer; + private volatile AppiumClientConfig appiumClientConfig; + private volatile @Nullable URL remoteServer; private final Map additionalCommands; @Getter private final Factory httpClientFactory; - private HttpClient client; + private volatile HttpClient client; private @Nullable CommandCodec commandCodec; private @Nullable ResponseCodec responseCodec; @@ -78,10 +78,14 @@ public AppiumCommandExecutor( @Nullable Factory httpClientFactory, AppiumClientConfig appiumClientConfig) { this.additionalCommands = new HashMap<>(requireNonNull(additionalCommands, "Additional commands")); - this.appiumClientConfig = requireNonNull(appiumClientConfig, "HTTP client configuration"); + AppiumClientConfig effectiveConfig = requireNonNull(appiumClientConfig, "HTTP client configuration"); + if (service != null && effectiveConfig.baseUri() == null) { + effectiveConfig = effectiveConfig.baseUrl(service.getUrl()); + } + this.appiumClientConfig = effectiveConfig; this.httpClientFactory = ofNullable(httpClientFactory).orElseGet(Factory::createDefault); - this.client = this.httpClientFactory.createClient(appiumClientConfig); - this.remoteServer = appiumClientConfig.baseUrl(); + this.client = this.httpClientFactory.createClient(effectiveConfig); + this.remoteServer = effectiveConfig.baseUrl(); this.serviceOptional = ofNullable(service); } @@ -168,7 +172,7 @@ public AppiumCommandExecutor(Map additionalCommands, */ public AppiumCommandExecutor(Map additionalCommands, AppiumDriverLocalService service, AppiumClientConfig appiumClientConfig) { - this(additionalCommands, service, Factory.createDefault(), appiumClientConfig); + this(additionalCommands, requireNonNull(service), Factory.createDefault(), appiumClientConfig); } /** @@ -240,9 +244,12 @@ public HttpClient getClient() { * and other non-special addresses are not rejected by this check. */ protected void overrideServerUrl(URL serverUrl) { - DirectConnectUrlSafety.requireSafeOverrideTarget(serverUrl); - HttpClient newClient = getHttpClientFactory().createClient(appiumClientConfig.baseUrl(serverUrl)); - HttpClient oldClient = this.client; + DirectConnectUrlSafety.requireSafeOverrideTarget(requireNonNull(serverUrl, "Server URL")); + AppiumClientConfig updatedConfig = appiumClientConfig.baseUrl(serverUrl); + HttpClient newClient = getHttpClientFactory().createClient(updatedConfig); + final HttpClient oldClient = this.client; + this.appiumClientConfig = updatedConfig; + this.remoteServer = updatedConfig.baseUrl(); this.client = newClient; oldClient.close(); } @@ -292,7 +299,7 @@ private void setDirectConnect(Response response) throws SessionNotCreatedExcepti return; } - if (!directConnect.getProtocol().equals("https")) { + if (!"https".equalsIgnoreCase(directConnect.getProtocol())) { throw new SessionNotCreatedException( String.format("The given protocol '%s' as the direct connection url returned by " + "the remote server is not accurate. Only 'https' is supported.", diff --git a/src/main/java/io/appium/java_client/remote/DirectConnect.java b/src/main/java/io/appium/java_client/remote/DirectConnect.java index c191c6eb8..938975a77 100644 --- a/src/main/java/io/appium/java_client/remote/DirectConnect.java +++ b/src/main/java/io/appium/java_client/remote/DirectConnect.java @@ -23,7 +23,6 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.Map; -import java.util.Objects; import java.util.stream.Stream; import static io.appium.java_client.internal.CapabilityHelpers.APPIUM_PREFIX; @@ -65,11 +64,13 @@ private String getDirectConnectValue(Map responseValue, String key) { } /** - * Returns true if the {@link DirectConnect} instance member has nonnull values. - * @return true if each connection information has a nonnull value + * Returns true if the {@link DirectConnect} instance member has valid values. + * @return true if protocol, host and port are non-blank and path is non-null */ public boolean isValid() { - return Stream.of(this.protocol, this.path, this.host, this.port).noneMatch(Objects::isNull); + return this.path != null + && Stream.of(this.protocol, this.host, this.port) + .allMatch(value -> value != null && !value.isBlank()); } /** @@ -78,15 +79,42 @@ public boolean isValid() { * @throws MalformedURLException if the built url was invalid */ public URL getUrl() throws MalformedURLException { - String newUrlCandidate = String.format("%s://%s:%s%s", this.protocol, this.host, this.port, this.path); + String formattedHost = formatHost(this.host); + String normalizedPath = normalizePath(this.path); + String newUrlCandidate = String.format("%s://%s:%s%s", + this.protocol, formattedHost, this.port, normalizedPath); + if (!isValid()) { + throw createMalformedUrlException(newUrlCandidate); + } try { return new URL(newUrlCandidate); } catch (MalformedURLException e) { - throw new MalformedURLException( - String.format("The remote server returned an invalid value to build the direct connect URL: %s", - newUrlCandidate) - ); + throw createMalformedUrlException(newUrlCandidate); + } + } + + private static String formatHost(@Nullable String host) { + if (host != null && !host.startsWith("[") && host.indexOf(':') != host.lastIndexOf(':')) { + return "[" + host + "]"; + } + return String.valueOf(host); + } + + private static String normalizePath(@Nullable String path) { + if (path == null) { + return "null"; + } + if (path.isEmpty() || path.startsWith("/")) { + return path; } + return "/" + path; + } + + private static MalformedURLException createMalformedUrlException(String candidate) { + return new MalformedURLException( + String.format("The remote server returned an invalid value to build the direct connect URL: %s", + candidate) + ); } } diff --git a/src/test/java/io/appium/java_client/internal/DirectConnectTest.java b/src/test/java/io/appium/java_client/internal/DirectConnectTest.java index 8255c3c5d..2d226a83c 100644 --- a/src/test/java/io/appium/java_client/internal/DirectConnectTest.java +++ b/src/test/java/io/appium/java_client/internal/DirectConnectTest.java @@ -55,5 +55,71 @@ void hasInvalidDirectConnect() { Map responseValue = new HashMap<>(); DirectConnect directConnect = new DirectConnect(responseValue); assertFalse(directConnect.isValid()); + assertThrowsExactly(MalformedURLException.class, directConnect::getUrl); + } + + @Test + void formatsBareIpv6HostAndPreservesBracketedIpv6Host() throws MalformedURLException { + Map bareIpv6 = Map.of( + "appium:directConnectProtocol", "https", + "appium:directConnectHost", "2001:db8::1", + "appium:directConnectPort", "4443", + "appium:directConnectPath", "/wd/hub" + ); + DirectConnect bare = new DirectConnect(bareIpv6); + assertTrue(bare.isValid()); + assertEquals("https://[2001:db8::1]:4443/wd/hub", bare.getUrl().toString()); + + Map bracketedIpv6 = Map.of( + "appium:directConnectProtocol", "https", + "appium:directConnectHost", "[2001:db8::1]", + "appium:directConnectPort", "4443", + "appium:directConnectPath", "/wd/hub" + ); + DirectConnect bracketed = new DirectConnect(bracketedIpv6); + assertTrue(bracketed.isValid()); + assertEquals("https://[2001:db8::1]:4443/wd/hub", bracketed.getUrl().toString()); + } + + @Test + void normalizesEmptyAndRelativeDirectConnectPath() throws MalformedURLException { + DirectConnect emptyPath = new DirectConnect(Map.of( + "appium:directConnectProtocol", "https", + "appium:directConnectHost", "203.0.113.1", + "appium:directConnectPort", "4443", + "appium:directConnectPath", "" + )); + assertTrue(emptyPath.isValid()); + assertEquals("https://203.0.113.1:4443", emptyPath.getUrl().toString()); + + DirectConnect relativePath = new DirectConnect(Map.of( + "appium:directConnectProtocol", "https", + "appium:directConnectHost", "203.0.113.1", + "appium:directConnectPort", "4443", + "appium:directConnectPath", "wd/hub" + )); + assertTrue(relativePath.isValid()); + assertEquals("https://203.0.113.1:4443/wd/hub", relativePath.getUrl().toString()); + } + + @Test + void rejectsSingleColonHostAndBlankFields() { + DirectConnect embeddedPortHost = new DirectConnect(Map.of( + "appium:directConnectProtocol", "https", + "appium:directConnectHost", "evil.example.com:8443", + "appium:directConnectPort", "4443", + "appium:directConnectPath", "/wd/hub" + )); + assertTrue(embeddedPortHost.isValid()); + assertThrowsExactly(MalformedURLException.class, embeddedPortHost::getUrl); + + DirectConnect blankHost = new DirectConnect(Map.of( + "appium:directConnectProtocol", "https", + "appium:directConnectHost", " ", + "appium:directConnectPort", "4443", + "appium:directConnectPath", "" + )); + assertFalse(blankHost.isValid()); + assertThrowsExactly(MalformedURLException.class, blankHost::getUrl); } } 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..d7a55ea59 100644 --- a/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java +++ b/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java @@ -2,18 +2,77 @@ import io.appium.java_client.AppiumClientConfig; import io.appium.java_client.MobileCommand; +import io.appium.java_client.android.AndroidDriver; +import io.appium.java_client.android.options.UiAutomator2Options; +import io.appium.java_client.http.ClientConfig; +import io.appium.java_client.http.Contents; +import io.appium.java_client.http.HttpClient; +import io.appium.java_client.http.HttpRequest; +import io.appium.java_client.http.HttpResponse; +import io.appium.java_client.http.WebSocket; +import io.appium.java_client.service.local.AppiumServiceBuilder; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.openqa.selenium.SessionNotCreatedException; +import java.io.File; +import java.io.IOException; import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class AppiumCommandExecutorTest { private static final String APPIUM_URL = "https://appium.example.com"; + private static final File NODE = new File(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + + @TempDir + Path tempDir; + + private static final class RecordingHttpClient implements HttpClient { + private final ClientConfig config; + private final AtomicBoolean closed = new AtomicBoolean(false); + private final String sessionCapabilitiesJson; + + RecordingHttpClient(ClientConfig config, String sessionCapabilitiesJson) { + this.config = config; + this.sessionCapabilitiesJson = sessionCapabilitiesJson; + } + + @Override + public HttpResponse execute(HttpRequest request) { + String body = "/session".equals(request.getUri()) + ? "{\"value\":{\"sessionId\":\"s1\",\"capabilities\":" + sessionCapabilitiesJson + "}}" + : "{\"value\":null}"; + return new HttpResponse() + .setStatus(200) + .setHeader("Content-Type", "application/json; charset=utf-8") + .setContent(Contents.utf8String(body)); + } + + @Override + public WebSocket openSocket(HttpRequest request, WebSocket.Listener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + closed.set(true); + } + } private AppiumCommandExecutor createExecutor() { URL baseUrl; @@ -38,12 +97,114 @@ void getHttpClientFactory() { @Test void overrideServerUrl() throws MalformedURLException { - assertDoesNotThrow(() -> createExecutor().overrideServerUrl(new URL("https://203.0.113.1/wd/hub"))); + List created = new ArrayList<>(); + HttpClient.Factory factory = cfg -> { + RecordingHttpClient client = new RecordingHttpClient(cfg, "{\"platformName\":\"Android\"}"); + created.add(client); + return client; + }; + AppiumClientConfig initialConfig = AppiumClientConfig.defaultConfig() + .baseUrl(new URL(APPIUM_URL)) + .readTimeout(Duration.ofSeconds(45)); + AppiumCommandExecutor executor = new AppiumCommandExecutor( + MobileCommand.commandRepository, null, factory, initialConfig); + final HttpClient firstClient = executor.getClient(); + + URL overrideUrl = new URL("https://203.0.113.1:4443/wd/hub"); + executor.overrideServerUrl(overrideUrl); + + assertEquals(2, created.size()); + assertTrue(created.get(0).closed.get()); + assertFalse(created.get(1).closed.get()); + assertNotSame(firstClient, executor.getClient()); + assertSame(created.get(1), executor.getClient()); + assertEquals(overrideUrl, executor.getAddressOfRemoteServer()); + assertEquals(overrideUrl, executor.getAppiumClientConfig().baseUrl()); + assertEquals(URI.create("https://203.0.113.1:4443/wd/hub"), executor.getAppiumClientConfig().baseUri()); + assertEquals(Duration.ofSeconds(45), executor.getAppiumClientConfig().readTimeout()); } @Test void overrideServerUrlRejectsLoopbackTarget() throws MalformedURLException { + List created = new ArrayList<>(); + HttpClient.Factory factory = cfg -> { + RecordingHttpClient client = new RecordingHttpClient(cfg, "{\"platformName\":\"Android\"}"); + created.add(client); + return client; + }; + URL initialUrl = new URL(APPIUM_URL); + AppiumCommandExecutor executor = new AppiumCommandExecutor( + MobileCommand.commandRepository, null, factory, + AppiumClientConfig.defaultConfig().baseUrl(initialUrl)); + assertThrows(SessionNotCreatedException.class, - () -> createExecutor().overrideServerUrl(new URL("https://127.0.0.1:4443/wd/hub"))); + () -> executor.overrideServerUrl(new URL("https://127.0.0.1:4443/wd/hub"))); + assertEquals(1, created.size()); + assertFalse(created.get(0).closed.get()); + assertEquals(initialUrl, executor.getAddressOfRemoteServer()); + assertEquals(initialUrl, executor.getAppiumClientConfig().baseUrl()); + } + + @Test + void populatesBaseUrlFromLocalServiceWhenUnsetInClientConfig() throws IOException { + File mainJs = Files.createFile(tempDir.resolve("main.js")).toFile(); + var service = new AppiumServiceBuilder() + .usingDriverExecutable(NODE) + .withAppiumJS(mainJs) + .usingPort(4777) + .withIPAddress("127.0.0.1") + .build(); + AppiumClientConfig customConfig = AppiumClientConfig.defaultConfig() + .readTimeout(Duration.ofSeconds(90)); + + AppiumCommandExecutor executor = new AppiumCommandExecutor( + MobileCommand.commandRepository, service, customConfig); + + assertEquals(service.getUrl(), executor.getAddressOfRemoteServer()); + assertEquals(service.getUrl(), executor.getAppiumClientConfig().baseUrl()); + assertEquals(Duration.ofSeconds(90), executor.getAppiumClientConfig().readTimeout()); + } + + @Test + void directConnectSupportsUppercaseProtocolIpv6LiteralAndRelativePath() throws MalformedURLException { + String caps = "{\"platformName\":\"Android\",\"appium:automationName\":\"UiAutomator2\"," + + "\"appium:directConnectProtocol\":\"HTTPS\"," + + "\"appium:directConnectHost\":\"2001:db8::1\"," + + "\"appium:directConnectPort\":4443," + + "\"appium:directConnectPath\":\"wd/hub\"}"; + HttpClient.Factory factory = cfg -> new RecordingHttpClient(cfg, caps); + AppiumCommandExecutor executor = new AppiumCommandExecutor( + MobileCommand.commandRepository, null, factory, + AppiumClientConfig.defaultConfig().baseUrl(new URL(APPIUM_URL)).directConnect(true)); + + AndroidDriver driver = new AndroidDriver(executor, new UiAutomator2Options().setDeviceName("emu")); + + URL expected = new URL("https://[2001:db8::1]:4443/wd/hub"); + assertEquals(expected, executor.getAddressOfRemoteServer()); + assertEquals(expected, executor.getAppiumClientConfig().baseUrl()); + assertEquals(expected, driver.getRemoteAddress()); + + URL nextOverride = new URL("https://203.0.113.2:4443/wd/hub"); + executor.overrideServerUrl(nextOverride); + assertEquals(nextOverride, driver.getRemoteAddress()); + } + + @Test + void directConnectIgnoresBlankCapabilityValues() throws MalformedURLException { + String caps = "{\"platformName\":\"Android\",\"appium:automationName\":\"UiAutomator2\"," + + "\"appium:directConnectProtocol\":\"\"," + + "\"appium:directConnectHost\":\"\"," + + "\"appium:directConnectPort\":\"\"," + + "\"appium:directConnectPath\":\"\"}"; + HttpClient.Factory factory = cfg -> new RecordingHttpClient(cfg, caps); + URL initialUrl = new URL(APPIUM_URL); + AppiumCommandExecutor executor = new AppiumCommandExecutor( + MobileCommand.commandRepository, null, factory, + AppiumClientConfig.defaultConfig().baseUrl(initialUrl).directConnect(true)); + + AndroidDriver driver = new AndroidDriver(executor, new UiAutomator2Options().setDeviceName("emu")); + + assertEquals(initialUrl, executor.getAddressOfRemoteServer()); + assertEquals(initialUrl, driver.getRemoteAddress()); } } diff --git a/src/test/java/io/appium/java_client/remote/DriverWireScenariosTest.java b/src/test/java/io/appium/java_client/remote/DriverWireScenariosTest.java index a8542460b..9ed100e8c 100644 --- a/src/test/java/io/appium/java_client/remote/DriverWireScenariosTest.java +++ b/src/test/java/io/appium/java_client/remote/DriverWireScenariosTest.java @@ -56,6 +56,8 @@ import java.util.function.Function; import java.util.regex.Pattern; +import static org.junit.jupiter.api.Assertions.assertEquals; + /** * Pins the HTTP requests a driver sends for each WebDriver/Appium API call, and what it returns * for scripted server responses. Set GOLDEN_UPDATE=1 to regenerate the golden file. @@ -593,6 +595,9 @@ void directConnect() { Map described = new LinkedHashMap<>(); try { var driver = newDriver(transport, AppiumClientConfig.defaultConfig().directConnect(true)); + if ("valid".equals(name)) { + assertEquals("https://203.0.113.1:4443/wd/hub", driver.getRemoteAddress().toString()); + } transport.requests.clear(); driver.getTitle(); } catch (Throwable t) {