const fs = require('fs')
const { execSync } = require('child_process')
const { Builder, By, Key, until, logging } = require('selenium-webdriver')
/**
* @typedef DriverOptions
* @type {object}
* @property {number} [timeout=1000] - Timeout (in ms) for each call to driver.wait()
* @property {string} [browser='chrome'] - Browser to use with this driver
* @property {string} [baseUrl='http://localhost:3000'] - Base URL to test against
* <p>Set via environment variable: `BASE_URL`</p>
* <p>`export BASE_URL=http://example.com`</p>
* <p>`BASE_URL=http://example.com npm run myTestScript`</p>
* </pre>
* @property {string} [logLevel='SEVERE'] - Log level for debug browser logs
* <p>Allowed values:</p>
* <ul style="list-style: none; padding: 0;">
* <li>`'OFF'` - Turns off logging</li>
* <li>`'SEVERE'` - Messages about things that went wrong. For instance, an unknown command.</li>
* <li>`'WARNING'` - Messages about things that may be wrong but was handled. For instance, a handled exception.</li>
* <li>`'INFO'` - Messages of an informative nature. For instance, information about received commands.</li>
* <li>`'DEBUG'` - Messages for debugging. For instance, information about the state of the driver.</li>
* <li>`'ALL'` - All log messages. A way to collect all information regardless of which log levels that are supported.</li>
* </ul>
* @property {string} [homePagePath='/'] - Path to homepage. Visited on {@link SeleniumDriver#start}
* @property {string} [debugDirectory='tmp/selenium-debug'] - Directory to save debug logs and screenshots from failures
**/
/** @type {DriverOptions} */
const defaults = {
timeout: 1000,
browser: 'chrome',
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
logLevel: 'SEVERE',
homePagePath: '/',
debugDirectory: 'tmp/selenium-debug'
}
/**
* SeleniumDriver helper for better E2E tests
*/
class SeleniumDriver {
/**
* Create a new SeleniumDriver object
* @constructor
* @param {DriverOptions} options - Options to initialize this driver
*/
constructor (options) {
this.options = Object.assign({}, defaults, options)
this.timeout = this.options.timeout
if (!logging.Level[this.options.logLevel]) {
this.options.logLevel = defaults.logLevel
}
let pref = new logging.Preferences()
pref.setLevel('browser', this.options.logLevel)
this.driver = new Builder()
.forBrowser(this.options.browser)
.setLoggingPrefs(pref)
.build()
}
/**
* Start this driver instance. Delete Cookies and visit homepage (see {@link DriverOptions}.`homePagePath`)
*/
async start () {
await this.driver.manage().deleteAllCookies()
await this.visit(this.options.homePagePath)
}
/** Quit this driver instance */
async quit () {
await this.driver.quit()
}
/**
* Visit specified page
* @param {string} page - Path of page to visit
*/
async visit (page) {
await this.driver.get(this.options.baseUrl + this._pagePath(page))
}
/**
* Click an element specified by css `selector`
* @param {string} selector - CSS selector to be clicked
* @throws {Error} - Unable to click selector: `'${selector}'`
*/
async click (selector) {
const element = await this.expectElement(selector)
await this.scrollTo(element)
try {
await element.click()
} catch (e) {
this.error(`Unable to click selector: '${selector}'`)
}
}
/**
* Fill in an element with `value` specified by css `selector`
* @param {string} selector - CSS selector of element to receive keys
* @param {string} value - Value to be sent to element as keys
* @param {boolean} [enter=false] - Flag to also send the `ENTER` key after `value`
* @throws {Error} - Unable to send keys to selector: `'${selector}'`
*/
async fillIn (selector, value, enter = false) {
const element = await this.expectElement(selector)
await this.scrollTo(element)
try {
if (enter) {
await element.sendKeys(value, Key.ENTER)
} else {
await element.sendKeys(value)
}
} catch (e) {
this.error(`Unable to send keys to selector: '${selector}`)
}
}
/**
* Select an option with value or text `value` specified by css `selector`
* @param {string} selector - CSS selector of `<select>` element to pick from
* @param {string} value - Value (or text) of an `<option>` element to be selected
* @throws {Error} - Unable to select value/text: `'${value}'` from selector: `'${selector}'`
*/
async select (selector, value) {
let option
const element = await this.expectElement(selector)
await this.scrollTo(element)
try {
await element.click()
const options = await element.findElements(By.tagName('option'))
await Promise.all(options.map(async opt => {
if (await opt.getAttribute('value') === value || await opt.getText() === value) {
option = opt
}
}))
await option.click()
await this.driver.wait(until.elementIsSelected(option), this.timeout)
} catch (e) {
this.error(`Unable to select value/text: '${value}' from selector: '${selector}'`)
}
}
/**
* Scroll to `element`
* @param {selenium-webdriver.WebElement} element - WebElement to scroll to
*/
async scrollTo (element) {
try {
await this.driver.executeScript('arguments[0].scrollIntoView()', element)
} catch (e) {
await this.error(`Unable to scroll to element: '${await this.elementSelector(element)}'`)
}
}
/**
* Wait for page title to be, contain, or match `title`
* @param {string|RegExp} title - Title to wait for
* @param {boolean} [exact=false] - If `true` and `title` is a `string`, then wait until page title is exactly `title`
* @throws {Error} - Title did not match: `'${title}'`
*/
async expectTitle (title, exact = false) {
try {
if (title.constructor === RegExp) {
await this.driver.wait(until.titleMatches(title), this.timeout)
} else if (exact) {
await this.driver.wait(until.titleIs(title), this.timeout)
} else {
await this.driver.wait(until.titleContains(title), this.timeout)
}
} catch (e) {
await this.error(`Title ${this._matchTextNot(title, exact)}`)
}
}
/**
* Wait for element specified by css `selector` and optional `content`.
* @param {string} selector - CSS selector of expected element
* @param {string|RegExp} [content] - Optional content to also be expected within element text
* @param {boolean} [exact=false] - If `true` and `content` is a `string`, then expect element with text exactly `content`
* @returns {selenium-webdriver.WebElement} - Returns [selenium-webdriver.WebElement]{@link http://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebElement.html}
* @throws {Error} - No element with selector: `'${selector}'`
*/
async expectElement (selector, content, exact = false) {
try {
await this.driver.wait(until.elementLocated(By.css(selector)), this.timeout)
const element = await this.driver.findElement(By.css(selector))
await this.driver.wait(until.elementIsVisible(element), this.timeout)
if (content) {
if (content.constructor === RegExp) {
await this.driver.wait(until.elementTextMatches(element, content), this.timeout)
} else if (exact) {
await this.driver.wait(until.elementTextIs(element, content), this.timeout)
} else {
await this.driver.wait(until.elementTextContains(element, content), this.timeout)
}
}
return element
} catch (e) {
let msg = `No element with selector: '${selector}'`
if (content) {
msg += ` and text that ${this._matchText(content, exact)}`
}
await this.error(msg)
}
}
/**
* Wait for specified `element` to become stale
* @param {selenium-webdriver.WebElement} element - Expects [selenium-webdriver.WebElement]{@link http://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebElement.html}
* @throws {Error} - Element is not stale: `'${await this.elementSelector(element)}'`
*/
async expectStale (element) {
try {
await this.driver.wait(until.stalenessOf(element), this.timeout)
} catch (e) {
await this.error(`Element is not stale: '${await this.elementSelector(element)}'`)
}
}
/**
* Wait for current path to be, match, or contain `page`
* @param {string|RegExp} page - Page path to wait for
* @param {boolean} [exact=false] - If `true` and `page` is a `string`, then expect exact full path
* @throws {Error} - Page did not match: `'${page}'`
*/
async expectPage (page, exact = false) {
try {
if (page.constructor === RegExp) {
await this.driver.wait(until.urlMatches(page), this.timeout)
} else if (exact) {
await this.driver.wait(until.urlIs(this.options.baseUrl + this._pagePath(page)), this.timeout)
} else {
await this.driver.wait(until.urlContains(page), this.timeout)
}
} catch (e) {
await this.error(`Page ${this._matchTextNot(page, exact)}`)
}
}
/**
* Build accurate element selector based on DOM attributes
* @param {selenium-webdriver.WebElement} element - Expects [selenium-webdriver.WebElement]{@link http://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebElement.html}
* @returns {string} - `${tag}#${id}.${class}`
*/
async elementSelector (element) {
const id = await element.getAttribute('id')
const className = await element.getAttribute('class')
let selector = await element.getTagName()
if (id && id.trim() !== '') {
selector += `#${id}`
}
if (className && className.trim() !== '') {
selector += `.${className.split(' ').join('.')}`
}
return selector
}
/**
* Handle error with `msg`. Save browser logs and screenshot to {@link DriverOptions}.`debugDirectory`
* @param {string} msg - Message for this error. Used for debug dir name and thrown error message
* @throws {Error} - `msg` + debug info
*/
async error (msg) {
const dirName = `${this.options.debugDirectory}/${Date.now()}-${msg.trim().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase().replace(/[-]*$/, '')}`
let logs = await this.driver.manage().logs().get('browser')
execSync(`mkdir -p ${dirName}`)
fs.appendFileSync(`${dirName}/browser.log`, logs.map(e => `[${e.timestamp}] (${e.level.name_}) - ${e.message}`).join('\n'))
require('fs').writeFile(`${dirName}/screenshot.png`, await this.driver.takeScreenshot(), 'base64', function (e) {
if (e) console.log(e)
})
msg += `\n\nDebug files created in: ${dirName}\n`
msg += execSync(`ls -l ${dirName}`).toString()
throw new Error(msg)
}
/**
* Ensure `page` begins with a slash
* @param {string} page
*/
_pagePath (page) {
let path = page
if (page.substr(0, 1) !== '/') {
path = `/${path}`
}
return path
}
/**
* Construct dynamic statement depending on `search` type for a positive match
* @param {string|RegExp} search - String to base statement construction
* @param {boolean} exact - If exact use wording `is` instead of `contains`
* @returns {string} - Constructed statement
*/
_matchText (search, exact) {
let text = 'contains'
if (search.constructor === RegExp) {
text = 'matches'
} else if (exact) {
text = 'is'
}
return `${text}: '${search}'`
}
/**
* Construct dynamic statement depending on `search` type for a negative match
* @param {string|RegExp} search - String to base statement construction
* @param {boolean} exact - If exact use wording `was not` instead of `did not contain`
* @returns {string} - Constructed statement
*/
_matchTextNot (search, exact) {
let text = 'did not '
if (search.constructor === RegExp) {
text += 'match'
} else if (exact) {
text = 'was not'
} else {
text += 'contain'
}
return `${text}: '${search}'`
}
}
module.exports = SeleniumDriver