@@ -3,7 +3,7 @@ import * as cp from "child_process"
3
3
import { promises as fs } from "fs"
4
4
import * as path from "path"
5
5
import { Page } from "playwright"
6
- import { logError } from "../../../src/common/util"
6
+ import { logError , plural } from "../../../src/common/util"
7
7
import { onLine } from "../../../src/node/util"
8
8
import { PASSWORD , workspaceDir } from "../../utils/constants"
9
9
import { idleTimer , tmpdir } from "../../utils/helpers"
@@ -13,14 +13,21 @@ interface CodeServerProcess {
13
13
address : string
14
14
}
15
15
16
- class CancelToken {
16
+ class Context {
17
17
private _canceled = false
18
+ private _done = false
18
19
public canceled ( ) : boolean {
19
20
return this . _canceled
20
21
}
22
+ public done ( ) : void {
23
+ this . _done = true
24
+ }
21
25
public cancel ( ) : void {
22
26
this . _canceled = true
23
27
}
28
+ public finish ( ) : boolean {
29
+ return this . _done
30
+ }
24
31
}
25
32
26
33
/**
@@ -287,57 +294,103 @@ export class CodeServerPage {
287
294
}
288
295
289
296
/**
290
- * Navigate through the specified set of menus. If it fails it will keep
291
- * trying .
297
+ * Navigate through the items in the selector. `open` is a function that will
298
+ * open the menu/popup containing the items through which to navigation .
292
299
*/
293
- async navigateMenus ( menus : string [ ] ) {
294
- const navigate = async ( cancelToken : CancelToken ) => {
295
- const steps : Array < ( ) => Promise < unknown > > = [ ( ) => this . page . waitForSelector ( `${ menuSelector } :focus-within` ) ]
296
- for ( const menu of menus ) {
300
+ async navigateItems ( items : string [ ] , selector : string , open ?: ( selector : string ) => void ) : Promise < void > {
301
+ const logger = this . codeServer . logger . named ( selector )
302
+
303
+ /**
304
+ * If the selector loses focus or gets removed this will resolve with false,
305
+ * signaling we need to try again.
306
+ */
307
+ const openThenWaitClose = async ( ctx : Context ) => {
308
+ if ( open ) {
309
+ await open ( selector )
310
+ }
311
+ this . codeServer . logger . debug ( `watching ${ selector } ` )
312
+ try {
313
+ await this . page . waitForSelector ( `${ selector } :not(:focus-within)` )
314
+ } catch ( error ) {
315
+ if ( ! ctx . done ( ) ) {
316
+ this . codeServer . logger . debug ( `${ selector } navigation: ${ error . message || error } ` )
317
+ }
318
+ }
319
+ return false
320
+ }
321
+
322
+ /**
323
+ * This will step through each item, aborting and returning false if
324
+ * canceled or if any navigation step has an error which signals we need to
325
+ * try again.
326
+ */
327
+ const navigate = async ( ctx : Context ) => {
328
+ const steps : Array < { fn : ( ) => Promise < unknown > , name : string } > = [ {
329
+ fn : ( ) => this . page . waitForSelector ( `${ selector } :focus-within` ) ,
330
+ name : "focus" ,
331
+ } ]
332
+
333
+ for ( const item of items ) {
297
334
// Normally these will wait for the item to be visible and then execute
298
335
// the action. The problem is that if the menu closes these will still
299
336
// be waiting and continue to execute once the menu is visible again,
300
337
// potentially conflicting with the new set of navigations (for example
301
338
// if the old promise clicks logout before the new one can). By
302
339
// splitting them into two steps each we can cancel before running the
303
340
// action.
304
- steps . push ( ( ) => this . page . hover ( `text= ${ menu } ` , { trial : true } ) )
305
- steps . push ( ( ) => this . page . hover ( `text= ${ menu } ` , { force : true } ) )
306
- steps . push ( ( ) => this . page . click ( `text= ${ menu } ` , { trial : true } ) )
307
- steps . push ( ( ) => this . page . click ( `text= ${ menu } ` , { force : true } ) )
341
+ steps . push ( { fn : ( ) => this . page . hover ( `${ selector } : text(" ${ item } ") ` , { trial : true } ) , name : ` ${ item } :hover:trial` } )
342
+ steps . push ( { fn : ( ) => this . page . hover ( `${ selector } : text(" ${ item } ") ` , { force : true } ) , name : ` ${ item } :hover:force` } )
343
+ steps . push ( { fn : ( ) => this . page . click ( `${ selector } : text(" ${ item } ") ` , { trial : true } ) , name : ` ${ item } :click:trial` } )
344
+ steps . push ( { fn : ( ) => this . page . click ( `${ selector } : text(" ${ item } ") ` , { force : true } ) , name : ` ${ item } :click:force` } )
308
345
}
346
+
309
347
for ( const step of steps ) {
310
- await step ( )
311
- if ( cancelToken . canceled ( ) ) {
312
- this . codeServer . logger . debug ( "menu navigation canceled" )
348
+ try {
349
+ logger . debug ( `navigation step: ${ step . name } ` )
350
+ await step . fn ( )
351
+ if ( ctx . canceled ( ) ) {
352
+ logger . debug ( "navigation canceled" )
353
+ return false
354
+ }
355
+ } catch ( error ) {
356
+ logger . debug ( `navigation: ${ error . message || error } ` )
313
357
return false
314
358
}
315
359
}
316
360
return true
317
361
}
318
362
319
- const menuSelector = '[aria-label="Application Menu"]'
320
- const open = async ( ) => {
321
- await this . page . click ( menuSelector )
322
- await this . page . waitForSelector ( `${ menuSelector } :not(:focus-within)` )
323
- return false
363
+ // We are seeing the menu closing after opening if we open it too soon and
364
+ // the picker getting recreated in the middle of trying to select an item.
365
+ // To counter this we will keep trying to navigate through the items every
366
+ // time we lose focus or there is an error.
367
+ let attempts = 1
368
+ let context = new Context ( )
369
+ while ( ! ( await Promise . race ( [ openThenWaitClose ( ) , navigate ( context ) ] ) ) ) {
370
+ ++ attempts
371
+ logger . debug ( "closed, retrying (${attempt}/∞)" )
372
+ context . cancel ( )
373
+ context = new Context ( )
324
374
}
325
375
326
- // TODO: Starting in 1.57 something closes the menu after opening it if we
327
- // open it too soon. To counter that we'll watch for when the menu loses
328
- // focus and when/if it does we'll try again.
329
- // I tried using the classic menu but it doesn't show up at all for some
330
- // reason. I also tried toggle but the menu disappears after toggling.
331
- let retryCount = 0
332
- let cancelToken = new CancelToken ( )
333
- while ( ! ( await Promise . race ( [ open ( ) , navigate ( cancelToken ) ] ) ) ) {
334
- this . codeServer . logger . debug ( "menu was closed, retrying" )
335
- ++ retryCount
336
- cancelToken . cancel ( )
337
- cancelToken = new CancelToken ( )
338
- }
376
+ context . finish ( )
377
+ logger . debug ( `navigation took ${ attempts } ${ plural ( attempts , "attempt" ) } ` )
378
+ }
339
379
340
- this . codeServer . logger . debug ( `menu navigation retries: ${ retryCount } ` )
380
+ /**
381
+ * Navigate through a currently opened picker, retrying on failure.
382
+ */
383
+ async navigatePicker ( items : string [ ] ) : Promise < void > {
384
+ await this . navigateItems ( items , ".quick-input-widget" )
385
+ }
386
+
387
+ /**
388
+ * Navigate through the menu, retrying on failure.
389
+ */
390
+ async navigateMenus ( menus : string [ ] ) : Promise < void > {
391
+ await this . navigateItems ( menus , '[aria-label="Application Menu"]' , async ( selector ) => {
392
+ await this . page . click ( selector )
393
+ } )
341
394
}
342
395
343
396
/**
0 commit comments