com.comcast.magicwand.spells.saucelabs.SauceProvider.java Source code

Java tutorial

Introduction

Here is the source code for com.comcast.magicwand.spells.saucelabs.SauceProvider.java

Source

/**
 * Copyright 2015 Comcast Cable Communications Management, LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * 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 com.comcast.magicwand.spells.saucelabs;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.comcast.cookie.CookieHandler;
import com.comcast.cookie.handlers.GeneralCookieHandler;
import com.comcast.drivethru.api.HTTPRequestManager;
import com.comcast.drivethru.api.HTTPRequestManager.Builder;
import com.comcast.drivethru.api.HTTPRequestManager.METHOD;
import com.comcast.drivethru.constants.ServerStatusCodes;
import com.comcast.drivethru.model.ResponseContainer;
import com.comcast.magicwand.builders.PhoenixDriverIngredients;
import com.comcast.magicwand.drivers.PhoenixDriver;
import com.comcast.magicwand.enums.DesktopOS;
import com.comcast.magicwand.exceptions.FlyingPhoenixException;
import com.saucelabs.ci.sauceconnect.SauceConnectFourManager;
import com.saucelabs.ci.sauceconnect.SauceConnectTwoManager;
import com.saucelabs.ci.sauceconnect.SauceTunnelManager;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import org.json.JSONException;
import org.json.JSONObject;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.remote.UnreachableBrowserException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.android.AndroidDriver;

/**
 * Provides a {@link RemoteWebDriver} for SauceLabs
 * @author Dmitry Jerusalimsky
 *
 */
public class SauceProvider {

    private static final String DEFAULT_URL = "http://ondemand.saucelabs.com:80/wd/hub";
    private static final String DEFAULT_SAUCE_STORAGE_URL = "https://saucelabs.com/rest/v1/storage";

    private static final int ZERO_FILE_UPLOAD_SIZE = 0;

    private static final Logger LOG = LoggerFactory.getLogger(SauceProvider.class);

    public static final String USERNAME = "sauceUser";
    public static final String API_KEY = "sauceApiKey";
    public static final String URL = "sauceUrl";
    public static final String VPN = "sauceVPN";
    public static final String VPN_PORT = "sauceVPNPort";
    public static final String VPN_QUIET_MODE = "sauceVPNQuiteMode";
    public static final String VPN_OPTIONS = "sauceVPNOptions";
    public static final String VPN_VERSION = "sauceVPNVersion";
    public static final String COOKIE_HANDLER = "cookieHandler";
    public static final String SAUCE_STOTAGE_URL = "sauceStorageUrl";
    public static final String SAUCE_FILES_UPLOAD = "sauceFilesUpload";

    private String username;
    private String apiKey;
    private String urlStr;
    private String sauceStorageUrlStr;
    private List filesToUpload;

    private DesiredCapabilities customCapabilities;

    private CookieHandler cookieHandler;

    // this will be used when we'll get sauce connect incorporated
    private boolean useVpn = false;

    private DesktopOS desktopOS;

    private Map<String, Object> driverCustomConfig;

    /**
     * Creates an instance of a SauceProvider class
     * @param ingridients Set of {@link PhoenixDriverIngredients} to use for driver creation
     * @param capabilities Set of {@link DesiredCapabilities} to use for driver creation
     *
     */
    public SauceProvider(PhoenixDriverIngredients ingridients, DesiredCapabilities capabilities) {
        // extract data
        this.driverCustomConfig = ingridients.getDriverConfigs();
        this.apiKey = (String) driverCustomConfig.get(API_KEY);
        this.cookieHandler = (CookieHandler) driverCustomConfig.get(COOKIE_HANDLER);
        String url = (String) driverCustomConfig.get(URL);

        urlStr = (null == url) ? DEFAULT_URL : url;

        // url for sauce storage location
        String sauceStorageUrl = (String) driverCustomConfig.get(SAUCE_STOTAGE_URL);
        sauceStorageUrlStr = (null == sauceStorageUrl) ? DEFAULT_SAUCE_STORAGE_URL : sauceStorageUrl;

        // Initializing to empty list
        filesToUpload = new ArrayList<String>();
        Object filesObject = this.driverCustomConfig.get(SAUCE_FILES_UPLOAD);
        // Adding a null check so that SAUCE_FILES_UPLOAD will not be a mandatory field
        if (null != filesObject) {
            if (filesObject instanceof List<?>) {
                filesToUpload = (List) filesObject;
            } else {
                LOG.error(
                        "Error: Invalid data type for driver ingredient {}; continuing with empty file list. Expected data type: List holding file path",
                        SAUCE_FILES_UPLOAD);
            }
        }

        this.username = (String) driverCustomConfig.get(USERNAME);

        Boolean vpn = (Boolean) driverCustomConfig.get(VPN);
        this.useVpn = (null == vpn) ? false : vpn.booleanValue();

        this.desktopOS = ingridients.getDesktopOS();

        this.customCapabilities = capabilities;
    }

    /**
     * Null safe check to see if the string starts or ends with the substring
     *
     * @param subject Subject string to check
     * @param substring Substring to search for at the beginning or end of the subject
     * @return true the substring is the beginning or end of the subject, false otherwise
     *         false if the subject is null
     */
    private boolean nullSafeStartsEnds(String subject, String substring) {
        if ((null == subject) || (null == substring)) {
            return false;
        }

        return (subject.startsWith(substring) || subject.endsWith(substring));
    }

    /**
     * Gets userInfo string from either the URL or system properties
     * @param url URL that might contain userInfo
     * @return String containing userInfo
     * @throws FlyingPhoenixException If one of the following is true:
     * <ul>
     * <li>Credentials were NOT specified via system properties or URL</li>
     * <li>Credentials were specified by BOTH system properties and URL</li>
     * <li>Part of the credentials is missing; No username or no API key was found</li>
     * </ul>
     *
     */
    protected String getUserInfo(URL url) throws FlyingPhoenixException {
        String urlUserInfo = url.getUserInfo();
        String credsUserInfo = null;

        if ((null != this.username) || (null != this.apiKey)) {
            credsUserInfo = String.format("%s:%s", (null == this.username ? "" : this.username),
                    (null == this.apiKey ? "" : this.apiKey));
        }

        // make sure that only 1 set of creds is present
        if ((null != urlUserInfo) && (null != credsUserInfo)) {
            String msg = String.format("URL userInfo '%s' cannot be specified if '%s' or '%s' is defined",
                    urlUserInfo, USERNAME, API_KEY);
            throw new FlyingPhoenixException(msg);
        }
        // if both sources of credentials are missing, throw an exception
        else if ((null == urlUserInfo) && (null == credsUserInfo)) {
            String msg = String.format(
                    "Credentials are missing. Please include them using '%s' and '%s' properties or as part of a URL.",
                    USERNAME, API_KEY);
            throw new FlyingPhoenixException(msg);
        }
        // make sure that both username and api key are present
        else if (nullSafeStartsEnds(credsUserInfo, ":") || nullSafeStartsEnds(urlUserInfo, ":")) {
            String msg = String.format("Either set '%s' and '%s' properties or define userInfo in the url",
                    USERNAME, API_KEY);
            throw new FlyingPhoenixException(msg);
        }

        return (null == urlUserInfo) ? credsUserInfo : urlUserInfo;
    }

    /**
     * Generates URL to use in order to connect to SauceLabs
     * @return URL for SauceLabs
     * @throws FlyingPhoenixException If URL is malformed or invalid
     */
    protected URL generateSauceURL() throws FlyingPhoenixException {
        try {
            URL baseUrl = new URL(urlStr);
            String userInfo = getUserInfo(baseUrl);

            String auth = String.format("http://%s@%s:%d%s", userInfo, baseUrl.getHost(), baseUrl.getPort(),
                    baseUrl.getFile());
            URL remoteAddress = new URL(auth);
            LOG.debug("Remote URL is: " + remoteAddress.toURI());
            return remoteAddress;
        } catch (MalformedURLException | URISyntaxException e) {
            String msg = "There was an error while creating sauce url " + e.getMessage();
            LOG.error(msg);
            throw new FlyingPhoenixException(msg, e);
        }
    }

    /**
     * Creates one of:
     * <ul>
     * <li>{@link RemoteWebDriver}</li>
     * <li>{@link AndroidDriver}</li>
     * <li>{@link IOSDriver}</li>
     * </ul>
     * driver that will be communicating with SauceLabs
     * @param remoteAddress URL of the location to connect to
     * @return Instance of a driver
     * @throws FlyingPhoenixException
     */
    protected WebDriver createWebDriver(URL remoteAddress) throws FlyingPhoenixException {
        WebDriver retVal = null;

        boolean isAppium = (null != this.customCapabilities.getCapability("appiumVersion"));
        String platform = (String) this.customCapabilities.getCapability("platformName");
        boolean isAndroid = ("android".equalsIgnoreCase(platform));

        // upload files
        if (!filesToUpload.isEmpty()) {
            SauceResponse sauceResponse = uploadFilesToSauceStorage();
            LOG.info("FileName:UploadSuccess {}", sauceResponse.getResponseObject());
            if (!sauceResponse.isSuccess()) {
                LOG.error(sauceResponse.getFailureReason());
                throw new FlyingPhoenixException("Uploading file/s to sauce storage failed");
            }
        }

        if (isAppium && isAndroid) {
            retVal = new AndroidDriver(remoteAddress, this.customCapabilities);
        } else if (isAppium && (null != platform)) {
            retVal = new IOSDriver(remoteAddress, this.customCapabilities);
        } else {
            retVal = new RemoteWebDriver(remoteAddress, this.customCapabilities);
        }

        return retVal;
    }

    /**
     * Creates an instance of a {@link PhoenixDriver} that is representing {@link SaucePhoenixDriver}
     * @return Instance of a {@link SaucePhoenixDriver}
     * @throws FlyingPhoenixException If there was an error creating a driver
     */
    public PhoenixDriver buildDriver() throws FlyingPhoenixException {
        if (null == this.username) {
            throw new FlyingPhoenixException("Errors while validating builder parameters",
                    new SauceException("Username must be provided"));
        }

        if (null == this.apiKey) {
            throw new FlyingPhoenixException("Errors while validating builder parameters",
                    new SauceException("API Key must be provided"));
        }

        this.customCapabilities.setCapability("platform", this.desktopOS.toString());

        if (null == this.cookieHandler) {
            this.cookieHandler = new GeneralCookieHandler();
        }

        URL remoteAddress = generateSauceURL();

        try {
            SauceTunnelManager vpnManager = initSauceConnect();

            WebDriver driver = createWebDriver(remoteAddress);
            SaucePhoenixDriver spd = new SaucePhoenixDriver(driver, cookieHandler, vpnManager,
                    this.driverCustomConfig);
            LOG.debug("Driver is: " + spd);
            return spd;
        } catch (UnreachableBrowserException e) {
            LOG.error("Unreachable Browser Exception was caught. " + e.getMessage());
            throw new FlyingPhoenixException("Errors while validating builder parameters",
                    new SauceException("There was an error creating a sauce driver", e));
        }
    }

    /**
     * Sets up a tunnel via SauceConnect
     * @return
     * @throws FlyingPhoenixException
     */
    private SauceTunnelManager initSauceConnect() throws FlyingPhoenixException {
        if (false == this.useVpn) {
            LOG.warn("Driver is not configured to establish a VPN connection. "
                    + "Please make sure that '{}' property is set to 'true'", SauceProvider.VPN);
            return null;
        }

        Integer port = (Integer) this.driverCustomConfig.get(SauceProvider.VPN_PORT);
        Boolean qm = (Boolean) this.driverCustomConfig.get(SauceProvider.VPN_QUIET_MODE);
        Integer vv = (Integer) this.driverCustomConfig.get(SauceProvider.VPN_VERSION);

        int vpnVersion;
        if (null == vv) {
            vpnVersion = 3;
            LOG.warn("Sauce Connect version was not specified... Defaulting to '{}'", Integer.toString(vpnVersion));
        } else {
            vpnVersion = vv.intValue();
        }

        String vpnOptions = (String) this.driverCustomConfig.get(SauceProvider.VPN_OPTIONS);
        int vpnPort;
        if (null == port) {
            vpnPort = 4445;
            LOG.warn("Sauce Connect port was not specified... Defaulting to '{}'", Integer.toString(vpnPort));
        } else {
            vpnPort = port.intValue();
        }

        boolean vpnQm;
        if (null == qm) {
            vpnQm = true;
            LOG.warn("Sauce Connect Quiet Mode was not specified... Defaulting to '{}'", Boolean.toString(vpnQm));
        } else {
            vpnQm = qm.booleanValue();
        }

        LOG.debug(
                "Establishing a VPN connection using the following arguments: { port = {}; options = {}; quietMode = {} }",
                vpnPort, vpnOptions, !vpnQm);

        if (4 != vpnVersion && 3 != vpnVersion) {
            String msg = String.format("Unsupported Sauce Connect version '%d'. Valid versions are '3' and '4'",
                    vpnVersion);
            LOG.error(msg);
            throw new FlyingPhoenixException(msg);
        }

        SauceTunnelManager vpnManager = (4 == vpnVersion) ? new SauceConnectFourManager(vpnQm)
                : new SauceConnectTwoManager(vpnQm);
        try {
            vpnManager.openConnection(username, apiKey, vpnPort, null, vpnOptions, null, null, !useVpn);
        } catch (IOException e) {
            vpnManager = null;
            String msg = "There was an error while establishing a VPN connection to SauceLabs";
            LOG.error(msg, e);
            throw new FlyingPhoenixException(msg, e);
        }

        return vpnManager;
    }

    /**
     * Upload files to sauce storage and return response of file uploads as SauceResponse
     *
     * @return SauceResponse holding response of file uploads
     */
    public SauceResponse uploadFilesToSauceStorage() {
        SauceResponse sauceResponse = new SauceResponse();
        StringBuilder failureReason = new StringBuilder();
        Map<String, Boolean> fileUploadStatus = new HashMap<String, Boolean>();
        for (Object filePath : this.filesToUpload) {
            if (filePath instanceof String) {
                File file = new File((String) filePath);
                if (file.isFile() && file.exists()) {
                    // Reset upload status to false each time before upload
                    boolean uploadStatus = false;
                    try {
                        String authString = username + ":" + apiKey;
                        /* Encoding authentication string */
                        String authStringEnc = new Base64().encodeAsString(new String(authString).getBytes());
                        LOG.debug("Base64 encoded auth string: " + authStringEnc);

                        // Setting request headers
                        Map<String, String> headers = new HashMap<String, String>();
                        headers.put("Accept", "application/octet-stream");
                        headers.put("Authorization", "Basic " + authStringEnc);
                        headers.put("Content-type", "application/json");

                        // Converts file content to byte data
                        byte[] data = IOUtils.toByteArray(new FileInputStream(file));

                        // Uploading file to sauce storage by sending multi-part encoded HTTP POST request to SauceLabs
                        Builder builder = new HTTPRequestManager.Builder();
                        HTTPRequestManager helper = builder
                                .url(this.sauceStorageUrlStr + "/" + username + "/" + file.getName()
                                        + "?overwrite=true")
                                .method(METHOD.POST).contentType("multipart/form-data").headers(headers).data(data)
                                .build();
                        LOG.info("Uploading file {} to sauce storage", file.getName());

                        ResponseContainer response = doSendRequest(helper);
                        LOG.info("Response - {}", response.getResponseBody());
                        if (ServerStatusCodes.OK == response.getStatusCode()) {
                            String responseBody = response.getResponseBody();
                            if (null != responseBody) {
                                JSONObject jsonResponse = new JSONObject(responseBody);
                                int size = (Integer) jsonResponse.get("size");
                                if (ZERO_FILE_UPLOAD_SIZE < size) {
                                    uploadStatus = true;
                                }
                            }
                        }
                    } catch (JSONException | IOException e) {
                        failureReason.append(String.format("Exception while uploading file %s to sauce storage",
                                file.getName()));
                        LOG.error("Exception while uploading file {} to sauce storage", file.getName());
                    }

                    fileUploadStatus.put(file.getName(), uploadStatus);
                    if (!uploadStatus) {
                        failureReason.append(String.format("Failed uploading file %s", file.getName()));
                    }

                } else {
                    failureReason.append(String.format(
                            "Either the given file is a directory or file does not exist %s \n", file.getName()));
                    LOG.error("Either the given file is a directory or file does not exist {}", file.getName());
                    fileUploadStatus.put(file.getName(), false);
                }
            } else {
                failureReason.append(
                        String.format("Invalid data type: %s not a file name \n", filePath.getClass().getName()));
                LOG.error("Invalid data type: {} not a file name", filePath.getClass().getName());
            }
        }
        if (this.filesToUpload.size() > 0 && failureReason.length() <= 0) {
            sauceResponse.setSuccess(true);
        }
        sauceResponse.setResponseObject(fileUploadStatus);
        sauceResponse.setFailureReason(failureReason.toString());

        return sauceResponse;
    }

    /**
     * Sends HTTP request. Also, to support unit testing, http request-response handling refactored to separate API
     *
     * @param requestManager
     * @return HTTP Response container
     * @throws IOException
     */
    protected ResponseContainer doSendRequest(HTTPRequestManager requestManager) throws IOException {
        ResponseContainer response = requestManager.sendRequest();
        return response;
    }
}