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..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 @@ -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; @@ -55,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"; @@ -83,7 +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, ipAddress, nodeJSPort)); + // URL encloses an IPv6 host in square brackets + this.url = new URL("http", ipAddress, nodeJSPort, "/"); } /** @@ -122,9 +123,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(), newHost, source.getPort(), source.getFile()); } /** @@ -167,18 +167,34 @@ private boolean ping(Duration timeout) throws InterruptedException { return availabilityChecker.waitUntilAvailable(statusUrl, timeout); } - private URL fixBroadcastAddresses(URL url) { + static URL fixBroadcastAddresses(URL url) { 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, 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..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 @@ -269,6 +269,7 @@ 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. @@ -362,6 +363,9 @@ List createArgs() { if (isNullOrEmpty(ipAddress)) { ipAddress = BROADCAST_IP4_ADDRESS; + } else { + // Appium expects an IPv6 address without square brackets + ipAddress = stripBrackets(ipAddress); } argList.add("--address"); argList.add(ipAddress); @@ -393,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. 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..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; @@ -52,6 +54,68 @@ 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 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")