diff --git a/src/main/java/io/appium/java_client/AppiumDriver.java b/src/main/java/io/appium/java_client/AppiumDriver.java index 0c72f9c86..debd404d3 100644 --- a/src/main/java/io/appium/java_client/AppiumDriver.java +++ b/src/main/java/io/appium/java_client/AppiumDriver.java @@ -16,9 +16,13 @@ package io.appium.java_client; +import com.google.common.collect.ImmutableMap; import io.appium.java_client.internal.CapabilityHelpers; +import io.appium.java_client.internal.ReflectionHelpers; +import io.appium.java_client.internal.SessionHelpers; import io.appium.java_client.remote.AppiumCommandExecutor; import io.appium.java_client.remote.AppiumNewSessionCommandPayload; +import io.appium.java_client.remote.AppiumW3CHttpCommandCodec; import io.appium.java_client.remote.MobileCapabilityType; import io.appium.java_client.remote.options.BaseOptions; import io.appium.java_client.service.local.AppiumDriverLocalService; @@ -36,11 +40,11 @@ import org.openqa.selenium.remote.HttpCommandExecutor; import org.openqa.selenium.remote.RemoteWebDriver; import org.openqa.selenium.remote.Response; +import org.openqa.selenium.remote.codec.w3c.W3CHttpResponseCodec; import org.openqa.selenium.remote.html5.RemoteLocationContext; import org.openqa.selenium.remote.http.HttpClient; import org.openqa.selenium.remote.http.HttpMethod; -import java.lang.reflect.Field; import java.net.URL; import java.util.Arrays; import java.util.Collections; @@ -128,6 +132,42 @@ public AppiumDriver(Capabilities capabilities) { this(AppiumDriverLocalService.buildDefaultService(), capabilities); } + /** + * This is a special constructor used to connect to a running driver instance. + * It does not do any necessary verifications, but rather assumes the given + * driver session is already running at `remoteSessionAddress`. + * The maintenance of driver state(s) is the caller's responsibility. + * !!! This API is supposed to be used for **debugging purposes only**. + * + * @param remoteSessionAddress The address of the **running** session including the session identifier. + * @param platformName The name of the target platform. + * @param automationName The name of the target automation. + */ + public AppiumDriver(URL remoteSessionAddress, String platformName, String automationName) { + super(); + ReflectionHelpers.setPrivateFieldValue( + RemoteWebDriver.class, this, "capabilities", new ImmutableCapabilities( + ImmutableMap.of( + PLATFORM_NAME, platformName, + APPIUM_PREFIX + AUTOMATION_NAME, automationName + ) + ) + ); + SessionHelpers.SessionAddress sessionAddress = SessionHelpers.parseSessionAddress(remoteSessionAddress); + AppiumCommandExecutor executor = new AppiumCommandExecutor( + MobileCommand.commandRepository, sessionAddress.getServerUrl() + ); + executor.setCommandCodec(new AppiumW3CHttpCommandCodec()); + executor.setResponseCodec(new W3CHttpResponseCodec()); + setCommandExecutor(executor); + this.executeMethod = new AppiumExecutionMethod(this); + locationContext = new RemoteLocationContext(executeMethod); + super.setErrorHandler(errorHandler); + this.remoteAddress = executor.getAddressOfRemoteServer(); + + setSessionId(sessionAddress.getId()); + } + /** * Changes platform name if it is not set and returns merged capabilities. * @@ -252,13 +292,9 @@ && isBlank((String) rawCapabilities.get(CapabilityType.BROWSER_NAME))) { rawCapabilities.remove(CapabilityType.BROWSER_NAME); } MutableCapabilities returnedCapabilities = new BaseOptions<>(rawCapabilities); - try { - Field capsField = RemoteWebDriver.class.getDeclaredField("capabilities"); - capsField.setAccessible(true); - capsField.set(this, returnedCapabilities); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new WebDriverException(e); - } + ReflectionHelpers.setPrivateFieldValue( + RemoteWebDriver.class, this, "capabilities", returnedCapabilities + ); setSessionId(response.getSessionId()); } diff --git a/src/main/java/io/appium/java_client/AppiumFluentWait.java b/src/main/java/io/appium/java_client/AppiumFluentWait.java index d914f6c16..9061600a0 100644 --- a/src/main/java/io/appium/java_client/AppiumFluentWait.java +++ b/src/main/java/io/appium/java_client/AppiumFluentWait.java @@ -17,6 +17,7 @@ package io.appium.java_client; import com.google.common.base.Throwables; +import io.appium.java_client.internal.ReflectionHelpers; import lombok.AccessLevel; import lombok.Getter; import org.openqa.selenium.TimeoutException; @@ -24,7 +25,6 @@ import org.openqa.selenium.support.ui.FluentWait; import org.openqa.selenium.support.ui.Sleeper; -import java.lang.reflect.Field; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -99,23 +99,11 @@ public AppiumFluentWait(T input, Clock clock, Sleeper sleeper) { } private B getPrivateFieldValue(String fieldName, Class fieldType) { - try { - final Field f = getClass().getSuperclass().getDeclaredField(fieldName); - f.setAccessible(true); - return fieldType.cast(f.get(this)); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new WebDriverException(e); - } + return ReflectionHelpers.getPrivateFieldValue(FluentWait.class, this, fieldName, fieldType); } private Object getPrivateFieldValue(String fieldName) { - try { - final Field f = getClass().getSuperclass().getDeclaredField(fieldName); - f.setAccessible(true); - return f.get(this); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new WebDriverException(e); - } + return getPrivateFieldValue(fieldName, Object.class); } protected Clock getClock() { 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 f3b812584..0fc1c5917 100644 --- a/src/main/java/io/appium/java_client/android/AndroidDriver.java +++ b/src/main/java/io/appium/java_client/android/AndroidDriver.java @@ -240,6 +240,20 @@ public AndroidDriver(Capabilities capabilities) { super(ensurePlatformName(capabilities, ANDROID_PLATFORM)); } + /** + * This is a special constructor used to connect to a running driver instance. + * It does not do any necessary verifications, but rather assumes the given + * driver session is already running at `remoteSessionAddress`. + * The maintenance of driver state(s) is the caller's responsibility. + * !!! This API is supposed to be used for **debugging purposes only**. + * + * @param remoteSessionAddress The address of the **running** session including the session identifier. + * @param automationName The name of the target automation. + */ + public AndroidDriver(URL remoteSessionAddress, String automationName) { + super(remoteSessionAddress, ANDROID_PLATFORM, automationName); + } + /** * Get test-coverage data. * 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 6a7a55cab..07cb9e3e7 100644 --- a/src/main/java/io/appium/java_client/gecko/GeckoDriver.java +++ b/src/main/java/io/appium/java_client/gecko/GeckoDriver.java @@ -75,6 +75,20 @@ public GeckoDriver(HttpClient.Factory httpClientFactory, Capabilities capabiliti super(httpClientFactory, ensureAutomationName(capabilities, AUTOMATION_NAME)); } + /** + * This is a special constructor used to connect to a running driver instance. + * It does not do any necessary verifications, but rather assumes the given + * driver session is already running at `remoteSessionAddress`. + * The maintenance of driver state(s) is the caller's responsibility. + * !!! This API is supposed to be used for **debugging purposes only**. + * + * @param remoteSessionAddress The address of the **running** session including the session identifier. + * @param platformName The name of the target platform. + */ + public GeckoDriver(URL remoteSessionAddress, String platformName) { + super(remoteSessionAddress, platformName, AUTOMATION_NAME); + } + /** * Creates a new instance based on the given ClientConfig and {@code capabilities}. * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}. diff --git a/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java b/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java new file mode 100644 index 000000000..cbaa7c796 --- /dev/null +++ b/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java @@ -0,0 +1,63 @@ +/* + * 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.internal; + +import org.openqa.selenium.WebDriverException; + +import java.lang.reflect.Field; + +public class ReflectionHelpers { + + /** + * Sets the given value to a private instance field. + * + * @param cls The target class or a superclass. + * @param target Target instance. + * @param fieldName Target field name. + * @param newValue The value to be set. + * @return The same instance for chaining. + */ + public static T setPrivateFieldValue(Class cls, T target, String fieldName, Object newValue) { + try { + final Field f = cls.getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, newValue); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new WebDriverException(e); + } + return target; + } + + /** + * Fetches the value of a private instance field. + * + * @param cls The target class or a superclass. + * @param target Target instance. + * @param fieldName Target field name. + * @param fieldType Field type. + * @return The retrieved field value. + */ + public static T getPrivateFieldValue(Class cls, Object target, String fieldName, Class fieldType) { + try { + final Field f = cls.getDeclaredField(fieldName); + f.setAccessible(true); + return fieldType.cast(f.get(target)); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new WebDriverException(e); + } + } +} diff --git a/src/main/java/io/appium/java_client/internal/SessionHelpers.java b/src/main/java/io/appium/java_client/internal/SessionHelpers.java new file mode 100644 index 000000000..b3b9f0eca --- /dev/null +++ b/src/main/java/io/appium/java_client/internal/SessionHelpers.java @@ -0,0 +1,59 @@ +/* + * 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.internal; + +import lombok.Data; +import org.openqa.selenium.InvalidArgumentException; +import org.openqa.selenium.WebDriverException; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SessionHelpers { + private static final Pattern SESSION = Pattern.compile("/session/([^/]+)"); + + @Data public static class SessionAddress { + private final URL serverUrl; + private final String id; + } + + /** + * Parses the address of a running remote session. + * + * @param address The address string containing /session/id suffix. + * @return Parsed address object. + * @throws InvalidArgumentException If no session identifier could be parsed. + */ + public static SessionAddress parseSessionAddress(URL address) { + String addressString = address.toString(); + Matcher matcher = SESSION.matcher(addressString); + if (!matcher.find()) { + throw new InvalidArgumentException( + String.format("The server URL '%s' must include /session/ suffix", addressString) + ); + } + try { + return new SessionAddress( + new URL(addressString.replace(matcher.group(), "")), matcher.group(1) + ); + } catch (MalformedURLException e) { + throw new WebDriverException(e); + } + } +} 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 d450c1b0f..ad00a5f90 100644 --- a/src/main/java/io/appium/java_client/ios/IOSDriver.java +++ b/src/main/java/io/appium/java_client/ios/IOSDriver.java @@ -31,6 +31,7 @@ import io.appium.java_client.PushesFiles; import io.appium.java_client.SupportsLegacyAppManagement; import io.appium.java_client.battery.HasBattery; +import io.appium.java_client.remote.AutomationName; import io.appium.java_client.remote.SupportsContextSwitching; import io.appium.java_client.remote.SupportsLocation; import io.appium.java_client.remote.SupportsRotation; @@ -220,6 +221,18 @@ public IOSDriver(AppiumClientConfig appiumClientConfig, Capabilities capabilitie super(appiumClientConfig, ensurePlatformName(capabilities, PLATFORM_NAME)); } + /** + * This is a special constructor used to connect to a running driver instance. + * It does not do any necessary verifications, but rather assumes the given + * driver session is already running at `remoteSessionAddress`. + * The maintenance of driver state(s) is the caller's responsibility. + * !!! This API is supposed to be used for **debugging purposes only**. + * + * @param remoteSessionAddress The address of the **running** session including the session identifier. + */ + public IOSDriver(URL remoteSessionAddress) { + super(remoteSessionAddress, PLATFORM_NAME, AutomationName.IOS_XCUI_TEST); + } /** * Creates a new instance based on {@code capabilities}. 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 905cbec77..46911c314 100644 --- a/src/main/java/io/appium/java_client/mac/Mac2Driver.java +++ b/src/main/java/io/appium/java_client/mac/Mac2Driver.java @@ -86,6 +86,19 @@ public Mac2Driver(HttpClient.Factory httpClientFactory, Capabilities capabilitie capabilities, PLATFORM_NAME, AUTOMATION_NAME)); } + /** + * This is a special constructor used to connect to a running driver instance. + * It does not do any necessary verifications, but rather assumes the given + * driver session is already running at `remoteSessionAddress`. + * The maintenance of driver state(s) is the caller's responsibility. + * !!! This API is supposed to be used for **debugging purposes only**. + * + * @param remoteSessionAddress The address of the **running** session including the session identifier. + */ + public Mac2Driver(URL remoteSessionAddress) { + super(remoteSessionAddress, PLATFORM_NAME, AUTOMATION_NAME); + } + /** * Creates a new instance based on the given ClientConfig and {@code capabilities}. * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}. 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 b3acea991..0085f457d 100644 --- a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java +++ b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java @@ -21,6 +21,7 @@ import com.google.common.net.HttpHeaders; import io.appium.java_client.AppiumClientConfig; import io.appium.java_client.AppiumUserAgentFilter; +import io.appium.java_client.internal.ReflectionHelpers; import org.openqa.selenium.SessionNotCreatedException; import org.openqa.selenium.WebDriverException; import org.openqa.selenium.remote.Command; @@ -42,7 +43,6 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; -import java.lang.reflect.Field; import java.net.ConnectException; import java.net.MalformedURLException; import java.net.URL; @@ -128,25 +128,13 @@ public AppiumCommandExecutor(Map additionalCommands, @SuppressWarnings("SameParameterValue") protected B getPrivateFieldValue( Class cls, String fieldName, Class fieldType) { - try { - final Field f = cls.getDeclaredField(fieldName); - f.setAccessible(true); - return fieldType.cast(f.get(this)); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new WebDriverException(e); - } + return ReflectionHelpers.getPrivateFieldValue(cls, this, fieldName, fieldType); } @SuppressWarnings("SameParameterValue") protected void setPrivateFieldValue( Class cls, String fieldName, Object newValue) { - try { - final Field f = cls.getDeclaredField(fieldName); - f.setAccessible(true); - f.set(this, newValue); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new WebDriverException(e); - } + ReflectionHelpers.setPrivateFieldValue(cls, this, fieldName, newValue); } protected Map getAdditionalCommands() { @@ -159,11 +147,11 @@ protected CommandCodec getCommandCodec() { return getPrivateFieldValue(HttpCommandExecutor.class, "commandCodec", CommandCodec.class); } - protected void setCommandCodec(CommandCodec newCodec) { + public void setCommandCodec(CommandCodec newCodec) { setPrivateFieldValue(HttpCommandExecutor.class, "commandCodec", newCodec); } - protected void setResponseCodec(ResponseCodec codec) { + public void setResponseCodec(ResponseCodec codec) { setPrivateFieldValue(HttpCommandExecutor.class, "responseCodec", codec); } 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 97d1f96e4..f8f10bb01 100644 --- a/src/main/java/io/appium/java_client/safari/SafariDriver.java +++ b/src/main/java/io/appium/java_client/safari/SafariDriver.java @@ -83,6 +83,19 @@ public SafariDriver(HttpClient.Factory httpClientFactory, Capabilities capabilit capabilities, PLATFORM_NAME, AUTOMATION_NAME)); } + /** + * This is a special constructor used to connect to a running driver instance. + * It does not do any necessary verifications, but rather assumes the given + * driver session is already running at `remoteSessionAddress`. + * The maintenance of driver state(s) is the caller's responsibility. + * !!! This API is supposed to be used for **debugging purposes only**. + * + * @param remoteSessionAddress The address of the **running** session including the session identifier. + */ + public SafariDriver(URL remoteSessionAddress) { + super(remoteSessionAddress, PLATFORM_NAME, AUTOMATION_NAME); + } + /** * Creates a new instance based on the given ClientConfig and {@code capabilities}. * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}. 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 62d2a98ce..b58191ec2 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 @@ -17,6 +17,7 @@ package io.appium.java_client.service.local; import com.google.common.annotations.VisibleForTesting; +import io.appium.java_client.internal.ReflectionHelpers; import lombok.SneakyThrows; import org.apache.commons.lang3.StringUtils; import org.openqa.selenium.net.UrlChecker; @@ -31,7 +32,6 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.lang.reflect.Field; import java.net.MalformedURLException; import java.net.URL; import java.time.Duration; @@ -226,15 +226,15 @@ private int destroyProcess(Duration timeout) { // it does not exit after two seconds, which is in most cases not enough for // Appium try { - Field processField = process.getClass().getDeclaredField("process"); - processField.setAccessible(true); - Object osProcess = processField.get(process); - Field watchdogField = osProcess.getClass().getDeclaredField("executeWatchdog"); - watchdogField.setAccessible(true); - Object watchdog = watchdogField.get(osProcess); - Field nativeProcessField = watchdog.getClass().getDeclaredField("process"); - nativeProcessField.setAccessible(true); - Process nativeProcess = (Process) nativeProcessField.get(watchdog); + Object osProcess = ReflectionHelpers.getPrivateFieldValue( + process.getClass(), process, "process", Object.class + ); + Object watchdog = ReflectionHelpers.getPrivateFieldValue( + osProcess.getClass(), osProcess, "executeWatchdog", Object.class + ); + Process nativeProcess = ReflectionHelpers.getPrivateFieldValue( + watchdog.getClass(), watchdog, "process", Process.class + ); nativeProcess.destroy(); nativeProcess.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS); } catch (Exception e) { 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 9a441d68a..7c6e68a7a 100644 --- a/src/main/java/io/appium/java_client/windows/WindowsDriver.java +++ b/src/main/java/io/appium/java_client/windows/WindowsDriver.java @@ -46,7 +46,6 @@ public WindowsDriver(HttpCommandExecutor executor, Capabilities capabilities) { super(executor, ensurePlatformAndAutomationNames(capabilities, PLATFORM_NAME, AUTOMATION_NAME)); } - public WindowsDriver(URL remoteAddress, Capabilities capabilities) { super(remoteAddress, ensurePlatformAndAutomationNames( capabilities, PLATFORM_NAME, AUTOMATION_NAME)); @@ -82,6 +81,10 @@ public WindowsDriver(HttpClient.Factory httpClientFactory, Capabilities capabili capabilities, PLATFORM_NAME, AUTOMATION_NAME)); } + public WindowsDriver(URL remoteSessionAddress) { + super(remoteSessionAddress, PLATFORM_NAME, AUTOMATION_NAME); + } + /** * Creates a new instance based on the given ClientConfig and {@code capabilities}. * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}. diff --git a/src/test/java/io/appium/java_client/internal/SessionConnectTest.java b/src/test/java/io/appium/java_client/internal/SessionConnectTest.java new file mode 100644 index 000000000..52b4b051a --- /dev/null +++ b/src/test/java/io/appium/java_client/internal/SessionConnectTest.java @@ -0,0 +1,37 @@ +/* + * 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.internal; + +import io.appium.java_client.ios.IOSDriver; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.WebDriverException; + +import java.net.MalformedURLException; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SessionConnectTest { + + @Test + void canConnectToASession() throws MalformedURLException { + IOSDriver driver = new IOSDriver(new URL("http://localhost:4723/session/1234")); + assertEquals(driver.getSessionId().toString(), "1234"); + assertThrows(WebDriverException.class, driver::quit); + } + +}