@@ -3,7 +3,7 @@ import * as cp from "child_process"
33import { promises as fs } from "fs"
44import * as path from "path"
55import { Page } from "playwright"
6- import { logError } from "../../../src/common/util"
6+ import { logError , plural } from "../../../src/common/util"
77import { onLine } from "../../../src/node/util"
88import { PASSWORD , workspaceDir } from "../../utils/constants"
99import { idleTimer , tmpdir } from "../../utils/helpers"
@@ -13,14 +13,21 @@ interface CodeServerProcess {
1313 address : string
1414}
1515
16- class CancelToken {
16+ class Context {
1717 private _canceled = false
18+ private _done = false
1819 public canceled ( ) : boolean {
1920 return this . _canceled
2021 }
22+ public done ( ) : void {
23+ this . _done = true
24+ }
2125 public cancel ( ) : void {
2226 this . _canceled = true
2327 }
28+ public finish ( ) : boolean {
29+ return this . _done
30+ }
2431}
2532
2633/**
@@ -30,6 +37,7 @@ export class CodeServer {
3037 private process : Promise < CodeServerProcess > | undefined
3138 public readonly logger : Logger
3239 private closed = false
40+ private _workspaceDir : Promise < string > | undefined
3341
3442 constructor ( name : string , private readonly codeServerArgs : string [ ] ) {
3543 this . logger = logger . named ( name )
@@ -47,11 +55,21 @@ export class CodeServer {
4755 return address
4856 }
4957
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+
5068 /**
5169 * Create a random workspace and seed it with settings.
5270 */
5371 private async createWorkspace ( ) : Promise < string > {
54- const dir = await tmpdir ( workspaceDir )
72+ const dir = await this . workspaceDir
5573 await fs . mkdir ( path . join ( dir , "User" ) )
5674 await fs . writeFile (
5775 path . join ( dir , "User/settings.json" ) ,
@@ -184,11 +202,18 @@ export class CodeServerPage {
184202 }
185203
186204 /**
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.
188213 */
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" } )
192217 }
193218
194219 /**
@@ -273,6 +298,29 @@ export class CodeServerPage {
273298 await this . page . waitForSelector ( "textarea.xterm-helper-textarea" )
274299 }
275300
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+
276324 /**
277325 * Navigate to the command palette via menus then execute a command by typing
278326 * it then clicking the match from the results.
@@ -287,66 +335,127 @@ export class CodeServerPage {
287335 }
288336
289337 /**
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 .
292340 */
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 ) {
297377 // Normally these will wait for the item to be visible and then execute
298378 // the action. The problem is that if the menu closes these will still
299379 // be waiting and continue to execute once the menu is visible again,
300380 // potentially conflicting with the new set of navigations (for example
301381 // if the old promise clicks logout before the new one can). By
302382 // splitting them into two steps each we can cancel before running the
303383 // 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+ } )
308400 }
401+
309402 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 } ` )
313412 return false
314413 }
315414 }
316415 return true
317416 }
318417
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 ( )
324429 }
325430
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+ }
339442
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+ } )
341450 }
342451
343452 /**
344453 * Navigates to code-server then reloads until the editor is ready.
345454 *
346455 * It is recommended to run setup before using this model in any tests.
347456 */
348- async setup ( authenticated : boolean ) {
349- await this . navigate ( )
457+ async setup ( authenticated : boolean , endpoint = "/" ) {
458+ await this . navigate ( endpoint )
350459 // If we aren't authenticated we'll see a login page so we can't wait until
351460 // the editor is ready.
352461 if ( authenticated ) {
0 commit comments