@@ -4,7 +4,7 @@ import bindAll from 'lodash.bindall';
44import 'chromedriver' ; // register path
55import webdriver from 'selenium-webdriver' ;
66
7- const { By , until , Button } = webdriver ;
7+ const { Button , By , until } = webdriver ;
88
99const 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.
1414const 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+
1654class 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