scratch-www/test/integration/selenium-helpers.js

594 lines
22 KiB
JavaScript
Raw Normal View History

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');
require('chromedriver');
2020-09-23 17:02:55 -04:00
const chromedriverVersion = require('chromedriver').version;
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.
2023-10-24 13:22:58 -04:00
* @extends Error
*/
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
* 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);
}
2023-10-24 13:22:58 -04:00
/**
* 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.
2023-10-24 13:22:58 -04:00
* @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.
*/
2023-10-24 13:22:58 -04:00
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.
2023-10-24 13:22:58 -04:00
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}`,
2023-10-24 13:22:58 -04:00
'Browser logs:',
'*****',
...logEntries.map(entry => entry.message),
'*****'
// 'Browser page source:', '*****', pageSource, '*****'
);
}
}
class SeleniumHelper {
constructor () {
bindAll(this, [
'buildDriver',
2019-05-20 17:16:34 -04:00
'clickButton',
'clickCss',
'clickText',
'clickXpath',
'containsClass',
2019-05-20 17:16:34 -04:00
'dragFromXpathToXpath',
'findByCss',
'findByXpath',
'findText',
2019-05-20 17:16:34 -04:00
'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;
}
/**
* @returns {string} The version of chromedriver being used.
*/
getChromeVersionNumber () {
const versionFinder = /\d+\.\d+/;
const versionArray = versionFinder.exec(chromedriverVersion);
if (versionArray === null) {
throw new Error('couldn\'t find version of chromedriver');
}
return versionArray[0];
}
/**
* 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) {
const chromeVersion = this.getChromeVersionNumber();
// 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',
version: chromeVersion
};
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
throw await outerError.chain(cause, this.driver);
}
2019-05-20 17:16:34 -04:00
}
/**
* @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.findByXpath(this.getPathForProfileName());
} catch (cause) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
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) {
2023-10-24 13:22:58 -04:00
throw await outerError.chain(cause, driver);
}
}
}
module.exports = SeleniumHelper;