Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP for mobile support #147

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
9 changes: 7 additions & 2 deletions java/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@
<dependencies>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>1.10.19</version>
<artifactId>mockito-core</artifactId>
<version>3.3.3</version>
<scope>test</scope>
</dependency>
<dependency>
Expand Down Expand Up @@ -94,6 +94,11 @@
<version>1.25</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.appium</groupId>
<artifactId>java-client</artifactId>
<version>7.3.0</version>
</dependency>
</dependencies>
<build>
<plugins>
Expand Down
20 changes: 17 additions & 3 deletions java/src/main/java/com/saucelabs/saucebindings/DataCenter.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,30 @@
import lombok.Getter;

public enum DataCenter {
US_WEST("ondemand.us-west-1.saucelabs.com"),
US_EAST("ondemand.us-east-1.saucelabs.com"),
EU_CENTRAL("ondemand.eu-central-1.saucelabs.com");
US_LEGACY("https://ondemand.saucelabs.com/wd/hub"),
US_WEST("https://ondemand.us-west-1.saucelabs.com/wd/hub"),
US_EAST("https://ondemand.us-east-1.saucelabs.com/wd/hub"),
HEADLESS("https://ondemand.us-east-1.saucelabs.com/wd/hub"),
EU_CENTRAL("https://ondemand.eu-central-1.saucelabs.com/wd/hub"),
US_MOBILE("https://us1.appium.testobject.com/wd/hub"),
EU_MOBILE("https://eu1.appium.testobject.com/wd/hub"),
US_TEST_OBJECT("https://us1-manual.app.testobject.com/wd/hub"),
EU_TEST_OBJECT("https://appium.testobject.com/wd/hub");

@Getter private final String value;

DataCenter(String value) {
this.value = value;
}

public boolean supportsW3C() {
return ("https://ondemand.saucelabs.com/wd/hub".equals(value));
}

public boolean isTestObject() {
return value.contains("testobject");
}

public String toString() {
return value;
}
Expand Down
175 changes: 175 additions & 0 deletions java/src/main/java/com/saucelabs/saucebindings/SauceMobileOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package com.saucelabs.saucebindings;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.openqa.selenium.MutableCapabilities;

import java.util.List;

@Accessors(chain = true)
@Setter @Getter
public class SauceMobileOptions extends SauceOptions {
@Setter(AccessLevel.NONE) private MutableCapabilities appiumCapabilities;

// Defined in W3C
private Browser browserName = Browser.CHROME;
private SaucePlatform platformName = SaucePlatform.ANDROID;

// Defined in Appium
// These are the only values that are handled by Sauce Labs by default
// Additional values will be populated by Appium's IOSOptions & AndroidOptions class instances
private String app;
private String deviceName;
private String deviceOrientation = "portrait";
private String platformVersion = "10";
private String automationName;

// Supported by Sauce
private String appiumVersion = "1.15.0";
private String deviceType; // "table" or "phone"

// Supported by Sauce for Real Devices
private String testobject_app_id;
private String privateDeviceOnly;
private String tabletOnly;
private String phoneOnly;
private String carrierConnectivityOnly;
private String recordDeviceVitals;
private String cacheId;
private String testobject_test_live_view_url;
private String testobject_test_report_url;
private String testobject_test_report_api_url;
private String testobject_session_creation_timeout;
private String commandTimeouts;
private String crosswalkApplication;
private String autoGrantPermissions;
private String enableAnimations;
private String name;

public static final List<String> mobileW3COptions = List.of(
"browserName",
"platformName");

public static final List<String> mobileSauceDefinedOptions = List.of(
"appiumVersion",
"deviceType");

public static final List<String> appiumDefinedOptions = List.of(
"app",
"automationName",
"deviceName",
"platformVersion",
"deviceOrientation");

public static final List<String> realDeviceSauceDefinedOptions = List.of(
"testobject_app_id",
"privateDeviceOnly",
"tabletOnly",
"phoneOnly",
"carrierConnectivityOnly",
"recordDeviceVitals",
"cacheId",
"testobject_test_live_view_url",
"testobject_test_report_url",
"testobject_test_report_api_url",
"testobject_session_creation_timeout",
"commandTimeouts",
"crosswalkApplication",
"autoGrantPermissions",
"enableAnimations",
"name");

public SauceMobileOptions() {
this(new MutableCapabilities());
}

// TODO: require users to work with Appium's MobileOptions class similar to Selenium
public SauceMobileOptions(MutableCapabilities options) {
appiumCapabilities = new MutableCapabilities(options.asMap());
}

public MutableCapabilities toCapabilities(DataCenter dataCenter) {
if (app != null) {
browserName = null;
}

mobileW3COptions.forEach((capability) -> {
addCapabilityIfDefined(appiumCapabilities, capability);
});

if (dataCenter.supportsW3C()) {
useW3cCapabilities();
} else {
useJwpCapabilities(dataCenter.isTestObject());
}
return appiumCapabilities;
}

private void useW3cCapabilities() {
MutableCapabilities sauceCapabilities = new MutableCapabilities();
useSaucePlatform(sauceCapabilities);

appiumDefinedOptions.forEach((capability) -> {
addAppiumCapabilityIfDefined(appiumCapabilities, capability);
});

mobileSauceDefinedOptions.forEach((capability) -> {
addCapabilityIfDefined(sauceCapabilities, capability);
});

sauceDefinedOptions.forEach((capability) -> {
addCapabilityIfDefined(appiumCapabilities, capability);
});

appiumCapabilities.setCapability("sauce:options", sauceCapabilities);
}

private void useJwpCapabilities(boolean to) {
if (to) {
appiumCapabilities.setCapability("testobject_api_key", getTestObjectKey());
if (deviceName == null) {
this.deviceName = "Google Pixel 2";
}
} else {
useSaucePlatform(appiumCapabilities);
}

appiumDefinedOptions.forEach((capability) -> {
addCapabilityIfDefined(appiumCapabilities, capability);
});

mobileSauceDefinedOptions.forEach((capability) -> {
addCapabilityIfDefined(appiumCapabilities, capability);
});

realDeviceSauceDefinedOptions.forEach((capability) -> {
addCapabilityIfDefined(appiumCapabilities, capability);
});
}

private void useSaucePlatform(MutableCapabilities capabilities) {
if (deviceName == null) {
this.deviceName = "Android GoogleAPI Emulator";
}
addAuthentication(capabilities);
}

private void addAppiumCapabilityIfDefined(MutableCapabilities capabilities, String capability) {
Object value = getCapability(capability);
if (value != null) {
capabilities.setCapability("appium:" + capability, value);
}
}

protected String getTestObjectKey() {
if (getSystemProperty("TEST_OBJECT_KEY") != null) {
return getSystemProperty("TEST_OBJECT_KEY");
} else if (getEnvironmentVariable("TEST_OBJECT_KEY") != null) {
return getEnvironmentVariable("TEST_OBJECT_KEY");
} else {
throw new SauceEnvironmentVariablesNotSetException("Test Object API Key was not provided");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.saucelabs.saucebindings;

import io.appium.java_client.AppiumDriver;
import lombok.Getter;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.InvalidArgumentException;
import org.openqa.selenium.MutableCapabilities;

import java.net.MalformedURLException;
import java.net.URL;

public class SauceMobileSession extends SauceSession {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how I feel about Dividing SauceSession and SauceMobileSession. Can we combine the logic here into SauceSession and have a single session concept?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need some way of differentiating Appium Driver from Selenium Driver type. I started to look into doing generics and interfaces, but I can't tell if that's actually better for users.

@Getter private final SauceMobileOptions sauceOptions;
@Getter private AppiumDriver driver;

public SauceMobileSession() {
this(new SauceMobileOptions());
}

public SauceMobileSession(SauceMobileOptions options) {
sauceOptions = options;
}

public AppiumDriver start() {
MutableCapabilities capabilities = sauceOptions.toCapabilities(getDataCenter());

URL url;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (dataCenter.supportsW3C() || dataCenter.isTestObject()) {
url = getSauceUrl();
} else {
String username = (String) capabilities.getCapability("username");
String key = (String) capabilities.getCapability("accessKey");
url = getSauceUrl(username, key);
}

driver = createAppiumDriver(url, capabilities);
return driver;
}

public AppiumDriver createAppiumDriver(URL url, Capabilities caps) {
return new AppiumDriver<>(url, caps);
}

public URL getSauceUrl(String username, String key) {
String url = dataCenter.getValue().replace("://", "://" + username + ":" + key + "@");
try {
return new URL(url);
} catch (MalformedURLException e) {
throw new InvalidArgumentException("Invalid URL");
}
}
}
47 changes: 41 additions & 6 deletions java/src/main/java/com/saucelabs/saucebindings/SauceOptions.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package com.saucelabs.saucebindings;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.AccessLevel;
import lombok.experimental.Accessors;

import org.openqa.selenium.MutableCapabilities;
import org.openqa.selenium.Proxy;
import org.openqa.selenium.chrome.ChromeOptions;
Expand Down Expand Up @@ -128,6 +127,12 @@ public class SauceOptions {
knownCITools.put("TeamCity", "TEAMCITY_PROJECT_NAME");
}

public static SauceOptions headless() {
SauceOptions headlessOptions = new SauceOptions();
headlessOptions.setPlatformName(SaucePlatform.LINUX);
return headlessOptions;
}

public SauceOptions() {
this(new MutableCapabilities());
}
Expand Down Expand Up @@ -168,6 +173,7 @@ private SauceOptions(MutableCapabilities options) {

public MutableCapabilities toCapabilities() {
MutableCapabilities sauceCapabilities = new MutableCapabilities();
addAuthentication(sauceCapabilities);

if (getCapability("jobVisibility") != null) {
sauceCapabilities.setCapability("public", getCapability("jobVisibility"));
Expand All @@ -189,7 +195,7 @@ public MutableCapabilities toCapabilities() {
return seleniumCapabilities;
}

private void addCapabilityIfDefined(MutableCapabilities capabilities, String capability) {
protected void addCapabilityIfDefined(MutableCapabilities capabilities, String capability) {
Object value = getCapability(capability);
if (value != null) {
capabilities.setCapability(capability, value);
Expand Down Expand Up @@ -227,11 +233,11 @@ public void mergeCapabilities(Map<String, Object> capabilities) {
}

// This might be made public in future version; For now, no good reason to prefer it over direct accessor
private Object getCapability(String capability) {
protected Object getCapability(String capability) {
try {
String getter = "get" + capability.substring(0, 1).toUpperCase() + capability.substring(1);
Method declaredMethod = null;
declaredMethod = SauceOptions.class.getDeclaredMethod(getter);
declaredMethod = this.getClass().getMethod(getter);
return declaredMethod.invoke(this);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
Expand All @@ -240,7 +246,7 @@ private Object getCapability(String capability) {
}

// This might be made public in future version; For now, no good reason to prefer it over direct accessor
public void setCapability(String key, Object value) {
protected void setCapability(String key, Object value) {
if (primaryEnum.contains(key) && value.getClass().equals(String.class)) {
setEnumCapability(key, (String) value);
} else if (secondaryEnum.contains(key) && isKeyString((HashMap) value)) {
Expand Down Expand Up @@ -326,6 +332,35 @@ private void setEnumCapability(String key, String value) {
}
}

protected void addAuthentication(MutableCapabilities caps) {
caps.setCapability("username", getSauceUsername());
caps.setCapability("accessKey", getSauceAccessKey());
}

protected String getSauceUsername() {
if (getSystemProperty("SAUCE_USERNAME") != null) {
return getSystemProperty("SAUCE_USERNAME");
} else if (getEnvironmentVariable("SAUCE_USERNAME") != null) {
return getEnvironmentVariable("SAUCE_USERNAME");
} else {
throw new SauceEnvironmentVariablesNotSetException("Sauce Username was not provided");
}
}

protected String getSauceAccessKey() {
if (getSystemProperty("SAUCE_ACCESS_KEY") != null) {
return getSystemProperty("SAUCE_ACCESS_KEY");
} else if (getEnvironmentVariable("SAUCE_ACCESS_KEY") != null) {
return getEnvironmentVariable("SAUCE_ACCESS_KEY");
} else {
throw new SauceEnvironmentVariablesNotSetException("Sauce Access Key was not provided");
}
}

protected String getSystemProperty(String key) {
return System.getProperty(key);
}

protected String getEnvironmentVariable(String key) {
return System.getenv(key);
}
Expand Down