@@ -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,27 @@ 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+ * 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+
1637class 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