Source: steps/browser.js

"use strict";
/**
 * Steps for web applications.
 *
 * @mixin BrowserSteps
 */

var fs = require("fs");
var path = require("path");
const util = require("util");

var _ = require("lodash");
var fp = require("lodash/fp");
const getPort = require("get-port");
var expect = require("chai").expect;
var selenium = require("selenium-standalone");
var wdio = require("webdriverio");
const uuid = require("uuid/v4");
var U = require("glace-utils");
var LOG = U.logger;

var CONF = require("../config");
const utils = require("../utils");

var defChromeOpts;

var BrowserSteps = {
    /* imports */
    __wdio: wdio,

    installSeleniumDrivers: async function (opts) {
        /**
         * Step to install selenium drivers.
         *
         * @async
         * @memberOf BrowserSteps
         * @method installSeleniumDrivers
         * @instance
         * @arg {object} [opts] - [selenium-standalone](https://github.com/vvo/selenium-standalone) options.
         * @return {Promise<void>}
         * @throws {Error} If there was error during drivers installation.
         */

        opts = U.defVal(opts, {});
        opts.logger = LOG.debug.bind(LOG);
        opts = fp.merge(CONF.web.seleniumOpts, opts);

        const driversInfo = fp.merge({ [CONF.web.browser]: {} }, opts.drivers);
        if (this._checkInstalledDrivers(driversInfo)) {
            LOG.info("Selenium drivers are installed already");
            return;
        }

        allure.step("Install selenium drivers");
        LOG.info("Installing selenium drivers...");

        await new Promise((resolve, reject) => {

            selenium.install(opts, err => {
                if (err) return reject(err);
                resolve();
            });
        });

        LOG.info("Selenium drivers are installed");
        allure.pass();
    },

    startSeleniumServer: async function (opts) {
        /**
         * Step to start selenium server. Step recall will be skipped if
         *  selenium server wasn't stopped before.
         *
         * @async
         * @memberOf BrowserSteps
         * @method startSeleniumServer
         * @arg {object} [opts] - [selenium-standalone](https://github.com/vvo/selenium-standalone) options.
         * @instance
         * @return {Promise<boolean>} `true` if step is executed, `false` if skipped.
         * @throws {Error} If there was error on selenium start.
         */

        if (this._seleniumProc) {
            LOG.warn("Step to start selenium server was passed already");
            return false;
        };

        opts = U.defVal(opts, {});
        opts = fp.merge(CONF.web.seleniumOpts, opts);

        if (!CONF.webdriver.port) CONF.webdriver.port = await getPort();
        opts.seleniumArgs = ["-port", CONF.webdriver.port];

        allure.step(`Start selenium server on port ${CONF.webdriver.port}`);
        LOG.info(`Starting selenium server on port ${CONF.webdriver.port}...`);

        this._seleniumProc = await new Promise((resolve, reject) => {
            selenium.start(opts, (err, child) => {
                if (err) return reject(err);
                resolve(child);
            });
        });

        LOG.info(`Selenium server is started with PID ${this._seleniumProc.pid}`);
        allure.pass();

        return true;
    },

    stopSeleniumServer: async function () {
        /**
         * Step to stop selenium server. Step call will be skipped if selenium
         *  server wasn't started before.
         *
         * @async
         * @memberOf BrowserSteps
         * @method stopSeleniumServer
         * @instance
         * @return {Promise<boolean>} `true` if step is executed, `false` if skipped.
         * @throws {Error} If there was error on selenium stop.
         */

        if (!this._seleniumProc) {
            LOG.warn("Step to start selenium server wasn't passed yet");
            return false;
        };

        allure.step("Stop selenium server");
        LOG.info("Stopping selenium server...");

        await new Promise((resolve, reject) => {

            this._seleniumProc.on("exit", (code, signal) => {
                LOG.debug(`Selenium server was stopped with code ${code} and signal ${signal}`);
                delete this._seleniumProc;
                resolve();
            });
            this._seleniumProc.on("error", reject);
            var result = this._seleniumProc.kill("SIGINT");
            if (!result) reject("Oops! Can't kill selenium server");
        });

        LOG.info("Selenium server is stopped");
        allure.pass();

        return true;
    },

    getLaunchedBrowsers: function (opts={}) {
        /**
         * Step to get launched browsers.
         *
         * @async
         * @memberOf BrowserSteps
         * @method getLaunchedBrowsers
         * @instance
         * @arg {object} [opts] - Step options.
         * @arg {boolean} [opts.check=true] - Flag to check that there are launched
         * browsers.
         * @return {object} - Dictionary: `key` - browser uniq name,
         * `val` - webdriver instance.
         * @throws {Error} If no one browser is launched.
         */

        opts.check = U.defVal(opts.check, true);

        allure.step("Get launched browsers");
        LOG.info("Getting launched browsers...");

        const launchedBrowsers = this._webdrivers().all();
        if (opts.check) {
            expect(launchedBrowsers, "No one browser is launched").to.not.be.empty;
        }

        LOG.info("Got launched browsers");
        allure.pass();

        return launchedBrowsers;
    },

    getCurrentBrowser: function (opts={}) {
        /**
         * Step to get current browser (webdriver instance).
         *
         * @async
         * @memberOf BrowserSteps
         * @method getCurrentBrowser
         * @instance
         * @arg {object} [opts] - Step options.
         * @arg {boolean} [opts.check=true] - Flag to check that there is launched
         * browser.
         * @return {object} - Dictionary: `key` - browser uniq name,
         * `val` - webdriver instance.
         * @throws {Error} If no one browser is launched.
         */

        opts.check = U.defVal(opts.check, true);

        allure.step("Get current browser");
        LOG.info("Getting current browser...");

        let currBrowser = null;
        if (this.webdriver) {
            currBrowser = { [this._webdrivers().getKey(this.webdriver)]: this.webdriver };
        }
        if (opts.check) {
            expect(currBrowser, "No one browser is launched").to.exist;
        }

        LOG.info("Got current browser");
        allure.pass();

        return currBrowser;
    },

    launchBrowser: async function (opts) {
        /**
         * Step to launch browser.
         *
         * @async
         * @memberOf BrowserSteps
         * @method launchBrowser
         * @instance
         * @arg {object} [opts] - Step options.
         * @arg {string} [opts.name=random] - Browser name.
         * @arg {object} [opts.webdriver=CONF.webdriver] - Webdriver config.
         * @arg {boolean} [opts.tryExisting=false] - Try to launch existing browser.
         * @arg {boolean} [opts.check=true] - Flag to check that browser was
         *  launched.
         * @arg {string[]} [opts.chromeOpts=[]] - List of chrome options.
         * @return {Promise<boolean>} `true` if step is executed, `false` if skipped.
         * @throws {AssertionError} If browser wasn't launched.
         *
         * @example
         *
         * await $.launchBrowser();
         * await $.launchBrowser({ chromeOpts: ["headless", "user-data-dir=/path/to/dir"] });
         */

        opts = U.defVal(opts, {});
        const check = U.defVal(opts.check, true);
        opts.tryExisting = U.defVal(opts.tryExisting, false);
        this.webUrl = U.defVal(this.webUrl, CONF.web.url);

        let name, isNewBrowser = false;
        if (!opts.tryExisting || !this.webdriver) {
            const webdriverConf = U.defVal(opts.webdriver, CONF.webdriver);

            name = U.defVal(
                opts.name, `${webdriverConf.desiredCapabilities.browserName}-${uuid()}`);
            expect(this._webdrivers().get(name),
                `Browser name "${name}" is used already. Choose another.`).to.not.exist;

            this.webdriver = this.__wdio.remote(webdriverConf);
            isNewBrowser = true;
        } else {
            name = this._webdrivers().getKey(this.webdriver);
            if (await this.webdriver.session()) {
                LOG.debug(`Browser "${name}" is launched already`);
                return false;
            }
        }

        allure.step(`Launch browser "${name}" via selenium`);
        LOG.info(`Launching browser "${name}" via selenium...`);

        if (CONF.web.platform === "pc" &&
            this.webdriver.desiredCapabilities.browserName === "chrome") {
            this._setChromeOpts(opts.chromeOpts);
        }

        await this.webdriver.init();
        await this._setTimeouts();

        if (check) {
            expect(await this.webdriver.session(),
                "Browser wasn't launched").to.exist;
        };

        LOG.info("Browser is launched");

        if (CONF.web.platform === "pc" && CONF.web.width && CONF.web.height) {
            await this.setViewport();
        };

        allure.pass();

        if (isNewBrowser) this._webdrivers().push(this.webdriver, name);
        return true;
    },

    /**
     * Helper to webdriver timeouts.
     * @ignore
     */
    _setTimeouts: async function () {
        var pageLoad = CONF.web.pageTimeout * 1000;
        try {
            await this.webdriver.timeouts("page load", pageLoad);
        } catch (e) {
            LOG.error(util.format("Can't set webdriver timeouts", e));
            await this.webdriver.timeouts({ pageLoad: pageLoad });
        }
    },

    /**
     * Helper to set chrome options.
     *
     * It composes chrome options list in next order:
     * - user provided options
     * - proxy options if they are not present in list
     * - default config options if they are not present in list
     *
     * @method
     * @instance
     * @protected
     * @arg {array} [chromeOpts=[]] - List of chrome options.
     */
    _setChromeOpts: function (chromeOpts) {
        var opt;
        /* store origin chrome options, because then webdriver options will be overridden */
        defChromeOpts = U.defVal(defChromeOpts,
            this.webdriver.desiredCapabilities.chromeOptions.args, []);

        chromeOpts = _.clone(U.defVal(chromeOpts, []));

        if (this.globalProxy && this.globalProxy.isRunning) {
            var proxyOptions = [
                "ignore-certificate-errors",
                `proxy-server=${U.hostname}:${this.globalProxy.getPort()}`,
                `proxy-bypass-list=localhost,127.0.0.1,${U.hostname}`,
            ];
            for (opt of proxyOptions) {
                if (!isOptPresent(opt, chromeOpts)) chromeOpts.push(opt);
            };
        };

        for (opt of defChromeOpts) {
            if (!isOptPresent(opt, chromeOpts)) chromeOpts.push(opt);
        };

        this.webdriver.desiredCapabilities.chromeOptions.args = chromeOpts;
    },

    setViewport: async function (opts) {
        /**
         * Step to set browser viewport size.
         * 
         * @async
         * @memberOf BrowserSteps
         * @method setViewport
         * @instance
         * @arg {object} [opts] - Step options.
         * @arg {number} [opts.width] - Browser viewport width.
         * @arg {number} [opts.height] - Browser viewport height.
         * @arg {boolean} [opts.check=true] - Flag to check step result.
         * @return {boolean} `true` when step is executed.
         * @throws {AssertionError} If width or height values are not a number.
         * @throws {AssertionError} If viewport size wasn't changed correctly.
         */

        opts = U.defVal(opts, {});
        var width = U.defVal(opts.width, CONF.web.width);
        var height = U.defVal(opts.height, CONF.web.height);
        var check = U.defVal(opts.check, true);

        expect(width, "Invalid browser viewport width").to.be.a("number");
        expect(height, "Invalid browser viewport height").to.be.a("number");

        allure.step(`Set browser viewport to [width=${width}, height=${height}]`);
        LOG.info(`Setting browser viewport to [width=${width}, height=${height}]...`);

        await this
            .webdriver
            .setViewportSize({ width: width, height: height });
        
        if (check) {
            var viewport = await this.webdriver.getViewportSize();

            expect(viewport.width,
                "Invalid browser viewport width")
                .to.be.equal(width);
            expect(viewport.height,
                "Invalid browser viewport height")
                .to.be.equal(height);
        };

        LOG.info("Browser viewport size is set");
        allure.pass();

        return true;
    },

    closeBrowser: async function (opts) {
        /**
         * Step to close browser.
         *
         * @async
         * @memberOf BrowserSteps
         * @method closeBrowser
         * @instance
         * @arg {object} [opts] - Step options.
         * @arg {object} [opts.switchPrev=true] - Flag to switch browser to last launched.
         * @arg {boolean} [opts.check=true] - Flag to check that browser was closed.
         * @return {Promise<boolean>} `true` if step is executed, `false` if skipped.
         * @throws {AssertionError} If browser wasn't closed.
         */

        if (!this._webdrivers().top()) {
            LOG.debug("No one browser is launched yet");
            return false;
        };

        opts = U.defVal(opts, {});
        opts.switchPrev = U.defVal(opts.switchPrev, true);
        opts.check = U.defVal(opts.check, true);

        const name = this._webdrivers().getKey(this.webdriver);
        const webdriver = this.webdriver;

        if (opts.switchPrev) {
            expect(this._webdrivers().pop(),
                "Browsers collection is misordered.").to.be.equal(this.webdriver);
            this.webdriver = this._webdrivers().top();
        }

        allure.step(`Close browser "${name}"`);
        LOG.info(`Closing browser "${name}"...`);

        await webdriver.end();
        await this.pause(1, "webdriver process will be stopped");

        if (opts.check) {
            expect(await webdriver.session(),
                "Browser wasn't closed").to.not.exist;
        };

        LOG.info("Browser is closed");
        allure.pass();
        return true;
    },

    restartBrowser: async function (opts={}) {
        /**
         * Step to restart browser.
         *
         * @async
         * @memberOf BrowserSteps
         * @method restartBrowser
         * @instance
         * @arg {object} [opts={}] - Step options.
         * @return {Promise<void>}
         */

        opts.switchPrev = false;
        opts.tryExisting = true;
        allure.step("Restart browser");
        await this.closeBrowser(opts);
        await this.launchBrowser(opts);
        allure.pass();
    },

    switchBrowser: function (name) {
        /**
         * Step to switch to browser by name.
         *
         * @memberOf BrowserSteps
         * @method switchBrowser
         * @instance
         * @arg {string} 
         * @arg {string} name - Browser uniq name.
         * @return {object} - Webdriver instance.
         */

        allure.step(`Switch browser to "${name}"`);
        LOG.info(`Switching browser to "${name}"...`);

        const webdriver = this._webdrivers().pop(name);
        this._webdrivers().push(webdriver);
        this.webdriver = webdriver;

        LOG.info("Browser is switched");
        allure.pass();

        return this.webdriver;
    },

    closeAllBrowsers: async function (opts={}) {
        /**
         * Step to close all browsers.
         *
         * @async
         * @memberOf BrowserSteps
         * @method closeAllBrowsers
         * @instance
         * @arg {object} [opts] - Step options.
         * @arg {boolean} [opts.check=true] - Flag to check that browser was closed.
         * @return {Promise<void>}
         */

        opts.switchPrev = true;
        let exitStatus = true;
        while (exitStatus) {
            exitStatus = await this.closeBrowser(opts);
        }
    },

    openUrl: async function (webUrl, opts) {
        /**
         * Step to open URL in browser.
         *
         * @async
         * @memberOf BrowserSteps
         * @method openUrl
         * @instance
         * @arg {string} webUrl - URL which should be opened in browser.
         * @arg {object} [opts] - Step options.
         * @arg {boolean} [opts.check=true] - Flag to check that URL is opened.
         * @arg {number} [opts.timeout] - Timeout to wait for URL will be opened
         *  in browser, sec. Default value is `CONF.web.pageTimeout`.
         * @return {Promise<void>}
         * @throws {Error} If URL wasn't opened after timeout.
         */

        opts = U.defVal(opts, {});
        var check = U.defVal(opts.check, true);
        var timeout = U.defVal(opts.timeout, CONF.web.pageTimeout) * 1000;

        allure.step(`Open URL "${webUrl}" in browser`);
        LOG.info(`Openning URL "${webUrl}" in browser...`);

        await this.webdriver.url(webUrl);

        if (check) {
            var errMsg = `Browser didn't navigate to "${webUrl}" ` +
                         `during ${timeout} ms`;

            await this.webdriver.waitUntil(async () => {
                var curUrl = await this.webdriver.getUrl();
                LOG.debug(`Compare current URL "${curUrl}" with expected "${webUrl}"`);
                return curUrl.startsWith(webUrl);
            }, timeout, errMsg);
        };

        LOG.info("URL is opened");
        allure.pass();
    },

    openApp: async function (opts) {
        /**
         * Step to open application URL in browser.
         *
         * @async
         * @memberOf BrowserSteps
         * @method openApp
         * @instance
         * @arg {object} [opts] - Step options.
         * @return {Promise<void>}
         * @throws {AssertionError} If application URL is not defined.
         */

        allure.step("Open web application");
        expect(this.webUrl,
            "Web URL isn't defined").to.exist;
        await this.openUrl(this.webUrl, opts);
        allure.pass();
    },

    _webdrivers: function () {
        if (!this.__webdrivers) {
            this.__webdrivers = new utils.OrderedCollection();
        }
        return this.__webdrivers;
    },

    /**
     * Helper to check whether selenium drivers are installed or no.
     *
     * @ignore
     */
    _checkInstalledDrivers: function (driversInfo) {
        const driversDir = path.join(
            path.dirname(require.resolve("selenium-standalone")), ".selenium");
        if (!fs.existsSync(driversDir)) return false;

        const browserNames = Object.keys(driversInfo);
        const seleniumConf = require("selenium-standalone/lib/default-config");
        driversInfo = fp.merge(seleniumConf.drivers, driversInfo);

        let isDriversInstalled;
        for (const browserName of browserNames) {
            const driverName = `${browserName}driver`;

            const driverDir = path.join(driversDir, driverName);
            if (!fs.existsSync(driverDir)) return false;

            const driverVersion = driversInfo[browserName].version;
            expect(driverVersion).to.exist;

            isDriversInstalled = false;
            for (let driverFile of fs.readdirSync(driverDir)) {
                driverFile = path.parse(driverFile).name.toLowerCase();

                if (driverFile.startsWith(driverVersion)
                        && driverFile.includes(driverName)) {
                    isDriversInstalled = true;
                    break;
                }
            }
        }
        return isDriversInstalled;
    },
};

module.exports = BrowserSteps;

/**
 * Helper to define whether chrome option is present in options list.
 *
 * @ignore
 * @function
 */
var isOptPresent = (opt, opts) => {
    var optStart = opt.split("=")[0];
    for (var o of opts) {
        if (o.split("=")[0] === optStart) return true;
    };
    return false;
};