@@ -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
/**
@@ -30,6 +37,7 @@ export class CodeServer {
30
37
private process : Promise < CodeServerProcess > | undefined
31
38
public readonly logger : Logger
32
39
private closed = false
40
+ private _workspaceDir : Promise < string > | undefined
33
41
34
42
constructor ( name : string , private readonly codeServerArgs : string [ ] ) {
35
43
this . logger = logger . named ( name )
@@ -47,11 +55,21 @@ export class CodeServer {
47
55
return address
48
56
}
49
57
58
+ /**
59
+ * The workspace directory code-server opens with.
60
+ */
61
+ get workspaceDir ( ) : Promise < string > {
62
+ if ( ! this . _workspaceDir ) {
63
+ this . _workspaceDir = tmpdir ( workspaceDir )
64
+ }
65
+ return this . _workspaceDir
66
+ }
67
+
50
68
/**
51
69
* Create a random workspace and seed it with settings.
52
70
*/
53
71
private async createWorkspace ( ) : Promise < string > {
54
- const dir = await tmpdir ( workspaceDir )
72
+ const dir = await this . workspaceDir
55
73
await fs . mkdir ( path . join ( dir , "User" ) )
56
74
await fs . writeFile (
57
75
path . join ( dir , "User/settings.json" ) ,
@@ -184,11 +202,18 @@ export class CodeServerPage {
184
202
}
185
203
186
204
/**
187
- * Navigate to code-server.
205
+ * The workspace directory code-server opens with.
206
+ */
207
+ get workspaceDir ( ) {
208
+ return this . codeServer . workspaceDir
209
+ }
210
+
211
+ /**
212
+ * Navigate to a code-server endpoint. By default go to the root.
188
213
*/
189
- async navigate ( ) {
190
- const address = await this . codeServer . address ( )
191
- await this . page . goto ( address , { waitUntil : "networkidle" } )
214
+ async navigate ( endpoint = "/" ) {
215
+ const to = new URL ( endpoint , await this . codeServer . address ( ) )
216
+ await this . page . goto ( to . toString ( ) , { waitUntil : "networkidle" } )
192
217
}
193
218
194
219
/**
@@ -273,6 +298,29 @@ export class CodeServerPage {
273
298
await this . page . waitForSelector ( "textarea.xterm-helper-textarea" )
274
299
}
275
300
301
+ /**
302
+ * Open a file by using menus.
303
+ */
304
+ async openFile ( file : string ) {
305
+ await this . navigateMenus ( [ "File" , "Open File" ] )
306
+ await this . navigateQuickInput ( [ path . basename ( file ) ] )
307
+ await this . waitForTab ( file )
308
+ }
309
+
310
+ /**
311
+ * Wait for a tab to open for the specified file.
312
+ */
313
+ async waitForTab ( file : string ) : Promise < void > {
314
+ return this . page . waitForSelector ( `.tab :text("${ path . basename ( file ) } ")` )
315
+ }
316
+
317
+ /**
318
+ * See if the specified tab is open.
319
+ */
320
+ async tabIsVisible ( file : string ) : Promise < void > {
321
+ return this . page . isVisible ( `.tab :text("${ path . basename ( file ) } ")` )
322
+ }
323
+
276
324
/**
277
325
* Navigate to the command palette via menus then execute a command by typing
278
326
* it then clicking the match from the results.
@@ -287,66 +335,127 @@ export class CodeServerPage {
287
335
}
288
336
289
337
/**
290
- * Navigate through the specified set of menus. If it fails it will keep
291
- * trying .
338
+ * Navigate through the items in the selector. `open` is a function that will
339
+ * open the menu/popup containing the items through which to navigation .
292
340
*/
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 ) {
341
+ async navigateItems ( items : string [ ] , selector : string , open ?: ( selector : string ) => void ) : Promise < void > {
342
+ const logger = this . codeServer . logger . named ( selector )
343
+
344
+ /**
345
+ * If the selector loses focus or gets removed this will resolve with false,
346
+ * signaling we need to try again.
347
+ */
348
+ const openThenWaitClose = async ( ctx : Context ) => {
349
+ if ( open ) {
350
+ await open ( selector )
351
+ }
352
+ this . codeServer . logger . debug ( `watching ${ selector } ` )
353
+ try {
354
+ await this . page . waitForSelector ( `${ selector } :not(:focus-within)` )
355
+ } catch ( error ) {
356
+ if ( ! ctx . done ( ) ) {
357
+ this . codeServer . logger . debug ( `${ selector } navigation: ${ error . message || error } ` )
358
+ }
359
+ }
360
+ return false
361
+ }
362
+
363
+ /**
364
+ * This will step through each item, aborting and returning false if
365
+ * canceled or if any navigation step has an error which signals we need to
366
+ * try again.
367
+ */
368
+ const navigate = async ( ctx : Context ) => {
369
+ const steps : Array < { fn : ( ) => Promise < unknown > ; name : string } > = [
370
+ {
371
+ fn : ( ) => this . page . waitForSelector ( `${ selector } :focus-within` ) ,
372
+ name : "focus" ,
373
+ } ,
374
+ ]
375
+
376
+ for ( const item of items ) {
297
377
// Normally these will wait for the item to be visible and then execute
298
378
// the action. The problem is that if the menu closes these will still
299
379
// be waiting and continue to execute once the menu is visible again,
300
380
// potentially conflicting with the new set of navigations (for example
301
381
// if the old promise clicks logout before the new one can). By
302
382
// splitting them into two steps each we can cancel before running the
303
383
// 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 } ) )
384
+ steps . push ( {
385
+ fn : ( ) => this . page . hover ( `${ selector } :text("${ item } ")` , { trial : true } ) ,
386
+ name : `${ item } :hover:trial` ,
387
+ } )
388
+ steps . push ( {
389
+ fn : ( ) => this . page . hover ( `${ selector } :text("${ item } ")` , { force : true } ) ,
390
+ name : `${ item } :hover:force` ,
391
+ } )
392
+ steps . push ( {
393
+ fn : ( ) => this . page . click ( `${ selector } :text("${ item } ")` , { trial : true } ) ,
394
+ name : `${ item } :click:trial` ,
395
+ } )
396
+ steps . push ( {
397
+ fn : ( ) => this . page . click ( `${ selector } :text("${ item } ")` , { force : true } ) ,
398
+ name : `${ item } :click:force` ,
399
+ } )
308
400
}
401
+
309
402
for ( const step of steps ) {
310
- await step ( )
311
- if ( cancelToken . canceled ( ) ) {
312
- this . codeServer . logger . debug ( "menu navigation canceled" )
403
+ try {
404
+ logger . debug ( `navigation step: ${ step . name } ` )
405
+ await step . fn ( )
406
+ if ( ctx . canceled ( ) ) {
407
+ logger . debug ( "navigation canceled" )
408
+ return false
409
+ }
410
+ } catch ( error ) {
411
+ logger . debug ( `navigation: ${ error . message || error } ` )
313
412
return false
314
413
}
315
414
}
316
415
return true
317
416
}
318
417
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
418
+ // We are seeing the menu closing after opening if we open it too soon and
419
+ // the picker getting recreated in the middle of trying to select an item.
420
+ // To counter this we will keep trying to navigate through the items every
421
+ // time we lose focus or there is an error.
422
+ let attempts = 1
423
+ let context = new Context ( )
424
+ while ( ! ( await Promise . race ( [ openThenWaitClose ( ) , navigate ( context ) ] ) ) ) {
425
+ ++ attempts
426
+ logger . debug ( "closed, retrying (${attempt}/∞)" )
427
+ context . cancel ( )
428
+ context = new Context ( )
324
429
}
325
430
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
- }
431
+ context . finish ( )
432
+ logger . debug ( `navigation took ${ attempts } ${ plural ( attempts , "attempt" ) } ` )
433
+ }
434
+
435
+ /**
436
+ * Navigate through a currently opened "quick input" widget, retrying on
437
+ * failure.
438
+ */
439
+ async navigateQuickInput ( items : string [ ] ) : Promise < void > {
440
+ await this . navigateItems ( items , ".quick-input-widget" )
441
+ }
339
442
340
- this . codeServer . logger . debug ( `menu navigation retries: ${ retryCount } ` )
443
+ /**
444
+ * Navigate through the menu, retrying on failure.
445
+ */
446
+ async navigateMenus ( menus : string [ ] ) : Promise < void > {
447
+ await this . navigateItems ( menus , '[aria-label="Application Menu"]' , async ( selector ) => {
448
+ await this . page . click ( selector )
449
+ } )
341
450
}
342
451
343
452
/**
344
453
* Navigates to code-server then reloads until the editor is ready.
345
454
*
346
455
* It is recommended to run setup before using this model in any tests.
347
456
*/
348
- async setup ( authenticated : boolean ) {
349
- await this . navigate ( )
457
+ async setup ( authenticated : boolean , endpoint = "/" ) {
458
+ await this . navigate ( endpoint )
350
459
// If we aren't authenticated we'll see a login page so we can't wait until
351
460
// the editor is ready.
352
461
if ( authenticated ) {
0 commit comments