@christian-bromann/webdriverio
Version:
A nodejs bindings implementation for selenium 2.0/webdriver
600 lines (502 loc) • 19.5 kB
JavaScript
import q from 'q'
import fs from 'fs'
import path from 'path'
import merge from 'deepmerge'
import mkdirp from 'mkdirp'
import events from 'events'
import RequestHandler from './utils/RequestHandler'
import { RuntimeError } from './utils/ErrorHandler'
import Logger from './utils/Logger'
import safeExecute from './helpers/safeExecute'
import sanitize from './helpers/sanitize'
import mobileDetector from './helpers/mobileDetector'
import detectSeleniumBackend from './helpers/detectSeleniumBackend'
import internalErrorHandler from './helpers/errorHandler'
import hasElementResult from './helpers/hasElementResultHelper'
const INTERNAL_EVENTS = ['init', 'command', 'error', 'result', 'end', 'screenshot']
const PROMISE_FUNCTIONS = ['then', 'catch', 'finally']
let EventEmitter = events.EventEmitter
/**
* WebdriverIO v4
*/
let WebdriverIO = function (args, modifier) {
let prototype = Object.create(Object.prototype)
let rawCommands = {}
let eventHandler = new EventEmitter()
let fulFilledPromise = q()
let stacktrace = []
let commandList = []
const EVENTHANDLER_FUNCTIONS = Object.getPrototypeOf(eventHandler)
/**
* merge default options with given user options
*/
let options = merge({
protocol: 'http',
waitforTimeout: 500,
waitforInterval: 500,
coloredLogs: true,
logLevel: 'silent',
baseUrl: null,
onError: [],
connectionRetryTimeout: 90000,
connectionRetryCount: 3
}, typeof args !== 'string' ? args : {})
/**
* define Selenium backend given on user options
*/
options = merge(detectSeleniumBackend(args), options)
/**
* only set globals we wouldn't get otherwise
*/
if (!process.env.WEBDRIVERIO_COLORED_LOGS) {
process.env.WEBDRIVERIO_COLORED_LOGS = options.coloredLogs
}
let logger = new Logger(options, eventHandler)
let requestHandler = new RequestHandler(options, eventHandler, logger)
/**
* assign instance to existing session
*/
if (typeof args === 'string') {
requestHandler.sessionID = args
}
/**
* sanitize error handler
*/
if (!Array.isArray(options.onError)) {
options.onError = [options.onError]
}
options.onError = options.onError.filter((fn) => {
return typeof fn === 'function'
})
let desiredCapabilities = merge({
javascriptEnabled: true,
locationContextEnabled: true,
handlesAlerts: true,
rotatable: true
}, options.desiredCapabilities || {})
let { isMobile, isIOS, isAndroid } = mobileDetector(desiredCapabilities)
/**
* if no caps are specified fall back to firefox
*/
if (!desiredCapabilities.browserName && !isMobile) {
desiredCapabilities.browserName = 'firefox'
}
if (!isMobile && typeof desiredCapabilities.loggingPrefs === 'undefined') {
desiredCapabilities.loggingPrefs = {
browser: 'ALL',
driver: 'ALL'
}
}
let resolve = function (result, onFulfilled, onRejected, context) {
if (typeof result === 'function') {
this.isExecuted = true
result = result.call(this)
}
/**
* run error handler if command fails
*/
if (result instanceof Error) {
let _result = result
this.defer.resolve(Promise.all(internalErrorHandler.map((fn) => {
return fn.call(context, result)
})).then((res) => {
const handlerResponses = res.filter((r) => typeof r !== 'undefined')
/**
* if no handler was triggered trough actual error
*/
if (handlerResponses.length === 0) {
return callErrorHandlerAndReject.call(context, _result, onRejected)
}
return onFulfilled.call(context, handlerResponses[0])
}, (e) => {
return callErrorHandlerAndReject.call(context, e, onRejected)
}))
} else {
this.defer.resolve(result)
}
return this.promise
}
/**
* middleware to call on error handler in wdio mode
*/
let callErrorHandlerAndReject = function (err, onRejected) {
/**
* only call error handler if there is any and if error has bubbled up
*/
if (!this || this.depth !== 0 || options.onError.length === 0) {
return reject.call(this, err, onRejected)
}
return new Promise((resolve, reject) => {
return Promise.all(options.onError.map((fn) => {
if (!global.wdioSync) {
return fn.call(this, err)
}
return new Promise((resolve) => global.wdioSync(fn, resolve).call(this, err))
})).then(resolve, reject)
}).then(() => {
return reject.call(this, err, onRejected)
})
}
/**
* By using finally in our next method we omit the duty to throw an exception at some
* point. To avoid propagating rejected promises until everything crashes silently we
* check if the last and current promise got rejected. If so we can throw the error.
*/
let reject = function (err, onRejected) {
if (!options.isWDIO && !options.screenshotOnReject && typeof onRejected === 'function') {
delete err.screenshot
return onRejected(err)
}
const onRejectedSafe = (err) => {
if (typeof onRejected === 'function') {
onRejected(err)
}
}
if (!this && !options.screenshotOnReject) {
onRejectedSafe(err)
throw err
}
if (this && this.depth !== 0) {
onRejectedSafe(err)
return this.promise
}
const shouldTakeScreenshot = options.screenshotOnReject || typeof options.screenshotPath === 'string'
if (!shouldTakeScreenshot || err.shotTaken || insideCommand('screenshot', this)) {
return fail(err, onRejected)
}
err.shotTaken = true
return takeScreenshot(err)
.catch((e) => logger.log('\tFailed to take screenshot on reject:', e))
.then(() => fail(err, onRejected))
}
function insideCommand (command, unit) {
const commands = unit && unit.commandList
return commands && commands[commands.length - 1].name === command
}
function takeScreenshot (err) {
const client = unit()
const failDate = new Date()
// don't take a new screenshot if we already got one from Selenium
const getScreenshot = typeof err.screenshot === 'string'
? () => err.screenshot
: () => rawCommands.screenshot.call(client).then((res) => res.value)
return q.fcall(getScreenshot)
.then((screenshot) => {
if (options.screenshotOnReject) {
err.screenshot = screenshot
}
if (typeof options.screenshotPath === 'string') {
const filename = saveScreenshotSync(screenshot, failDate)
client.emit('screenshot', { data: screenshot, filename })
}
})
}
function saveScreenshotSync (screenshot, date) {
const screenshotPath = path.isAbsolute(options.screenshotPath)
? options.screenshotPath
: path.join(process.cwd(), options.screenshotPath)
/**
* create directory if not existing
*/
try {
fs.statSync(screenshotPath)
} catch (e) {
mkdirp.sync(screenshotPath)
}
const capId = sanitize.caps(desiredCapabilities)
const timestamp = date.toJSON().replace(/:/g, '-')
const filename = `ERROR_${capId}_${timestamp}.png`
const filePath = path.join(screenshotPath, filename)
fs.writeFileSync(filePath, new Buffer(screenshot, 'base64'))
logger.log(`\tSaved screenshot: ${filename}`)
return filename
}
function fail (e, onRejected) {
if (!e.stack) {
e = new Error(e)
}
let stack = stacktrace.slice().map(trace => ' at ' + trace)
e.stack = e.type + ': ' + e.message + '\n' + stack.reverse().join('\n')
/**
* the waitUntil command can execute a lot of functions until it resolves
* to keep the stacktrace sane we just shrink down the stacktrack and
* only keep waitUntil in it
*/
if (e.type === 'WaitUntilTimeoutError') {
stack = e.stack.split('\n')
stack.splice(1, stack.length - 2)
e.stack = stack.join('\n')
}
/**
* ToDo useful feature for standalone mode:
* option that if true causes script to throw exception if command fails:
*
* process.nextTick(() => {
* throw e
* })
*/
if (typeof onRejected !== 'function') {
throw e
}
return onRejected(e)
}
/**
* WebdriverIO Monad
*/
function unit (lastPromise) {
let client = Object.create(prototype)
let defer = q.defer()
let promise = defer.promise
client.defer = defer
client.promise = promise
client.lastPromise = lastPromise || fulFilledPromise
client.desiredCapabilities = desiredCapabilities
client.requestHandler = requestHandler
client.logger = logger
client.options = options
client.commandList = commandList
client.isMobile = isMobile
client.isIOS = isIOS
client.isAndroid = isAndroid
/**
* actual bind function
*/
client.next = function (func, args, name) {
/**
* use finally to propagate rejected promises up the chain
*/
return this.lastPromise.then((val) => {
/**
* store command into command list so `getHistory` can return it
*/
commandList.push({name, args})
/**
* allow user to leave out selector argument if they have already queried an element before
*/
let lastResult = val || this.lastResult
if (hasElementResult(lastResult) && args.length < func.length && func.toString().indexOf(`function ${name}(selector`) === 0) {
if (lastResult.selector && name === 'waitForExist') {
this.lastResult = null
args.unshift(lastResult.selector)
} else {
args.unshift(null)
}
}
return resolve.call(this, safeExecute(func, args))
}, (e) => {
/**
* this will get reached only in standalone mode if the command
* fails and doesn't get followed by a then or catch method
*/
return resolve.call(this, e, null, null, { depth: 0 })
})
}
client.finally = function (fn) {
let client = unit(this.promise.finally(() => {
return resolve.call(client, safeExecute(fn, []).bind(this))
}))
return client
}
client.call = function (fn) {
let client = unit(this.promise.done(() => {
return resolve.call(client, safeExecute(fn, []).bind(this))
}))
return client
}
client.then = function (onFulfilled, onRejected) {
if (typeof onFulfilled !== 'function' && typeof onRejected !== 'function') {
return this
}
/**
* execute then function in context of the new instance
* but resolve result with this
*/
let client = unit(this.promise.then((...args) => {
/**
* store result in commandList
*/
if (commandList.length) {
commandList[commandList.length - 1].result = args[0]
}
/**
* resolve command
*/
return resolve.call(client, safeExecute(onFulfilled, args).bind(this))
}, (e) => {
let result = safeExecute(onRejected, [e]).bind(this)
/**
* handle error once command was bubbled up the command chain
*/
if (this.depth === 0) {
result = e
}
return resolve.call(client,
result,
onFulfilled,
onRejected,
this
)
}))
return client
}
client.catch = function (onRejected) {
return this.then(undefined, onRejected)
}
client.inspect = function () {
return this.promise.inspect()
}
/**
* internal helper method to handle command results
*
* @param {Promise[]} promises list of promises
* @param {Boolean} option if true extract value property from selenium result
*/
client.unify = function (promises, option) {
option = option || {}
promises = Array.isArray(promises) ? promises : [promises]
return Promise.all(promises)
/**
* extract value property from result if desired
*/
.then((result) => {
if (!option.extractValue || !Array.isArray(result)) {
return result
}
return result.map(res =>
res.value && typeof res.value === 'string' ? res.value.trim() : res.value)
/**
* sanitize result for better assertion
*/
}).then((result) => {
if (Array.isArray(result) && result.length === 1) {
result = result[0]
}
if (option.lowercase && typeof result === 'string') {
result = result.toLowerCase()
}
return result
})
}
client.addCommand = function (fnName, fn, forceOverwrite) {
if (typeof fn === 'string') {
const namespace = arguments[0]
fnName = arguments[1]
fn = arguments[2]
forceOverwrite = arguments[3]
switch (typeof client[namespace]) {
case 'function':
throw new RuntimeError(`Command namespace "${namespace}" is used internally, and can't be overwritten!`)
case 'object':
if (client[namespace][fnName] && !forceOverwrite) {
throw new RuntimeError(`Command "${fnName}" is already defined!`)
}
break
}
return unit.lift(namespace, fnName, fn)
}
if (client[fnName] && !forceOverwrite) {
throw new RuntimeError(`Command "${fnName}" is already defined!`)
}
return unit.lift(fnName, fn)
}
client.getPrototype = function () {
return prototype
}
client.transferPromiseness = function (target, promise) {
/**
* transfer WebdriverIO commands
*/
let clientFunctions = Object.keys(prototype)
let functionsToTranfer = clientFunctions.concat(PROMISE_FUNCTIONS)
for (let fnName of functionsToTranfer) {
if (typeof promise[fnName] === 'function') {
target[fnName] = promise[fnName].bind(promise)
}
}
}
if (typeof modifier === 'function') {
client = modifier(client, options)
}
return client
}
/**
* enhance base monad prototype with methods
*/
unit.lift = function (name, func) {
let commandGroup = prototype
if (typeof func === 'string') {
const namespace = arguments[0]
name = arguments[1]
func = arguments[2]
if (!prototype[namespace]) {
prototype[namespace] = {}
}
commandGroup = prototype[namespace]
}
rawCommands[name] = func
commandGroup[name] = function (...args) {
let nextPromise = this.promise
/**
* commands executed inside commands don't have to wait
* on any promise
*/
if (this.isExecuted) {
nextPromise = this.lastPromise
}
let client = unit(nextPromise)
/**
* catch stack to find information about where the command that causes
* the error was used (stack line 2) and only save it when it was not
* within WebdriverIO context
*/
let stack = new Error().stack
let lineInTest = stack.split('\n').slice(2, 3).join('\n')
let fileAndPosition = lineInTest.slice(lineInTest.indexOf('(') + 1, lineInTest.indexOf(')'))
let atCommand = lineInTest.trim().slice(3).split(' ')[0]
atCommand = atCommand.slice(atCommand.lastIndexOf('.') + 1)
let trace = name + '(' + sanitize.args(args) + ') - ' + fileAndPosition.slice(fileAndPosition.lastIndexOf('/') + 1)
if (Object.keys(prototype).indexOf(atCommand) === -1 && atCommand !== 'exports') {
stacktrace = [trace]
} else {
/**
* save trace for nested commands
*/
stacktrace.push(trace)
}
/**
* determine execution depth:
* This little tweak helps us to determine whether the command was executed
* by the test script or by another command. With that we can make sure
* that errors are getting thrown once they bubbled up the command chain.
*/
client.depth = stack.split('\n').filter((line) => !!line.match(/\/lib\/(commands|protocol)\/(\w+)\.js/)).length
/**
* queue command
*/
client.name = name
client.lastResult = this.lastResult
client.next(func, args, name)
return client
}
return unit
}
/**
* register event emitter
*/
for (let eventCommand in EVENTHANDLER_FUNCTIONS) {
prototype[eventCommand] = function (...args) {
/**
* custom commands needs to get emitted and registered in order
* to prevent race conditions
*/
if (INTERNAL_EVENTS.indexOf(args[0]) === -1) {
return this.finally(() => eventHandler[eventCommand].apply(eventHandler, args))
}
eventHandler[eventCommand].apply(eventHandler, args)
return this
}
}
return unit
}
export default WebdriverIO