Skip to content

Commit 05a2d16

Browse files
committed
test: improve Selenium helper debugging and consistency
1 parent c78735e commit 05a2d16

File tree

1 file changed

+233
-58
lines changed

1 file changed

+233
-58
lines changed

test/helpers/selenium-helper.js

Lines changed: 233 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import bindAll from 'lodash.bindall';
44
import 'chromedriver'; // register path
55
import webdriver from 'selenium-webdriver';
66

7-
const {By, until, Button} = webdriver;
7+
const {Button, By, until} = webdriver;
88

99
const USE_HEADLESS = process.env.USE_HEADLESS !== 'no';
1010

@@ -13,6 +13,27 @@ const USE_HEADLESS = process.env.USE_HEADLESS !== 'no';
1313
// The Jasmine default timeout is 30 seconds so make sure this is lower.
1414
const DEFAULT_TIMEOUT_MILLISECONDS = 20 * 1000;
1515

16+
/**
17+
* Embed a causal error into an outer error, and add its message to the outer error's message.
18+
* This compensates for the loss of context caused by `regenerator-runtime`.
19+
* @param {Error} outerError The error to embed the cause into.
20+
* @param {Error} cause The "inner" error to embed.
21+
* @returns {Error} The outerError, with the cause embedded.
22+
*/
23+
const embedCause = (outerError, cause) => {
24+
if (cause) {
25+
// This is the official way to nest errors in modern Node.js, but Jest ignores this field.
26+
// It's here in case a future version uses it, or in case the caller does.
27+
outerError.cause = cause;
28+
}
29+
if (cause && cause.message) {
30+
outerError.message += `\n${['Cause:', ...cause.message.split('\n')].join('\n ')}`;
31+
} else {
32+
outerError.message += '\nCause: unknown';
33+
}
34+
return outerError;
35+
};
36+
1637
class SeleniumHelper {
1738
constructor () {
1839
bindAll(this, [
@@ -33,14 +54,41 @@ class SeleniumHelper {
3354
]);
3455

3556
this.Key = webdriver.Key; // map Key constants, for sending special keys
57+
58+
// this type declaration suppresses IDE type warnings throughout this file
59+
/** @type {webdriver.ThenableWebDriver} */
60+
this.driver = null;
3661
}
3762

38-
elementIsVisible (element, timeoutMessage = 'elementIsVisible timed out') {
39-
return this.driver.wait(until.elementIsVisible(element), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage);
63+
/**
64+
* Set the browser window title. Useful for debugging.
65+
* @param {string} title The title to set.
66+
* @returns {Promise<void>} A promise that resolves when the title is set.
67+
*/
68+
async setTitle (title) {
69+
await this.driver.executeScript(`document.title = arguments[0];`, title);
4070
}
4171

72+
/**
73+
* Wait for an element to be visible.
74+
* @param {webdriver.WebElement} element The element to wait for.
75+
* @returns {Promise<void>} A promise that resolves when the element is visible.
76+
*/
77+
async elementIsVisible (element) {
78+
const outerError = new Error('elementIsVisible failed');
79+
try {
80+
await this.setTitle(`elementIsVisible ${await element.getId()}`);
81+
await this.driver.wait(until.elementIsVisible(element), DEFAULT_TIMEOUT_MILLISECONDS);
82+
} catch (cause) {
83+
throw embedCause(outerError, cause);
84+
}
85+
}
86+
87+
/**
88+
* List of useful xpath scopes for finding elements.
89+
* @returns {object} An object mapping names to xpath strings.
90+
*/
4291
get scope () {
43-
// List of useful xpath scopes for finding elements
4492
return {
4593
blocksTab: "*[@id='react-tabs-1']",
4694
costumesTab: "*[@id='react-tabs-3']",
@@ -54,6 +102,10 @@ class SeleniumHelper {
54102
};
55103
}
56104

105+
/**
106+
* Instantiate a new Selenium driver.
107+
* @returns {webdriver.ThenableWebDriver} The new driver.
108+
*/
57109
getDriver () {
58110
const chromeCapabilities = webdriver.Capabilities.chrome();
59111
const args = [];
@@ -79,6 +131,16 @@ class SeleniumHelper {
79131
return this.driver;
80132
}
81133

134+
/**
135+
* Instantiate a new Selenium driver for Sauce Labs.
136+
* @param {string} username The Sauce Labs username.
137+
* @param {string} accessKey The Sauce Labs access key.
138+
* @param {object} configs The Sauce Labs configuration.
139+
* @param {string} configs.browserName The name of the desired browser.
140+
* @param {string} configs.platform The name of the desired platform.
141+
* @param {string} configs.version The desired browser version.
142+
* @returns {webdriver.ThenableWebDriver} The new driver.
143+
*/
82144
getSauceDriver (username, accessKey, configs) {
83145
this.driver = new webdriver.Builder()
84146
.withCapabilities({
@@ -88,98 +150,211 @@ class SeleniumHelper {
88150
username: username,
89151
accessKey: accessKey
90152
})
91-
.usingServer(`http://${username}:${accessKey
92-
}@ondemand.saucelabs.com:80/wd/hub`)
153+
.usingServer(`http://${username}:${accessKey}@ondemand.saucelabs.com:80/wd/hub`)
93154
.build();
94155
return this.driver;
95156
}
96157

97-
findByXpath (xpath, timeoutMessage = `findByXpath timed out for path: ${xpath}`) {
98-
return this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage)
99-
.then(el => (
100-
this.driver.wait(el.isDisplayed(), DEFAULT_TIMEOUT_MILLISECONDS, `${xpath} is not visible`)
101-
.then(() => el)
102-
));
158+
/**
159+
* Find an element by xpath.
160+
* @param {string} xpath The xpath to search for.
161+
* @returns {Promise<webdriver.WebElement>} A promise that resolves to the element.
162+
*/
163+
async findByXpath (xpath) {
164+
const outerError = new Error(`findByXpath failed with arguments:\n\txpath: ${xpath}`);
165+
try {
166+
await this.setTitle(`findByXpath ${xpath}`);
167+
const el = await this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS);
168+
// await this.driver.wait(() => el.isDisplayed(), DEFAULT_TIMEOUT_MILLISECONDS);
169+
return el;
170+
} catch (cause) {
171+
throw embedCause(outerError, cause);
172+
}
103173
}
104174

175+
/**
176+
* Generate an xpath that finds an element by its text.
177+
* @param {string} text The text to search for.
178+
* @param {string} [scope] An optional xpath scope to search within.
179+
* @returns {string} The xpath.
180+
*/
105181
textToXpath (text, scope) {
106182
return `//body//${scope || '*'}//*[contains(text(), '${text}')]`;
107183
}
108184

185+
/**
186+
* Find an element by its text.
187+
* @param {string} text The text to search for.
188+
* @param {string} [scope] An optional xpath scope to search within.
189+
* @returns {Promise<webdriver.WebElement>} A promise that resolves to the element.
190+
*/
109191
findByText (text, scope) {
110192
return this.findByXpath(this.textToXpath(text, scope));
111193
}
112194

113-
textExists (text, scope) {
114-
return this.driver.findElements(By.xpath(this.textToXpath(text, scope)))
115-
.then(elements => elements.length > 0);
195+
/**
196+
* Check if an element exists by its text.
197+
* @param {string} text The text to search for.
198+
* @param {string} [scope] An optional xpath scope to search within.
199+
* @returns {Promise<boolean>} A promise that resolves to true if the element exists.
200+
*/
201+
async textExists (text, scope) {
202+
const outerError = new Error(`textExists failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
203+
try {
204+
await this.setTitle(`textExists ${text}`);
205+
const elements = await this.driver.findElements(By.xpath(this.textToXpath(text, scope)));
206+
return elements.length > 0;
207+
} catch (cause) {
208+
throw embedCause(outerError, cause);
209+
}
116210
}
117211

118-
loadUri (uri) {
119-
const WINDOW_WIDTH = 1024;
120-
const WINDOW_HEIGHT = 768;
121-
return this.driver
122-
.get(`file://${uri}`)
123-
.then(() => (
124-
this.driver.executeScript('window.onbeforeunload = undefined;')
125-
))
126-
.then(() => (
127-
this.driver.manage()
128-
.window()
129-
.setSize(WINDOW_WIDTH, WINDOW_HEIGHT)
130-
));
212+
/**
213+
* Load a URI in the driver.
214+
* @param {string} uri The URI to load.
215+
* @returns {Promise} A promise that resolves when the URI is loaded.
216+
*/
217+
async loadUri (uri) {
218+
const outerError = new Error(`loadUri failed with arguments:\n\turi: ${uri}`);
219+
try {
220+
await this.setTitle(`loadUri ${uri}`);
221+
const WINDOW_WIDTH = 1024;
222+
const WINDOW_HEIGHT = 768;
223+
await this.driver
224+
.get(`file://${uri}`);
225+
await this.driver
226+
.executeScript('window.onbeforeunload = undefined;');
227+
await this.driver.manage().window()
228+
.setSize(WINDOW_WIDTH, WINDOW_HEIGHT);
229+
await this.driver.wait(
230+
async () => await this.driver.executeScript('return document.readyState;') === 'complete',
231+
DEFAULT_TIMEOUT_MILLISECONDS
232+
);
233+
} catch (cause) {
234+
throw embedCause(outerError, cause);
235+
}
131236
}
132237

133-
clickXpath (xpath) {
134-
return this.findByXpath(xpath).then(el => el.click());
238+
/**
239+
* Click an element by xpath.
240+
* @param {string} xpath The xpath to click.
241+
* @returns {Promise<void>} A promise that resolves when the element is clicked.
242+
*/
243+
async clickXpath (xpath) {
244+
const outerError = new Error(`clickXpath failed with arguments:\n\txpath: ${xpath}`);
245+
try {
246+
await this.setTitle(`clickXpath ${xpath}`);
247+
const el = await this.findByXpath(xpath);
248+
return el.click();
249+
} catch (cause) {
250+
throw embedCause(outerError, cause);
251+
}
135252
}
136253

137-
clickText (text, scope) {
138-
return this.findByText(text, scope).then(el => el.click());
254+
/**
255+
* Click an element by its text.
256+
* @param {string} text The text to click.
257+
* @param {string} [scope] An optional xpath scope to search within.
258+
* @returns {Promise<void>} A promise that resolves when the element is clicked.
259+
*/
260+
async clickText (text, scope) {
261+
const outerError = new Error(`clickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
262+
try {
263+
await this.setTitle(`clickText ${text}`);
264+
const el = await this.findByText(text, scope);
265+
return el.click();
266+
} catch (cause) {
267+
throw embedCause(outerError, cause);
268+
}
139269
}
140270

271+
/**
272+
* Click a category in the blocks pane.
273+
* @param {string} categoryText The text of the category to click.
274+
* @returns {Promise<void>} A promise that resolves when the category is clicked.
275+
*/
141276
async clickBlocksCategory (categoryText) {
277+
const outerError = new Error(`clickBlocksCategory failed with arguments:\n\tcategoryText: ${categoryText}`);
142278
// The toolbox is destroyed and recreated several times, so avoid clicking on a nonexistent element and erroring
143279
// out. First we wait for the block pane itself to appear, then wait 100ms for the toolbox to finish refreshing,
144280
// then finally click the toolbox text.
145-
146-
await this.findByXpath('//div[contains(@class, "blocks_blocks")]');
147-
await this.driver.sleep(100);
148-
await this.clickText(categoryText, 'div[contains(@class, "blocks_blocks")]');
149-
await this.driver.sleep(500); // Wait for scroll to finish
281+
try {
282+
await this.setTitle(`clickBlocksCategory ${categoryText}`);
283+
await this.findByXpath('//div[contains(@class, "blocks_blocks")]');
284+
await this.driver.sleep(100);
285+
await this.clickText(categoryText, 'div[contains(@class, "blocks_blocks")]');
286+
await this.driver.sleep(500); // Wait for scroll to finish
287+
} catch (cause) {
288+
throw embedCause(outerError, cause);
289+
}
150290
}
151291

152-
rightClickText (text, scope) {
153-
return this.findByText(text, scope).then(el => this.driver.actions()
154-
.click(el, Button.RIGHT)
155-
.perform());
292+
/**
293+
* Right click an element by its text.
294+
* @param {string} text The text to right click.
295+
* @param {string} [scope] An optional xpath scope to search within.
296+
* @returns {Promise<void>} A promise that resolves when the element is right clicked.
297+
*/
298+
async rightClickText (text, scope) {
299+
const outerError = new Error(`rightClickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
300+
try {
301+
await this.setTitle(`rightClickText ${text}`);
302+
const el = await this.findByText(text, scope);
303+
return this.driver.actions()
304+
.click(el, Button.RIGHT)
305+
.perform();
306+
} catch (cause) {
307+
throw embedCause(outerError, cause);
308+
}
156309
}
157310

158-
clickButton (text) {
159-
return this.clickXpath(`//button//*[contains(text(), '${text}')]`);
311+
/**
312+
* Click a button by its text.
313+
* @param {string} text The text to click.
314+
* @returns {Promise<void>} A promise that resolves when the button is clicked.
315+
*/
316+
async clickButton (text) {
317+
const outerError = new Error(`clickButton failed with arguments:\n\ttext: ${text}`);
318+
try {
319+
await this.setTitle(`clickButton ${text}`);
320+
await this.clickXpath(`//button//*[contains(text(), '${text}')]`);
321+
} catch (cause) {
322+
throw embedCause(outerError, cause);
323+
}
160324
}
161325

162-
getLogs (whitelist) {
163-
if (!whitelist) {
164-
// Default whitelist
165-
whitelist = [
166-
'The play() request was interrupted by a call to pause()'
167-
];
168-
}
169-
return this.driver.manage()
170-
.logs()
171-
.get('browser')
172-
.then(entries => entries.filter(entry => {
326+
/**
327+
* Get selected browser log entries.
328+
* @param {Array.<string>} [whitelist] An optional list of log strings to allow. Default: see implementation.
329+
* @returns {Promise<Array.<webdriver.logging.Entry>>} A promise that resolves to the log entries.
330+
*/
331+
async getLogs (whitelist) {
332+
const outerError = new Error(`getLogs failed with arguments:\n\twhitelist: ${whitelist}`);
333+
try {
334+
await this.setTitle(`getLogs ${whitelist}`);
335+
if (!whitelist) {
336+
// Default whitelist
337+
whitelist = [
338+
'The play() request was interrupted by a call to pause()'
339+
];
340+
}
341+
const entries = await this.driver.manage()
342+
.logs()
343+
.get('browser');
344+
return entries.filter(entry => {
173345
const message = entry.message;
174-
for (let i = 0; i < whitelist.length; i++) {
175-
if (message.indexOf(whitelist[i]) !== -1) {
346+
for (const element of whitelist) {
347+
if (message.indexOf(element) !== -1) {
176348
return false;
177-
} else if (entry.level !== 'SEVERE') {
349+
} else if (entry.level !== 'SEVERE') { // WARNING: this doesn't do what it looks like it does!
178350
return false;
179351
}
180352
}
181353
return true;
182-
}));
354+
});
355+
} catch (cause) {
356+
throw embedCause(outerError, cause);
357+
}
183358
}
184359
}
185360

0 commit comments

Comments
 (0)