/*
* The MIT License
*
* Copyright (c) 2010, InfraDNA, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.saucelabs.sauce_ondemand.driver;
import com.saucelabs.selenium.client.factory.SeleniumFactory;
import com.saucelabs.selenium.client.factory.spi.SeleniumFactorySPI;
import com.thoughtworks.selenium.Selenium;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.kohsuke.MetaInfServices;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxProfile;
import org.openqa.selenium.remote.CapabilityType;
import org.openqa.selenium.remote.DesiredCapabilities;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.text.MessageFormat;
import java.util.*;
import java.util.Map.Entry;
import java.util.concurrent.*;
/**
* {@link SeleniumFactorySPI} that talks to Sauce OnDemand.
*
* @author Kohsuke Kawaguchi
*/
@MetaInfServices
public class SauceOnDemandSPIImpl extends SeleniumFactorySPI {
private static final String DEFAULT_WEBDRIVER_HOST = "ondemand.saucelabs.com";
private static final String DEFAULT_WEBDRIVER_PORT = "80";
private static final String DEFAULT_SELENIUM_HOST = "saucelabs.com";
private static final int DEFAULT_SELENIUM_PORT = 4444;
public static final String SELENIUM_HOST = "SELENIUM_HOST";
public static final String SELENIUM_PORT = "SELENIUM_PORT";
public static final String OS = "os";
public static final String BROWSER = "browser";
public static final String BROWSER_VERSION = "browser-version";
public static final String USERNAME = "username";
public static final String ACCESS_KEY = "access-key";
private static final String[] NON_PROFILE_PARAMETERS = new String[]{ACCESS_KEY, BROWSER, BROWSER_VERSION, OS, USERNAME};
@Override
public Selenium createSelenium(SeleniumFactory factory, String browserURL) {
String uri = factory.getUri();
if (!canHandle(uri))
return null; // not ours
uri = uri.substring(SCHEME.length());
if (!uri.startsWith("?"))
throw new IllegalArgumentException("Missing '?':" + factory.getUri());
Map<String, List<String>> paramMap = populateParameterMap(uri);
String host = readPropertyOrEnv("SELENIUM_HOST", DEFAULT_SELENIUM_HOST);
String portAsString = readPropertyOrEnv("SELENIUM_PORT", null);
int port;
if (portAsString == null || portAsString.equals("")) {
port = DEFAULT_SELENIUM_PORT;
} else {
port = Integer.parseInt(portAsString);
}
return new SeleniumImpl(
host, port,
toJSON(paramMap),
browserURL,
new Credential(paramMap.get(USERNAME).get(0), paramMap.get(ACCESS_KEY).get(0)),
paramMap.get("job-name").get(0));
}
private Map<String, List<String>> populateParameterMap(String uri) {
// massage parameter into JSON format
Map<String, List<String>> paramMap = new HashMap<String, List<String>>();
for (String param : uri.substring(1).split("&")) {
int idx = param.indexOf('=');
if (idx < 0) throw new IllegalArgumentException("Invalid parameter format: " + uri);
String key = param.substring(0, idx);
String value = param.substring(idx + 1);
List<String> v = paramMap.get(key);
if (v == null) paramMap.put(key, v = new ArrayList<String>());
v.add(value);
}
if (paramMap.get(USERNAME) == null && paramMap.get(ACCESS_KEY) == null) {
try {
// read the credential from a credential file
Credential cred = new Credential();
paramMap.put(USERNAME, Collections.singletonList(cred.getUsername()));
paramMap.put(ACCESS_KEY, Collections.singletonList(cred.getKey()));
} catch (IOException e) {
throw new IllegalArgumentException("Failed to read " + Credential.getDefaultCredentialFile(), e);
}
}
if (paramMap.get("job-name") == null)
paramMap.put("job-name", Collections.singletonList(getJobName()));
return paramMap;
}
@Override
public WebDriver createWebDriver(SeleniumFactory factory, String browserURL, DesiredCapabilities capabilities) {
String uri = factory.getUri();
if (!uri.startsWith(SCHEME))
return null; // not ours
uri = uri.substring(SCHEME.length());
if (!uri.startsWith("?"))
throw new IllegalArgumentException("Missing '?':" + factory.getUri());
return createWebDriver(browserURL, capabilities, uri);
}
private WebDriver createWebDriver(String browserURL, DesiredCapabilities capabilities, String uri) {
// massage parameter into JSON format
Map<String, List<String>> paramMap = populateParameterMap(uri);
DesiredCapabilities desiredCapabilities;
if (hasParameter(paramMap, OS) &&
hasParameter(paramMap, BROWSER) &&
hasParameter(paramMap, BROWSER_VERSION)) {
String browser = getFirstParameter(paramMap, BROWSER);
desiredCapabilities = new DesiredCapabilities(capabilities);
desiredCapabilities.setBrowserName(browser);
desiredCapabilities.setVersion(getFirstParameter(paramMap, BROWSER_VERSION));
desiredCapabilities.setCapability(CapabilityType.PLATFORM, getFirstParameter(paramMap, OS));
if (browser.equals("firefox")) {
setFirefoxProfile(paramMap, desiredCapabilities);
}
populateDesiredCapabilities(paramMap, desiredCapabilities);
} else {
//use Firefox as a default
desiredCapabilities = capabilities;
desiredCapabilities.merge(DesiredCapabilities.firefox());
setFirefoxProfile(paramMap, desiredCapabilities);
}
String host = readPropertyOrEnv(SELENIUM_HOST, DEFAULT_WEBDRIVER_HOST);
String portAsString = readPropertyOrEnv(SELENIUM_PORT, null);
if (portAsString == null || portAsString.equals("")) {
portAsString = DEFAULT_WEBDRIVER_PORT;
}
try {
WebDriver driver = new RemoteWebDriverImpl(
new URL(
MessageFormat.format(
"http://{2}:{3}@{0}:{1}/wd/hub",
host, portAsString,
getFirstParameter(paramMap, USERNAME),
getFirstParameter(paramMap, ACCESS_KEY))),
desiredCapabilities,
new Credential(getFirstParameter(paramMap, USERNAME), getFirstParameter(paramMap, ACCESS_KEY)),
getFirstParameter(paramMap, "job-name"));
if (browserURL != null) {
driver.get(browserURL);
}
return driver;
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Invalid URL: " + uri, e);
}
}
private void populateDesiredCapabilities(Map<String, List<String>> paramMap, DesiredCapabilities desiredCapabilities) {
for (Entry<String, List<String>> entry : paramMap.entrySet()) {
desiredCapabilities.setCapability(entry.getKey(), entry.getValue().get(0));
}
}
private void setFirefoxProfile(Map<String, List<String>> paramMap, DesiredCapabilities desiredCapabilities) {
FirefoxProfile profile = new FirefoxProfile();
populateProfilePreferences(profile, paramMap);
desiredCapabilities.setCapability("firefox_profile", profile);
}
private void populateProfilePreferences(FirefoxProfile profile, Map<String, List<String>> paramMap) {
for (Map.Entry<String, List<String>> mapEntry : paramMap.entrySet()) {
String key = mapEntry.getKey();
if (Arrays.binarySearch(NON_PROFILE_PARAMETERS, key) == -1) {
//add it to the profile
profile.setPreference(key, getFirstParameter(paramMap, key));
}
}
}
@Override
public boolean canHandle(String uri) {
return uri.startsWith(SCHEME);
}
/**
* Try to find the name of the test as best we can.
*/
public String getJobName() {
// look for the caller of SeleniumFactory
StackTraceElement[] trace = Thread.currentThread().getStackTrace();
boolean foundFactory = false;
String callerName = null;
for (StackTraceElement e : trace) {
if (foundFactory)
callerName = e.getClassName() + "." + e.getMethodName();
foundFactory = e.getClassName().equals(SeleniumFactory.class.getName());
}
return callerName;
}
/**
* Converts a multi-map to a JSON format.
*/
private String toJSON(Map<String, List<String>> paramMap) {
boolean first = true;
StringBuilder buf = new StringBuilder("{");
for (Entry<String, List<String>> e : paramMap.entrySet()) {
if (first) first = false;
else buf.append(',');
buf.append('"').append(e.getKey()).append("\":");
List<String> v = e.getValue();
if (v.size() == 1) {
buf.append('"').append(v.get(0)).append('"');
} else {
buf.append('[');
for (int i = 0; i < v.size(); i++) {
if (i != 0) buf.append(',');
buf.append('"').append(v.get(i)).append('"');
}
buf.append(']');
}
}
buf.append('}');
return buf.toString();
}
private String getFirstParameter(Map<String, List<String>> paramMap, String parameterName) {
List<String> values = paramMap.get(parameterName);
return values.get(0);
}
private boolean hasParameter(Map<String, List<String>> paramMap, String parameterName) {
List<String> values = paramMap.get(parameterName);
return values != null && !values.isEmpty();
}
private static final String SCHEME = "sauce-ondemand:";
private static String readPropertyOrEnv(String key, String defaultValue) {
String v = System.getProperty(key);
if (v == null)
v = System.getenv(key);
if (v == null)
v = defaultValue;
return v;
}
/**
* Creates a list of WebDriver instances based on the contents of a SAUCE_ONDEMAND_BROWSERS environment variable (typically set
* by the Sauce Jenkins plugin).
*
* @param seleniumFactory
* @param browserURL
* @return
*/
@Override
public List<WebDriver> createWebDrivers(SeleniumFactory seleniumFactory, final String browserURL) {
List<WebDriver> webDrivers = new ArrayList<WebDriver>();
String browserJson = readPropertyOrEnv("SAUCE_ONDEMAND_BROWSERS", null);
if (browserJson == null) {
throw new IllegalArgumentException("Unable to find SAUCE_ONDEMAND_BROWSERS environment variable");
}
//parse JSON and extract the browser urls, so that we know how many threads to schedule
List<String> browsers = new ArrayList<String>();
try {
JSONArray array = new JSONArray(new JSONTokener(browserJson));
for (int i = 0; i < array.length(); i++) {
JSONObject object = array.getJSONObject(i);
String uri = object.getString("url");
if (!uri.startsWith(SCHEME))
return null; // not ours
uri = uri.substring(SCHEME.length());
if (!uri.startsWith("?"))
throw new IllegalArgumentException("Missing '?':" + uri);
browsers.add(uri);
}
} catch (JSONException e) {
throw new IllegalArgumentException("Error parsing JSON", e);
}
//create a fixed thread pool for the number of browser
ExecutorService service = Executors.newFixedThreadPool(browsers.size());
List<Callable<WebDriver>> callables = new ArrayList<Callable<WebDriver>>();
for (final String browser : browsers) {
callables.add(new Callable<WebDriver>() {
public WebDriver call() throws Exception {
return createWebDriver(browserURL, null, browser);
}
});
}
//invoke all the callables, and wait for each thread to return
try {
List<Future<WebDriver>> futures = service.invokeAll(callables);
for (Future<WebDriver> future : futures) {
webDrivers.add(future.get());
}
} catch (InterruptedException e) {
throw new IllegalArgumentException("Error retrieving webdriver", e);
} catch (ExecutionException e) {
throw new IllegalArgumentException("Error retrieving webdriver", e);
}
service.shutdown();
return Collections.unmodifiableList(webDrivers);
}
}