repo_description: Supercharged End 2 End Testing Framework for NodeJS
repo_topics: acceptance-testing bdd bdd-framework codeceptjs e2e-tests end-to-end-testing graphql-testing hacktoberfest javascript nodejs pageobject playwright selenium selenium-webdriver testing webdriver webdriverio
code: let webdriverio
  
  const assert = require('assert')
  const path = require('path')
  
  const Helper = require('@codeceptjs/helper')
  const promiseRetry = require('promise-retry')
  const stringIncludes = require('../assert/include').includes
  const { urlEquals, equals } = require('../assert/equal')
  const store = require('../store')
  const { debug } = require('../output')
  const { empty } = require('../assert/empty')
  const { truth } = require('../assert/truth')
  const { xpathLocator, fileExists, decodeUrl, chunkArray, convertCssPropertiesToCamelCase, screenshotOutputFolder, getNormalizedKeyAttributeValue, modifierKeys } = require('../utils')
  const { isColorProperty, convertColorToRGBA } = require('../colorUtils')
  const ElementNotFound = require('./errors/ElementNotFound')
  const ConnectionRefused = require('./errors/ConnectionRefused')
  const Locator = require('../locator')
  const { highlightElement } = require('./scripts/highlightElement')
  const { focusElement } = require('./scripts/focusElement')
  const { blurElement } = require('./scripts/blurElement')
  const { dontSeeElementError, seeElementError, seeElementInDOMError, dontSeeElementInDOMError } = require('./errors/ElementAssertion')
  const { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } = require('./network/actions')
  const WebElement = require('../element/WebElement')
  
  const SHADOW = 'shadow'
  const webRoot = 'body'
  let browserLogs = []
  
  /**
   * ## Configuration
   *
   * This helper should be configured in codecept.conf.js
   *
   * @typedef WebDriverConfig
   * @type {object}
   * @prop {string} url - base url of website to be tested.
   * @prop {string} browser - Browser in which to perform testing.
   * @prop {boolean} [bidiProtocol=false] - WebDriver Bidi Protocol. Default: false. More info: https://webdriver.io/docs/api/webdriverBidi/
   * @prop {string} [basicAuth] - (optional) the basic authentication to pass to base url. Example: {username: 'username', password: 'password'}
   * @prop {string} [host=localhost] - WebDriver host to connect.
   * @prop {number} [port=4444] - WebDriver port to connect.
   * @prop {string} [protocol=http] - protocol for WebDriver server.
   * @prop {string} [path=/wd/hub] - path to WebDriver server.
   * @prop {boolean} [restart=true] - restart browser between tests.
   * @prop {boolean|number} [smartWait=false] - **enables [SmartWait](http://codecept.io/acceptance/#smartwait)**; wait for additional milliseconds for element to appear. Enable for 5 secs: "smartWait": 5000.
   * @prop {boolean} [disableScreenshots=false] - don't save screenshots on failure.
   * @prop {boolean} [fullPageScreenshots=false] (optional - make full page screenshots on failure.
   * @prop {boolean} [uniqueScreenshotNames=false] - option to prevent screenshot override if you have scenarios with the same name in different suites.
   * @prop {boolean} [keepBrowserState=false] - keep browser state between tests when `restart` is set to false.
   * @prop {boolean} [keepCookies=false] - keep cookies between tests when `restart` set to false.
   * @prop {string} [windowSize=window] default window size. Set to `maximize` or a dimension in the format `640x480`.
   * @prop {number} [waitForTimeout=1000] sets default wait time in *ms* for all `wait*` functions.
   * @prop {object} [desiredCapabilities] Selenium's [desired capabilities](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities).
   * @prop {boolean} [manualStart=false] - do not start browser before a test, start it manually inside a helper with `this.helpers["WebDriver"]._startBrowser()`.
   * @prop {object} [timeouts] [WebDriver timeouts](http://webdriver.io/docs/timeouts.html) defined as hash.
   * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
   * @prop {string} [logLevel=silent] - level of logging verbosity. Default: silent. Options: trace | debug | info | warn | error | silent. More info: https://webdriver.io/docs/configuration/#loglevel
   */
  const config = {}
  
  /**
   * WebDriver helper which wraps [webdriverio](http://webdriver.io/) library to
   * manipulate browser using Selenium WebDriver or PhantomJS.
   *
   * WebDriver requires Selenium Server and ChromeDriver/GeckoDriver to be installed. Those tools can be easily installed via NPM. Please check [Testing with WebDriver](https://codecept.io/webdriver/#testing-with-webdriver) for more details.
   *
   * With the release of WebdriverIO version v8.14.0, and onwards, all driver management hassles are now a thing of the past 🙌. Read more [here](https://webdriver.io/blog/2023/07/31/driver-management/).
   * One of the significant advantages of this update is that you can now get rid of any driver services you previously had to manage, such as
   * `wdio-chromedriver-service`, `wdio-geckodriver-service`, `wdio-edgedriver-service`, `wdio-safaridriver-service`, and even `@wdio/selenium-standalone-service`.
   *
   * For those who require custom driver options, fear not; WebDriver Helper allows you to pass in driver options through custom WebDriver configuration.
   * If you have a custom grid, use a cloud service, or prefer to run your own driver, there's no need to worry since WebDriver Helper will only start a driver when there are no other connection information settings like hostname or port specified.
   *
   * <!-- configuration -->
   *
   * Example:
   *
   * ```js
   * {
   *    helpers: {
   *      WebDriver : {
   *        smartWait: 5000,
   *        browser: "chrome",
   *        restart: false,
   *        windowSize: "maximize",
   *        timeouts: {
   *          "script": 60000,
   *          "page load": 10000
   *        }
   *      }
   *    }
   * }
   * ```
   *
   * Testing Chrome locally is now more convenient than ever. You can define a browser channel, and WebDriver Helper will take care of downloading the specified browser version for you.
   * For example:
   *
   * ```js
   * {
   *    helpers: {
   *      WebDriver : {
   *        smartWait: 5000,
   *        browser: "chrome",
   *        browserVersion: '116.0.5793.0', // or 'stable', 'beta', 'dev' or 'canary'
   *        restart: false,
   *        windowSize: "maximize",
   *        timeouts: {
   *          "script": 60000,
   *          "page load": 10000
   *        }
   *      }
   *    }
   * }
   * ```
   *
   *
   * Example with basic authentication
   * ```js
   * {
   *    helpers: {
   *      WebDriver : {
   *        smartWait: 5000,
   *        browser: "chrome",
   *        basicAuth: {username: 'username', password: 'password'},
   *        restart: false,
   *        windowSize: "maximize",
   *        timeouts: {
   *          "script": 60000,
   *          "page load": 10000
   *        }
   *      }
   *    }
   * }
   * ```
   *
   * Additional configuration params can be used from [webdriverio
   * website](http://webdriver.io/guide/getstarted/configuration.html).
   *
   * ### Headless Chrome
   *
   * ```js
   * {
   *    helpers: {
   *      WebDriver : {
   *        url: "http://localhost",
   *        browser: "chrome",
   *        desiredCapabilities: {
   *          chromeOptions: {
   *            args: [ "--headless", "--disable-gpu", "--no-sandbox" ]
   *          }
   *        }
   *      }
   *    }
   * }
   * ```
   *
   * ### Running with devtools protocol
   *
   * ```js
   * {
   *    helpers: {
   *      WebDriver : {
   *        url: "http://localhost",
   *        browser: "chrome",
   *        desiredCapabilities: {
   *          chromeOptions: {
   *            args: [ "--headless", "--disable-gpu", "--no-sandbox" ]
   *          }
   *        }
   *      }
   *    }
   * }
   * ```
   *
   * ### Internet Explorer
   *
   * Additional configuration params can be used from [IE options](https://seleniumhq.github.io/selenium/docs/api/rb/Selenium/WebDriver/IE/Options.html)
   *
   * ```js
   * {
   *    helpers: {
   *      WebDriver : {
   *        url: "http://localhost",
   *        browser: "internet explorer",
   *        desiredCapabilities: {
   *          ieOptions: {
   *            "ie.browserCommandLineSwitches": "-private",
   *            "ie.usePerProcessProxy": true,
   *            "ie.ensureCleanSession": true,
   *          }
   *        }
   *      }
   *    }
   * }
   * ```
   *
   * ### Selenoid Options
   *
   * [Selenoid](https://aerokube.com/selenoid/latest/) is a modern way to run Selenium inside Docker containers.
   * Selenoid is easy to set up and provides more features than original Selenium Server. Use `selenoidOptions` to set Selenoid capabilities
   *
   * ```js
   * {
   *    helpers: {
   *      WebDriver : {
   *        url: "http://localhost",
   *        browser: "chrome",
   *        desiredCapabilities: {
   *          selenoidOptions: {
   *            enableVNC: true,
   *          }
   *        }
   *      }
   *    }
   * }
   * ```
   *
   * ### Connect Through proxy
   *
   * CodeceptJS also provides flexible options when you want to execute tests to Selenium servers through proxy. You will
   * need to update the `helpers.WebDriver.capabilities.proxy` key.
   *
   * ```js
   * {
   *     helpers: {
   *         WebDriver: {
   *             capabilities: {
   *                 proxy: {
   *                     "proxyType": "manual|pac",
   *                     "proxyAutoconfigUrl": "URL TO PAC FILE",
   *                     "httpProxy": "PROXY SERVER",
   *                     "sslProxy": "PROXY SERVER",
   *                     "ftpProxy": "PROXY SERVER",
   *                     "socksProxy": "PROXY SERVER",
   *                     "socksUsername": "USERNAME",
   *                     "socksPassword": "PASSWORD",
   *                     "noProxy": "BYPASS ADDRESSES"
   *                 }
   *             }
   *         }
   *     }
   * }
   * ```
   * For example,
   *
   * ```js
   * {
   *     helpers: {
   *         WebDriver: {
   *             capabilities: {
   *                 proxy: {
   *                     "proxyType": "manual",
   *                     "httpProxy": "http://corporate.proxy:8080",
   *                     "socksUsername": "codeceptjs",
   *                     "socksPassword": "secret",
   *                     "noProxy": "127.0.0.1,localhost"
   *                 }
   *             }
   *         }
   *     }
   * }
   * ```
   *
   * Please refer to [Selenium - Proxy Object](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities) for more
   * information.
   *
   * ### Cloud Providers
   *
   * WebDriver makes it possible to execute tests against services like `Sauce Labs` `BrowserStack` `TestingBot`
   * Check out their documentation on [available parameters](http://webdriver.io/guide/usage/cloudservices.html)
   *
   * Connecting to `BrowserStack` and `Sauce Labs` is simple. All you need to do
   * is set the `user` and `key` parameters. WebDriver automatically know which
   * service provider to connect to.
   *
   * ```js
   * {
   *     helpers:{
   *         WebDriver: {
   *             url: "YOUR_DESIRED_HOST",
   *             user: "YOUR_BROWSERSTACK_USER",
   *             key: "YOUR_BROWSERSTACK_KEY",
   *             capabilities: {
   *                 "browserName": "chrome",
   *
   *                 // only set this if you're using BrowserStackLocal to test a local domain
   *                 // "browserstack.local": true,
   *
   *                 // set this option to tell browserstack to provide addition debugging info
   *                 // "browserstack.debug": true,
   *             }
   *         }
   *     }
   * }
   * ```
   *
   * #### SauceLabs
   *
   * SauceLabs can be configured via wdio service, which should be installed additionally:
   *
   * ```
   * npm i @wdio/sauce-service --save
   * ```
   *
   * It is important to make sure it is compatible with current webdriverio version.
   *
   * Enable `wdio` plugin in plugins list and add `sauce` service:
   *
   * ```js
   * plugins: {
   *    wdio: {
   *       enabled: true,
   *        services: ['sauce'],
   *        user: ... ,// saucelabs username
   *        key: ... // saucelabs api key
   *        // additional config, from sauce service
   *    }
   * }
   * ```
   *
   * See [complete reference on webdriver.io](https://webdriver.io/docs/sauce-service.html).
   *
   * > Alternatively, use [codeceptjs-saucehelper](https://github.com/puneet0191/codeceptjs-saucehelper/) for better reporting.
   *
   * #### BrowserStack
   *
   * BrowserStack can be configured via wdio service, which should be installed additionally:
   *
   * ```
   * npm i @wdio/browserstack-service --save
   * ```
   *
   * It is important to make sure it is compatible with current webdriverio version.
   *
   * Enable `wdio` plugin in plugins list and add `browserstack` service:
   *
   * ```js
   * plugins: {
   *    wdio: {
   *       enabled: true,
   *        services: ['browserstack'],
   *        user: ... ,// browserstack username
   *        key: ... // browserstack api key
   *        // additional config, from browserstack service
   *    }
   * }
   * ```
   *
   * See [complete reference on webdriver.io](https://webdriver.io/docs/browserstack-service.html).
   *
   * > Alternatively, use [codeceptjs-bshelper](https://github.com/PeterNgTr/codeceptjs-bshelper) for better reporting.
   *
   * #### TestingBot
   *
   * > **Recommended**: use official [TestingBot Helper](https://github.com/testingbot/codeceptjs-tbhelper).
   *
   * Alternatively, TestingBot can be configured via wdio service, which should be installed additionally:
   *
   * ```
   * npm i @wdio/testingbot-service --save
   * ```
   *
   * It is important to make sure it is compatible with current webdriverio version.
   *
   * Enable `wdio` plugin in plugins list and add `testingbot` service:
   *
   * ```js
   * plugins: {
   *   wdio: {
   *       enabled: true,
   *       services: ['testingbot'],
   *       user: ... ,// testingbot key
   *       key: ... // testingbot secret
   *       // additional config, from testingbot service
   *   }
   * }
   * ```
   *
   * See [complete reference on webdriver.io](https://webdriver.io/docs/testingbot-service.html).
   *
   * #### Applitools
   *
   * Visual testing via Applitools service
   *
   * > Use [CodeceptJS Applitools Helper](https://github.com/PeterNgTr/codeceptjs-applitoolshelper) with Applitools wdio service.
   *
   *
   * ### Multiremote Capabilities
   *
   * This is a work in progress but you can control two browsers at a time right out of the box.
   * Individual control is something that is planned for a later version.
   *
   * Here is the [webdriverio docs](http://webdriver.io/guide/usage/multiremote.html) on the subject
   *
   * ```js
   * {
   *     helpers: {
   *         WebDriver: {
   *             "multiremote": {
   *                 "MyChrome": {
   *                     "desiredCapabilities": {
   *                         "browserName": "chrome"
   *                      }
   *                 },
   *                 "MyFirefox": {
   *                    "desiredCapabilities": {
   *                        "browserName": "firefox"
   *                    }
   *                 }
   *             }
   *         }
   *     }
   * }
   * ```
   *
   * ## Access From Helpers
   *
   * Receive a WebDriver client from a custom helper by accessing `browser` property:
   *
   * ```js
   * const { WebDriver } = this.helpers;
   * const browser = WebDriver.browser
   * ```
   *
   * ## Methods
   */
  class WebDriver extends Helper {
    constructor(config) {
      super(config)
      webdriverio = require('webdriverio')
  
      // set defaults
      this.root = webRoot
      this.isWeb = true
      this.isRunning = false
      this.sessionWindows = {}
      this.activeSessionName = ''
      this.customLocatorStrategies = config.customLocatorStrategies
  
      // for network stuff
      this.requests = []
      this.recording = false
      this.recordedAtLeastOnce = false
  
      this._setConfig(config)
  
      Locator.addFilter((locator, result) => {
        if (typeof locator === 'string' && locator.indexOf('~') === 0) {
          // accessibility locator
          if (this.isWeb) {
            result.value = `[aria-label="${locator.slice(1)}"]`
            result.type = 'css'
            result.output = `aria-label=${locator.slice(1)}`
          }
        }
      })
    }
  
    _validateConfig(config) {
      const defaults = {
        logLevel: 'silent',
        // codeceptjs
        remoteFileUpload: true,
        smartWait: 0,
        waitForTimeout: 1000, // ms
        capabilities: {},
        restart: true,
        uniqueScreenshotNames: false,
        disableScreenshots: false,
        fullPageScreenshots: false,
        manualStart: false,
        keepCookies: false,
        keepBrowserState: false,
        deprecationWarnings: false,
        highlightElement: false,
      }
  
      // override defaults with config
      config = Object.assign(defaults, config)
  
      if (config.host) {
        // webdriverio spec
        config.hostname = config.host
        config.path = config.path ? config.path : '/wd/hub'
      }
  
      config.baseUrl = config.url || config.baseUrl
      if (config.desiredCapabilities && Object.keys(config.desiredCapabilities).length) {
        config.capabilities = config.desiredCapabilities
      }
      config.capabilities.browserName = config.browser || config.capabilities.browserName
  
      // WebDriver Bidi Protocol. Default: true
      config.capabilities.webSocketUrl = config.bidiProtocol ?? config.capabilities.webSocketUrl ?? true
  
      config.capabilities.browserVersion = config.browserVersion || config.capabilities.browserVersion
      if (config.capabilities.chromeOptions) {
        config.capabilities['goog:chromeOptions'] = config.capabilities.chromeOptions
        delete config.capabilities.chromeOptions
      }
      if (config.capabilities.firefoxOptions) {
        config.capabilities['moz:firefoxOptions'] = config.capabilities.firefoxOptions
        delete config.capabilities.firefoxOptions
      }
      if (config.capabilities.ieOptions) {
        config.capabilities['se:ieOptions'] = config.capabilities.ieOptions
        delete config.capabilities.ieOptions
      }
      if (config.capabilities.selenoidOptions) {
        config.capabilities['selenoid:options'] = config.capabilities.selenoidOptions
        delete config.capabilities.selenoidOptions
      }
  
      config.waitForTimeoutInSeconds = config.waitForTimeout / 1000 // convert to seconds
  
      if (!config.capabilities.platformName && (!config.url || !config.browser)) {
        throw new Error(`
          WebDriver requires at url and browser to be set.
          Check your codeceptjs config file to ensure these are set properly
            {
              "helpers": {
                "WebDriver": {
                  "url": "YOUR_HOST"
                  "browser": "YOUR_PREFERRED_TESTING_BROWSER"
                }
              }
            }
        `)
      }
  
      return config
    }
  
    static _checkRequirements() {
      try {
        require('webdriverio')
      } catch (e) {
        return ['webdriverio@^6.12.1']
      }
    }
  
    static _config() {
      return [
        {
          name: 'url',
          message: 'Base url of site to be tested',
          default: 'http://localhost',
        },
        {
          name: 'browser',
          message: 'Browser in which testing will be performed',
          default: 'chrome',
        },
      ]
    }
  
    _beforeSuite() {
      if (!this.options.restart && !this.options.manualStart && !this.isRunning) {
        this.debugSection('Session', 'Starting singleton browser session')
        return this._startBrowser()
      }
    }
  
    _lookupCustomLocator(customStrategy) {
      if (typeof this.customLocatorStrategies !== 'object') {
        return null
      }
      const strategy = this.customLocatorStrategies[customStrategy]
      return typeof strategy === 'function' ? strategy : null
    }
  
    _isCustomLocator(locator) {
      const locatorObj = new Locator(locator)
      if (locatorObj.isCustom()) {
        const customLocator = this._lookupCustomLocator(locatorObj.type)
        if (customLocator) {
          return true
        }
        throw new Error('Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function".')
      }
      return false
    }
  
    async _res(locator) {
      const res = this._isShadowLocator(locator) || this._isCustomLocator(locator) ? await this._locate(locator) : await this.$$(withStrictLocator(locator))
      return res
    }
  
    async _startBrowser() {
      try {
        if (this.options.multiremote) {
          this.browser = await webdriverio.multiremote(this.options.multiremote)
        } else {
          // remove non w3c capabilities
          delete this.options.capabilities.protocol
          delete this.options.capabilities.hostname
          delete this.options.capabilities.port
          delete this.options.capabilities.path
          this.browser = await webdriverio.remote(this.options)
        }
      } catch (err) {
        if (err.toString().indexOf('ECONNREFUSED')) {
          throw new ConnectionRefused(err)
        }
        throw err
      }
  
      this.isRunning = true
      if (this.options.timeouts && this.isWeb) {
        await this.defineTimeout(this.options.timeouts)
      }
  
      await this._resizeWindowIfNeeded(this.browser, this.options.windowSize)
  
      this.$$ = this.browser.$$.bind(this.browser)
  
      if (this._isCustomLocatorStrategyDefined()) {
        Object.keys(this.customLocatorStrategies).forEach(async customLocator => {
          this.debugSection('Weddriver', `adding custom locator strategy: ${customLocator}`)
          const locatorFunction = this._lookupCustomLocator(customLocator)
          this.browser.addLocatorStrategy(customLocator, locatorFunction)
        })
      }
  
      if (this.browser.capabilities && this.browser.capabilities.platformName) {
        this.browser.capabilities.platformName = this.browser.capabilities.platformName.toLowerCase()
      }
  
      this.browser.on('dialog', () => {})
  
      // Check for Bidi, because "sessionSubscribe" is an exclusive Bidi protocol feature. Otherwise, error will be thrown.
      if (this.browser.capabilities && this.browser.capabilities.webSocketUrl) {
        await this.browser.sessionSubscribe({ events: ['log.entryAdded'] })
        this.browser.on('log.entryAdded', logEvents)
      }
  
      return this.browser
    }
  
    _isCustomLocatorStrategyDefined() {
      return this.customLocatorStrategies && Object.keys(this.customLocatorStrategies).length
    }
  
    async _stopBrowser() {
      if (this.browser && this.isRunning) await this.browser.deleteSession()
    }
  
    async _before() {
      this.context = this.root
      if (this.options.restart && !this.options.manualStart) return this._startBrowser()
      if (!this.isRunning && !this.options.manualStart) return this._startBrowser()
      if (this.browser) this.$$ = this.browser.$$.bind(this.browser)
      return this.browser
    }
  
    async _after() {
      if (!this.isRunning) return
      if (this.options.restart) {
        this.isRunning = false
        return this.browser.deleteSession()
      }
      if (this.browser.isInsideFrame) await this.browser.switchFrame(null)
  
      if (this.options.keepBrowserState) return
  
      if (!this.options.keepCookies && this.options.capabilities.browserName) {
        this.debugSection('Session', 'cleaning cookies and localStorage')
        await this.browser.deleteCookies()
      }
      await this.browser.execute('localStorage.clear();').catch(err => {
        if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
      })
      await this.closeOtherTabs()
      browserLogs = []
      return this.browser
    }
  
    _afterSuite() {}
  
    _finishTest() {
      if (!this.options.restart && this.isRunning) return this._stopBrowser()
    }
  
    _session() {
      const defaultSession = this.browser
      return {
        start: async (sessionName, opts) => {
          // opts.disableScreenshots = true; // screenshots cant be saved as session will be already closed
          opts = this._validateConfig(Object.assign(this.options, opts))
          this.debugSection('New Browser', JSON.stringify(opts))
          const browser = await webdriverio.remote(opts)
          this.activeSessionName = sessionName
          if (opts.timeouts && this.isWeb) {
            await this._defineBrowserTimeout(browser, opts.timeouts)
          }
  
          await this._resizeWindowIfNeeded(browser, opts.windowSize)
  
          return browser
        },
        stop: async browser => {
          if (!browser) return
          return browser.deleteSession()
        },
        loadVars: async browser => {
          if (this.context !== this.root) throw new Error("Can't start session inside within block")
          this.browser = browser
          this.$$ = this.browser.$$.bind(this.browser)
          this.sessionWindows[this.activeSessionName] = browser
        },
        restoreVars: async session => {
          if (!session) {
            this.activeSessionName = ''
          }
          this.browser = defaultSession
          this.$$ = this.browser.$$.bind(this.browser)
        },
      }
    }
  
    /**
     * Use [webdriverio](https://webdriver.io/docs/api.html) API inside a test.
     *
     * First argument is a description of an action.
     * Second argument is async function that gets this helper as parameter.
     *
     * { [`browser`](https://webdriver.io/docs/api.html)) } object from WebDriver API is available.
     *
     * ```js
     * I.useWebDriverTo('open multiple windows', async ({ browser }) {
     *    // create new window
     *    await browser.newWindow('https://webdriver.io');
     * });
     * ```
     *
     * @param {string} description used to show in logs.
     * @param {function} fn async functuion that executed with WebDriver helper as argument
     */
    useWebDriverTo(description, fn) {
      return this._useTo(...arguments)
    }
  
    async _failed() {
      if (this.context !== this.root) await this._withinEnd()
    }
  
    async _withinBegin(locator) {
      const frame = isFrameLocator(locator)
      if (frame) {
        this.browser.isInsideFrame = true
        if (Array.isArray(frame)) {
          // this.switchTo(null);
          await forEachAsync(frame, async f => this.switchTo(f))
          return
        }
        await this.switchTo(frame)
        return
      }
      this.context = locator
  
      let res = await this.browser.$$(withStrictLocator(locator))
      assertElementExists(res, locator)
      res = usingFirstElement(res)
      this.context = res.selector
      this.$$ = res.$$.bind(res)
    }
  
    async _withinEnd() {
      if (this.browser.isInsideFrame) {
        this.browser.isInsideFrame = false
        return this.switchTo(null)
      }
      this.context = this.root
      this.$$ = this.browser.$$.bind(this.browser)
    }
  
    /**
     * Check if locator is type of "Shadow"
     *
     * @param {object} locator
     */
    _isShadowLocator(locator) {
      return locator.type === SHADOW || locator[SHADOW]
    }
  
    /**
     * Locate Element within the Shadow Dom
     *
     * @param {object} locator
     */
    async _locateShadow(locator) {
      const shadow = locator.value ? locator.value : locator[SHADOW]
      const shadowSequence = []
      let elements
  
      if (!Array.isArray(shadow)) {
        throw new Error(`Shadow '${shadow}' should be defined as an Array of elements.`)
      }
  
      // traverse through the Shadow locators in sequence
      for (let index = 0; index < shadow.length; index++) {
        const shadowElement = shadow[index]
        shadowSequence.push(shadowElement)
  
        if (!elements) {
          elements = await this.browser.$$(shadowElement)
        } else if (Array.isArray(elements)) {
          elements = await elements[0].shadow$$(shadowElement)
        } else if (elements) {
          elements = await elements.shadow$$(shadowElement)
        }
  
        if (!elements || !elements[0]) {
          throw new Error(
            `Shadow Element '${shadowElement}' is not found. It is possible the element is incorrect or elements sequence is incorrect. Please verify the sequence '${shadowSequence.join('>')}' is correctly chained.`,
          )
        }
      }
  
      this.debugSection('Elements', `Found ${elements.length} '${SHADOW}' elements`)
  
      return elements
    }
  
    /**
     * Smart Wait to locate an element
     *
     * @param {object} locator
     */
    async _smartWait(locator) {
      this.debugSection(`SmartWait (${this.options.smartWait}ms)`, `Locating ${JSON.stringify(locator)} in ${this.options.smartWait}`)
      await this.defineTimeout({ implicit: this.options.smartWait })
    }
  
    /**
     * Get elements by different locator types, including strict locator.
     * Should be used in custom helpers:
     *
     * ```js
     * this.helpers['WebDriver']._locate({name: 'password'}).then //...
     * ```
     *
     *
     * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
     */
    async _locate(locator, smartWait = false) {
      if (require('../store').debugMode) smartWait = false
  
      // special locator type for Shadow DOM
      if (this._isShadowLocator(locator)) {
        if (!this.options.smartWait || !smartWait) {
          const els = await this._locateShadow(locator)
          return els
        }
  
        const els = await this._locateShadow(locator)
        return els
      }
  
      // special locator type for React
      if (locator.react) {
        const els = await this.browser.react$$(locator.react, locator.props || undefined, locator.state || undefined)
        this.debugSection('Elements', `Found ${els.length} react components`)
        return els
      }
  
      if (!this.options.smartWait || !smartWait) {
        if (this._isCustomLocator(locator)) {
          const locatorObj = new Locator(locator)
          return this.browser.custom$$(locatorObj.type, locatorObj.value)
        }
  
        const els = await this.$$(withStrictLocator(locator))
        return els
      }
  
      await this._smartWait(locator)
  
      if (this._isCustomLocator(locator)) {
        const locatorObj = new Locator(locator)
        return this.browser.custom$$(locatorObj.type, locatorObj.value)
      }
  
      const els = await this.$$(withStrictLocator(locator))
      await this.defineTimeout({ implicit: 0 })
      return els
    }
  
    _grabCustomLocator(locator) {
      if (typeof locator === 'string') {
        locator = new Locator(locator)
      }
      return locator.value ? locator.value : locator.custom
    }
  
    /**
     * Find a checkbox by providing human-readable text:
     *
     * ```js
     * this.helpers['WebDriver']._locateCheckable('I agree with terms and conditions').then // ...
     * ```
     *
     * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
     */
    async _locateCheckable(locator) {
      return findCheckable.call(this, locator, this.$$.bind(this)).then(res => res)
    }
  
    /**
     * Find a clickable element by providing human-readable text:
     *
     * ```js
     * const els = await this.helpers.WebDriver._locateClickable('Next page');
     * const els = await this.helpers.WebDriver._locateClickable('Next page', '.pages');
     * ```
     *
     * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
     */
    async _locateClickable(locator, context) {
      const locateFn = prepareLocateFn.call(this, context)
      return findClickable.call(this, locator, locateFn)
    }
  
    /**
     * Find field elements by providing human-readable text:
     *
     * ```js
     * this.helpers['WebDriver']._locateFields('Your email').then // ...
     * ```
     *
     * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
     */
    async _locateFields(locator) {
      return findFields.call(this, locator).then(res => res)
    }
  
    /**
     * {{> grabWebElements }}
     *
     */
    async grabWebElements(locator) {
      const elements = await this._locate(locator)
      return elements.map(element => new WebElement(element, this))
    }
  
    /**
     * {{> grabWebElement }}
     *
     */
    async grabWebElement(locator) {
      const elements = await this._locate(locator)
      if (elements.length === 0) {
        throw new ElementNotFound(locator, 'Element')
      }
      return new WebElement(elements[0], this)
    }
  
    /**
     * Set [WebDriver timeouts](https://webdriver.io/docs/timeouts.html) in realtime.
     *
     * Timeouts are expected to be passed as object:
     *
     * ```js
     * I.defineTimeout({ script: 5000 });
     * I.defineTimeout({ implicit: 10000, pageLoad: 10000, script: 5000 });
     * ```
     *
     * @param {*} timeouts WebDriver timeouts object.
     */
    defineTimeout(timeouts) {
      return this._defineBrowserTimeout(this.browser, timeouts)
    }
  
    _defineBrowserTimeout(browser, timeouts) {
      return browser.setTimeout(timeouts)
    }
  
    /**
     * {{> amOnPage }}
     *
     */
    amOnPage(url) {
      let split_url
      if (this.options.basicAuth) {
        if (url.startsWith('/')) {
          url = this.options.url + url
        }
        split_url = url.split('//')
        url = `${split_url[0]}//${this.options.basicAuth.username}:${this.options.basicAuth.password}@${split_url[1]}`
      }
      return this.browser.url(url)
    }
  
    /**
     * {{> click }}
     *
     * {{ react }}
     */
    async click(locator, context = null) {
      const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick'
      const locateFn = prepareLocateFn.call(this, context)
  
      const res = await findClickable.call(this, locator, locateFn)
      if (context) {
        assertElementExists(res, locator, 'Clickable element', `was not found inside element ${new Locator(context)}`)
      } else {
        assertElementExists(res, locator, 'Clickable element')
      }
      const elem = usingFirstElement(res)
      highlightActiveElement.call(this, elem)
      return this.browser[clickMethod](getElementId(elem))
    }
  
    /**
     * {{> forceClick }}
     *
     * {{ react }}
     */
    async forceClick(locator, context = null) {
      const locateFn = prepareLocateFn.call(this, context)
  
      const res = await findClickable.call(this, locator, locateFn)
      if (context) {
        assertElementExists(res, locator, 'Clickable element', `was not found inside element ${new Locator(context)}`)
      } else {
        assertElementExists(res, locator, 'Clickable element')
      }
      const elem = usingFirstElement(res)
      highlightActiveElement.call(this, elem)
  
      return this.executeScript(el => {
        if (document.activeElement instanceof HTMLElement) {
          document.activeElement.blur()
        }
        const event = document.createEvent('MouseEvent')
        event.initEvent('click', true, true)
        return el.dispatchEvent(event)
      }, elem)
    }
  
    /**
     * {{> doubleClick }}
     *
     * {{ react }}
     */
    async doubleClick(locator, context = null) {
      const locateFn = prepareLocateFn.call(this, context)
  
      const res = await findClickable.call(this, locator, locateFn)
      if (context) {
        assertElementExists(res, locator, 'Clickable element', `was not found inside element ${new Locator(context)}`)
      } else {
        assertElementExists(res, locator, 'Clickable element')
      }
  
      const elem = usingFirstElement(res)
      highlightActiveElement.call(this, elem)
      return elem.doubleClick()
    }
  
    /**
     * {{> rightClick }}
     *
     * {{ react }}
     */
    async rightClick(locator, context) {
      const locateFn = prepareLocateFn.call(this, context)
  
      const res = await findClickable.call(this, locator, locateFn)
      if (context) {
        assertElementExists(res, locator, 'Clickable element', `was not found inside element ${new Locator(context)}`)
      } else {
        assertElementExists(res, locator, 'Clickable element')
      }
  
      const el = usingFirstElement(res)
  
      await el.moveTo()
  
      if (this.browser.isW3C) {
        return el.click({ button: 'right' })
      }
      // JSON Wire version
      await this.browser.buttonDown(2)
    }
  
    /**
     * {{> forceRightClick }}
     *
     * {{ react }}
     */
    async forceRightClick(locator, context = null) {
      const locateFn = prepareLocateFn.call(this, context)
  
      const res = await findClickable.call(this, locator, locateFn)
      if (context) {
        assertElementExists(res, locator, 'Clickable element', `was not found inside element ${new Locator(context)}`)
      } else {
        assertElementExists(res, locator, 'Clickable element')
      }
      const elem = usingFirstElement(res)
  
      return this.executeScript(el => {
        if (document.activeElement instanceof HTMLElement) {
          document.activeElement.blur()
        }
        const event = document.createEvent('MouseEvent')
        event.initEvent('contextmenu', true, true)
        return el.dispatchEvent(event)
      }, elem)
    }
  
    /**
     * {{> fillField }}
     * {{ react }}
     * {{ custom }}
     *
     */
    async fillField(field, value) {
      const res = await findFields.call(this, field)
      assertElementExists(res, field, 'Field')
      const elem = usingFirstElement(res)
      highlightActiveElement.call(this, elem)
      await elem.clearValue()
      await elem.setValue(value.toString())
    }
  
    /**
     * {{> appendField }}
     * {{ react }}
     */
    async appendField(field, value) {
      const res = await findFields.call(this, field)
      assertElementExists(res, field, 'Field')
      const elem = usingFirstElement(res)
      highlightActiveElement.call(this, elem)
      return elem.addValue(value.toString())
    }
  
    /**
     * {{> clearField }}
     *
     */
    async clearField(field) {
      const res = await findFields.call(this, field)
      assertElementExists(res, field, 'Field')
      const elem = usingFirstElement(res)
      highlightActiveElement.call(this, elem)
      return elem.clearValue(getElementId(elem))
    }
  
    /**
     * {{> selectOption }}
     */
    async selectOption(select, option) {
      const res = await findFields.call(this, select)
      assertElementExists(res, select, 'Selectable field')
      const elem = usingFirstElement(res)
      highlightActiveElement.call(this, elem)
  
      if (!Array.isArray(option)) {
        option = [option]
      }
  
      // select options by visible text
      let els = await forEachAsync(option, async opt => this.browser.findElementsFromElement(getElementId(elem), 'xpath', Locator.select.byVisibleText(xpathLocator.literal(opt))))
  
      const clickOptionFn = async el => {
        if (el[0]) el = el[0]
        const elementId = getElementId(el)
        if (elementId) return this.browser.elementClick(elementId)
      }
  
      if (Array.isArray(els) && els.length) {
        return forEachAsync(els, clickOptionFn)
      }
      // select options by value
      els = await forEachAsync(option, async opt => this.browser.findElementsFromElement(getElementId(elem), 'xpath', Locator.select.byValue(xpathLocator.literal(opt))))
      if (els.length === 0) {
        throw new ElementNotFound(select, `Option "${option}" in`, 'was not found neither by a visible text nor by a value')
      }
      return forEachAsync(els, clickOptionFn)
    }
  
    /**
     * Appium: not tested
     *
     * {{> attachFile }}
     */
    async attachFile(locator, pathToFile) {
      let file = path.join(global.codecept_dir, pathToFile)
      if (!fileExists(file)) {
        throw new Error(`File at ${file} can not be found on local system`)
      }
  
      const res = await findFields.call(this, locator)
      this.debug(`Uploading ${file}`)
      assertElementExists(res, locator, 'File field')
      const el = usingFirstElement(res)
  
      // Remote Upload (when running Selenium Server)
      if (this.options.remoteFileUpload) {
        try {
          this.debugSection('File', 'Uploading file to remote server')
          file = await this.browser.uploadFile(file)
        } catch (err) {
          throw new Error(`File can't be transferred to remote server. Set \`remoteFileUpload: false\` in config to upload file locally.\n${err.message}`)
        }
      }
  
      return el.addValue(file)
    }
  
    /**
     * Appium: not tested
     * {{> checkOption }}
     */
    async checkOption(field, context = null) {
      const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick'
      const locateFn = prepareLocateFn.call(this, context)
  
      const res = await findCheckable.call(this, field, locateFn)
  
      assertElementExists(res, field, 'Checkable')
      const elem = usingFirstElement(res)
      const elementId = getElementId(elem)
      highlightActiveElement.call(this, elem)
  
      const isSelected = await this.browser.isElementSelected(elementId)
      if (isSelected) return Promise.resolve(true)
      return this.browser[clickMethod](elementId)
    }
  
    /**
     * Appium: not tested
     * {{> uncheckOption }}
     */
    async uncheckOption(field, context = null) {
      const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick'
      const locateFn = prepareLocateFn.call(this, context)
  
      const res = await findCheckable.call(this, field, locateFn)
  
      assertElementExists(res, field, 'Checkable')
      const elem = usingFirstElement(res)
      const elementId = getElementId(elem)
      highlightActiveElement.call(this, elem)
  
      const isSelected = await this.browser.isElementSelected(elementId)
      if (!isSelected) return Promise.resolve(true)
      return this.browser[clickMethod](elementId)
    }
  
    /**
     * {{> grabTextFromAll }}
     *
     */
    async grabTextFromAll(locator) {
      const res = await this._locate(locator, true)
      let val = []
      await forEachAsync(res, async el => {
        const text = await this.browser.getElementText(getElementId(el))
        val.push(text)
      })
      this.debugSection('GrabText', String(val))
      return val
    }
  
    /**
     * {{> grabTextFrom }}
     *
     */
    async grabTextFrom(locator) {
      const texts = await this.grabTextFromAll(locator)
      assertElementExists(texts, locator)
      if (texts.length > 1) {
        this.debugSection('GrabText', `Using first element out of ${texts.length}`)
      }
  
      return texts[0]
    }
  
    /**
     * {{> grabHTMLFromAll }}
     *
     */
    async grabHTMLFromAll(locator) {
      const elems = await this._locate(locator, true)
      const html = await forEachAsync(elems, elem => elem.getHTML(false))
      this.debugSection('GrabHTML', String(html))
      return html
    }
  
    /**
     * {{> grabHTMLFrom }}
     *
     */
    async grabHTMLFrom(locator) {
      const html = await this.grabHTMLFromAll(locator)
      assertElementExists(html, locator)
      if (html.length > 1) {
        this.debugSection('GrabHTML', `Using first element out of ${html.length}`)
      }
  
      return html[0]
    }
  
    /**
     * {{> grabValueFromAll }}
     *
     */
    async grabValueFromAll(locator) {
      const res = await this._locate(locator, true)
      const val = await forEachAsync(res, el => el.getValue())
      this.debugSection('GrabValue', String(val))
  
      return val
    }
  
    /**
     * {{> grabValueFrom }}
     *
     */
    async grabValueFrom(locator) {
      const values = await this.grabValueFromAll(locator)
      assertElementExists(values, locator)
      if (values.length > 1) {
        this.debugSection('GrabValue', `Using first element out of ${values.length}`)
      }
  
      return values[0]
    }
  
    /**
     * {{> grabCssPropertyFromAll }}
     */
    async grabCssPropertyFromAll(locator, cssProperty) {
      const res = await this._locate(locator, true)
      const val = await forEachAsync(res, async el => this.browser.getElementCSSValue(getElementId(el), cssProperty))
      this.debugSection('Grab', String(val))
      return val
    }
  
    /**
     * {{> grabCssPropertyFrom }}
     */
    async grabCssPropertyFrom(locator, cssProperty) {
      const cssValues = await this.grabCssPropertyFromAll(locator, cssProperty)
      assertElementExists(cssValues, locator)
  
      if (cssValues.length > 1) {
        this.debugSection('GrabCSS', `Using first element out of ${cssValues.length}`)
      }
  
      return cssValues[0]
    }
  
    /**
     * {{> grabAttributeFromAll }}
     */
    async grabAttributeFromAll(locator, attr) {
      const res = await this._locate(locator, true)
      const val = await forEachAsync(res, async el => el.getAttribute(attr))
      this.debugSection('GrabAttribute', String(val))
      return val
    }
  
    /**
     * {{> grabAttributeFrom }}
     */
    async grabAttributeFrom(locator, attr) {
      const attrs = await this.grabAttributeFromAll(locator, attr)
      assertElementExists(attrs, locator)
      if (attrs.length > 1) {
        this.debugSection('GrabAttribute', `Using first element out of ${attrs.length}`)
      }
      return attrs[0]
    }
  
    /**
     * {{> seeInTitle }}
     */
    async seeInTitle(text) {
      const title = await this.browser.getTitle()
      return stringIncludes('web page title').assert(text, title)
    }
  
    /**
     * {{> seeTitleEquals }}
     */
    async seeTitleEquals(text) {
      const title = await this.browser.getTitle()
      return assert.equal(title, text, `expected web page title to be ${text}, but found ${title}`)
    }
  
    /**
     * {{> dontSeeInTitle }}
     */
    async dontSeeInTitle(text) {
      const title = await this.browser.getTitle()
      return stringIncludes('web page title').negate(text, title)
    }
  
    /**
     * {{> grabTitle }}
     */
    async grabTitle() {
      const title = await this.browser.getTitle()
      this.debugSection('Title', title)
      return title
    }
  
    /**
     * {{> see }}
     *
     * {{ react }}
     */
    async see(text, context = null) {
      return proceedSee.call(this, 'assert', text, context)
    }
  
    /**
     * {{> seeTextEquals }}
     */
    async seeTextEquals(text, context = null) {
      return proceedSee.call(this, 'assert', text, context, true)
    }
  
    /**
     * {{> dontSee }}
     *
     * {{ react }}
     */
    async dontSee(text, context = null) {
      return proceedSee.call(this, 'negate', text, context)
    }
  
    /**
     * {{> seeInField }}
     *
     */
    async seeInField(field, value) {
      const _value = typeof value === 'boolean' ? value : value.toString()
      return proceedSeeField.call(this, 'assert', field, _value)
    }
  
    /**
     * {{> dontSeeInField }}
     *
     */
    async dontSeeInField(field, value) {
      const _value = typeof value === 'boolean' ? value : value.toString()
      return proceedSeeField.call(this, 'negate', field, _value)
    }
  
    /**
     * Appium: not tested
     * {{> seeCheckboxIsChecked }}
     */
    async seeCheckboxIsChecked(field) {
      return proceedSeeCheckbox.call(this, 'assert', field)
    }
  
    /**
     * Appium: not tested
     * {{> dontSeeCheckboxIsChecked }}
     */
    async dontSeeCheckboxIsChecked(field) {
      return proceedSeeCheckbox.call(this, 'negate', field)
    }
  
    /**
     * {{> seeElement }}
     * {{ react }}
     *
     */
    async seeElement(locator) {
      const res = await this._locate(locator, true)
      assertElementExists(res, locator)
      const selected = await forEachAsync(res, async el => el.isDisplayed())
      try {
        return truth(`elements of ${new Locator(locator)}`, 'to be seen').assert(selected)
      } catch (e) {
        dontSeeElementError(locator)
      }
    }
  
    /**
     * {{> dontSeeElement }}
     * {{ react }}
     */
    async dontSeeElement(locator) {
      const res = await this._locate(locator, false)
      if (!res || res.length === 0) {
        return truth(`elements of ${new Locator(locator)}`, 'to be seen').negate(false)
      }
      const selected = await forEachAsync(res, async el => el.isDisplayed())
      try {
        return truth(`elements of ${new Locator(locator)}`, 'to be seen').negate(selected)
      } catch (e) {
        seeElementError(locator)
      }
    }
  
    /**
     * {{> seeElementInDOM }}
     *
     */
    async seeElementInDOM(locator) {
      const res = await this._res(locator)
      try {
        return empty('elements').negate(res)
      } catch (e) {
        dontSeeElementInDOMError(locator)
      }
    }
  
    /**
     * {{> dontSeeElementInDOM }}
     *
     */
    async dontSeeElementInDOM(locator) {
      const res = await this._res(locator)
      try {
        return empty('elements').assert(res)
      } catch (e) {
        seeElementInDOMError(locator)
      }
    }
  
    /**
     * {{> seeInSource }}
     *
     */
    async seeInSource(text) {
      const source = await this.browser.getPageSource()
      return stringIncludes('HTML source of a page').assert(text, source)
    }
  
    /**
     * {{> grabSource }}
     *
     */
    async grabSource() {
      return this.browser.getPageSource()
    }
  
    /**
     * {{> grabBrowserLogs }}
     */
    async grabBrowserLogs() {
      return browserLogs
    }
  
    /**
     * {{> grabCurrentUrl }}
     */
    async grabCurrentUrl() {
      const res = await this.browser.getUrl()
      this.debugSection('Url', res)
      return res
    }
  
    /**
     * {{> dontSeeInSource }}
     */
    async dontSeeInSource(text) {
      const source = await this.browser.getPageSource()
      return stringIncludes('HTML source of a page').negate(text, source)
    }
  
    /**
     * {{> seeNumberOfElements }}
     * {{ react }}
     */
    async seeNumberOfElements(locator, num) {
      const res = await this._locate(locator)
      return assert.equal(res.length, num, `expected number of elements (${new Locator(locator)}) is ${num}, but found ${res.length}`)
    }
  
    /**
     * {{> seeNumberOfVisibleElements }}
     * {{ react }}
     */
    async seeNumberOfVisibleElements(locator, num) {
      const res = await this.grabNumberOfVisibleElements(locator)
      return assert.equal(res, num, `expected number of visible elements (${new Locator(locator)}) is ${num}, but found ${res}`)
    }
  
    /**
     * {{> seeCssPropertiesOnElements }}
     */
    async seeCssPropertiesOnElements(locator, cssProperties) {
      const res = await this._locate(locator)
      assertElementExists(res, locator)
  
      const cssPropertiesCamelCase = convertCssPropertiesToCamelCase(cssProperties)
      const elemAmount = res.length
      let props = []
  
      for (const element of res) {
        for (const prop of Object.keys(cssProperties)) {
          const cssProp = await this.grabCssPropertyFrom(locator, prop)
          if (isColorProperty(prop)) {
            props.push(convertColorToRGBA(cssProp))
          } else {
            props.push(cssProp)
          }
        }
      }
  
      const values = Object.keys(cssPropertiesCamelCase).map(key => cssPropertiesCamelCase[key])
      if (!Array.isArray(props)) props = [props]
      let chunked = chunkArray(props, values.length)
      chunked = chunked.filter(val => {
        for (let i = 0; i < val.length; ++i) {
          if (val[i] != values[i]) return false
        }
        return true
      })
      return equals(`all elements (${new Locator(locator)}) to have CSS property ${JSON.stringify(cssProperties)}`).assert(chunked.length, elemAmount)
    }
  
    /**
     * {{> seeAttributesOnElements }}
     */
    async seeAttributesOnElements(locator, attributes) {
      const res = await this._locate(locator)
      assertElementExists(res, locator)
      const elemAmount = res.length
  
      let attrs = await forEachAsync(res, async el => {
        return forEachAsync(Object.keys(attributes), async attr => el.getAttribute(attr))
      })
  
      const values = Object.keys(attributes).map(key => attributes[key])
      if (!Array.isArray(attrs)) attrs = [attrs]
      let chunked = chunkArray(attrs, values.length)
      chunked = chunked.filter(val => {
        for (let i = 0; i < val.length; ++i) {
          const _actual = Number.isNaN(val[i]) || typeof values[i] === 'string' ? val[i] : Number.parseInt(val[i], 10)
          const _expected = Number.isNaN(values[i]) || typeof values[i] === 'string' ? values[i] : Number.parseInt(values[i], 10)
          // the attribute could be a boolean
          if (typeof _actual === 'boolean') return _actual === _expected
          if (_actual !== _expected) return false
        }
        return true
      })
      return assert.ok(chunked.length === elemAmount, `expected all elements (${new Locator(locator)}) to have attributes ${JSON.stringify(attributes)}`)
    }
  
    /**
     * {{> grabNumberOfVisibleElements }}
     */
    async grabNumberOfVisibleElements(locator) {
      const res = await this._locate(locator)
  
      let selected = await forEachAsync(res, async el => el.isDisplayed())
      if (!Array.isArray(selected)) selected = [selected]
      selected = selected.filter(val => val === true)
      return selected.length
    }
  
    /**
     * {{> seeInCurrentUrl }}
     *
     */
    async seeInCurrentUrl(url) {
      const res = await this.browser.getUrl()
      return stringIncludes('url').assert(url, decodeUrl(res))
    }
  
    /**
     * {{> dontSeeInCurrentUrl }}
     *
     */
    async dontSeeInCurrentUrl(url) {
      const res = await this.browser.getUrl()
      return stringIncludes('url').negate(url, decodeUrl(res))
    }
  
    /**
     * {{> seeCurrentUrlEquals }}
     *
     */
    async seeCurrentUrlEquals(url) {
      const res = await this.browser.getUrl()
      return urlEquals(this.options.url).assert(url, decodeUrl(res))
    }
  
    /**
     * {{> dontSeeCurrentUrlEquals }}
     *
     */
    async dontSeeCurrentUrlEquals(url) {
      const res = await this.browser.getUrl()
      return urlEquals(this.options.url).negate(url, decodeUrl(res))
    }
  
    /**
     * Wraps [execute](http://webdriver.io/api/protocol/execute.html) command.
     *
     * {{> executeScript }}
     */
    executeScript(...args) {
      return this.browser.execute.apply(this.browser, args)
    }
  
    /**
     * {{> executeAsyncScript }}
     *
     */
    executeAsyncScript(...args) {
      return this.browser.executeAsync.apply(this.browser, args)
    }
  
    /**
     * {{> scrollIntoView }}
     *
     */
    async scrollIntoView(locator, scrollIntoViewOptions) {
      const res = await this._locate(withStrictLocator(locator), true)
      assertElementExists(res, locator)
      const elem = usingFirstElement(res)
      return elem.scrollIntoView(scrollIntoViewOptions)
    }
  
    /**
     * {{> scrollTo }}
     *
     */
    async scrollTo(locator, offsetX = 0, offsetY = 0) {
      if (typeof locator === 'number' && typeof offsetX === 'number') {
        offsetY = offsetX
        offsetX = locator
        locator = null
      }
  
      if (locator) {
        const res = await this._locate(withStrictLocator(locator), true)
        assertElementExists(res, locator)
        const elem = usingFirstElement(res)
        const elementId = getElementId(elem)
        if (this.browser.isMobile && !this.browser.isW3C) return this.browser.touchScroll(offsetX, offsetY, elementId)
        const location = await elem.getLocation()
        assertElementExists(location, locator, 'Failed to receive', 'location')
  
        return this.browser.execute(
          function (x, y) {
            return window.scrollTo(x, y)
          },
          location.x + offsetX,
          location.y + offsetY,
        )
      }
  
      if (this.browser.isMobile && !this.browser.isW3C) return this.browser.touchScroll(locator, offsetX, offsetY)
  
      return this.browser.execute(
        function (x, y) {
          return window.scrollTo(x, y)
        },
        offsetX,
        offsetY,
      )
    }
  
    /**
     * {{> moveCursorTo }}
     */
    async moveCursorTo(locator, xOffset, yOffset) {
      const res = await this._locate(withStrictLocator(locator), true)
      assertElementExists(res, locator)
      const elem = usingFirstElement(res)
      try {
        await elem.moveTo({ xOffset, yOffset })
      } catch (e) {
        debug(e.message)
      }
    }
  
    /**
     * {{> saveElementScreenshot }}
     *
     */
    async saveElementScreenshot(locator, fileName) {
      const outputFile = screenshotOutputFolder(fileName)
  
      const res = await this._locate(withStrictLocator(locator), true)
      assertElementExists(res, locator)
      const elem = usingFirstElement(res)
  
      this.debug(`Screenshot of ${new Locator(locator)} element has been saved to ${outputFile}`)
      return elem.saveScreenshot(outputFile)
    }
  
    /**
     * {{> saveScreenshot }}
     */
    async saveScreenshot(fileName, fullPage = false) {
      let outputFile = screenshotOutputFolder(fileName)
  
      if (this.activeSessionName) {
        const browser = this.sessionWindows[this.activeSessionName]
  
        for (const sessionName in this.sessionWindows) {
          const activeSessionPage = this.sessionWindows[sessionName]
          outputFile = screenshotOutputFolder(`${sessionName}_${fileName}`)
  
          this.debug(`${sessionName} - Screenshot is saving to ${outputFile}`)
  
          if (browser) {
            this.debug(`Screenshot of ${sessionName} session has been saved to ${outputFile}`)
            await browser.saveScreenshot(outputFile)
          }
        }
      }
  
      if (!fullPage) {
        this.debug(`Screenshot has been saved to ${outputFile}`)
        await this.browser.saveScreenshot(outputFile)
      }
  
      const originalWindowSize = await this.browser.getWindowSize()
  
      // this case running on device, so we could not set the windowSize
      if (this.browser.isMobile) {
        this.debug(`Screenshot has been saved to ${outputFile}, size: ${originalWindowSize.width}x${originalWindowSize.height}`)
        const buffer = await this.browser.saveScreenshot(outputFile)
        return buffer
      }
  
      let { width, height } = await this.browser
        .execute(function () {
          return {
            height: document.body.scrollHeight,
            width: document.body.scrollWidth,
          }
        })
        .then(res => res)
  
      if (height < 100) height = 500 // errors for very small height
  
      await this.browser.setWindowSize(width, height)
      this.debug(`Screenshot has been saved to ${outputFile}, size: ${width}x${height}`)
      const buffer = await this.browser.saveScreenshot(outputFile)
      await this.browser.setWindowSize(originalWindowSize.width, originalWindowSize.height)
      return buffer
    }
  
    /**
     * Uses Selenium's JSON [cookie format](https://code.google.com/p/selenium/wiki/JsonWireProtocol#Cookie_JSON_Object).
     * {{> setCookie }}
     */
    async setCookie(cookie) {
      return this.browser.setCookies(cookie)
    }
  
    /**
     * {{> clearCookie }}
     */
    async clearCookie(cookie) {
      return this.browser.deleteCookies(cookie)
    }
  
    /**
     * {{> seeCookie }}
     */
    async seeCookie(name) {
      const cookie = await this.browser.getCookies([name])
      return truth(`cookie ${name}`, 'to be set').assert(cookie)
    }
  
    /**
     * {{> dontSeeCookie }}
     */
    async dontSeeCookie(name) {
      const cookie = await this.browser.getCookies([name])
      return truth(`cookie ${name}`, 'to be set').negate(cookie)
    }
  
    /**
     * {{> grabCookie }}
     */
    async grabCookie(name) {
      if (!name) return this.browser.getCookies()
      const cookie = await this.browser.getCookies([name])
      this.debugSection('Cookie', JSON.stringify(cookie))
      return cookie[0]
    }
  
    /**
     * {{> waitForCookie }}
     */
    async waitForCookie(name, sec) {
      // by default, we will retry 3 times
      let retries = 3
      const waitTimeout = sec || this.options.waitForTimeoutInSeconds
  
      if (sec) {
        retries = sec
      } else {
        retries = waitTimeout - 1
      }
  
      return promiseRetry(
        async (retry, number) => {
          const _grabCookie = async name => {
            const cookie = await this.browser.getCookies([name])
            if (cookie.length === 0) throw Error(`Cookie ${name} is not found after ${retries}s`)
          }
  
          this.debugSection('Wait for cookie: ', name)
          if (number > 1) this.debugSection('Retrying... Attempt #', number)
  
          try {
            await _grabCookie(name)
          } catch (e) {
            retry(e)
          }
        },
        { retries, maxTimeout: 1000 },
      )
    }
  
    /**
     * Accepts the active JavaScript native popup window, as created by window.alert|window.confirm|window.prompt.
     * Don't confuse popups with modal windows, as created by [various
     * libraries](http://jster.net/category/windows-modals-popups).
     */
    async acceptPopup() {
      const text = await this.browser.getAlertText()
      if (text) {
        return await this.browser.acceptAlert()
      }
    }
  
    /**
     * Dismisses the active JavaScript popup, as created by `window.alert|window.confirm|window.prompt`.
     *
     */
    async cancelPopup() {
      const text = await this.browser.getAlertText()
      if (text) {
        return await this.browser.dismissAlert()
      }
    }
  
    /**
     * Checks that the active JavaScript popup, as created by `window.alert|window.confirm|window.prompt`, contains the
     * given string.
     *
     * @param {string} text value to check.
     */
    async seeInPopup(text) {
      return await this.browser.getAlertText().then(res => {
        if (res === null) {
          throw new Error('Popup is not opened')
        }
        stringIncludes('text in popup').assert(text, res)
      })
    }
  
    /**
     * {{> grabPopupText }}
     */
    async grabPopupText() {
      try {
        return await this.browser.getAlertText()
      } catch (err) {
        this.debugSection('Popup', 'Error getting text from popup')
      }
    }
  
    /**
     * {{> pressKeyDown }}
     */
    async pressKeyDown(key) {
      key = getNormalizedKey.call(this, key)
      if (!this.browser.isW3C) {
        return this.browser.sendKeys([key])
      }
      return this.browser.performActions([
        {
          type: 'key',
          id: 'keyboard',
          actions: [
            {
              type: 'keyDown',
              value: key,
            },
          ],
        },
      ])
    }
  
    /**
     * {{> pressKeyUp }}
     */
    async pressKeyUp(key) {
      key = getNormalizedKey.call(this, key)
      if (!this.browser.isW3C) {
        return this.browser.sendKeys([key])
      }
      return this.browser.performActions([
        {
          type: 'key',
          id: 'keyboard',
          actions: [
            {
              type: 'keyUp',
              value: key,
            },
          ],
        },
      ])
    }
  
    /**
     * _Note:_ In case a text field or textarea is focused be aware that some browsers do not respect active modifier when combining modifier keys with other keys.
     *
     * {{> pressKeyWithKeyNormalization }}
     */
    async pressKey(key) {
      const modifiers = []
      if (Array.isArray(key)) {
        for (let k of key) {
          k = getNormalizedKey.call(this, k)
          if (isModifierKey(k)) {
            modifiers.push(k)
          } else {
            key = k
            break
          }
        }
      } else {
        key = getNormalizedKey.call(this, key)
      }
      for (const modifier of modifiers) {
        await this.pressKeyDown(modifier)
      }
      if (!this.browser.isW3C) {
        await this.browser.sendKeys([key])
      } else {
        await this.browser.performActions([
          {
            type: 'key',
            id: 'keyboard',
            actions: [
              {
                type: 'keyDown',
                value: key,
              },
              {
                type: 'keyUp',
                value: key,
              },
            ],
          },
        ])
      }
      for (const modifier of modifiers) {
        await this.pressKeyUp(modifier)
      }
    }
  
    /**
     * {{> type }}
     */
    async type(keys, delay = null) {
      if (!Array.isArray(keys)) {
        keys = keys.toString()
        keys = keys.split('')
      }
      if (delay) {
        for (const key of keys) {
          await this.browser.keys(key)
          await this.wait(delay / 1000)
        }
        return
      }
      await this.browser.keys(keys)
    }
  
    /**
     * Appium: not tested in web, in apps doesn't work
     *
     * {{> resizeWindow }}
     */
    async resizeWindow(width, height) {
      return this.browser.setWindowSize(width, height)
    }
  
    async _resizeBrowserWindow(browser, width, height) {
      if (width === 'maximize') {
        const size = await browser.maximizeWindow()
        this.debugSection('Window Size', size)
        return
      }
      if (browser.isW3C) {
        return browser.setWindowRect(null, null, parseInt(width, 10), parseInt(height, 10))
      }
      return browser.setWindowSize(parseInt(width, 10), parseInt(height, 10))
    }
  
    async _resizeWindowIfNeeded(browser, windowSize) {
      if (this.isWeb && windowSize === 'maximize') {
        await this._resizeBrowserWindow(browser, 'maximize')
      } else if (this.isWeb && windowSize && windowSize.indexOf('x') > 0) {
        const dimensions = windowSize.split('x')
        await this._resizeBrowserWindow(browser, dimensions[0], dimensions[1])
      }
    }
  
    /**
     * {{> focus }}
     *
     */
    async focus(locator) {
      const els = await this._locate(locator)
      assertElementExists(els, locator, 'Element to focus')
      const el = usingFirstElement(els)
  
      await focusElement(el, this.browser)
    }
  
    /**
     * {{> blur }}
     *
     */
    async blur(locator) {
      const els = await this._locate(locator)
      assertElementExists(els, locator, 'Element to blur')
      const el = usingFirstElement(els)
  
      await blurElement(el, this.browser)
    }
  
    /**
     * Appium: not tested
     * {{> dragAndDrop }}
     */
    async dragAndDrop(srcElement, destElement) {
      let sourceEl = await this._locate(srcElement)
      assertElementExists(sourceEl, srcElement)
      sourceEl = usingFirstElement(sourceEl)
  
      let destEl = await this._locate(destElement)
      assertElementExists(destEl, destElement)
      destEl = usingFirstElement(destEl)
  
      return sourceEl.dragAndDrop(destEl)
    }
  
    /**
     * {{> dragSlider }}
     */
    async dragSlider(locator, offsetX = 0) {
      const browser = this.browser
      await this.moveCursorTo(locator)
  
      // for chrome
      if (browser.isW3C) {
        const xOffset = await this.grabElementBoundingRect(locator, 'x')
        const yOffset = await this.grabElementBoundingRect(locator, 'y')
  
        return browser.performActions([
          {
            type: 'pointer',
            id: 'pointer1',
            parameters: { pointerType: 'mouse' },
            actions: [
              {
                type: 'pointerMove',
                origin: 'pointer',
                duration: 1000,
                x: xOffset,
                y: yOffset,
              },
              { type: 'pointerDown', button: 0 },
              {
                type: 'pointerMove',
                origin: 'pointer',
                duration: 1000,
                x: offsetX,
                y: 0,
              },
              { type: 'pointerUp', button: 0 },
            ],
          },
        ])
      }
  
      await browser.buttonDown(0)
      await browser.moveToElement(null, offsetX, 0)
      await browser.buttonUp(0)
    }
  
    /**
     * {{> grabAllWindowHandles }}
     */
    async grabAllWindowHandles() {
      return this.browser.getWindowHandles()
    }
  
    /**
     * {{> grabCurrentWindowHandle }}
     */
    async grabCurrentWindowHandle() {
      return this.browser.getWindowHandle()
    }
  
    /**
     * Switch to the window with a specified handle.
     *
     * ```js
     * const windows = await I.grabAllWindowHandles();
     * // ... do something
     * await I.switchToWindow( windows[0] );
     *
     * const window = await I.grabCurrentWindowHandle();
     * // ... do something
     * await I.switchToWindow( window );
     * ```
     * @param {string} window name of window handle.
     */
    async switchToWindow(window) {
      await this.browser.switchToWindow(window)
    }
  
    /**
     * {{> closeOtherTabs }}
     */
    async closeOtherTabs() {
      const handles = await this.browser.getWindowHandles()
      const currentHandle = await this.browser.getWindowHandle()
      const otherHandles = handles.filter(handle => handle !== currentHandle)
  
      await forEachAsync(otherHandles, async handle => {
        await this.browser.switchToWindow(handle)
        await this.browser.closeWindow()
      })
      await this.browser.switchToWindow(currentHandle)
    }
  
    /**
     * {{> wait }}
     */
    async wait(sec) {
      return new Promise(resolve => {
        setTimeout(resolve, sec * 1000)
      })
    }
  
    /**
     * {{> waitForEnabled }}
     */
    async waitForEnabled(locator, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return this.browser.waitUntil(
        async () => {
          const res = await this._res(locator)
          if (!res || res.length === 0) {
            return false
          }
          const selected = await forEachAsync(res, async el => this.browser.isElementEnabled(getElementId(el)))
          if (Array.isArray(selected)) {
            return selected.filter(val => val === true).length > 0
          }
          return selected
        },
        {
          timeout: aSec * 1000,
          timeoutMsg: `element (${new Locator(locator)}) still not enabled after ${aSec} sec`,
        },
      )
    }
  
    /**
     * {{> waitForElement }}
     */
    async waitForElement(locator, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return this.browser.waitUntil(
        async () => {
          const res = await this._res(locator)
          return res && res.length
        },
        {
          timeout: aSec * 1000,
          timeoutMsg: `element (${new Locator(locator)}) still not present on page after ${aSec} sec`,
        },
      )
    }
  
    /**
     * {{> waitForClickable }}
     */
    async waitForClickable(locator, waitTimeout) {
      waitTimeout = waitTimeout || this.options.waitForTimeoutInSeconds
      let res = await this._locate(locator)
      res = usingFirstElement(res)
      assertElementExists(res, locator)
  
      return res.waitForClickable({
        timeout: waitTimeout * 1000,
        timeoutMsg: `element ${res.selector} still not clickable after ${waitTimeout} sec`,
      })
    }
  
    /**
     * {{> waitInUrl }}
     */
    async waitInUrl(urlPart, sec = null) {
      const client = this.browser
      const aSec = sec || this.options.waitForTimeoutInSeconds
      let currUrl = ''
  
      return client
        .waitUntil(
          function () {
            return this.getUrl().then(res => {
              currUrl = decodeUrl(res)
              return currUrl.indexOf(urlPart) > -1
            })
          },
          { timeout: aSec * 1000 },
        )
        .catch(e => {
          if (e.message.indexOf('timeout')) {
            throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
          }
          throw e
        })
    }
  
    /**
     * {{> waitUrlEquals }}
     */
    async waitUrlEquals(urlPart, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
      const baseUrl = this.options.url
      if (urlPart.indexOf('http') < 0) {
        urlPart = baseUrl + urlPart
      }
      let currUrl = ''
      return this.browser
        .waitUntil(function () {
          return this.getUrl().then(res => {
            currUrl = decodeUrl(res)
            return currUrl === urlPart
          })
        }, aSec * 1000)
        .catch(e => {
          if (e.message.indexOf('timeout')) {
            throw new Error(`expected url to be ${urlPart}, but found ${currUrl}`)
          }
          throw e
        })
    }
  
    /**
     * {{> waitForText }}
     *
     */
    async waitForText(text, sec = null, context = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
      const _context = context || this.root
  
      return this.browser.waitUntil(
        async () => {
          const res = await this.$$(withStrictLocator.call(this, _context))
          if (!res || res.length === 0) return false
          const selected = await forEachAsync(res, async el => this.browser.getElementText(getElementId(el)))
          if (Array.isArray(selected)) {
            return selected.filter(part => part.indexOf(text) >= 0).length > 0
          }
          return selected.indexOf(text) >= 0
        },
        {
          timeout: aSec * 1000,
          timeoutMsg: `element (${_context}) is not in DOM or there is no element(${_context}) with text "${text}" after ${aSec} sec`,
        },
      )
    }
  
    /**
     * {{> waitForValue }}
     */
    async waitForValue(field, value, sec = null) {
      const client = this.browser
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return client.waitUntil(
        async () => {
          const res = await findFields.call(this, field)
          if (!res || res.length === 0) return false
          const selected = await forEachAsync(res, async el => el.getValue())
          if (Array.isArray(selected)) {
            return selected.filter(part => part.indexOf(value) >= 0).length > 0
          }
          return selected.indexOf(value) >= 0
        },
        {
          timeout: aSec * 1000,
          timeoutMsg: `element (${field}) is not in DOM or there is no element(${field}) with value "${value}" after ${aSec} sec`,
        },
      )
    }
  
    /**
     * {{> waitForVisible }}
     *
     */
    async waitForVisible(locator, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return this.browser.waitUntil(
        async () => {
          const res = await this._res(locator)
          if (!res || res.length === 0) return false
          const selected = await forEachAsync(res, async el => el.isDisplayed())
          if (Array.isArray(selected)) {
            return selected.filter(val => val === true).length > 0
          }
          return selected
        },
        {
          timeout: aSec * 1000,
          timeoutMsg: `element (${new Locator(locator)}) still not visible after ${aSec} sec`,
        },
      )
    }
  
    /**
     * {{> waitNumberOfVisibleElements }}
     */
    async waitNumberOfVisibleElements(locator, num, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return this.browser.waitUntil(
        async () => {
          const res = await this._res(locator)
          if (!res || res.length === 0) return false
          let selected = await forEachAsync(res, async el => el.isDisplayed())
  
          if (!Array.isArray(selected)) selected = [selected]
          selected = selected.filter(val => val === true)
          return selected.length === num
        },
        {
          timeout: aSec * 1000,
          timeoutMsg: `The number of elements (${new Locator(locator)}) is not ${num} after ${aSec} sec`,
        },
      )
    }
  
    /**
     * {{> waitForInvisible }}
     */
    async waitForInvisible(locator, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return this.browser.waitUntil(
        async () => {
          const res = await this._res(locator)
          if (!res || res.length === 0) return true
          const selected = await forEachAsync(res, async el => el.isDisplayed())
          return !selected.length
        },
        { timeout: aSec * 1000, timeoutMsg: `element (${new Locator(locator)}) still visible after ${aSec} sec` },
      )
    }
  
    /**
     * {{> waitToHide }}
     */
    async waitToHide(locator, sec = null) {
      return this.waitForInvisible(locator, sec)
    }
  
    /**
     * {{> waitForDetached }}
     */
    async waitForDetached(locator, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return this.browser.waitUntil(
        async () => {
          const res = await this._res(locator)
          if (!res || res.length === 0) {
            return true
          }
          return false
        },
        { timeout: aSec * 1000, timeoutMsg: `element (${new Locator(locator)}) still on page after ${aSec} sec` },
      )
    }
  
    /**
     * {{> waitForFunction }}
     */
    async waitForFunction(fn, argsOrSec = null, sec = null) {
      let args = []
      if (argsOrSec) {
        if (Array.isArray(argsOrSec)) {
          args = argsOrSec
        } else if (typeof argsOrSec === 'number') {
          sec = argsOrSec
        }
      }
  
      const aSec = sec || this.options.waitForTimeoutInSeconds
  
      return this.browser.waitUntil(async () => this.browser.execute(fn, ...args), {
        timeout: aSec * 1000,
        timeoutMsg: '',
      })
    }
  
    /**
     * {{> waitForNumberOfTabs }}
     */
    async waitForNumberOfTabs(expectedTabs, sec) {
      const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeoutInSeconds
      let currentTabs
      let count = 0
  
      do {
        currentTabs = await this.grabNumberOfOpenTabs()
        await this.wait(1)
        count += 1000
        if (currentTabs >= expectedTabs) return
      } while (count <= waitTimeout)
  
      throw new Error(`Expected ${expectedTabs} tabs are not met after ${waitTimeout / 1000} sec.`)
    }
  
    /**
     * {{> switchTo }}
     */
    async switchTo(locator) {
      this.browser.isInsideFrame = true
      if (!locator) {
        return this.browser.switchFrame(null)
      }
  
      let res = await this._locate(locator, true)
      assertElementExists(res, locator)
      res = usingFirstElement(res)
      return this.browser.switchFrame(res)
    }
  
    /**
     * {{> switchToNextTab }}
     */
    async switchToNextTab(num = 1, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
      let target
      const current = await this.browser.getWindowHandle()
  
      await this.browser.waitUntil(
        async () => {
          await this.browser.getWindowHandles().then(handles => {
            if (handles.indexOf(current) + num + 1 <= handles.length) {
              target = handles[handles.indexOf(current) + num]
            }
          })
          return target
        },
        { timeout: aSec * 1000, timeoutMsg: `There is no ability to switch to next tab with offset ${num}` },
      )
      return this.browser.switchToWindow(target)
    }
  
    /**
     * {{> switchToPreviousTab }}
     */
    async switchToPreviousTab(num = 1, sec = null) {
      const aSec = sec || this.options.waitForTimeoutInSeconds
      const current = await this.browser.getWindowHandle()
      let target
  
      await this.browser.waitUntil(
        async () => {
          await this.browser.getWindowHandles().then(handles => {
            if (handles.indexOf(current) - num > -1) {
              target = handles[handles.indexOf(current) - num]
            }
          })
          return target
        },
        { timeout: aSec * 1000, timeoutMsg: `There is no ability to switch to previous tab with offset ${num}` },
      )
      return this.browser.switchToWindow(target)
    }
  
    /**
     * {{> closeCurrentTab }}
     */
    async closeCurrentTab() {
      await this.browser.closeWindow()
      const handles = await this.browser.getWindowHandles()
      if (handles[0]) await this.browser.switchToWindow(handles[0])
    }
  
    /**
     * {{> openNewTab }}
     */
    async openNewTab(url = 'about:blank', windowName = null) {
      const client = this.browser
      const crypto = require('crypto')
      if (windowName == null) {
        windowName = crypto.randomBytes(32).toString('hex')
      }
      return client.newWindow(url, windowName)
    }
  
    /**
     * {{> grabNumberOfOpenTabs }}
     */
    async grabNumberOfOpenTabs() {
      const pages = await this.browser.getWindowHandles()
      this.debugSection('Tabs', `Total ${pages.length}`)
      return pages.length
    }
  
    /**
     * {{> refreshPage }}
     */
    async refreshPage() {
      const client = this.browser
      return client.refresh()
    }
  
    /**
     * {{> scrollPageToTop }}
     */
    scrollPageToTop() {
      const client = this.browser
  
      return client.execute(function () {
        window.scrollTo(0, 0)
      })
    }
  
    /**
     * {{> scrollPageToBottom }}
     */
    scrollPageToBottom() {
      const client = this.browser
  
      return client.execute(function () {
        const body = document.body
        const html = document.documentElement
        window.scrollTo(0, Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight))
      })
    }
  
    /**
     * {{> grabPageScrollPosition }}
     */
    async grabPageScrollPosition() {
      function getScrollPosition() {
        return {
          x: window.pageXOffset,
          y: window.pageYOffset,
        }
      }
  
      return this.executeScript(getScrollPosition)
    }
  
    /**
     * {{> grabElementBoundingRect }}
     */
    async grabElementBoundingRect(locator, prop) {
      const res = await this._locate(locator, true)
      assertElementExists(res, locator)
      const el = usingFirstElement(res)
  
      const rect = {
        ...(await el.getLocation()),
        ...(await el.getSize()),
      }
      if (prop) return rect[prop]
      return rect
    }
  
    /**
     * Placeholder for ~ locator only test case write once run on both Appium and WebDriver.
     * @param {*} caps
     * @param {*} fn
     */
  
    runOnIOS(caps, fn) {}
  
    /**
     * Placeholder for ~ locator only test case write once run on both Appium and WebDriver.
     * @param {*} caps
     * @param {*} fn
     */
    runOnAndroid(caps, fn) {}
  
    /**
     * Placeholder for ~ locator only test case write once run on both Appium and WebDriver.
     */
    async runInWeb(fn) {
      return fn()
    }
  }
  
  async function proceedSee(assertType, text, context, strict = false) {
    let description
    if (!context) {
      if (this.context === webRoot) {
        context = this.context
        description = 'web page'
      } else {
        description = `current context ${this.context}`
        context = './/*'
      }
    } else {
      description = `element ${context}`
    }
  
    const smartWaitEnabled = assertType === 'assert'
    const res = await this._locate(withStrictLocator(context), smartWaitEnabled)
    assertElementExists(res, context)
    let selected = await forEachAsync(res, async el => this.browser.getElementText(getElementId(el)))
  
    // apply ignoreCase option
    if (store?.currentStep?.opts?.ignoreCase === true) {
      text = text.toLowerCase()
      selected = selected.map(elText => elText.toLowerCase())
    }
  
    if (strict) {
      if (Array.isArray(selected) && selected.length !== 0) {
        return selected.map(elText => equals(description)[assertType](text, elText))
      }
      return equals(description)[assertType](text, selected)
    }
    return stringIncludes(description)[assertType](text, selected)
  }
  
  /**
   * Mimic Array.forEach() API, but with an async callback function.
   * Execute each callback on each array item serially. Useful when using WebDriver API.
   *
   * Added due because of problem with chrome driver when too many requests
   * are made simultaneously. https://bugs.chromium.org/p/chromedriver/issues/detail?id=2152#c9
   * @private
   * @param {Array} array Input array items to iterate over.
   * @param {Function} callback - Async function to excute on each array item.
   * @param {object} [options] - Configurable options.
   * @param {boolean} [options.expandArrayResults=true] - Flag to flat one dimension array results.
   * @return {Promise<Array>} - Array of values.
   */
  async function forEachAsync(array, callback, options = { expandArrayResults: true }) {
    const { expandArrayResults = true } = options
    const inputArray = Array.isArray(array) ? array : [array]
    const values = []
    for (let index = 0; index < inputArray.length; index++) {
      const res = await callback(inputArray[index], index, inputArray)
  
      if (Array.isArray(res) && expandArrayResults) {
        res.forEach(val => values.push(val))
      } else if (res) {
        values.push(res)
      }
    }
    return values
  }
  
  /**
   * Mimic Array.filter() API, but with an async callback function.
   * Execute each callback on each array item serially. Useful when using WebDriver API.
   *
   * Added due because of problem with chrome driver when too many requests
   * are made simultaneously. https://bugs.chromium.org/p/chromedriver/issues/detail?id=2152#c9
   * @private
   * @param {Array} array - Input array items to iterate over.
   * @param {Function} callback - Async functin to excute on each array item.
   * @return {Promise<Array>} - Array of values.
   */
  async function filterAsync(array, callback) {
    const inputArray = Array.isArray(array) ? array : [array]
    const values = []
    for (let index = 0; index < inputArray.length; index++) {
      const res = await callback(inputArray[index], index, inputArray)
      const value = Array.isArray(res) ? res[0] : res
  
      if (value) {
        values.push(inputArray[index])
      }
    }
    return values
  }
  
  async function findClickable(locator, locateFn) {
    locator = new Locator(locator)
  
    if (this._isCustomLocator(locator)) {
      return locateFn(locator.value)
    }
  
    if (locator.isAccessibilityId() && !this.isWeb) return locateFn(locator, true)
    if (!locator.isFuzzy()) return locateFn(locator, true)
  
    let els
    const literal = xpathLocator.literal(locator.value)
  
    els = await locateFn(Locator.clickable.narrow(literal))
    if (els.length) return els
  
    els = await locateFn(Locator.clickable.wide(literal))
    if (els.length) return els
  
    els = await locateFn(Locator.clickable.self(literal))
    if (els.length) return els
  
    return locateFn(locator.value) // by css or xpath
  }
  
  async function findFields(locator) {
    locator = new Locator(locator)
  
    if (this._isCustomLocator(locator)) {
      return this._locate(locator)
    }
  
    if (locator.isAccessibilityId() && !this.isWeb) return this._locate(locator, true)
    if (!locator.isFuzzy()) return this._locate(locator, true)
  
    const literal = xpathLocator.literal(locator.value)
    let els = await this._locate(Locator.field.labelEquals(literal))
    if (els.length) return els
  
    els = await this._locate(Locator.field.labelContains(literal))
    if (els.length) return els
  
    els = await this._locate(Locator.field.byName(literal))
    if (els.length) return els
    return this._locate(locator.value) // by css or xpath
  }
  
  async function proceedSeeField(assertType, field, value) {
    const res = await findFields.call(this, field)
    assertElementExists(res, field, 'Field')
    const elem = usingFirstElement(res)
    const elemId = getElementId(elem)
  
    const proceedMultiple = async fields => {
      const fieldResults = toArray(
        await forEachAsync(fields, async el => {
          const elementId = getElementId(el)
          return this.browser.getElementAttribute(elementId, 'value')
        }),
      )
  
      if (typeof value === 'boolean') {
        equals(`no. of items matching > 0: ${field}`)[assertType](value, !!fieldResults.length)
      } else {
        // Assert that results were found so the forEach assert does not silently pass
        equals(`no. of items matching > 0:  ${field}`)[assertType](true, !!fieldResults.length)
        fieldResults.forEach(val => stringIncludes(`fields by ${field}`)[assertType](value, val))
      }
    }
  
    const proceedSingle = el =>
      el.getValue().then(res => {
        if (res === null) {
          throw new Error(`Element ${el.selector} has no value attribute`)
        }
        stringIncludes(`fields by ${field}`)[assertType](value, res)
      })
  
    const filterBySelected = async elements => filterAsync(elements, async el => this.browser.isElementSelected(getElementId(el)))
  
    const filterSelectedByValue = async (elements, value) => {
      return filterAsync(elements, async el => {
        const elementId = getElementId(el)
        const currentValue = await this.browser.getElementAttribute(elementId, 'value')
        const isSelected = await this.browser.isElementSelected(elementId)
        return currentValue === value && isSelected
      })
    }
  
    const tag = await elem.getTagName()
    if (tag === 'select') {
      let subOptions
  
      try {
        subOptions = await this.browser.findElementsFromElement(elemId, 'css', 'option')
      } catch (e) {
        subOptions = await this.browser.findElementsFromElement(elemId, 'xpath', 'option')
      }
  
      if (value === '') {
        // Don't filter by value
        const selectedOptions = await filterBySelected(subOptions)
        return proceedMultiple(selectedOptions)
      }
  
      const options = await filterSelectedByValue(subOptions, value)
      return proceedMultiple(options)
    }
  
    if (tag === 'input') {
      const fieldType = await elem.getAttribute('type')
  
      if (fieldType === 'checkbox' || fieldType === 'radio') {
        if (typeof value === 'boolean') {
          // Support boolean values
          const options = await filterBySelected(res)
          return proceedMultiple(options)
        }
  
        const options = await filterSelectedByValue(res, value)
        return proceedMultiple(options)
      }
      return proceedSingle(elem)
    }
    return proceedSingle(elem)
  }
  
  function toArray(item) {
    if (!Array.isArray(item)) {
      return [item]
    }
    return item
  }
  
  async function proceedSeeCheckbox(assertType, field) {
    const res = await findFields.call(this, field)
    assertElementExists(res, field, 'Field')
  
    const selected = await forEachAsync(res, async el => this.browser.isElementSelected(getElementId(el)))
    return truth(`checkable field "${field}"`, 'to be checked')[assertType](selected)
  }
  
  async function findCheckable(locator, locateFn) {
    let els
    locator = new Locator(locator)
  
    if (this._isCustomLocator(locator)) {
      return locateFn(locator.value)
    }
  
    if (locator.isAccessibilityId() && !this.isWeb) return locateFn(locator, true)
    if (!locator.isFuzzy()) return locateFn(locator, true)
  
    const literal = xpathLocator.literal(locator.value)
    els = await locateFn(Locator.checkable.byText(literal))
    if (els.length) return els
    els = await locateFn(Locator.checkable.byName(literal))
    if (els.length) return els
  
    return locateFn(locator.value) // by css or xpath
  }
  
  function withStrictLocator(locator) {
    locator = new Locator(locator)
    return locator.simplify()
  }
  
  function isFrameLocator(locator) {
    locator = new Locator(locator)
    if (locator.isFrame()) return locator.value
    return false
  }
  
  function assertElementExists(res, locator, prefix, suffix) {
    if (!res || res.length === 0) {
      throw new ElementNotFound(locator, prefix, suffix)
    }
  }
  
  function usingFirstElement(els) {
    if (els.length > 1) debug(`[Elements] Using first element out of ${els.length}`)
    return els[0]
  }
  
  function getElementId(el) {
    // W3C WebDriver web element identifier
    // https://w3c.github.io/webdriver/#dfn-web-element-identifier
    if (el['element-6066-11e4-a52e-4f735466cecf']) {
      return el['element-6066-11e4-a52e-4f735466cecf']
    }
    // (deprecated) JsonWireProtocol identifier
    // https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#webelement-json-object
    if (el.ELEMENT) {
      return el.ELEMENT
    }
  
    return null
  }
  
  // List of known key values to unicode code points
  // https://www.w3.org/TR/webdriver/#keyboard-actions
  const keyUnicodeMap = {
    Unidentified: '\uE000',
    Cancel: '\uE001',
    Clear: '\uE005',
    Help: '\uE002',
    Pause: '\uE00B',
    Backspace: '\uE003',
    Return: '\uE006',
    Enter: '\uE007',
    Escape: '\uE00C',
    Alt: '\uE00A',
    AltLeft: '\uE00A',
    AltRight: '\uE052',
    Control: '\uE009',
    ControlLeft: '\uE009',
    ControlRight: '\uE051',
    Meta: '\uE03D',
    MetaLeft: '\uE03D',
    MetaRight: '\uE053',
    Shift: '\uE008',
    ShiftLeft: '\uE008',
    ShiftRight: '\uE050',
    Space: '\uE00D',
    ' ': '\uE00D',
    Tab: '\uE004',
    Insert: '\uE016',
    Delete: '\uE017',
    End: '\uE010',
    Home: '\uE011',
    PageUp: '\uE00E',
    PageDown: '\uE00F',
    ArrowDown: '\uE015',
    ArrowLeft: '\uE012',
    ArrowRight: '\uE014',
    ArrowUp: '\uE013',
    F1: '\uE031',
    F2: '\uE032',
    F3: '\uE033',
    F4: '\uE034',
    F5: '\uE035',
    F6: '\uE036',
    F7: '\uE037',
    F8: '\uE038',
    F9: '\uE039',
    F10: '\uE03A',
    F11: '\uE03B',
    F12: '\uE03C',
    Numpad0: '\uE01A',
    Numpad1: '\uE01B',
    Numpad2: '\uE01C',
    Numpad3: '\uE01D',
    Numpad4: '\uE01E',
    Numpad5: '\uE01F',
    Numpad6: '\uE020',
    Numpad7: '\uE021',
    Numpad8: '\uE022',
    Numpad9: '\uE023',
    NumpadMultiply: '\uE024',
    NumpadAdd: '\uE025',
    NumpadSubtract: '\uE027',
    NumpadDecimal: '\uE028',
    NumpadDivide: '\uE029',
    NumpadEnter: '\uE007',
    NumpadInsert: '\uE05C', // 'Numpad0' alternate (when NumLock off)
    NumpadDelete: '\uE05D', // 'NumpadDecimal' alternate (when NumLock off)
    NumpadEnd: '\uE056', // 'Numpad1' alternate (when NumLock off)
    NumpadHome: '\uE057', // 'Numpad7' alternate (when NumLock off)
    NumpadPageDown: '\uE055', // 'Numpad3' alternate (when NumLock off)
    NumpadPageUp: '\uE054', // 'Numpad9' alternate (when NumLock off)
    NumpadArrowDown: '\uE05B', // 'Numpad2' alternate (when NumLock off)
    NumpadArrowLeft: '\uE058', // 'Numpad4' alternate (when NumLock off)
    NumpadArrowRight: '\uE05A', // 'Numpad6' alternate (when NumLock off)
    NumpadArrowUp: '\uE059', // 'Numpad8' alternate (when NumLock off)
    Comma: '\uE026', // ',' alias
    Digit0: '0', // '0' alias
    Digit1: '1', // '1' alias
    Digit2: '2', // '2' alias
    Digit3: '3', // '3' alias
    Digit4: '4', // '4' alias
    Digit5: '5', // '5' alias
    Digit6: '6', // '6' alias
    Digit7: '7', // '7' alias
    Digit8: '8', // '8' alias
    Digit9: '9', // '9' alias
    Equal: '\uE019', // '=' alias
    KeyA: 'a', // 'a' alias
    KeyB: 'b', // 'b' alias
    KeyC: 'c', // 'c' alias
    KeyD: 'd', // 'd' alias
    KeyE: 'e', // 'e' alias
    KeyF: 'f', // 'f' alias
    KeyG: 'g', // 'g' alias
    KeyH: 'h', // 'h' alias
    KeyI: 'i', // 'i' alias
    KeyJ: 'j', // 'j' alias
    KeyK: 'k', // 'k' alias
    KeyL: 'l', // 'l' alias
    KeyM: 'm', // 'm' alias
    KeyN: 'n', // 'n' alias
    KeyO: 'o', // 'o' alias
    KeyP: 'p', // 'p' alias
    KeyQ: 'q', // 'q' alias
    KeyR: 'r', // 'r' alias
    KeyS: 's', // 's' alias
    KeyT: 't', // 't' alias
    KeyU: 'u', // 'u' alias
    KeyV: 'v', // 'v' alias
    KeyW: 'w', // 'w' alias
    KeyX: 'x', // 'x' alias
    KeyY: 'y', // 'y' alias
    KeyZ: 'z', // 'z' alias
    Period: '.', // '.' alias
    Semicolon: '\uE018', // ';' alias
    Slash: '/', // '/' alias
    ZenkakuHankaku: '\uE040',
  }
  
  function convertKeyToRawKey(key) {
    if (Object.prototype.hasOwnProperty.call(keyUnicodeMap, key)) {
      return keyUnicodeMap[key]
    }
    // Key is raw key when no representative unicode code point for value
    return key
  }
  
  function getNormalizedKey(key) {
    let normalizedKey = getNormalizedKeyAttributeValue(key)
    // Always use "left" modifier keys for non-W3C sessions,
    // as JsonWireProtocol does not support "right" modifier keys
    if (!this.browser.isW3C) {
      normalizedKey = normalizedKey.replace(/^(Alt|Control|Meta|Shift)Right$/, '$1')
    }
    if (key !== normalizedKey) {
      this.debugSection('Input', `Mapping key '${key}' to '${normalizedKey}'`)
    }
    return convertKeyToRawKey(normalizedKey)
  }
  
  const unicodeModifierKeys = modifierKeys.map(k => convertKeyToRawKey(k))
  function isModifierKey(key) {
    return unicodeModifierKeys.includes(key)
  }
  
  function highlightActiveElement(element) {
    if (this.options.highlightElement && global.debugMode) {
      highlightElement(element, this.browser)
    }
  }
  
  function prepareLocateFn(context) {
    if (!context) return this._locate.bind(this)
    return l => {
      l = new Locator(l, 'css')
      return this._locate(context, true).then(async res => {
        assertElementExists(res, context, 'Context element')
        if (l.react) {
          return res[0].react$$(l.react, l.props || undefined)
        }
        return res[0].$$(l.simplify())
      })
    }
  }
  
  function logEvents(event) {
    browserLogs.push(event.text) // add log message to the array
  }
  
  module.exports = WebDriver
  