jest.setTimeout(30000); // eslint-disable-line no-undef const webdriver = require('selenium-webdriver'); const {PageLoadStrategy} = require('selenium-webdriver/lib/capabilities'); const bindAll = require('lodash.bindall'); const headless = process.env.SMOKE_HEADLESS || false; const remote = process.env.SMOKE_REMOTE || false; const ciBuildPrefix = process.env.CI ? `CI #${process.env.GITHUB_RUN_ID}/${process.env.GITHUB_RUN_ATTEMPT}` : ''; // no prefix if not in CI const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env; const {By, Key, until} = webdriver; // The main reason for this timeout is so that we can control the timeout message and report details; // if we hit the Jasmine default timeout then we get a terse message that we can't control. // The Jasmine default timeout is 30 seconds so make sure this is lower. const DEFAULT_TIMEOUT_MILLISECONDS = 20 * 1000; // This removes confusing `regenerator-runtime` noise from stack traces. // This is V8-specific code. Please don't use it in a browser or any production code. const oldPrepareStackTrace = Error.prepareStackTrace; Error.prepareStackTrace = function (error, stack) { stack = stack.filter(callsite => { const filename = callsite.getFileName(); return filename && !filename.includes('regenerator-runtime'); }); if (oldPrepareStackTrace) { return oldPrepareStackTrace(error, stack); } return [ `${error.constructor.name}: ${error.message}`, ...stack.map(callsite => ` at ${callsite.toString()}`) ].join('\n'); }; /** * An error thrown by SeleniumHelper. * @extends Error */ class SeleniumHelperError extends Error { /** * Instantiate a new SeleniumHelperError. * @param {string} message The error message for this layer. * @param {Array} [kvList] Optional keys & values to add to the error message, for example to capture arguments. * @example * const e = new SeleniumHelperError('something failed', [{xpath}, {somethingElse}]) * try { * doThings(); * } catch (inner) { * throw await e.chain(inner, driver); * } */ constructor (message, kvList = []) { const baseMessage = [ message, ...kvList.map(kv => ` ${Object.keys(kv)[0]}: ${Object.values(kv)[0]}`) ].join('\n'); super(baseMessage); Object.setPrototypeOf(this, SeleniumHelperError.prototype); // see https://stackoverflow.com/a/41102306 this.name = 'SeleniumHelperError'; Error.captureStackTrace(this, this.constructor); } /** * Add a new layer to the error chain. * Collects context from the webdriver if it is present AND this is the innermost `SeleniumHelperError`. * @param {Error|SeleniumHelperError} innerError The error to add to the chain. * @param {webdriver.ThenableWebDriver} [driver] Optional webdriver instance to collect context from. * @returns {Promise<SeleniumHelperError>} This error, with the new layer added. */ async chain (innerError, driver) { const messageLines = [ this.message, innerError.message ]; // If the inner error has already collected context, don't collect it again. if (driver && !(innerError && innerError.collectContext)) { await this.collectContext(messageLines, driver); } this.message = messageLines.join('\n'); return this; } /** * Collect error context from the webdriver. * @param {Array<string>} messageLines Add context lines to this array. * @param {webdriver.ThenableWebDriver} driver The webdriver instance to collect context from. * @returns {Promise} A promise that resolves when the context is collected. */ async collectContext (messageLines, driver) { // It would be really nice to wait until `message` time to collect all this information, // but that's not an option because of all these async calls. const [ url, title, // pageSource, logEntries ] = await Promise.all([ driver.getCurrentUrl(), driver.getTitle(), // driver.getPageSource(), driver.manage() .logs() .get('browser') ]); messageLines.push( `Browser URL: ${url}`, `Browser title: ${title}`, 'Browser logs:', '*****', ...logEntries.map(entry => entry.message), '*****' // 'Browser page source:', '*****', pageSource, '*****' ); } } class SeleniumHelper { constructor () { bindAll(this, [ 'buildDriver', 'clickButton', 'clickCss', 'clickText', 'clickXpath', 'containsClass', 'dragFromXpathToXpath', 'findByCss', 'findByXpath', 'findText', 'getKey', 'getDriver', 'getLogs', 'getSauceDriver', 'isSignedIn', 'navigate', 'signIn', 'urlMatches', 'waitUntilDocumentReady', 'waitUntilGone' ]); // Tests call this static function as if it were a method on an instance. this.waitUntilVisible = SeleniumHelper.waitUntilVisible; // this type declaration suppresses IDE type warnings throughout this file /** @type {webdriver.ThenableWebDriver} */ this.driver = null; } /** * Build a new webdriver instance. This will use Sauce Labs if the SMOKE_REMOTE environment variable is 'true', or * `chromedriver` otherwise. * @param {string} name The name to give to Sauce Labs. * @returns {webdriver.ThenableWebDriver} The new webdriver instance. */ buildDriver (name) { if (remote === 'true'){ let nameToUse; if (ciBuildPrefix){ nameToUse = `${ciBuildPrefix}: ${name}`; } else { nameToUse = name; } this.driver = this.getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, nameToUse); } else { this.driver = this.getDriver(); } return this.driver; } /** * Build a new webdriver instance using `chromedriver`. * You should probably use `buildDriver` instead. * @returns {webdriver.ThenableWebDriver} The new webdriver instance. */ getDriver () { const chromeCapabilities = webdriver.Capabilities.chrome(); const args = []; if (headless) { args.push('--headless'); args.push('window-size=1024,1680'); args.push('--no-sandbox'); } chromeCapabilities.set('chromeOptions', {args}); chromeCapabilities.setPageLoadStrategy(PageLoadStrategy.EAGER); const driver = new webdriver.Builder() .forBrowser('chrome') .withCapabilities(chromeCapabilities) .build(); return driver; } /** * Build a new webdriver instance using Sauce Labs. * You should probably use `buildDriver` instead. * @param {string} username The Sauce Labs username. * @param {string} accessKey The Sauce Labs access key. * @param {string} name The name to give to Sauce Labs. * @returns {webdriver.ThenableWebDriver} The new webdriver instance. */ getSauceDriver (username, accessKey, name) { // Driver configs can be generated with the Sauce Platform Configurator // https://wiki.saucelabs.com/display/DOCS/Platform+Configurator const driverConfig = { browserName: 'chrome', platform: 'macOS 10.15' }; const driver = new webdriver.Builder() .withCapabilities({ browserName: driverConfig.browserName, platform: driverConfig.platform, version: driverConfig.version, username: username, accessKey: accessKey, name: name }) .usingServer(`http://${username}:${accessKey}@ondemand.saucelabs.com:80/wd/hub`) .build(); return driver; } /** * Retrieves a key string by name. * @example * getKey('ENTER') // => '\uE007' * @param {string} keyName The name of the key to retrieve. * @returns {string} The key. */ getKey (keyName) { return Key[keyName]; } /** * Wait until the document is ready (i.e. the document.readyState is 'complete') * @returns {Promise} A promise that resolves when the document is ready */ async waitUntilDocumentReady () { const outerError = new SeleniumHelperError('waitUntilDocumentReady failed'); try { await this.driver.wait( async () => await this.driver.executeScript('return document.readyState;') === 'complete', DEFAULT_TIMEOUT_MILLISECONDS ); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Navigate to the given URL and wait until the document is ready. * The Selenium docs say the promise returned by `driver.get()` "will be resolved when the document has finished * loading." In practice, that doesn't mean the page is ready for testing. I suspect it comes down to the * difference between "interactive" and "complete" (or `DOMContentLoaded` and `load`). * @param {string} url The URL to navigate to. * @returns {Promise} A promise that resolves when the document is ready */ async navigate (url) { const outerError = new SeleniumHelperError('navigate failed', [{url}]); try { await this.driver.get(url); await this.waitUntilDocumentReady(); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Find an element by xpath. * @param {string} xpath The xpath to search for. * @returns {Promise<webdriver.WebElement>} A promise that resolves to the element. */ async findByXpath (xpath) { const outerError = new SeleniumHelperError('findByXpath failed', [{xpath}]); try { const el = await this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS); await this.driver.wait(el.isDisplayed(), DEFAULT_TIMEOUT_MILLISECONDS); return el; } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * @param {webdriver.WebElement} element Wait until this element is gone (stale). * @returns {Promise} A promise that resolves when the element is gone. */ async waitUntilGone (element) { const outerError = new SeleniumHelperError('waitUntilGone failed', [{element}]); try { await this.driver.wait(until.stalenessOf(element), DEFAULT_TIMEOUT_MILLISECONDS); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until an element can be found by the provided xpath, then click on it. * @param {string} xpath The xpath to click. * @returns {Promise} A promise that resolves when the element is clicked. */ async clickXpath (xpath) { const outerError = new SeleniumHelperError('clickXpath failed', [{xpath}]); try { return await this.driver.wait(new webdriver.WebElementCondition( 'for element click to succeed', async () => { const element = await this.findByXpath(xpath); if (!element) { return null; } try { await element.click(); return element; } catch (e) { if (e instanceof webdriver.error.ElementClickInterceptedError) { // something is in front of the element we want to click // probably the loading screen // this is the main reason for using wait() return null; } throw e; } } ), DEFAULT_TIMEOUT_MILLISECONDS); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until an element can be found by the provided text, then click on it. * @param {string} text The text to click. * @returns {Promise} A promise that resolves when the element is clicked. */ async clickText (text) { const outerError = new SeleniumHelperError('clickText failed', [{text}]); try { await this.clickXpath(`//*[contains(text(), '${text}')]`); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until an element can be found by the provided text. * @param {string} text The text to find. * @returns {Promise<webdriver.WebElement>} The element containing the text. */ async findText (text) { const outerError = new SeleniumHelperError('findText failed', [{text}]); try { return await this.driver.wait( until.elementLocated(By.xpath(`//*[contains(text(), '${text}')]`)), DEFAULT_TIMEOUT_MILLISECONDS ); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until a button can be found by the provided text, then click on it. * @param {string} text The button text to find and click. * @returns {Promise} A promise that resolves when the button is clicked. */ async clickButton (text) { const outerError = new SeleniumHelperError('clickButton failed', [{text}]); try { await this.clickXpath(`//button[contains(text(), '${text}')]`); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until an element can be found by the provided CSS selector. * @param {string} css The CSS selector to find. * @returns {Promise<webdriver.WebElement>} The element matching the CSS selector. */ async findByCss (css) { const outerError = new SeleniumHelperError('findByCss failed', [{css}]); try { return await this.driver.wait(until.elementLocated(By.css(css)), DEFAULT_TIMEOUT_MILLISECONDS); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until an element can be found by the provided CSS selector, then click on it. * @param {string} css The CSS selector to find and click. * @returns {Promise} A promise that resolves when the element is clicked. */ async clickCss (css) { const outerError = new SeleniumHelperError('clickCss failed', [{css}]); try { const el = await this.findByCss(css); await el.click(); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until the two elements can be found, then drag from the first to the second. * @param {string} startXpath The xpath to drag from. * @param {string} endXpath The xpath to drag to. * @returns {Promise} A promise that resolves when the drag is complete. */ async dragFromXpathToXpath (startXpath, endXpath) { const outerError = new SeleniumHelperError('dragFromXpathToXpath failed', [{startXpath}, {endXpath}]); try { const startEl = await this.findByXpath(startXpath); const endEl = await this.findByXpath(endXpath); await this.driver.actions() .dragAndDrop(startEl, endEl) .perform(); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * @returns {string} The xpath to the login button, which is present only if signed out. */ getPathForLogin () { return '//li[@class="link right login-item"]/a'; } /** * @returns {string} The xpath to the profile name, which is present only if signed in. */ getPathForProfileName () { return '//span[contains(@class, "profile-name")]'; } /** * @returns {Promise<boolean>} True if the user is signed in, false otherwise. * @throws {SeleniumHelperError} If the user's sign-in state cannot be determined. */ async isSignedIn () { const outerError = new SeleniumHelperError('isSignedIn failed'); try { const state = await this.driver.wait( () => this.driver.executeScript( ` if (document.evaluate(arguments[0], document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null) .singleNodeValue) { return 'signed in'; } if (document.evaluate(arguments[1], document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null) .singleNodeValue) { return 'signed out'; } `, this.getPathForProfileName(), this.getPathForLogin() ), DEFAULT_TIMEOUT_MILLISECONDS ); switch (state) { case 'signed in': return true; case 'signed out': return false; default: throw new Error('Could not determine whether or not user is signed in'); } } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Sign in on a `scratch-www` page. * @param {string} username The username to sign in with. * @param {string} password The password to sign in with. * @returns {Promise} A promise that resolves when the user is signed in. */ async signIn (username, password) { const outerError = new SeleniumHelperError('signIn failed', [ {username}, {password: password ? 'provided' : 'absent'} ]); try { await this.clickXpath(this.getPathForLogin()); const name = await this.findByXpath('//input[@id="frc-username-1088"]'); await name.sendKeys(username); const word = await this.findByXpath('//input[@id="frc-password-1088"]'); await word.sendKeys(password + this.getKey('ENTER')); await this.waitUntilDocumentReady(); await this.findByXpath(this.getPathForProfileName()); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Wait until the URL matches the provided regex. * @param {RegExp} regex The regex to match the url against. * @returns {Promise} A promise that resolves when the url matches the regex. */ async urlMatches (regex) { const outerError = new SeleniumHelperError('urlMatches failed', [{regex}]); try { await this.driver.wait(until.urlMatches(regex), DEFAULT_TIMEOUT_MILLISECONDS); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Get selected browser log entries. * @param {Array<string>} whitelist A list of log strings to allow. * @returns {Promise<Array<webdriver.logging.Entry>>} A promise that resolves to the log entries. */ async getLogs (whitelist) { const outerError = new SeleniumHelperError('getLogs failed', [{whitelist}]); try { const entries = await this.driver.manage() .logs() .get('browser'); return entries.filter(entry => { const message = entry.message; for (const element of whitelist) { if (message.indexOf(element) !== -1) { // eslint-disable-next-line no-console // console.warn('Ignoring whitelisted error: ' + whitelist[i]); return false; } else if (entry.level !== 'SEVERE') { // WARNING: this doesn't do what it looks like it does! // eslint-disable-next-line no-console // console.warn('Ignoring non-SEVERE entry: ' + message); return false; } return true; } return true; }); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * Check if an element's class attribute contains a given class. * @param {webdriver.WebElement} element The element to check. * @param {string} cl The class to check for. * @returns {Promise<boolean>} True if the element's class attribute contains the given class, false otherwise. */ async containsClass (element, cl) { const outerError = new SeleniumHelperError('containsClass failed', [{element}, {cl}]); try { const classes = await element.getAttribute('class'); const classList = classes.split(' '); return classList.includes(cl); } catch (cause) { throw await outerError.chain(cause, this.driver); } } /** * @param {webdriver.WebElement} element Wait until this element is visible. * @param {webdriver.ThenableWebDriver} driver The webdriver instance. * @returns {Promise} A promise that resolves when the element is visible. */ static async waitUntilVisible (element, driver) { const outerError = new SeleniumHelperError('waitUntilVisible failed', [{element}]); try { await driver.wait(until.elementIsVisible(element), DEFAULT_TIMEOUT_MILLISECONDS); } catch (cause) { throw await outerError.chain(cause, driver); } } } module.exports = SeleniumHelper;