Skip to content

Commit d41f67a

Browse files
authored
Merge pull request #9283 from scratchfoundation/improve-test-debugging
Improve test debugging
2 parents 2a25d1d + 2f3b6f5 commit d41f67a

File tree

5 files changed

+265
-70
lines changed

5 files changed

+265
-70
lines changed

test/helpers/selenium-helper.js

Lines changed: 250 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,44 @@ 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+
* Add more debug information to an error:
18+
* - Merge a causal error into an outer error with valuable stack information
19+
* - Add the causal error's message to the outer error's message.
20+
* - Add debug information from the web driver, if available.
21+
* The outerError compensates for the loss of context caused by `regenerator-runtime`.
22+
* @param {Error} outerError The error to embed the cause into.
23+
* @param {Error} cause The "inner" error to embed.
24+
* @param {webdriver.ThenableWebDriver} [driver] Optional driver to capture debug info from.
25+
* @returns {Promise<Error>} The outerError, with the cause embedded.
26+
*/
27+
const enhanceError = async (outerError, cause, driver) => {
28+
if (cause) {
29+
// This is the official way to nest errors in modern Node.js, but Jest ignores this field.
30+
// It's here in case a future version uses it, or in case the caller does.
31+
outerError.cause = cause;
32+
}
33+
if (cause && cause.message) {
34+
outerError.message += `\n${['Cause:', ...cause.message.split('\n')].join('\n ')}`;
35+
} else {
36+
outerError.message += '\nCause: unknown';
37+
}
38+
if (driver) {
39+
const url = await driver.getCurrentUrl();
40+
const title = await driver.getTitle();
41+
const pageSource = await driver.getPageSource();
42+
const browserLogEntries = await driver.manage()
43+
.logs()
44+
.get('browser');
45+
const browserLogText = browserLogEntries.map(entry => entry.message).join('\n');
46+
outerError.message += `\nBrowser URL: ${url}`;
47+
outerError.message += `\nBrowser title: ${title}`;
48+
outerError.message += `\nBrowser logs:\n*****\n${browserLogText}\n*****\n`;
49+
outerError.message += `\nBrowser page source:\n*****\n${pageSource}\n*****\n`;
50+
}
51+
return outerError;
52+
};
53+
1654
class SeleniumHelper {
1755
constructor () {
1856
bindAll(this, [
@@ -33,14 +71,41 @@ class SeleniumHelper {
3371
]);
3472

3573
this.Key = webdriver.Key; // map Key constants, for sending special keys
74+
75+
// this type declaration suppresses IDE type warnings throughout this file
76+
/** @type {webdriver.ThenableWebDriver} */
77+
this.driver = null;
3678
}
3779

38-
elementIsVisible (element, timeoutMessage = 'elementIsVisible timed out') {
39-
return this.driver.wait(until.elementIsVisible(element), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage);
80+
/**
81+
* Set the browser window title. Useful for debugging.
82+
* @param {string} title The title to set.
83+
* @returns {Promise<void>} A promise that resolves when the title is set.
84+
*/
85+
async setTitle (title) {
86+
await this.driver.executeScript(`document.title = arguments[0];`, title);
4087
}
4188

89+
/**
90+
* Wait for an element to be visible.
91+
* @param {webdriver.WebElement} element The element to wait for.
92+
* @returns {Promise<void>} A promise that resolves when the element is visible.
93+
*/
94+
async elementIsVisible (element) {
95+
const outerError = new Error('elementIsVisible failed');
96+
try {
97+
await this.setTitle(`elementIsVisible ${await element.getId()}`);
98+
await this.driver.wait(until.elementIsVisible(element), DEFAULT_TIMEOUT_MILLISECONDS);
99+
} catch (cause) {
100+
throw await enhanceError(outerError, cause, this.driver);
101+
}
102+
}
103+
104+
/**
105+
* List of useful xpath scopes for finding elements.
106+
* @returns {object} An object mapping names to xpath strings.
107+
*/
42108
get scope () {
43-
// List of useful xpath scopes for finding elements
44109
return {
45110
blocksTab: "*[@id='react-tabs-1']",
46111
costumesTab: "*[@id='react-tabs-3']",
@@ -54,6 +119,10 @@ class SeleniumHelper {
54119
};
55120
}
56121

122+
/**
123+
* Instantiate a new Selenium driver.
124+
* @returns {webdriver.ThenableWebDriver} The new driver.
125+
*/
57126
getDriver () {
58127
const chromeCapabilities = webdriver.Capabilities.chrome();
59128
const args = [];
@@ -79,6 +148,16 @@ class SeleniumHelper {
79148
return this.driver;
80149
}
81150

151+
/**
152+
* Instantiate a new Selenium driver for Sauce Labs.
153+
* @param {string} username The Sauce Labs username.
154+
* @param {string} accessKey The Sauce Labs access key.
155+
* @param {object} configs The Sauce Labs configuration.
156+
* @param {string} configs.browserName The name of the desired browser.
157+
* @param {string} configs.platform The name of the desired platform.
158+
* @param {string} configs.version The desired browser version.
159+
* @returns {webdriver.ThenableWebDriver} The new driver.
160+
*/
82161
getSauceDriver (username, accessKey, configs) {
83162
this.driver = new webdriver.Builder()
84163
.withCapabilities({
@@ -88,98 +167,211 @@ class SeleniumHelper {
88167
username: username,
89168
accessKey: accessKey
90169
})
91-
.usingServer(`http://${username}:${accessKey
92-
}@ondemand.saucelabs.com:80/wd/hub`)
170+
.usingServer(`http://${username}:${accessKey}@ondemand.saucelabs.com:80/wd/hub`)
93171
.build();
94172
return this.driver;
95173
}
96174

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-
));
175+
/**
176+
* Find an element by xpath.
177+
* @param {string} xpath The xpath to search for.
178+
* @returns {Promise<webdriver.WebElement>} A promise that resolves to the element.
179+
*/
180+
async findByXpath (xpath) {
181+
const outerError = new Error(`findByXpath failed with arguments:\n\txpath: ${xpath}`);
182+
try {
183+
await this.setTitle(`findByXpath ${xpath}`);
184+
const el = await this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS);
185+
// await this.driver.wait(() => el.isDisplayed(), DEFAULT_TIMEOUT_MILLISECONDS);
186+
return el;
187+
} catch (cause) {
188+
throw await enhanceError(outerError, cause, this.driver);
189+
}
103190
}
104191

192+
/**
193+
* Generate an xpath that finds an element by its text.
194+
* @param {string} text The text to search for.
195+
* @param {string} [scope] An optional xpath scope to search within.
196+
* @returns {string} The xpath.
197+
*/
105198
textToXpath (text, scope) {
106199
return `//body//${scope || '*'}//*[contains(text(), '${text}')]`;
107200
}
108201

202+
/**
203+
* Find an element by its text.
204+
* @param {string} text The text to search for.
205+
* @param {string} [scope] An optional xpath scope to search within.
206+
* @returns {Promise<webdriver.WebElement>} A promise that resolves to the element.
207+
*/
109208
findByText (text, scope) {
110209
return this.findByXpath(this.textToXpath(text, scope));
111210
}
112211

113-
textExists (text, scope) {
114-
return this.driver.findElements(By.xpath(this.textToXpath(text, scope)))
115-
.then(elements => elements.length > 0);
212+
/**
213+
* Check if an element exists by its text.
214+
* @param {string} text The text to search for.
215+
* @param {string} [scope] An optional xpath scope to search within.
216+
* @returns {Promise<boolean>} A promise that resolves to true if the element exists.
217+
*/
218+
async textExists (text, scope) {
219+
const outerError = new Error(`textExists failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
220+
try {
221+
await this.setTitle(`textExists ${text}`);
222+
const elements = await this.driver.findElements(By.xpath(this.textToXpath(text, scope)));
223+
return elements.length > 0;
224+
} catch (cause) {
225+
throw await enhanceError(outerError, cause, this.driver);
226+
}
116227
}
117228

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-
));
229+
/**
230+
* Load a URI in the driver.
231+
* @param {string} uri The URI to load.
232+
* @returns {Promise} A promise that resolves when the URI is loaded.
233+
*/
234+
async loadUri (uri) {
235+
const outerError = new Error(`loadUri failed with arguments:\n\turi: ${uri}`);
236+
try {
237+
await this.setTitle(`loadUri ${uri}`);
238+
const WINDOW_WIDTH = 1024;
239+
const WINDOW_HEIGHT = 768;
240+
await this.driver
241+
.get(`file://${uri}`);
242+
await this.driver
243+
.executeScript('window.onbeforeunload = undefined;');
244+
await this.driver.manage().window()
245+
.setSize(WINDOW_WIDTH, WINDOW_HEIGHT);
246+
await this.driver.wait(
247+
async () => await this.driver.executeScript('return document.readyState;') === 'complete',
248+
DEFAULT_TIMEOUT_MILLISECONDS
249+
);
250+
} catch (cause) {
251+
throw await enhanceError(outerError, cause, this.driver);
252+
}
131253
}
132254

133-
clickXpath (xpath) {
134-
return this.findByXpath(xpath).then(el => el.click());
255+
/**
256+
* Click an element by xpath.
257+
* @param {string} xpath The xpath to click.
258+
* @returns {Promise<void>} A promise that resolves when the element is clicked.
259+
*/
260+
async clickXpath (xpath) {
261+
const outerError = new Error(`clickXpath failed with arguments:\n\txpath: ${xpath}`);
262+
try {
263+
await this.setTitle(`clickXpath ${xpath}`);
264+
const el = await this.findByXpath(xpath);
265+
return el.click();
266+
} catch (cause) {
267+
throw await enhanceError(outerError, cause, this.driver);
268+
}
135269
}
136270

137-
clickText (text, scope) {
138-
return this.findByText(text, scope).then(el => el.click());
271+
/**
272+
* Click an element by its text.
273+
* @param {string} text The text to click.
274+
* @param {string} [scope] An optional xpath scope to search within.
275+
* @returns {Promise<void>} A promise that resolves when the element is clicked.
276+
*/
277+
async clickText (text, scope) {
278+
const outerError = new Error(`clickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
279+
try {
280+
await this.setTitle(`clickText ${text}`);
281+
const el = await this.findByText(text, scope);
282+
return el.click();
283+
} catch (cause) {
284+
throw await enhanceError(outerError, cause, this.driver);
285+
}
139286
}
140287

288+
/**
289+
* Click a category in the blocks pane.
290+
* @param {string} categoryText The text of the category to click.
291+
* @returns {Promise<void>} A promise that resolves when the category is clicked.
292+
*/
141293
async clickBlocksCategory (categoryText) {
294+
const outerError = new Error(`clickBlocksCategory failed with arguments:\n\tcategoryText: ${categoryText}`);
142295
// The toolbox is destroyed and recreated several times, so avoid clicking on a nonexistent element and erroring
143296
// out. First we wait for the block pane itself to appear, then wait 100ms for the toolbox to finish refreshing,
144297
// 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
298+
try {
299+
await this.setTitle(`clickBlocksCategory ${categoryText}`);
300+
await this.findByXpath('//div[contains(@class, "blocks_blocks")]');
301+
await this.driver.sleep(100);
302+
await this.clickText(categoryText, 'div[contains(@class, "blocks_blocks")]');
303+
await this.driver.sleep(500); // Wait for scroll to finish
304+
} catch (cause) {
305+
throw await enhanceError(outerError, cause);
306+
}
150307
}
151308

152-
rightClickText (text, scope) {
153-
return this.findByText(text, scope).then(el => this.driver.actions()
154-
.click(el, Button.RIGHT)
155-
.perform());
309+
/**
310+
* Right click an element by its text.
311+
* @param {string} text The text to right click.
312+
* @param {string} [scope] An optional xpath scope to search within.
313+
* @returns {Promise<void>} A promise that resolves when the element is right clicked.
314+
*/
315+
async rightClickText (text, scope) {
316+
const outerError = new Error(`rightClickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
317+
try {
318+
await this.setTitle(`rightClickText ${text}`);
319+
const el = await this.findByText(text, scope);
320+
return this.driver.actions()
321+
.click(el, Button.RIGHT)
322+
.perform();
323+
} catch (cause) {
324+
throw await enhanceError(outerError, cause, this.driver);
325+
}
156326
}
157327

158-
clickButton (text) {
159-
return this.clickXpath(`//button//*[contains(text(), '${text}')]`);
328+
/**
329+
* Click a button by its text.
330+
* @param {string} text The text to click.
331+
* @returns {Promise<void>} A promise that resolves when the button is clicked.
332+
*/
333+
async clickButton (text) {
334+
const outerError = new Error(`clickButton failed with arguments:\n\ttext: ${text}`);
335+
try {
336+
await this.setTitle(`clickButton ${text}`);
337+
await this.clickXpath(`//button//*[contains(text(), '${text}')]`);
338+
} catch (cause) {
339+
throw await enhanceError(outerError, cause, this.driver);
340+
}
160341
}
161342

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 => {
343+
/**
344+
* Get selected browser log entries.
345+
* @param {Array.<string>} [whitelist] An optional list of log strings to allow. Default: see implementation.
346+
* @returns {Promise<Array.<webdriver.logging.Entry>>} A promise that resolves to the log entries.
347+
*/
348+
async getLogs (whitelist) {
349+
const outerError = new Error(`getLogs failed with arguments:\n\twhitelist: ${whitelist}`);
350+
try {
351+
await this.setTitle(`getLogs ${whitelist}`);
352+
if (!whitelist) {
353+
// Default whitelist
354+
whitelist = [
355+
'The play() request was interrupted by a call to pause()'
356+
];
357+
}
358+
const entries = await this.driver.manage()
359+
.logs()
360+
.get('browser');
361+
return entries.filter(entry => {
173362
const message = entry.message;
174-
for (let i = 0; i < whitelist.length; i++) {
175-
if (message.indexOf(whitelist[i]) !== -1) {
363+
for (const element of whitelist) {
364+
if (message.indexOf(element) !== -1) {
176365
return false;
177-
} else if (entry.level !== 'SEVERE') {
366+
} else if (entry.level !== 'SEVERE') { // WARNING: this doesn't do what it looks like it does!
178367
return false;
179368
}
180369
}
181370
return true;
182-
}));
371+
});
372+
} catch (cause) {
373+
throw await enhanceError(outerError, cause);
374+
}
183375
}
184376
}
185377

0 commit comments

Comments
 (0)