From 036153016acd48cd1f62931e7f418681b2a6351f Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:06:11 +0000 Subject: [PATCH 1/4] fix: support IPv6 addresses in the local Appium service URL --- .../service/local/AppiumDriverLocalService.java | 7 +++++-- .../service/local/AppiumServiceBuilderTest.java | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java index b956fd7d6..14b1f6ca4 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java @@ -83,7 +83,9 @@ public final class AppiumDriverLocalService implements Closeable { this.nodeJSArgs = nodeJSArgs; this.nodeJSEnvironment = nodeJSEnvironment; this.startupTimeout = startupTimeout; - this.url = new URL(String.format(URL_MASK, ipAddress, nodeJSPort)); + // IPv6 literals must be enclosed in square brackets to be a valid URL host + String host = ipAddress.contains(":") && !ipAddress.startsWith("[") ? "[" + ipAddress + "]" : ipAddress; + this.url = new URL(String.format(URL_MASK, host, nodeJSPort)); } /** @@ -168,7 +170,8 @@ private boolean ping(Duration timeout) throws InterruptedException { } private URL fixBroadcastAddresses(URL url) { - var host = url.getHost(); + // IPv6 hosts are returned in square brackets + var host = url.getHost().replaceAll("^\\[(.*)]$", "$1"); // The operating system will block direct access to the universal broadcast IP address if (host.equals(BROADCAST_IP4_ADDRESS)) { return replaceHost(url, BROADCAST_IP4_ADDRESS, "127.0.0.1"); diff --git a/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java b/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java index c2b157b12..d97cac53c 100644 --- a/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java +++ b/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java @@ -52,6 +52,23 @@ void buildsServiceWithGivenPortAndAddress() throws IOException { assertFalse(service.isRunning()); } + @Test + void buildsServiceWithIpv6Address() throws IOException { + var builder = newBuilder().usingPort(4777).withIPAddress("::1"); + + assertEquals(List.of(tempDir.resolve("main.js").toString(), "--port", "4777", "--address", "::1"), + builder.createArgs()); + assertEquals("http://[::1]:4777/", builder.build().getUrl().toString()); + } + + @Test + void buildsServiceWithIpv6BroadcastAddress() throws IOException { + var service = newBuilder().usingPort(4777) + .withIPAddress(AppiumServiceBuilder.BROADCAST_IP6_ADDRESS).build(); + + assertEquals("http://[::]:4777/", service.getUrl().toString()); + } + @Test void appliesBasePath() throws IOException { var service = newBuilder().usingPort(4777).withIPAddress("127.0.0.1") From 83acd963ba740514173240643b70d749f41cb496 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:55:48 +0000 Subject: [PATCH 2/4] fix: normalize IPv6 addresses and detect every IPv6 wildcard form Strip square brackets once in withIPAddress, so --address always gets the bare address. Treat any form of the unspecified IPv6 address as a wildcard bind when pinging, and rebuild the ping URL from its parts instead of a regex replacement. --- .../local/AppiumDriverLocalService.java | 63 +++++++++++++++---- .../service/local/AppiumServiceBuilder.java | 3 +- .../local/AppiumServiceBuilderTest.java | 47 ++++++++++++++ 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java index 14b1f6ca4..d88f6b68b 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java @@ -29,7 +29,9 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.net.InetAddress; import java.net.URL; +import java.net.UnknownHostException; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -43,7 +45,6 @@ import static io.appium.java_client.internal.Strings.isNullOrEmpty; import static io.appium.java_client.service.local.AppiumServiceBuilder.BROADCAST_IP4_ADDRESS; -import static io.appium.java_client.service.local.AppiumServiceBuilder.BROADCAST_IP6_ADDRESS; import static java.util.Locale.ROOT; import static java.util.Objects.requireNonNull; import static java.util.Optional.ofNullable; @@ -83,9 +84,32 @@ public final class AppiumDriverLocalService implements Closeable { this.nodeJSArgs = nodeJSArgs; this.nodeJSEnvironment = nodeJSEnvironment; this.startupTimeout = startupTimeout; - // IPv6 literals must be enclosed in square brackets to be a valid URL host - String host = ipAddress.contains(":") && !ipAddress.startsWith("[") ? "[" + ipAddress + "]" : ipAddress; - this.url = new URL(String.format(URL_MASK, host, nodeJSPort)); + this.url = new URL(String.format(URL_MASK, toUrlHost(ipAddress), nodeJSPort)); + } + + /** + * Removes the square brackets around an IPv6 literal. + * + * @param address an IP address or host name, with or without square brackets + * @return the address without enclosing square brackets + */ + static String stripBrackets(String address) { + return address.length() > 1 && address.startsWith("[") && address.endsWith("]") + ? address.substring(1, address.length() - 1) + : address; + } + + /** + * Encloses an IPv6 literal in square brackets, as required for the host of a URL. + * A zone ID such as {@code %en0} is kept as is, because {@link URL} and + * {@link InetAddress} expect it unencoded. + * + * @param address an IP address or host name, with or without square brackets + * @return the address in the form of a URL host + */ + static String toUrlHost(String address) { + var host = stripBrackets(address); + return host.contains(":") ? "[" + host + "]" : host; } /** @@ -124,9 +148,8 @@ private static URL addSuffix(URL url, String suffix) { } @SneakyThrows - @SuppressWarnings("SameParameterValue") - private static URL replaceHost(URL source, String oldHost, String newHost) { - return new URL(source.toString().replaceFirst(oldHost, newHost)); + private static URL replaceHost(URL source, String newHost) { + return new URL(source.getProtocol(), toUrlHost(newHost), source.getPort(), source.getFile()); } /** @@ -169,19 +192,35 @@ private boolean ping(Duration timeout) throws InterruptedException { return availabilityChecker.waitUntilAvailable(statusUrl, timeout); } - private URL fixBroadcastAddresses(URL url) { + static URL fixBroadcastAddresses(URL url) { // IPv6 hosts are returned in square brackets - var host = url.getHost().replaceAll("^\\[(.*)]$", "$1"); + var host = stripBrackets(url.getHost()); // The operating system will block direct access to the universal broadcast IP address if (host.equals(BROADCAST_IP4_ADDRESS)) { - return replaceHost(url, BROADCAST_IP4_ADDRESS, "127.0.0.1"); + return replaceHost(url, "127.0.0.1"); } - if (host.equals(BROADCAST_IP6_ADDRESS)) { - return replaceHost(url, BROADCAST_IP6_ADDRESS, "::1"); + if (isIpv6AnyAddress(host)) { + return replaceHost(url, "::1"); } return url; } + /** + * Checks for any form of the IPv6 unspecified address, such as {@code ::}, {@code ::0} + * or {@code 0:0:0:0:0:0:0:0}. + */ + private static boolean isIpv6AnyAddress(String host) { + if (!host.contains(":")) { + return false; + } + try { + // An IPv6 literal is parsed without a name lookup + return InetAddress.getByName(host).isAnyLocalAddress(); + } catch (UnknownHostException e) { + return false; + } + } + /** * Starts the defined appium server. * diff --git a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java index cd25cc702..26deeac8a 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java @@ -269,12 +269,13 @@ public AppiumServiceBuilder withAppiumJS(File appiumJS) { /** * Sets the IP address the server listens on. + * An IPv6 address can be given with or without square brackets. * * @param ipAddress the IP address * @return the self-reference. */ public AppiumServiceBuilder withIPAddress(String ipAddress) { - this.ipAddress = ipAddress; + this.ipAddress = ipAddress == null ? null : AppiumDriverLocalService.stripBrackets(ipAddress); return this; } diff --git a/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java b/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java index d97cac53c..d6a1c1f7c 100644 --- a/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java +++ b/src/test/java/io/appium/java_client/service/local/AppiumServiceBuilderTest.java @@ -18,6 +18,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.File; import java.io.IOException; @@ -69,6 +71,51 @@ void buildsServiceWithIpv6BroadcastAddress() throws IOException { assertEquals("http://[::]:4777/", service.getUrl().toString()); } + @Test + void buildsServiceWithBracketedIpv6Address() throws IOException { + var builder = newBuilder().usingPort(4777).withIPAddress("[::1]"); + + assertEquals(List.of(tempDir.resolve("main.js").toString(), "--port", "4777", "--address", "::1"), + builder.createArgs()); + assertEquals("http://[::1]:4777/", builder.build().getUrl().toString()); + } + + @Test + void buildsServiceWithIpv6AddressWithZoneId() throws IOException { + var builder = newBuilder().usingPort(4777).withIPAddress("fe80::1%en0"); + + assertEquals(List.of(tempDir.resolve("main.js").toString(), "--port", "4777", "--address", "fe80::1%en0"), + builder.createArgs()); + assertEquals("http://[fe80::1%en0]:4777/", builder.build().getUrl().toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"::", "::0", "0:0:0:0:0:0:0:0"}) + void pingsIpv6LoopbackForIpv6BroadcastAddress(String address) throws IOException { + var service = newBuilder().usingPort(4777).withIPAddress(address) + .withArgument(io.appium.java_client.service.local.flags.GeneralServerFlag.BASEPATH, "/wd/hub") + .build(); + + assertEquals("http://[::1]:4777/wd/hub/", + AppiumDriverLocalService.fixBroadcastAddresses(service.getUrl()).toString()); + } + + @Test + void pingsIpv4LoopbackForIpv4BroadcastAddress() throws IOException { + var service = newBuilder().usingPort(4777).withIPAddress(AppiumServiceBuilder.BROADCAST_IP4_ADDRESS).build(); + + assertEquals("http://127.0.0.1:4777/", + AppiumDriverLocalService.fixBroadcastAddresses(service.getUrl()).toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"::1", "127.0.0.1", "localhost"}) + void pingsGivenAddressWhenItIsNotABroadcastAddress(String address) throws IOException { + var url = newBuilder().usingPort(4777).withIPAddress(address).build().getUrl(); + + assertEquals(url.toString(), AppiumDriverLocalService.fixBroadcastAddresses(url).toString()); + } + @Test void appliesBasePath() throws IOException { var service = newBuilder().usingPort(4777).withIPAddress("127.0.0.1") From 3c5dcbf2032437034b23b6683a745c640f4ddd6e Mon Sep 17 00:00:00 2001 From: RaphaelFakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:23:02 +0000 Subject: [PATCH 3/4] refactor: strip IPv6 brackets in createArgs only Normalize the address once where the server arguments are built, so toUrlHost no longer strips brackets that it then adds back. --- .../java_client/service/local/AppiumDriverLocalService.java | 5 ++--- .../java_client/service/local/AppiumServiceBuilder.java | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java index d88f6b68b..6688385b1 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java @@ -104,12 +104,11 @@ static String stripBrackets(String address) { * A zone ID such as {@code %en0} is kept as is, because {@link URL} and * {@link InetAddress} expect it unencoded. * - * @param address an IP address or host name, with or without square brackets + * @param address an IP address or host name without square brackets * @return the address in the form of a URL host */ static String toUrlHost(String address) { - var host = stripBrackets(address); - return host.contains(":") ? "[" + host + "]" : host; + return address.contains(":") ? "[" + address + "]" : address; } /** diff --git a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java index 26deeac8a..94e87a73e 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java @@ -275,7 +275,7 @@ public AppiumServiceBuilder withAppiumJS(File appiumJS) { * @return the self-reference. */ public AppiumServiceBuilder withIPAddress(String ipAddress) { - this.ipAddress = ipAddress == null ? null : AppiumDriverLocalService.stripBrackets(ipAddress); + this.ipAddress = ipAddress; return this; } @@ -363,6 +363,9 @@ List createArgs() { if (isNullOrEmpty(ipAddress)) { ipAddress = BROADCAST_IP4_ADDRESS; + } else { + // Appium expects an IPv6 address without square brackets + ipAddress = AppiumDriverLocalService.stripBrackets(ipAddress); } argList.add("--address"); argList.add(ipAddress); From 5d8659447596de492c9f25c32a55fb147b0cec44 Mon Sep 17 00:00:00 2001 From: RaphaelFakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:26:24 +0000 Subject: [PATCH 4/4] refactor: let URL and InetAddress handle IPv6 brackets --- .../local/AppiumDriverLocalService.java | 33 +++---------------- .../service/local/AppiumServiceBuilder.java | 13 +++++++- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java index 6688385b1..ee822a607 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java @@ -56,7 +56,6 @@ */ public final class AppiumDriverLocalService implements Closeable { - private static final String URL_MASK = "http://%s:%d/"; private static final Logger LOG = LoggerFactory.getLogger(AppiumDriverLocalService.class); private static final Pattern LOGGER_CONTEXT_PATTERN = Pattern.compile("^(\\[debug\\] )?\\[(.+?)\\]"); private static final String APPIUM_SERVICE_SLF4J_LOGGER_PREFIX = "appium.service"; @@ -84,31 +83,8 @@ public final class AppiumDriverLocalService implements Closeable { this.nodeJSArgs = nodeJSArgs; this.nodeJSEnvironment = nodeJSEnvironment; this.startupTimeout = startupTimeout; - this.url = new URL(String.format(URL_MASK, toUrlHost(ipAddress), nodeJSPort)); - } - - /** - * Removes the square brackets around an IPv6 literal. - * - * @param address an IP address or host name, with or without square brackets - * @return the address without enclosing square brackets - */ - static String stripBrackets(String address) { - return address.length() > 1 && address.startsWith("[") && address.endsWith("]") - ? address.substring(1, address.length() - 1) - : address; - } - - /** - * Encloses an IPv6 literal in square brackets, as required for the host of a URL. - * A zone ID such as {@code %en0} is kept as is, because {@link URL} and - * {@link InetAddress} expect it unencoded. - * - * @param address an IP address or host name without square brackets - * @return the address in the form of a URL host - */ - static String toUrlHost(String address) { - return address.contains(":") ? "[" + address + "]" : address; + // URL encloses an IPv6 host in square brackets + this.url = new URL("http", ipAddress, nodeJSPort, "/"); } /** @@ -148,7 +124,7 @@ private static URL addSuffix(URL url, String suffix) { @SneakyThrows private static URL replaceHost(URL source, String newHost) { - return new URL(source.getProtocol(), toUrlHost(newHost), source.getPort(), source.getFile()); + return new URL(source.getProtocol(), newHost, source.getPort(), source.getFile()); } /** @@ -192,8 +168,7 @@ private boolean ping(Duration timeout) throws InterruptedException { } static URL fixBroadcastAddresses(URL url) { - // IPv6 hosts are returned in square brackets - var host = stripBrackets(url.getHost()); + var host = url.getHost(); // The operating system will block direct access to the universal broadcast IP address if (host.equals(BROADCAST_IP4_ADDRESS)) { return replaceHost(url, "127.0.0.1"); diff --git a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java index 94e87a73e..9b8840c33 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java @@ -365,7 +365,7 @@ List createArgs() { ipAddress = BROADCAST_IP4_ADDRESS; } else { // Appium expects an IPv6 address without square brackets - ipAddress = AppiumDriverLocalService.stripBrackets(ipAddress); + ipAddress = stripBrackets(ipAddress); } argList.add("--address"); argList.add(ipAddress); @@ -397,6 +397,17 @@ List createArgs() { return Collections.unmodifiableList(argList); } + /** + * Removes the square brackets around an IPv6 literal. + * + * @param address an IP address or host name, with or without square brackets + * @return the address without enclosing square brackets + */ + private static String stripBrackets(String address) { + return address.length() > 1 && address.startsWith("[") && address.endsWith("]") + ? address.substring(1, address.length() - 1) + : address; + } /** * Sets which Node.js the builder will use.