UNPKG

dce-selenium

Version:

Selenium library to simplify testing and automatically snapshot the DOM.

1,281 lines (1,168 loc) 40.6 kB
// Import dependencies const { By, until } = require('selenium-webdriver'); const prompt = require('prompt-sync')(); const queryString = require('query-string'); const path = require('path'); // Import helpers const saveScreenshotAndSource = require('./snapshots/saveScreenshotAndSource'); const saveJSON = require('./snapshots/saveJSON'); const saveText = require('./snapshots/saveText'); const sendRequest = require('./sendRequest'); const consoleLog = require('../consoleLog'); /** * Replaces all occurrences in a string */ const replaceAll = (str, search, replacement) => { return str.replace(new RegExp(search, 'g'), replacement); }; /** * Fixes a url (adds protocol and host) */ const fixURL = (url) => { if (url.startsWith('http')) { // URL is complete! return url; } // Need to add a host and protocol const protocol = ( global.dceSeleniumConfig.dontUseHTTPS ? 'http://' : 'https://' ); if (!global.dceSeleniumConfig.defaultHost) { consoleLog('\nOops! We can\'t visit the following url without a hostname:'); consoleLog(url); consoleLog('Either include a hostname in the url or initialize with a defaultHost'); process.exit(0); } return `${protocol}${global.dceSeleniumConfig.defaultHost}${url.startsWith('/') ? '' : '/'}${url}`; }; /** * Makes sure all required arguments are included, throws an error if any are * excluded * @param {object} args - an object where keys are the names of arguments and * values are the respective values of those arguments */ const ensureIncluded = (args) => { const funcName = `Driver.${global.actionTitle}`; if (args === undefined) { throw new Error(`${funcName} missing required arguments.`); } // Find the missing arguments const missingArgs = ( Object.keys(args) .filter((name) => { return (args[name] === undefined); }) ); // Create an error if (missingArgs.length > 0) { const prefix = ( missingArgs.length === 1 ? 'missing required argument' : 'missing required arguments' ); const error = new Error(`${funcName} ${prefix} ${JSON.stringify(missingArgs)}`); throw error; } }; /*------------------------------------------------------------------------*/ /* Snapshots */ /*------------------------------------------------------------------------*/ // Types of snapshots const SNAPSHOT_TYPES = { JSON: 'json', // Save output of the function as JSON SCREENSHOT_AND_SOURCE: 'screenshot-and-source', // Screenshot & save page src TEXT: 'text', // Save output of the function as text }; // To add snapshots to a command, add another parameter to your Driver function // Driver.funcName.snapshotType = { // before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, <-- optional // during: SNAPSHOT_TYPES.TEXT <or> SNAPSHOT_TYPES.JSON, <-- options // after: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, <-- optional // }; /*------------------------------------------------------------------------*/ /* Driver Class */ /*------------------------------------------------------------------------*/ // NOTE: to add a new driver function, do not add them to the class // Instead, add them as static functions // Driver.funcName = async function (param1, param2) { // ... // }; class Driver { constructor(webdriver, log) { this.webdriver = webdriver; this.log = log || (() => {}); // Surround static functions with pre/post Object.keys(Driver).forEach((prop) => { const baseFunction = Driver[prop].bind(this); // Add function and bind it this[prop] = async (...args) => { // Take snapshot before (if applicable) await this._pre(prop); // Run the function const ret = await baseFunction(...args); // Take snapshot during (if applicable) if (global.actionTitle === prop) { // Take snapshot const snapshotType = ( Driver[prop].snapshotType ? Driver[prop].snapshotType.during : null ); if (snapshotType === SNAPSHOT_TYPES.JSON) { await saveJSON(ret); } else if (snapshotType === SNAPSHOT_TYPES.TEXT) { await saveText(ret); } } // Take snapshot after (if applicable) await this._post(prop); // Return the value returned from the base function return ret; }; }); } /** * Function to be called before each command */ async _pre(prop) { if (global.actionTitle) { return; } // Save the action title so we the snapshot filenames are valid global.actionTitle = prop; // Take snapshot const snapshotType = ( Driver[prop].snapshotType ? Driver[prop].snapshotType.before : null ); if (snapshotType === SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE) { await saveScreenshotAndSource(this.webdriver, 'before'); } } /** * Function to be called after each command */ async _post(prop) { if (global.actionTitle !== prop) { // We're currently inside of a function that was called by another // function (do not log this) return; } // Take snapshot const snapshotType = ( Driver[prop].snapshotType ? Driver[prop].snapshotType.post : null ); if (snapshotType === SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE) { await saveScreenshotAndSource(this.webdriver, 'post'); } // Remove the title for the next test global.actionTitle = null; } /** * Quit webdriver (close automated browser window) */ async quit() { // Don't quit again if already dead if (this.dead) { return; } // Attempt to quit the webdriver try { await this.webdriver.quit(); this.dead = true; } catch (err) { // Session already dead. No error here } } } // Allow bindable async functions /* eslint-disable func-names */ /*------------------------------------------------------------------------*/ /* Navigation */ /*------------------------------------------------------------------------*/ /** * Visits a location (path/url). Prepends defaultHost if no host included and * prepends https:// if no protocol included. * @param {string} location - the path or url to visit * @param {string|string[]} [locationToWaitFor=location] - the location or * list of locations to wait for before resolving. If the location we're * visiting redirects the user elsewhere, we need to wait for that location * instead. Put that final location as this locationToWaitFor. If there are * more than one possible final locations, include an array * @return {Promise} */ Driver.visit = async function (location, finalLocation) { ensureIncluded({ location }); this.log(`visit ${location}`); const url = fixURL(location); const ret = await this.webdriver.get(url); if (finalLocation) { this.log('waiting for redirect to final location'); await this.waitForLocations(finalLocation); } return ret; }; Driver.visit.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, post: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Visits a location (path/url) with POST. Prepends defaultHost if no host * included and prepends https:// if no protocol included. * @param {string} location - the path or url to visit * @param {object} [body={}] - the POST request body to send * @param {string|string[]} [locationToWaitFor=location] - the location or * list of locations to wait for before resolving. If the location we're * visiting redirects the user elsewhere, we need to wait for that location * instead. Put that final location as this locationToWaitFor. If there are * more than one possible final locations, include an array * @return {Promise} */ Driver.post = async function (location, body = {}, finalLocation = null) { ensureIncluded({ location }); this.log(`post ${location}`); const url = fixURL(location); await sendRequest({ url, body, driver: this, method: 'POST', }); if (finalLocation) { this.log('waiting for redirect to final location'); await this.waitForLocations(finalLocation); } }; Driver.post.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, post: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /*------------------------------------------------------------------------*/ /* Session */ /*------------------------------------------------------------------------*/ /** * Resets the session and navigates back to about:blank */ Driver.reset = async function () { await this.webdriver.manage().deleteAllCookies(); await this.webdriver.get('about:blank'); }; /*------------------------------------------------------------------------*/ /* Waiting */ /*------------------------------------------------------------------------*/ /** * Wait for user to hit "enter" in terminal */ Driver.pause = async function () { return new Promise((resolve) => { const text = '\u2551 > Execution Paused! Press enter to continue.'; if (prompt(text) === null) { // User pressed ctrl+c process.exit(0); } resolve(); }); }; /** * Wait for specific amount of time * @param {number} ms - number of ms to wait */ Driver.wait = async function (ms = 0) { ensureIncluded({ ms }); return new Promise((resolve) => { setTimeout(resolve, ms); }); }; /** * Wait for an element to be visible * @param {string} selectorOrElem - the css selector or the element * @param {number} [timeout=10000] - the number of ms to wait before timing out */ Driver.waitForElementVisible = ( async function (selectorOrElem, timeout = 10000) { ensureIncluded({ selectorOrElem }); let el; if (typeof selectorOrElem === 'string') { this.log(`wait for ${selectorOrElem} to be visible`); const by = By.css(selectorOrElem); await this.webdriver.wait(until.elementLocated(by, timeout)); el = this.webdriver.findElement(by); } else { this.log('wait for element to be visible'); el = selectorOrElem; } return this.webdriver.wait(until.elementIsVisible(el), timeout); } ); /** * Wait for an element with contents to be visible * @param {object} options - an object holding all arguments * @param {string} options.contents - the element contents to search for * @param {string} [options.selector] - a css selector to limit the search * Supported css selectors: * All Elements: null or "*" * All P Elements: "p" * All Child Elements of p: "p > *" * Element By ID: "#foo" * Element By Class: ".foo" * Element With Attribute: "*[title]" * First Child of P: "p > *:first-child" * Next Element after P: "p + *" * @param {number} [options.timeout=10000] - the number of ms to wait before * timing out */ Driver.waitForElementWithContentsVisible = ( async function (...args) { let contents; let selector; let timeout; if (typeof args[0] === 'object') { ({ contents, selector } = args[0]); timeout = args[0].timeout || 10000; } else { contents = args[0]; selector = args[1]; timeout = args[2] || 10000; } ensureIncluded({ contents }); // Print log message if (selector) { this.log(`wait for item with contents "${contents}" and selector "${selector}" to be visible`); } else { this.log(`wait for item with contents "${contents}" to be visible`); } const by = await this._genContentsByObject(contents, selector); await this.webdriver.wait(until.elementLocated(by, timeout)); const el = this.webdriver.findElement(by); return this.webdriver.wait(until.elementIsVisible(el), timeout); } ); /** * Wait for the browser location (path/url) to match * @param {string} location - location template string. Use form: * /course/:courseid/assignment/:assignmentid where parts starting with ":" * may be replaced with anything * @param {number} [timeout=10000] - the number of ms to wait before timing out */ Driver.waitForLocation = async function (location, timeout = 10000) { ensureIncluded({ location }); const startTime = Date.now(); let webPath; let regStr; if (!location.startsWith('http')) { // Path webPath = location; if (!global.dceSeleniumConfig.defaultHost) { consoleLog('\nCannot wait for path:'); consoleLog(location); consoleLog('No host was included in the location and we have no default host'); process.exit(0); } // Use defaults for location and defaultHost regStr = `^http${global.dceSeleniumConfig.dontUseHTTPS ? '' : 's'}://${global.dceSeleniumConfig.defaultHost}`; } else { // URL try { const urlSections = location.split('/'); const host = urlSections[2]; webPath = location.split(host)[1]; regStr = `^${location.split(host)[0]}${host}`; } catch (err) { consoleLog('\nCannot wait for url: it is invalid'); consoleLog(err.message); process.exit(0); } } // Add in path, part by part const parts = webPath.substring(1).split('/'); parts.forEach((part) => { if (part.startsWith(':')) { // This is a placeholder regStr += '/[^/]*'; } else { // This is a defined part of the url regStr += '/' + part; } }); if (location.includes('?')) { // Require the proper query string regStr = regStr.replace('?', '\\?') + '$'; } else { // Add ending that ignores query strings and following "/" regStr += '[/]?(?:\\?[^]*|$)'; } // Wait until url matches await this.webdriver.wait(until.urlMatches(new RegExp(regStr)), timeout); const currentURL = await this.webdriver.getCurrentUrl(); // Wait until page is ready await new Promise((resolve, reject) => { let done; const checkAgain = async () => { if (done) { return; } // Make sure browser is loaded and at correct location const windowURL = await this.webdriver.executeScript('return window.location.href'); const readyState = await this.webdriver.executeScript('return document.readyState'); const ready = (readyState === 'complete' && windowURL === currentURL); const msElapsed = Date.now() - startTime; // Check if done if (ready) { done = true; return resolve(); } // Check for timeout if (msElapsed >= timeout) { done = true; return reject(new Error(`We reached a timeout (${timeout}ms) while waiting for the page to be ready.`)); } // Not done or timed out. Try again setTimeout(checkAgain, 15); }; checkAgain(); }); }; /** * Wait for the browser location (path/url) to match any * @param {string[]} locations - list of location template string. Use form: * /course/:courseid/assignment/:assignmentid where parts starting with ":" * may be replaced with anything * @param {number} [timeout=10000] - the number of ms to wait before timing out */ Driver.waitForLocations = async function (locations, timeout = 10000) { ensureIncluded({ locations }); // Ensure included is an array const finalLocations = ( Array.isArray(locations) ? locations : [locations] ); // Start promises let waitPromises = finalLocations.map((loc) => { return this.waitForLocation(loc, timeout); }); // Wait an if any succeed, resolve. If none succeed, throw a custom error return new Promise((resolve, reject) => { // if any succeed, resolve waitPromises = waitPromises.map((prom) => { return prom.then(resolve); }); // If all fail, reject const promisesThatResolveOnFailure = waitPromises.map((prom) => { return new Promise((r) => { prom.catch(r); }); }); Promise.all(promisesThatResolveOnFailure).then(() => { const errorMessage = `We waited for ${finalLocations.length === 1 ? 'a' : 'any of ' + finalLocations.length} location${finalLocations.length === 1 ? '' : 's'} to load but it did not finished loading within the timeout.`; return reject(new Error(errorMessage)); }); }); }; /*------------------------------------------------------------------------*/ /* Element Finding */ /*------------------------------------------------------------------------*/ /** * Create's a By instance for using in finding an element * @param {string} contents - the element contents to search for * @param {string} [selector] - a css selector to limit the search * * Supported css selectors: * All Elements: null or "*" * All P Elements: "p" * All Child Elements of p: "p > *" * Element By ID: "#foo" * Element By Class: ".foo" * Element With Attribute: "*[title]" * First Child of P: "p > *:first-child" * Next Element after P: "p + *" * * @return {By} Selenium By instance */ Driver._genContentsByObject = async function (contents, selector) { let start = '*'; if (selector) { if (selector.startsWith('#')) { // id start = `*[@id='${selector.substring(1)}']`; } else if (selector.startsWith('.')) { // class start = `*[contains(@class,'${selector.substring(1)}')]`; } else if (selector.startsWith('*[')) { // element with attribute start = `*[@${selector.substring(2)}]`; } else if (selector === '*') { // wildcard start = '*'; } else { // just a tag names const tagName = selector.split('>')[0].split('+')[0]; if (selector.includes('+')) { // assume looking for next sibling start = `${tagName}/following-sibling::*[0]`; } else if (selector.includes('>')) { if (selector.includes('first-child')) { // assume looking for first child of tagName start = `${tagName}/*[0]`; } else { // assume looking for all children start = `${tagName}/*`; } } else { // assume just a tag name start = tagName; } } } // Build the xpath let contentsEscaped; if (!contents.includes('\'')) { contentsEscaped = `'${contents}'`; } else if (!contents.includes('"')) { contentsEscaped = `"${contents}"`; } else { contentsEscaped = `concat('${replaceAll(contents, '\'', '\',"\'", \'')}')`; } const xpath = `//${start}[text()[contains(.,${contentsEscaped})]]`; return By.xpath(xpath); }; /** * Find an element by css selector * @param {string} selector - a css selector * * Supported css selectors: * All Elements: null or "*" * All P Elements: "p" * All Child Elements of p: "p > *" * Element By ID: "#foo" * Element By Class: ".foo" * Element With Attribute: "*[title]" * First Child of P: "p > *:first-child" * Next Element after P: "p + *" * * @return {WebElement} Selenium WebElement */ Driver.getElement = async function (selector) { ensureIncluded({ selector }); this.log(`get element ${selector}`); return this.webdriver.findElement(By.css(selector)); }; /** * Find all elements that match a css selector * @param {string} selector - a css selector * * Supported css selectors: * All Elements: null or "*" * All P Elements: "p" * All Child Elements of p: "p > *" * Element By ID: "#foo" * Element By Class: ".foo" * Element With Attribute: "*[title]" * First Child of P: "p > *:first-child" * Next Element after P: "p + *" * * @return {WebElement[]} list of Selenium WebElements */ Driver.getElements = async function (selector) { return this.webdriver.findElements(By.css(selector)); }; /** * Find an element by its contents and optionally by a css selector * @param {string} contents - the element contents to search for * @param {string} [selector] - a css selector to limit the search * * Supported css selectors: * All Elements: null or "*" * All P Elements: "p" * All Child Elements of p: "p > *" * Element By ID: "#foo" * Element By Class: ".foo" * Element With Attribute: "*[title]" * First Child of P: "p > *:first-child" * Next Element after P: "p + *" * * @return {WebElement} Selenium WebElement */ Driver.getElementByContents = async function (contents, selector) { ensureIncluded({ contents }); const by = await this._genContentsByObject(contents, selector); return this.webdriver.findElement(by); }; /** * If given a selector, will find the element that matches a selector. If given * an element, just returns the element * @param {string|WebElement} selectorOrElement - the css selector or the * element * @return {WebElement} the web element */ Driver._makeSureIsElement = async function (selectorOrElement) { if (typeof selectorOrElement === 'string') { // This is a selector. Find the element return this.webdriver.findElement(By.css(selectorOrElement)); } // This is already an element. Just return it. return selectorOrElement; }; /** * Given a css selector or element, finds the parent of the elemnt * @param {string|WebElement} selectorOrElement - the css selector or the * element * @return {WebElement} */ Driver.parentOf = async function (selectorOrElement) { ensureIncluded({ selectorOrElement }); const element = await this._makeSureIsElement(selectorOrElement); const parent = await element.findElement(By.xpath('./..')); return parent; }; /** * Given a parent element and a css selector, finds the first descendent of * @param {WebElement} parent - the parent element * @param {string} selector - css selector * @return {WebElement} */ Driver.descendantOf = async function (parent, selector) { ensureIncluded({ parent, selector }); const element = await this._makeSureIsElement(parent); const descendant = await element.findElement(By.css(selector)); return descendant; }; /*------------------------------------------------------------------------*/ /* Interactions */ /*------------------------------------------------------------------------*/ /** * Finds an element by its css selector, then clicks it * @param {string|WebElement} selectorOrElem - the css selector of the element * to click or the element to click */ Driver.click = async function (selector) { ensureIncluded({ selector }); if (typeof selector === 'string') { this.log(`click "${selector}"`); } else { this.log('click element'); } const elem = await this._makeSureIsElement(selector); const ret = await elem.click(); return ret; }; Driver.click.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Finds an anchor tag and forces it to open in the same tab * @param {string|WebElement} selectorOrElem - the css selector of the anchor * tag to follow * @param {string|string[]} [locationToWaitFor=location] - the location or * list of locations to wait for before resolving. If the location we're * visiting redirects the user elsewhere, we need to wait for that location * instead. Put that final location as this locationToWaitFor. If there are * more than one possible final locations, include an array */ Driver.openAnchorInSameTab = async function (selector, finalLocation) { ensureIncluded({ selector }); if (typeof selector === 'string') { this.log(`open anchor "${selector}" in same tab`); } else { this.log('open anchor in same tab'); } const elem = await this._makeSureIsElement(selector); const url = await elem.getAttribute('href'); await this.visit(url, finalLocation); return elem; }; Driver.openAnchorInSameTab.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Click by contents * @param {string} contents - the text contents of the item to click * @param {string} [selector] - the css selector to narrow down the search */ Driver.clickByContents = async function (contents, selector) { ensureIncluded({ contents }); const ending = ( selector ? ` and selector "${selector}"` : '' ); this.log(`click by contents "${contents}"${ending}`); const elem = await this.getElementByContents(contents, selector); const ret = await elem.click(); return ret; }; Driver.clickByContents.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Types text into a field * @param {string|WebElement} selectorOrElem - the css selector identifying the * field or the element to type into * @param {string} text - the text to type */ Driver.typeInto = async function (selectorOrElem, text) { ensureIncluded({ selectorOrElem, text }); if (typeof selectorOrElem === 'string') { this.log(`type "${text}" into ${selectorOrElem}`); } else { this.log(`type "${text}" into a field`); } const elem = await this._makeSureIsElement(selectorOrElem); return elem.sendKeys(text); }; Driver.typeInto.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, post: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Simulate keystrokes into the page document, * @param {string} text - the text to type * @param {boolean} [finishWithEnter] - if true, the enter key is pressed * after the text is typed */ Driver.simulateKeystrokesIntoDocument = async function (text, finishWithEnter) { ensureIncluded({ text }); const timeBetweenKeystrokes = 10; await this.webdriver.executeScript(` (() => { let time = 0; const timeBetweenKeystrokes = ${timeBetweenKeystrokes}; const finishWithEnterKey = ${!!finishWithEnter}; const text = '${text}'; const textChars = String(text).split(''); textChars.forEach((char, i) => { // Type the key setTimeout(() => { const keyEvent = new CustomEvent('keydown'); keyEvent.key = char; document.dispatchEvent(keyEvent); }, time); time += timeBetweenKeystrokes; }); // Hit enter if applicable if (finishWithEnterKey) { setTimeout(() => { const keyEvent = new CustomEvent('keydown'); keyEvent.key = 'Enter'; document.dispatchEvent(keyEvent); }, time); } })(); `); // Wait for process to finish await new Promise((r) => { setTimeout(r, timeBetweenKeystrokes * (text.length + 1)); }); }; Driver.simulateKeystrokesIntoDocument.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, post: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Scrolls to an element * @param {string|WebElement} selectorOrElem - the css selector identifying the * element to scroll to, or the WebElement to scroll to */ Driver.scrollTo = async function (selectorOrElem) { ensureIncluded({ selectorOrElem }); let el; if (typeof selectorOrElem === 'string') { this.log(`scroll to ${selectorOrElem}`); el = this.webdriver.findElement(By.css(selectorOrElem)); } else { this.log('scroll to element'); el = selectorOrElem; } this.webdriver.executeScript('arguments[0].scrollIntoView(true);', el); return new Promise((resolve) => { setTimeout(resolve, 250); }); }; Driver.scrollTo.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, post: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Chooses an option from a dropdown based on its human-readable text * @param {string|WebElement} selectorOrElem - the css selector identifying the * field or a WebElement * @param {string} item - the human-readable text of the option to choose */ Driver.chooseSelectItem = async function (selectorOrElem, item) { ensureIncluded({ selectorOrElem, item }); if (typeof selectorOrElem === 'string') { this.log(`select item "${item}" from dropdown ${selectorOrElem}`); } else { this.log(`select item "${item}" from a dropdown`); } // Click the dropdown (await this._makeSureIsElement(selectorOrElem)).click(); // Get dropdown items const elements = await this._listSelectElements(selectorOrElem); // Find the appropriate item const matchingOptions = []; for (let i = 0; i < elements.length; i++) { const isMatch = (item.trim() === (await elements[i].getText())); if (isMatch) { matchingOptions.push(elements[i]); } } // Throw errors if (matchingOptions.length === 0) { throw new Error(`Could not select item "${item}" from dropdown because it could not be found in the dropdown`); } if (matchingOptions.length > 1) { throw new Error(`Could not select item "${item}" from dropdown because more than one match was found in the dropdown`); } // Click the option return matchingOptions[0].click(); }; Driver.chooseSelectItem.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, post: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Chooses an option from a dropdown based on its value * @param {string|WebElement} selectorOrElem - the css selector identifying the * field or the WebElement * @param {string} value - the value attribute of the option to select */ Driver.chooseSelectValue = async function (selectorOrElem, value) { ensureIncluded({ selectorOrElem, value }); if (typeof selectorOrElem === 'string') { this.log(`select item with value "${value}" from dropdown ${selectorOrElem}`); } else { this.log(`select item with value "${value}" from a dropdown`); } // Click the dropdown (await this._makeSureIsElement(selectorOrElem)).click(); // Get dropdown items const elements = await this._listSelectElements(selectorOrElem); // Find the appropriate item const matchingValues = []; for (let i = 0; i < elements.length; i++) { const isMatch = ( value.trim() === (await elements[i].getAttribute('value')) ); if (isMatch) { matchingValues.push(elements[i]); } } // Throw errors if (matchingValues.length === 0) { throw new Error(`Could not select item with value "${value}" from a dropdown because it could not be found in the dropdown`); } if (matchingValues.length > 1) { throw new Error(`Could not select item with value "${value}" from a dropdown because more than one match was found in the dropdown`); } // Click the option return matchingValues[0].click(); }; Driver.chooseSelectValue.snapshotType = { before: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, post: SNAPSHOT_TYPES.SCREENSHOT_AND_SOURCE, }; /** * Chooses a file in a file chooser * @param {string|WebElement} selectorOrElem - the css selector identifying the * file upload field or a WebElement * @param {string} filePath - the relative path to the file with respect to the * project directory (the directory from which you started the tests) */ Driver.chooseFile = async function (selectorOrElem, filePath) { ensureIncluded({ selectorOrElem, filePath }); const fileFullPath = path.join(process.env.PWD, filePath); if (typeof selectorOrElem === 'string') { this.log(`choose file in chooser "${selectorOrElem}" with path: ${filePath}`); } else { this.log(`choose file with path: ${filePath}`); } await this.typeInto(selectorOrElem, fileFullPath); return fileFullPath; }; Driver.chooseFile.snapshotType = { during: SNAPSHOT_TYPES.TEXT, }; /*------------------------------------------------------------------------*/ /* Getting Page Data */ /*------------------------------------------------------------------------*/ /** * Gets the title of the page * @return {Promise.<string>} Promise that resolves with the title of page */ Driver.getTitle = async function () { this.log('get title'); const title = await this.webdriver.getTitle(); return title; }; Driver.getTitle.snapshotType = { during: SNAPSHOT_TYPES.JSON, }; /** * Gets page source * @return {Promise.<string>} Promise that resolves with the source of the page */ Driver.getSource = async function () { return this.webdriver.executeScript('return document.documentElement.outerHTML'); }; Driver.getSource.snapshotType = { during: SNAPSHOT_TYPES.TEXT, }; /** * Get page JSON (if the body is JSON) * @return {Promise.<string>} Promise that resolves with the json of the page */ Driver.getJSON = async function () { // Get json contents // Handle differently for different browsers let source; const { browser } = global.dceSeleniumConfig; if (browser === 'chrome' || browser === 'safari') { source = await this.webdriver.executeScript('return document.getElementsByTagName("pre")[0].innerHTML'); } else if (browser === 'firefox') { await this.wait(1000); await this.webdriver.findElement(By.css('#rawdata-tab')).click(); source = await this.webdriver.executeScript('return document.getElementsByClassName("data")[0].innerHTML'); } else { source = '{}'; } try { const json = JSON.parse(source); return json; } catch (err) { throw new Error(`Page could not be parsed as JSON. Contents: "${source}"`); } }; Driver.getJSON.snapshotType = { during: SNAPSHOT_TYPES.JSON, }; /** * Gets the current url of the page * @return {Promise.<string>} Promise that resolves with the url of the page */ Driver.getURL = async function () { return this.webdriver.executeScript('return window.location.href'); }; Driver.getURL.snapshotType = { during: SNAPSHOT_TYPES.TEXT, }; /** * Gets the query information from the page url * @return {Promise.<string>} Promise that resolves with the url of the page */ Driver.getQuery = async function () { const url = await this.getURL(); const query = ( url.includes('?') ? url.split('?')[1] : '' ); return queryString.parse(query); }; Driver.getQuery.snapshotType = { during: SNAPSHOT_TYPES.JSON, }; /** * Gets the body of the page * @return {Promise.<string>} Promise that resolves with the body of the page */ Driver.getBody = async function () { try { return this.webdriver.executeScript('return document.getElementsByTagName("body")[0].innerHTML'); } catch (err) { return ''; } }; Driver.getBody.snapshotType = { during: SNAPSHOT_TYPES.TEXT, }; /** * Gets the list of options in a select dropdown * @param {string|WebElement} selectorOrElem - the css selector identifying the * field or a WebElement * @return {WebElement[]} the list of web elements */ Driver._listSelectElements = async function (selectorOrElem) { const dropdown = await this._makeSureIsElement(selectorOrElem); return dropdown.findElements(By.tagName('option')); }; /** * Gets the list of dropdown items (the human-readable text) * @param {string|WebElement} selectorOrElem - the css selector identifying the * field or a WebElement * @return {string[]} the list of human-readable text items in the dropdown */ Driver.listSelectItems = async function (selectorOrElem) { ensureIncluded({ selectorOrElem }); const elements = await this._listSelectElements(selectorOrElem); const textOptions = []; for (let i = 0; i < elements.length; i++) { textOptions.push(await elements[i].getText()); } return textOptions; }; Driver.listSelectItems.snapshotType = { during: SNAPSHOT_TYPES.JSON, }; /** * Gets the list of dropdown values (the value attribute of each item) * @param {string|WebElement} selectorOrElem - the css selector identifying the * field or a WebElement * @return {string[]} the list of values of the items in the dropdown */ Driver.listSelectValues = async function (selectorOrElem) { ensureIncluded({ selectorOrElem }); const elements = await this._listSelectElements(selectorOrElem); const values = []; for (let i = 0; i < elements.length; i++) { values.push(await elements[i].getAttribute('value')); } return values; }; Driver.listSelectValues.snapshotType = { during: SNAPSHOT_TYPES.JSON, }; /** * Gets the innerHTML of an element * @param {string|WebElement} selectorOrElem - the css selector identifying the * element or a WebElement * @return {string} the innerHTML of the field */ Driver.getElementInnerHTML = async function (selectorOrElem) { ensureIncluded({ selectorOrElem }); const elem = await this._makeSureIsElement(selectorOrElem); return elem.getAttribute('innerHTML'); }; Driver.getElementInnerHTML.snapshotType = { during: SNAPSHOT_TYPES.TEXT, }; /** * Searches the page for a <pre id="embedded-metadata">[stringified json]</pre> * element and parses the stringified json content inside it * @return {Promise.<string>} Promise that resolves with the json of the * embedded metadata */ Driver.getEmbeddedMetadata = async function () { return JSON.parse(await this.getElementInnerHTML('#embedded-metadata')); }; Driver.getEmbeddedMetadata.snapshotType = { during: SNAPSHOT_TYPES.JSON, }; /* -------------------------- Existence ------------------------- */ /** * Checks if an element exists * @param {string} selector - the css selector identifying the field * @return {boolean} if true, the element exists */ Driver.elementExists = async function (selector) { ensureIncluded({ selector }); try { await this.webdriver.findElement(By.css(selector)); return true; } catch (err) { if ( err.message.includes('Unable to locate element') || err.name === 'NoSuchElementError' ) { // No element return false; } // Error occurred while checking throw new Error(`Encountered an error while attempting to check if the element "${selector}" exists: ${err.message}`); } }; /** * Checks if an element does not exist * @param {string} selector - the css selector identifying the field * @return {boolean} if true, the element does not exist */ Driver.elementAbsent = async function (selector) { ensureIncluded({ selector }); try { await this.webdriver.findElement(By.css(selector)); return false; } catch (err) { if ( err.message.includes('Unable to locate element') || err.name === 'NoSuchElementError' ) { // No element return true; } // Error occurred while checking throw new Error(`Encountered an error while attempting to check if the element "${selector}" is absent: ${err.message}`); } }; /** * Checks if an element with specific contents exists * @param {string} contents - the element contents to search for * @param {string} [selector] - a css selector to limit the search * * Supported css selectors: * All Elements: null or "*" * All P Elements: "p" * All Child Elements of p: "p > *" * Element By ID: "#foo" * Element By Class: ".foo" * Element With Attribute: "*[title]" * First Child of P: "p > *:first-child" * Next Element after P: "p + *" * @return {boolean} if true, the element exists */ Driver.elementWithContentsExists = async function (contents, selector) { ensureIncluded({ contents }); try { await this.getElementByContents(contents, selector); return true; } catch (err) { if ( err.message.includes('Unable to locate element') || err.name === 'NoSuchElementError' ) { // No element return false; } // Error occurred while checking throw new Error(`Encountered an error while attempting to check if the element${selector ? ' "' + selector + '"' : ''} with contents "${contents}" exists: ${err.message}`); } }; /** * Checks if an element with specific contents does not exist * @param {string} contents - the element contents to search for * @param {string} [selector] - a css selector to limit the search * * Supported css selectors: * All Elements: null or "*" * All P Elements: "p" * All Child Elements of p: "p > *" * Element By ID: "#foo" * Element By Class: ".foo" * Element With Attribute: "*[title]" * First Child of P: "p > *:first-child" * Next Element after P: "p + *" * @return {boolean} if true, the element does not exist */ Driver.elementWithContentsAbsent = async function (contents, selector) { ensureIncluded({ contents }); try { await this.getElementByContents(contents, selector); return false; } catch (err) { if ( err.message.includes('Unable to locate element') || err.name === 'NoSuchElementError' ) { // No element return true; } // Error occurred while checking throw new Error(`Encountered an error while attempting to check if the element${selector ? ' "' + selector + '"' : ''} with contents "${contents}" exists: ${err.message}`); } }; // Export the driver function module.exports = Driver;