Skip to content

Commit b216a3d

Browse files
authored
Add Appium support for Selenium 4 Grid (#274)
1 parent de09dc2 commit b216a3d

34 files changed

+703
-310
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,10 @@ In addition to support for all of the standard Java-based browser drivers, the `
129129

130130
Unlike the other drivers supported by **Selenium Foundation** which are implemented in Java, the "engines" provided by [Appium](https://appium.io) are implemented in NodeJS. To launch a **Selenium Grid** collection that includes Appium nodes, you'll need the following additional tools:
131131
* Platform-specific Node Version Manager: The installation page for `npm` (below) provides links to recommended version managers.
132-
* [NodeJS (node)](https://nodejs.org): Currently, I'm running version 17.5.0
133-
* [Node Package Manager (npm)](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm): Currently, I'm running version 8.13.2
134-
* [Node Process Manager (pm2)](https://pm2.io/): Currently, I'm running version 5.2.0
135-
* [Appium](https://appium.io): Currently, I'm running version 1.22.3
132+
* [NodeJS (node)](https://nodejs.org): Currently, I'm running version 22.7.0
133+
* [Node Package Manager (npm)](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm): Currently, I'm running version 10.8.2
134+
* [Node Process Manager (pm2)](https://pm2.io/): Currently, I'm running version 5.4.2
135+
* [Appium](https://appium.io): Currently, I'm running version 2.11.3
136136

137137
Typically, these tools must be on the system file path. However, you can provide specific paths for each of these via **Selenium Foundation** settings:
138138
* **NPM_BINARY_PATH**: If unspecified, the `PATH` is searched

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ repositories {
237237

238238
dependencies {
239239
constraints {
240-
api 'com.nordstrom.tools:java-utils:3.2.1'
240+
api 'com.nordstrom.tools:java-utils:3.3.1'
241241
api 'com.nordstrom.tools:settings:3.0.5'
242242
api 'com.nordstrom.tools:junit-foundation:17.1.1'
243243
api 'com.github.sbabcoc:logback-testng:2.0.0'

espressoDeps.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ dependencies {
88
testImplementation('io.appium:java-client') {
99
exclude group: 'org.seleniumhq.selenium', module: 'selenium-java'
1010
exclude group: 'org.seleniumhq.selenium', module: 'selenium-support'
11+
exclude group: 'org.slf4j', module: 'slf4j-api'
1112
}
1213
}

mac2Deps.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ dependencies {
88
testImplementation('io.appium:java-client') {
99
exclude group: 'org.seleniumhq.selenium', module: 'selenium-java'
1010
exclude group: 'org.seleniumhq.selenium', module: 'selenium-support'
11+
exclude group: 'org.slf4j', module: 'slf4j-api'
1112
}
1213
}

selenium4Deps.gradle

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ ext.libsDir = new File(buildRoot, 'libs')
33

44
java {
55
toolchain {
6-
languageVersion = JavaLanguageVersion.of(11)
6+
languageVersion = JavaLanguageVersion.of(17)
77
}
88
}
99

@@ -25,25 +25,25 @@ sourceSets {
2525
dependencies {
2626
constraints {
2727
api 'com.nordstrom.tools:testng-foundation:5.1.1-j11'
28-
api 'org.seleniumhq.selenium:selenium-grid:4.23.0'
29-
api 'org.seleniumhq.selenium:selenium-support:4.23.0'
30-
api 'org.seleniumhq.selenium:selenium-chrome-driver:4.23.0'
31-
api 'org.seleniumhq.selenium:selenium-edge-driver:4.23.0'
32-
api 'org.seleniumhq.selenium:selenium-firefox-driver:4.23.0'
28+
api 'org.seleniumhq.selenium:selenium-grid:4.25.0'
29+
api 'org.seleniumhq.selenium:selenium-support:4.25.0'
30+
api 'org.seleniumhq.selenium:selenium-chrome-driver:4.25.0'
31+
api 'org.seleniumhq.selenium:selenium-edge-driver:4.25.0'
32+
api 'org.seleniumhq.selenium:selenium-firefox-driver:4.25.0'
3333
api 'org.seleniumhq.selenium:selenium-opera-driver:4.4.0'
34-
api 'org.seleniumhq.selenium:selenium-safari-driver:4.23.0'
34+
api 'org.seleniumhq.selenium:selenium-safari-driver:4.25.0'
3535
api 'com.nordstrom.ui-tools:htmlunit-remote:4.23.0'
3636
api 'org.seleniumhq.selenium:htmlunit3-driver:4.23.0'
3737
api 'org.htmlunit:htmlunit:4.4.0'
3838
api 'com.codeborne:phantomjsdriver:1.5.0'
3939
api 'org.apache.httpcomponents:httpclient:4.5.14'
4040
api 'org.eclipse.jetty:jetty-servlet:9.4.50.v20221201'
4141
api 'org.jsoup:jsoup:1.15.3'
42-
api 'org.apache.commons:commons-lang3:3.12.0'
42+
api 'org.apache.commons:commons-lang3:3.16.0'
4343
api 'com.beust:jcommander:1.82'
4444
api 'io.netty:netty-transport-native-epoll:4.1.93.Final'
4545
api 'io.netty:netty-transport-native-kqueue:4.1.93.Final'
46-
testImplementation 'io.appium:java-client:7.6.0'
46+
testImplementation 'io.appium:java-client:9.3.0'
4747
testImplementation 'org.mockito:mockito-core:4.6.1'
4848
}
4949
api 'com.nordstrom.tools:testng-foundation'

src/main/java/com/nordstrom/automation/selenium/AbstractSeleniumConfig.java

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.slf4j.Logger;
3333
import org.slf4j.LoggerFactory;
3434

35+
import com.nordstrom.automation.selenium.core.FoundationSlotMatcher;
3536
import com.nordstrom.automation.selenium.core.GridUtility;
3637
import com.nordstrom.automation.selenium.core.SeleniumGrid;
3738
import com.nordstrom.automation.selenium.servlet.ExamplePageLauncher;
@@ -183,6 +184,14 @@ public enum SeleniumSettings implements SettingsCore.SettingsAPI {
183184
*/
184185
HUB_PORT("selenuim.hub.port", null),
185186

187+
/**
188+
* This setting specifies the slot matcher used by the local <b>Selenium Grid</b> hub server.
189+
*
190+
* name: <b>selenium.slot.matcher</b><br>
191+
* default: <b>com.nordstrom.automation.selenium.core.FoundationSlotMatcher</b>
192+
*/
193+
SLOT_MATCHER("selenium.slot.matcher", FoundationSlotMatcher.class.getName()),
194+
186195
/**
187196
* This setting specifies a comma-delimited list of fully-qualified names of servlet classes to extend the
188197
* capabilities of the local <b>Selenium Grid</b> hub server.
@@ -564,6 +573,11 @@ public static SeleniumConfig getConfig() {
564573
throw new IllegalStateException("SELENIUM_CONFIG must be populated by subclass static initializer");
565574
}
566575

576+
/**
577+
* Get the major version of the target Selenium API.
578+
*
579+
* @return target Selenium major version
580+
*/
567581
public abstract int getVersion();
568582

569583
/**
@@ -610,7 +624,7 @@ public synchronized URL getHubUrl() {
610624
String hostStr = getString(SeleniumSettings.HUB_HOST.key());
611625
if (hostStr != null) {
612626
try {
613-
hubUrl = new URL(hostStr);
627+
hubUrl = URI.create(hostStr).toURL();
614628
} catch (MalformedURLException e) {
615629
throw UncheckedThrow.throwUnchecked(e);
616630
}
@@ -926,12 +940,12 @@ private static URI getConfigUri(final String path, final URL url) throws URISynt
926940
public String[] getDependencyContexts() {
927941
String gridLauncher = getString(SeleniumSettings.GRID_LAUNCHER.key());
928942
if (gridLauncher != null) {
943+
StringBuilder builder = new StringBuilder(gridLauncher);
944+
String slotMatcher = getString(SeleniumSettings.SLOT_MATCHER.key());
945+
if (slotMatcher != null) builder.append(File.pathSeparator).append(slotMatcher);
929946
String dependencies = getString(SeleniumSettings.LAUNCHER_DEPS.key());
930-
if (dependencies != null) {
931-
return (gridLauncher + File.pathSeparator + dependencies).split(File.pathSeparator);
932-
} else {
933-
return new String[] { gridLauncher };
934-
}
947+
if (dependencies != null) builder.append(File.pathSeparator).append(dependencies);
948+
return builder.toString().split(File.pathSeparator);
935949
} else {
936950
return new String[] {};
937951
}
@@ -1007,6 +1021,15 @@ public String getContextPlatform() {
10071021
return getConfig().getString(SeleniumSettings.CONTEXT_PLATFORM.key());
10081022
}
10091023

1024+
/**
1025+
* Determine if the {@code Appium} server should be managed by the {@code PM2} utility.
1026+
*
1027+
* @return {@code true} if Appium should be managed by PM2; otherwise {@code false}
1028+
*/
1029+
public boolean appiumWithPM2() {
1030+
return getConfig().getBoolean(SeleniumSettings.APPIUM_WITH_PM2.key());
1031+
}
1032+
10101033
/**
10111034
* {@inheritDoc}
10121035
*/

src/main/java/com/nordstrom/automation/selenium/core/GridUtility.java

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.net.InetAddress;
1010
import java.net.NetworkInterface;
1111
import java.net.SocketException;
12+
import java.net.URI;
1213
import java.net.URISyntaxException;
1314
import java.net.URL;
1415
import java.net.UnknownHostException;
@@ -47,6 +48,7 @@
4748
import com.nordstrom.automation.selenium.utility.NetIdentity;
4849
import com.nordstrom.common.base.UncheckedThrow;
4950
import com.nordstrom.common.file.PathUtils;
51+
import com.nordstrom.common.uri.UriUtils;
5052

5153
/**
5254
* This class provides basic support for interacting with a Selenium Grid instance.
@@ -67,12 +69,12 @@ private GridUtility() {
6769
* Determine if the specified Selenium Grid host (hub or node) is active.
6870
*
6971
* @param hostUrl {@link URL} to be checked
70-
* @param request request path (may include parameters)
72+
* @param pathAndParams path and query parameters
7173
* @return 'true' if specified host is active; otherwise 'false'
7274
*/
73-
public static boolean isHostActive(final URL hostUrl, final String request) {
75+
public static boolean isHostActive(final URL hostUrl, final String... pathAndParams) {
7476
try {
75-
HttpResponse response = getHttpResponse(hostUrl, request);
77+
HttpResponse response = getHttpResponse(hostUrl, pathAndParams);
7678
return (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK);
7779
} catch (IOException eaten) {
7880
// nothing to do here
@@ -84,16 +86,15 @@ public static boolean isHostActive(final URL hostUrl, final String request) {
8486
* Send the specified GET request to the indicated host.
8587
*
8688
* @param hostUrl {@link URL} of target host
87-
* @param request request path (may include parameters)
89+
* @param pathAndParams path and query parameters
8890
* @return host response for the specified GET request
8991
* @throws IOException if the request triggered an I/O exception
9092
*/
91-
public static HttpResponse getHttpResponse(final URL hostUrl, final String request) throws IOException {
93+
public static HttpResponse getHttpResponse(final URL hostUrl, final String... pathAndParams) throws IOException {
9294
Objects.requireNonNull(hostUrl, "[hostUrl] must be non-null");
93-
Objects.requireNonNull(request, "[request] must be non-null");
9495
HttpClient client = HttpClientBuilder.create().build();
95-
URL url = new URL(hostUrl.getProtocol(), hostUrl.getHost(), hostUrl.getPort(), request);
96-
return client.execute(extractHost(hostUrl), new HttpGet(url.toExternalForm()));
96+
URI uri = UriUtils.makeBasicURI(hostUrl.getProtocol(), hostUrl.getHost(), hostUrl.getPort(), pathAndParams);
97+
return client.execute(extractHost(hostUrl), new HttpGet(uri.toURL().toExternalForm()));
9798
}
9899

99100
/**
@@ -108,8 +109,8 @@ public static HttpResponse callGraphQLService(final URL hostUrl, String query) t
108109
Objects.requireNonNull(hostUrl, "[hostUrl] must be non-null");
109110
Objects.requireNonNull(query, "[query] must be non-null");
110111
HttpClient client = HttpClientBuilder.create().build();
111-
URL url = new URL(hostUrl.getProtocol(), hostUrl.getHost(), hostUrl.getPort(), "/graphql");
112-
HttpPost httpRequest = new HttpPost(url.toExternalForm());
112+
URI uri = UriUtils.makeBasicURI(hostUrl.getProtocol(), hostUrl.getHost(), hostUrl.getPort(), "/graphql");
113+
HttpPost httpRequest = new HttpPost(uri.toURL().toExternalForm());
113114
httpRequest.setEntity(new StringEntity(query, ContentType.APPLICATION_JSON));
114115
return client.execute(extractHost(hostUrl), httpRequest);
115116
}
@@ -294,14 +295,18 @@ public static String getLocalHost() {
294295
* Get next configured output path for Grid server of specified role.
295296
*
296297
* @param config {@link SeleniumConfig} object
297-
* @param isHub role of Grid server being started ({@code true} = hub; {@code false} = node)
298+
* @param isHub role of Grid server being started: <ul>
299+
* <li>{@code true} = hub</li>
300+
* <li>{@code false} = node</li>
301+
* <li>{@code null} = relay</li>
302+
* </ul>
298303
* @return Grid server output path (may be {@code null})
299304
*/
300-
public static Path getOutputPath(SeleniumConfig config, boolean isHub) {
305+
public static Path getOutputPath(SeleniumConfig config, Boolean isHub) {
301306
Path outputPath = null;
302307

303308
if (!config.getBoolean(SeleniumSettings.GRID_NO_REDIRECT.key())) {
304-
String gridRole = isHub ? "hub" : "node";
309+
String gridRole = (isHub == null) ? "relay" : (isHub) ? "hub" : "node";
305310
String logsFolder = config.getString(SeleniumSettings.GRID_LOGS_FOLDER.key());
306311
Path logsPath = Paths.get(logsFolder);
307312
if (!logsPath.isAbsolute()) {

src/main/java/com/nordstrom/automation/selenium/core/SeleniumGrid.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.nordstrom.automation.selenium.SeleniumConfig;
3333
import com.nordstrom.automation.selenium.core.LocalSeleniumGrid.LocalGridServer;
3434
import com.nordstrom.automation.selenium.plugins.PluginUtils;
35+
import com.nordstrom.common.uri.UriUtils;
3536

3637
/**
3738
* <b>The {@code SeleniumGrid} Object</b>
@@ -91,8 +92,8 @@ public SeleniumGrid(SeleniumConfig config, URL hubUrl) throws IOException {
9192
} else {
9293
LOGGER.debug("Mapping structure of grid at: {}", hubUrl);
9394
for (URL nodeEndpoint : nodeEndpoints) {
94-
URL nodeUrl = new URL(nodeEndpoint, GridServer.HUB_BASE);
95-
nodeServers.put(nodeEndpoint, new GridServer(nodeUrl, false));
95+
URI nodeUri = UriUtils.uriForPath(nodeEndpoint, GridServer.HUB_BASE);
96+
nodeServers.put(nodeEndpoint, new GridServer(nodeUri.toURL(), false));
9697
addNodePersonalities(config, hubServer.getUrl(), nodeEndpoint);
9798
}
9899
LOGGER.debug("{}: Personalities => {}", hubServer.getUrl(), personalities.keySet());

src/main/java/com/nordstrom/automation/selenium/model/ComponentContainer.java

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.ArrayList;
99
import java.util.Arrays;
1010
import java.util.Collections;
11+
import java.util.HashMap;
1112
import java.util.Iterator;
1213
import java.util.List;
1314
import java.util.Map;
@@ -20,6 +21,7 @@
2021
import org.apache.http.client.utils.URLEncodedUtils;
2122
import org.apache.http.message.BasicNameValuePair;
2223
import org.openqa.selenium.By;
24+
import org.openqa.selenium.JavascriptExecutor;
2325
import org.openqa.selenium.Keys;
2426
import org.openqa.selenium.SearchContext;
2527
import org.openqa.selenium.StaleElementReferenceException;
@@ -91,31 +93,6 @@ public interface ByEnum {
9193
private static final String UPDATE_VALUE =
9294
"arguments[0].value=arguments[1]; arguments[0].dispatchEvent(new Event('input',{bubbles:true}));";
9395

94-
private static final Class<?> ACTIVITY_CLASS;
95-
private static final Constructor<?> ACTIVITY_CTOR;
96-
private static final Method START_ACTIVITY;
97-
98-
static {
99-
Class<?> activityClass;
100-
Constructor<?> activityCtor;
101-
Method startActivity;
102-
103-
try {
104-
Class<?> androidDriver = Class.forName("io.appium.java_client.android.AndroidDriver");
105-
activityClass = Class.forName("io.appium.java_client.android.Activity");
106-
activityCtor = activityClass.getConstructor(String.class, String.class);
107-
startActivity = androidDriver.getMethod("startActivity", activityClass);
108-
} catch (ClassNotFoundException | NoSuchMethodException | SecurityException e) {
109-
activityClass = null;
110-
activityCtor = null;
111-
startActivity = null;
112-
}
113-
114-
ACTIVITY_CLASS = activityClass;
115-
ACTIVITY_CTOR = activityCtor;
116-
START_ACTIVITY = startActivity;
117-
}
118-
11996
private final Logger logger;
12097

12198
/**
@@ -620,14 +597,12 @@ public static void getUrl(final String url, final WebDriver driver) {
620597
Objects.requireNonNull(driver, "[driver] must be non-null");
621598

622599
if (url.startsWith("activity://")) {
623-
Objects.requireNonNull(ACTIVITY_CLASS, "AndroidDriver is required to launch activities");
624-
try {
625-
String[] components = url.split("/");
626-
START_ACTIVITY.invoke(driver, ACTIVITY_CTOR.newInstance(components[2], components[3]));
627-
} catch (SecurityException | InstantiationException | IllegalAccessException
628-
| IllegalArgumentException | InvocationTargetException e) {
629-
throw new RuntimeException("Unable to launch specified activity", e);
630-
}
600+
String[] components = url.split("/");
601+
((JavascriptExecutor) driver).executeScript("mobile: startActivity",
602+
new HashMap<String, String>() {{
603+
put("package", components[2]);
604+
put("appActivity", components[3]);
605+
}});
631606
} else {
632607
driver.get(url);
633608
}

0 commit comments

Comments
 (0)