scratch-gui / test /helpers /selenium-helper.js
soiz1's picture
Upload folder using huggingface_hub
8fd7a1d verified
jest.setTimeout(30000); // eslint-disable-line no-undef
import bindAll from 'lodash.bindall';
import 'chromedriver'; // register path
import webdriver from 'selenium-webdriver';
const {Button, By, until} = webdriver;
const USE_HEADLESS = process.env.USE_HEADLESS !== 'no';
// 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;
/**
* Add more debug information to an error:
* - Merge a causal error into an outer error with valuable stack information
* - Add the causal error's message to the outer error's message.
* - Add debug information from the web driver, if available.
* The outerError compensates for the loss of context caused by `regenerator-runtime`.
* @param {Error} outerError The error to embed the cause into.
* @param {Error} cause The "inner" error to embed.
* @param {webdriver.ThenableWebDriver} [driver] Optional driver to capture debug info from.
* @returns {Promise<Error>} The outerError, with the cause embedded.
*/
const enhanceError = async (outerError, cause, driver) => {
if (cause) {
// This is the official way to nest errors in modern Node.js, but Jest ignores this field.
// It's here in case a future version uses it, or in case the caller does.
outerError.cause = cause;
}
if (cause && cause.message) {
outerError.message += `\n${['Cause:', ...cause.message.split('\n')].join('\n ')}`;
} else {
outerError.message += '\nCause: unknown';
}
if (driver) {
const url = await driver.getCurrentUrl();
const title = await driver.getTitle();
const pageSource = await driver.getPageSource();
const browserLogEntries = await driver.manage()
.logs()
.get('browser');
const browserLogText = browserLogEntries.map(entry => entry.message).join('\n');
outerError.message += `\nBrowser URL: ${url}`;
outerError.message += `\nBrowser title: ${title}`;
outerError.message += `\nBrowser logs:\n*****\n${browserLogText}\n*****\n`;
outerError.message += `\nBrowser page source:\n*****\n${pageSource}\n*****\n`;
}
return outerError;
};
class SeleniumHelper {
constructor () {
bindAll(this, [
'clickText',
'clickButton',
'clickXpath',
'clickBlocksCategory',
'elementIsVisible',
'findByText',
'textToXpath',
'findByXpath',
'textExists',
'getDriver',
'getSauceDriver',
'getLogs',
'loadUri',
'rightClickText'
]);
this.Key = webdriver.Key; // map Key constants, for sending special keys
// this type declaration suppresses IDE type warnings throughout this file
/** @type {webdriver.ThenableWebDriver} */
this.driver = null;
}
/**
* Set the browser window title. Useful for debugging.
* @param {string} title The title to set.
* @returns {Promise<void>} A promise that resolves when the title is set.
*/
async setTitle (title) {
await this.driver.executeScript(`document.title = arguments[0];`, title);
}
/**
* Wait for an element to be visible.
* @param {webdriver.WebElement} element The element to wait for.
* @returns {Promise<void>} A promise that resolves when the element is visible.
*/
async elementIsVisible (element) {
const outerError = new Error('elementIsVisible failed');
try {
await this.setTitle(`elementIsVisible ${await element.getId()}`);
await this.driver.wait(until.elementIsVisible(element), DEFAULT_TIMEOUT_MILLISECONDS);
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* List of useful xpath scopes for finding elements.
* @returns {object} An object mapping names to xpath strings.
*/
get scope () {
return {
blocksTab: "*[@id='react-tabs-1']",
costumesTab: "*[@id='react-tabs-3']",
modal: '*[@class="ReactModalPortal"]',
reportedValue: '*[@class="blocklyDropDownContent"]',
soundsTab: "*[@id='react-tabs-5']",
spriteTile: '*[starts-with(@class,"react-contextmenu-wrapper")]',
menuBar: '*[contains(@class,"menu-bar_menu-bar_")]',
monitors: '*[starts-with(@class,"stage_monitor-wrapper")]',
contextMenu: '*[starts-with(@class,"react-contextmenu")]'
};
}
/**
* Instantiate a new Selenium driver.
* @returns {webdriver.ThenableWebDriver} The new driver.
*/
getDriver () {
const chromeCapabilities = webdriver.Capabilities.chrome();
const args = [];
if (USE_HEADLESS) {
args.push('--headless');
}
// Stub getUserMedia to always not allow access
args.push('--use-fake-ui-for-media-stream=deny');
// Suppress complaints about AudioContext starting before a user gesture
// This is especially important on Windows, where Selenium directs JS console messages to stdout
args.push('--autoplay-policy=no-user-gesture-required');
chromeCapabilities.set('chromeOptions', {args});
chromeCapabilities.setLoggingPrefs({
performance: 'ALL'
});
this.driver = new webdriver.Builder()
.forBrowser('chrome')
.withCapabilities(chromeCapabilities)
.build();
return this.driver;
}
/**
* Instantiate a new Selenium driver for Sauce Labs.
* @param {string} username The Sauce Labs username.
* @param {string} accessKey The Sauce Labs access key.
* @param {object} configs The Sauce Labs configuration.
* @param {string} configs.browserName The name of the desired browser.
* @param {string} configs.platform The name of the desired platform.
* @param {string} configs.version The desired browser version.
* @returns {webdriver.ThenableWebDriver} The new driver.
*/
getSauceDriver (username, accessKey, configs) {
this.driver = new webdriver.Builder()
.withCapabilities({
browserName: configs.browserName,
platform: configs.platform,
version: configs.version,
username: username,
accessKey: accessKey
})
.usingServer(`http://${username}:${accessKey}@ondemand.saucelabs.com:80/wd/hub`)
.build();
return 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 Error(`findByXpath failed with arguments:\n\txpath: ${xpath}`);
try {
await this.setTitle(`findByXpath ${xpath}`);
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 enhanceError(outerError, cause, this.driver);
}
}
/**
* Generate an xpath that finds an element by its text.
* @param {string} text The text to search for.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {string} The xpath.
*/
textToXpath (text, scope) {
return `//body//${scope || '*'}//*[contains(text(), '${text}')]`;
}
/**
* Find an element by its text.
* @param {string} text The text to search for.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<webdriver.WebElement>} A promise that resolves to the element.
*/
findByText (text, scope) {
return this.findByXpath(this.textToXpath(text, scope));
}
/**
* Check if an element exists by its text.
* @param {string} text The text to search for.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<boolean>} A promise that resolves to true if the element exists.
*/
async textExists (text, scope) {
const outerError = new Error(`textExists failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
try {
await this.setTitle(`textExists ${text}`);
const elements = await this.driver.findElements(By.xpath(this.textToXpath(text, scope)));
return elements.length > 0;
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Load a URI in the driver.
* @param {string} uri The URI to load.
* @returns {Promise} A promise that resolves when the URI is loaded.
*/
async loadUri (uri) {
const outerError = new Error(`loadUri failed with arguments:\n\turi: ${uri}`);
try {
await this.setTitle(`loadUri ${uri}`);
const WINDOW_WIDTH = 1024;
const WINDOW_HEIGHT = 768;
await this.driver
.get(`file://${uri}`);
await this.driver
.executeScript('window.onbeforeunload = undefined;');
await this.driver.manage().window()
.setSize(WINDOW_WIDTH, WINDOW_HEIGHT);
await this.driver.wait(
async () => await this.driver.executeScript('return document.readyState;') === 'complete',
DEFAULT_TIMEOUT_MILLISECONDS
);
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click an element by xpath.
* @param {string} xpath The xpath to click.
* @returns {Promise<void>} A promise that resolves when the element is clicked.
*/
async clickXpath (xpath) {
const outerError = new Error(`clickXpath failed with arguments:\n\txpath: ${xpath}`);
try {
await this.setTitle(`clickXpath ${xpath}`);
const el = await this.findByXpath(xpath);
return el.click();
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click an element by its text.
* @param {string} text The text to click.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<void>} A promise that resolves when the element is clicked.
*/
async clickText (text, scope) {
const outerError = new Error(`clickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
try {
await this.setTitle(`clickText ${text}`);
const el = await this.findByText(text, scope);
return el.click();
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click a category in the blocks pane.
* @param {string} categoryText The text of the category to click.
* @returns {Promise<void>} A promise that resolves when the category is clicked.
*/
async clickBlocksCategory (categoryText) {
const outerError = new Error(`clickBlocksCategory failed with arguments:\n\tcategoryText: ${categoryText}`);
// The toolbox is destroyed and recreated several times, so avoid clicking on a nonexistent element and erroring
// out. First we wait for the block pane itself to appear, then wait 100ms for the toolbox to finish refreshing,
// then finally click the toolbox text.
try {
await this.setTitle(`clickBlocksCategory ${categoryText}`);
await this.findByXpath('//div[contains(@class, "blocks_blocks")]');
await this.driver.sleep(100);
await this.clickText(categoryText, 'div[contains(@class, "blocks_blocks")]');
await this.driver.sleep(500); // Wait for scroll to finish
} catch (cause) {
throw await enhanceError(outerError, cause);
}
}
/**
* Right click an element by its text.
* @param {string} text The text to right click.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<void>} A promise that resolves when the element is right clicked.
*/
async rightClickText (text, scope) {
const outerError = new Error(`rightClickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
try {
await this.setTitle(`rightClickText ${text}`);
const el = await this.findByText(text, scope);
return this.driver.actions()
.click(el, Button.RIGHT)
.perform();
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click a button by its text.
* @param {string} text The text to click.
* @returns {Promise<void>} A promise that resolves when the button is clicked.
*/
async clickButton (text) {
const outerError = new Error(`clickButton failed with arguments:\n\ttext: ${text}`);
try {
await this.setTitle(`clickButton ${text}`);
await this.clickXpath(`//button//*[contains(text(), '${text}')]`);
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Get selected browser log entries.
* @param {Array.<string>} [whitelist] An optional list of log strings to allow. Default: see implementation.
* @returns {Promise<Array.<webdriver.logging.Entry>>} A promise that resolves to the log entries.
*/
async getLogs (whitelist) {
const outerError = new Error(`getLogs failed with arguments:\n\twhitelist: ${whitelist}`);
try {
await this.setTitle(`getLogs ${whitelist}`);
if (!whitelist) {
// Default whitelist
whitelist = [
'The play() request was interrupted by a call to pause()'
];
}
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) {
return false;
} else if (entry.level !== 'SEVERE') { // WARNING: this doesn't do what it looks like it does!
return false;
}
}
return true;
});
} catch (cause) {
throw await enhanceError(outerError, cause);
}
}
}
export default SeleniumHelper;