const consolelog = require('debug')('webdriver:Driver')
const getPort = require('get-port')
const axios = require('axios')
const utils = require('../utils')
const Element = require('../Element.js')
const FindHelpersMixin = require('../FindHelpersMixin')
const USING = require('../USING')
const KEY = require('../KEY')
/**
* The main driver class used to create a driver and actually use this API
* It will spawn a webdriver process by default.
* @mixes FindHelpersMixin
* @augments FindHelpersMixin
*
* @example
* // Create a driver using the Chrome browser
* var driver1 = new Driver(new Config())
* await driver.newSession()
*
* // Create a driver, but does NOT spawn a new webcontroller process
* var driver2 = new Driver(new Config(), {
* spawn: false,
* hostname: '127.0.0.1',
* port: 4444
* })
* await driver.newSession()
*
*/
var Driver = class {
//
/**
* Constructor returning a Driver object, which will be used to interface
* with a Webdriver.
*
* It's used as a base class for specific drivers ({@link ChromeDriver},
* {@link FirefoxDriver}, {@link EdgeDriver}, {@link SafariDriver}).
*
* Specific drivers know how to run a local webdriver command (if present)
* and are able to adjust calls and return values so that they follow the
* w3c webdriver Protocol
*
* @param {Config} config The session config that will be sent to the webdriver
* @param {Object} opt Options to configure the API itself
* @param {string} opt.hostname=127.0.0.1 The hostname to connect to.
* @param {number} opt.port The port. If not specified, a free port will automatically be found
* @param {number} opt.pollInterval=300 How many milliseconds to wait between each poll when using `waitFor()` by default
* @param {boolean} opt.spawn=true If true, it will spawn a new webdriver process when a new session is created
* @param {Object} opt.env=process.env (If spawn === true) The environment to pass to the spawn webdriver
* @param {string} opt.stdio=ignore (If spawn === true) The default parameter to pass to {@link https://nodejs.org/api/child_process.html#child_process_options_stdio stdio} when spawning new preocess.
* @param {Array} opt.args (If spawn === true) The arguments to pass to the webdriver command
*/
constructor (config, options = {}) {
this._config = config
this._hostname = options.hostname || '127.0.0.1'
this._spawn = typeof options.spawn !== 'undefined' ? !!options.spawn : true
this._executable = null
this._webDriverRunning = !this._spawn
// Parameters passed onto child process
this._port = options.port
this._env = utils.isObject(options.env) ? options.env : process.env
this._stdio = options.stdio || 'ignore'
this._args = Array.isArray(options.args) ? options.args : []
this._killCommand = null
this._commandResult = null
this._pollInterval = 300
this._pollTimeout = 10000
this._urlBase = null
}
/**
* Set executable for the specific webdriver.
* Deriving classes like {@link ChromeDriver}, {@link SafariDriver} etc.
* will set the specific executable to run.
*
* @param {string} executable The name of the executable to run
*/
setExecutable (executable) {
this._executable = executable
}
/**
* Run the actual webdriver's executable, depending on the browser
* This method might be overridden by deriving classes like
* {@link ChromeDriver} or {@link SafariDriver}
*
* @param {Object} opt Options to configure the webdriver executable
* @param {number} opt.port The port the webdriver executable will listen to
* @param {Array} opt.args The arguments to pass to the webdriver executable
* @param {Object} opt.env The environment to pass to the spawn webdriver
* @param {string} opt.stdio The default parameter to pass to {@link https://nodejs.org/api/child_process.html#child_process_options_stdio stdio} when spawning new preocess.
*
*/
async run (options) {
options.args.push('--port=' + options.port)
return utils.exec(this._executable, options)
}
/**
* Set the default poll interval
*
* @param {number} interval How many milliseconds between each polling attempt
*
* @example
* // Create a driver using the Chrome browser
* var driver = new drivers.ChromeDriver(new Config())
* driver.setPollInterval(200)
*/
setPollInterval (ms) {
this._pollInterval = ms
}
/**
* Set the default waitFor timeout
*
* @param {number} timeout How many milliseconds between failing the call
*
* @example
* var driver = new drivers.ChromeDriver(new Config())
* driver.setPollTimeout(200)
*/
setPollTimeout (ms) {
this._pollTimeout = ms
}
/**
* This method will wrap ANY method in Driver or Element with a polling mechanism, which will retry
* the call every `pollInterval` milliseconds (it defaults to 300, but it can
* be set when running the Driver's constructor)
* It's also possible to pass an extra parameter to the original method in Driver,
* which represents a checker function that will need to return truly for success
*
* @async
* @param {(number)} timeout How long to poll for
*
* @example
* // Create a driver using the Chrome browser
* var driver = new drivers.ChromeDriver(new Config())
* await driver.navigateTo('http://www.google.com')
*
* // The findElementCss has 5 seconds to work
* var el = driver.waitFor(5000).findElementCss('[name=q]')
*
* // The findElementsCss (note the plural: it returns an array)
* // has 10 seconds to work, AND the result needs to be not-empty
* // (see the tester function added)
* await el.waitFor(10000).findElementsCss('.listItem', (r) => r.length))
*/
waitFor (timeout = 0, pollInterval = 0) {
timeout = timeout || this._pollTimeout
pollInterval = pollInterval || this._pollInterval
var self = this
return new Proxy({}, {
get (target, name) {
// if (name in target) { return target[name] }
if (typeof self[name] === 'function') {
return async function (...args) {
// If the last argument is a function, it's assumed
// to be the checker. If not, the checker is set as
// a yes-man noop function
if (typeof args[args.length - 1] === 'function') {
var checker = args.pop()
} else {
checker = () => true
}
var endTime = new Date(Date.now() + timeout)
var success = false
var errors = []
while (true) {
try {
consolelog(`Attempting call ${name} with timeout ${timeout} and arguments ${args}`)
var res = await self[name].apply(self, args)
// If the flow gets to this point, the call was successful. However,
// it's also too late. Even though the call went through, it will be
// considered failed
if (new Date() > endTime) {
consolelog('Call was successful, BUT it was too late. This will fail.')
errors.push(new Error('Call successful but too late'))
break
}
consolelog('Call was successful, checking the result with the provided checker...')
success = !!checker(res)
consolelog('Checker returned:', success)
} catch (e) {
consolelog('Call resulted in error, checker won\'t be run')
errors.push(e)
}
if (success || new Date() > endTime) {
consolelog(`Time to get out of the cycle. Success is ${success}`)
break
}
consolelog(`Sleeping for ${pollInterval}, trying again later...`)
await utils.sleep(pollInterval)
}
// If attempt is successful, return res
if (success) return res
else {
var error = new Error('Call was unsuccessful')
error.errors = errors
throw error
}
}
} else {
return self[name]
}
} // End of Proxy getter
}) // End of Proxy
}
/**
* @private
*/
_ready () {
return !!(this._sessionId && this._webDriverRunning)
}
/**
* Start the right webdriver, depending on what browser is attached to it.
* Since webdrivers can take a little while to answer, this method also checks
* that the webdriver process is actively and properly dealing with connections
* This method is called by {@link Driver#newSession|newSession}
*
* @example
* var driver = new drivers.ChromeDriver(new Config())
* await driver.startWebDriver()
*/
async startWebDriver () {
// If spawning is required, do so
if (!this._webDriverRunning && this._spawn && this._executable) {
// No port: find a free port
if (!this._port) {
this._port = await getPort({ host: this._hostname })
}
// Options: port, args, env, stdio
var res = await this.run({
port: this._port,
args: this._args,
env: this._env,
stdio: this._stdio }
)
this._killCommand = res.killCommand
}
// It's still possible that no port has been set, since spawn was false
// and no port was defined. In such a case, use the default 4444
if (!this._port) this._port = '4444'
// `_hostname` and `_port` are finally set: make up the first `urlBase`
this._urlBase = `http://${this._hostname}:${this._port}/session`
await this.sleep(200)
// Check that the server is up, by asking for the status
// It might take a little while for the
var success = false
for (var i = 0; i < 40; i++) {
if (i > 5) {
console.log(`Attempt n. ${i} to connect to ${this._hostname}, port ${this._port}... `)
}
try {
await this.status()
success = true
break
} catch (e) {
await this.sleep(300)
}
}
if (!success) {
throw new Error(`Could not connect to the driver`)
}
// Connection worked...
this._webDriverRunning = true
}
/**
* Stops the webdriver process, if running. It does so by sending it a SIGTERM message.
* You can specify the message to send the process
*
* @param {(string|number)} signal=SIGTERM The signal to send. To know which signals you can send,
*
* @example
* // Create a driver using the Chrome browser
* var driver = new drivers.ChromeDriver(new Config())
* // ...
* // ...
* await driver.stopWebDriver()
*/
async stopWebDriver (signal = 'SIGTERM') {
if (this._killCommand) {
this._killCommand(signal)
// this.killWebDriver('SIGTERM')
this._webDriverRunning = false
this._port = 0
}
}
/**
* Sleep for ms milliseconds
* NOTE: you shouldn't use this as a quick means to wait for AJAX calls to finish.
* If you need to poll-wait, use {@link Driver#waitFor waitFor}
*
* @param {number} ms The number of milliseconds to wait for
* *
* @example
* await driver.sleep(1000)
*
*/
async sleep (ms) {
return utils.sleep(ms)
}
/**
* @private
*/
inspect () {
return `Driver { ip: ${this.Name}, port: ${this._port} }`
}
/**
* Create a new session. If the driver was created with `spawn` set to `true`, it will
* run the webdriver for the associated browser before asking for the session
* @return {Promise<object>} An object containing the keys `sessionId` and `capabilities`
*
* @example
* var driver = new drivers.ChromeDriver(new Config())
* var session = await driver.newSession()
*/
async newSession () {
try {
if (this._sessionId) {
throw new Error('Session already created. Call deleteSession() first')
}
// First of all, try and run the webdriver, if it's not running already
await this.startWebDriver()
var value = await this._execute('post', '', this._config.getSessionParameters())
// W3C conforming response; check if value is an object containing a `capabilities` object property
// and a `sessionId` string property
if (utils.isObject(value) &&
utils.isObject(value.capabilities) &&
typeof value.capabilities.browserName === 'string' &&
typeof value.sessionId === 'string'
) {
this._sessionCapabilities = value.capabilities
this._sessionId = value.sessionId
}
if (!this._sessionId || !this._sessionCapabilities) throw new Error('Could not get sessionId and capabilities out of returned object')
this._urlBase = `http://${this._hostname}:${this._port}/session/${this._sessionId}`
return value
} catch (e) {
this._sessionId = null
this._sessionData = {}
this._urlBase = `http://${this._hostname}:${this._port}/session`
throw (e)
}
}
/**
* Delete the current session. This will not kill the webdriver process (if one
* was spawned). You can create a new session with {@link Driver#newSession|newSession}
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.deleteSession()
*/
async deleteSession () {
try {
var value = await this._execute('delete', '')
this._sessionId = null
this._sessionData = {}
this._urlBase = `http://${this._hostname}:${this._port}/session`
return value
} catch (e) {
throw (e)
}
}
/**
* Get status
*
* @return {Promise<object>} An object status, which is guaranteed by the superClass
* to include `ready` (boolean) and `message`
*
* @example
* var status = await driver.status()
*/
async status () {
var _urlBase = `http://${this._hostname}:${this._port}`
var res = await axios({ method: 'get', url: `${_urlBase}/status` })
return utils.checkRes(res.data).value
}
/**
* Perform actions as specified in the passed actions object
* To see how to create an actions object, check the
* {@link Actions Actions class}
*
* @param {Actions} actions An object
*
* @return {Promise<Driver>} The driver itself
*
* @example
* var actions = new Actions()
* actions.tick.keyboardDown('R').keyboardUp('R')
* await driver.performActions(actions)
*/
async performActions (actions) {
actions.compile()
await this._execute('post', '/actions', { actions: actions.compiledActions })
return this
}
/**
* Release the current actions
* @example
* await driver.releaseActions(actions)
*
*/
async releaseActions () {
await this._execute('delete', '/actions')
return this
}
/**
* @private
*/
async _execute (method, command, params) {
// Check that session has been created
if (!(method === 'post' && command === '' && !this._sessionId)) {
if (!this._ready()) throw new Error('Executing command on non-ready driver')
}
var p = { method, url: null }
if (method === 'post' || method === 'put') p.data = params || {}
p.url = `${this._urlBase}${command}`
// Getting the result
try {
var res = await axios(p)
} catch(e){
console.log(e)
}
if (!res) debugger
// Return the result, checking if everything is OK
return utils.checkRes(res.data).value
}
/**
* A value used by {@link Driver#findElement} and
* {@link Driver#findElements} to decide what kind of
* filter will be applied
*/
static get Using () {
return USING
}
/** Constant returning special KEY characters (enter, etc.)
* Constant are from the global variable {@link KEY}
*
* @example
* var body = await driver.findElementTagName('body')
* body.sendKeys(driver.Key.ENTER + 'n')
*/
static get Key () { return KEY }
/**
* Get timeout settings in the page
* @async
*
* @return {Promise<Object>} A promise resolving to an object
* with keys `implicit`, `pageLoad` and `script `. E.g.
* `{ implicit: 0, pageLoad: 300000, script: 30000 }`
*
* @example
* var timeouts = await driver.getTimeouts()
*/
getTimeouts () {
return this._execute('get', '/timeouts')
}
/**
* Set timeouts
* @async
*
* @param {Object} param The object with the timeouts
* @param {number} param.implicit Implicit timeout
* @param {number} param.pageLoad Timeout for page loads
* @param {number} param.script Timeout for scripts
*
* @example
* var timeouts = await driver.setTimeouts({ implicit: 7000 })
*/
setTimeouts (parameters) {
return this._execute('post', '/timeouts', parameters)
}
/**
* Navigate to page
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.navigateTo('http://www.google.com')
*/
async navigateTo (url) {
await this._execute('post', '/url', { url })
return this
}
/**
* Bake a cake without coffee
* Also, get the current URL
* @async
*
* @return {Promise<string>} The URL
* @example
* var currentUrl = await driver.getCurrentUrl()
*/
getCurrentUrl () {
return this._execute('get', '/url')
}
/**
* Go back one step
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.back()
*/
async back () {
await this._execute('post', '/back')
return this
}
/**
* Go forward one step
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.forward()
*/
async forward () {
await this._execute('post', '/forward')
return this
}
/**
* Refresh the page
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.refresh()
*/
async refresh () {
await this._execute('post', '/refresh')
return this
}
/**
* Get page's title
*
* @return {Promise<string>} The page's title
* @async
*
* @example
* var title = await driver.getTitle()
*/
getTitle () {
return this._execute('get', '/title')
}
/**
* Get the current window's handle
* @async
*
* @return {Promise<string>} The handle, as a string
*
* @example
* var title = await driver.getWindowHandle()
*/
getWindowHandle () {
return this._execute('get', '/window')
}
/**
* Close the current window
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.closeWindow()
*/
async closeWindow () {
await this._execute('delete', '/window')
}
/**
* Get window handles as an array
* @async
*
* @return {Promise<Array<string>>} An array of window handles
*
* @example
* var wins = await driver.getWindowHandles()
*/
getWindowHandles () {
return this._execute('get', '/window/handles')
}
/**
* Switch to window
*
* @return {Promise<Driver>} The driver itself
*
* @param {handle} string The window handle to switched to
* @example
* var wins = await driver.getWindowHandles()
* if (wins[1]) await switchToWindow (wins[1])
*/
async switchToWindow (handle) {
await this._execute('post', '/window', { handle })
return this
}
/**
* Switch to frame
* @async
*
* @param {string|number|Element} frame Element object, or element ID, of the frame
* @return {Promise<Driver>} The driver itself
*
* @example
* var frame = await driver.findElementCss('iframe')
* await driver.switchToFrame(frame)
*/
switchToFrame (id) {
var param = id
// If it's an Element, then make up an object that represents
// a webdriver element
// The same logic is applied if it was an object
// w3c: Chrome still needs the ELEMENT key
if (id instanceof Element || typeof id === 'object') {
param = { 'element-6066-11e4-a52e-4f735466cecf': id.id, ELEMENT: id.id }
}
return this._execute('post', '/frame', { id: param })
}
/**
* Switch to the parent frame
*
* @return {Promise<Driver>} The driver itself
*
* @example
* var frame = await driver.findElementCss('iframe')
* await driver.switchToFrame(frame)
* await driver.switchToParentFrame()
*/
async switchToParentFrame () {
await this._execute('post', '/frame/parent')
return this
}
/**
* Get window rect
* @async
*
* @return {Promise<Object>} An object with properties `height`, `width`, `x`, `y`
*
* @example
* await driver.getWindowRect()
*/
getWindowRect () {
return this._execute('get', '/window/rect')
}
/**
* Set window rect
* @async
*
* @param {Promise<Object>} rect An object with properties `height`, `width`, `x`, `y`
* @param {Object} rect.height The desired height
* @param {Object} rect.width The desired width
* @param {Object} rect.x The desired x position
* @param {Object} rect.y The desired y position
*
* @return {Promise<Object>} An object with properties `height`, `width`, `x`, `y`
*
* @example
* await driver.getWindowRect()
*/
setWindowRect (rect) {
return this._execute('post', '/window/rect', rect)
}
/**
* Maximize window
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.maximizeWindow()
*/
async maximizeWindow () {
return this._execute('post', '/window/maximize')
}
/**
* Minimize window
* @async
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.minimizeWindow()
*/
minimizeWindow () {
return this._execute('post', '/window/minimize')
}
/**
* Make window full screen
* @async
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.fullScreenWindow()
*/
fullScreenWindow () {
return this._execute('post', '/window/fullscreen')
}
/**
* Get page source
* @async
*
* @return {Promise<string>} The current page's source
*
* @example
* await driver.getPageSource()
*/
getPageSource () {
return this._execute('get', '/source')
}
/**
* Execute sync script
* @async
*
* @param {string} script The string with the script to be executed
* @param {array} args The arguments to be passed to the script
* @return Whatever was returned by the javascript code with a `return` statement
*
* @example
* await driver.executeScript('return 'Hello ' + arguments[0];', ['tony'])
*/
executeScript (script, args = []) {
return this._execute('post', '/execute/sync', { script, args })
}
/**
* Execute async script
* NOTE: An extra argument is passed to the script, in addition to the arguments
* in `args`: it's the callback the script will need to call
* once the script has executed.
* To return a value from the async script, pass that value to the callback
* @async
*
* @param {string} script The string with the script to be executed
* @param {Array} args The arguments to be passed to the script
*
* @return Whatever was returned by the javascript code by calling the callback
*
* @example
* var script = 'var name = arguments[0];var cb = arguments[1];cb(\'Hello \' + name);'
* await driver.executeAsyncScript(script, ['tony'])
*/
executeAsyncScript (script, args = []) {
return this._execute('post', '/execute/async', { script, args })
}
/**
* Get all cookies
* @async
*
* @return {Array<Object>} An array of cookie objects.
*
* @example
* var list = await driver.getAllCookies()
*/
getAllCookies () {
return this._execute('get', '/cookie')
}
/**
* Get cookies matching a specific name
* @async
*
* @return {Object} A cookie object
*
* @example
* var cookie = await driver.getNamedCookies('NID')
*/
getNamedCookie (name) {
return this._execute('get', `/cookie/${name}`)
}
/**
* Get cookies matching a specific name
* @param {object} cookie The cookie object
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.addCookie({
* name: 'test',
* value: 'a test',
* path: '/',
* domain: 'google.com.au',
* expiry: 1732569047,
* secure: true,
* httpOnly: true})
*/
async addCookie (cookie) {
await this._execute('post', '/cookie', { cookie })
return this
}
/**
* Delete cookie matching a specific name
*
* @return {Promise<Driver>} The driver itself
*
* @example
* var cookie = await driver.deleteCookie('test')
*/
async deleteCookie (name) {
await this._execute('delete', `/cookie/${name}`)
return this
}
/**
* Delete all cookies
*
* @return {Promise<Driver>} The driver itself
*
* @example
* var list = await driver.deleteAllCookies()
*/
async deleteAllCookies () {
await this._execute('delete', '/cookie')
return this
}
/**
* Dismiss an alert
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.dismissAlert()
*/
async dismissAlert () {
await this._execute('post', '/alert/dismiss')
return this
}
/**
* Accepts an alert
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.acceptAlert()
*/
async acceptAlert () {
await this._execute('post', '/alert/accept')
return this
}
/**
* Get an alert's text
* @async
*
* @return {string} The alert's text
*
* @example
* var text = await driver.getAlertText()
*/
getAlertText () {
return this._execute('get', '/alert/text')
}
/**
* Send text to an alert
*
* @param {string} text The text that should be sent
*
* @return {Promise<Driver>} The driver itself
*
* @example
* await driver.sendAlertText('This is my answer')
*/
async sendAlertText (text) {
await this._execute('post', '/alert/text', { text })
return this
}
/**
* Take screenshot
* *
* @return {buffer} The screenshot data
*
* @example
* await driver.sendAlertText()
*/
async takeScreenshot () {
var value = await this._execute('get', '/screenshot')
return Buffer.from(value, 'base64')
}
/**
* Get active element
*
* @return {Element} An object representing the element.
*
* @example
* var el await driver.getActiveElement()
*/
async getActiveElement () {
var value = await this._execute('get', '/element/active')
return new Element(this, value)
}
/**
* Find an element.
* Note that you are encouraged to use one of the helper functions:
* `findElementCss()`, `findElementLinkText()`, `findElementPartialLinkText()`,
* `findElementTagName()`, `findElementXpath()`
*
* @param {string} using It can be `Driver.Using.CSS`, `Driver.Using.LINK_TEXT`,
* `Driver.Using.PARTIAL_LINK_TEXT`, `Driver.Using.TAG_NAME`,
* `Driver.Using.XPATH`
* @param {string} value The parameter to the `using` method
*
* @return {Element} An object representing the element.
* @example
* var el = await driver.findElement({ Driver.Using.CSS, value: '[name=q]' )
*
*/
async findElement (using, value) {
var el = await this._execute('post', '/element', {using, value})
return new Element(this, el)
}
/**
* Find several elements
* Note that you are encouraged to use one of the helper functions:
* `findElementsCss()`, `findElemenstLinkText()`, `findElementsPartialLinkText()`,
* `findElementsTagName()`, `findElementsXpath()`
*
* @param {string} using It can be `Driver.Using.CSS`, `Driver.Using.LINK_TEXT`,
* `Driver.Using.PARTIAL_LINK_TEXT`, `Driver.Using.TAG_NAME`,
* `Driver.Using.XPATH`
* @param {string} value The parameter to the `using` method
*
* @return [{Element},{Element},...] An array of elements
*
* @example
* var el = await driver.findElements({ Driver.Using.CSS, value: '.item' )
*
*/
async findElements (using, value) {
var els = await this._execute('post', '/elements', {using, value})
if (!Array.isArray(els)) throw new Error('Result from findElements must be an array')
return els.map((v) => new Element(this, v))
}
}
exports = module.exports = Driver = FindHelpersMixin(Driver)