Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
378
scratch-gui/test/helpers/selenium-helper.js
Normal file
378
scratch-gui/test/helpers/selenium-helper.js
Normal file
@@ -0,0 +1,378 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user