import * as playwright from '@playwright/test' ; import { ChildProcess , spawn } from 'child_process' ; import { join } from 'path' ; import { mkdir } from 'fs' ; import { promisify } from 'util' ; import { IDriver , IDisposable , IWindowDriver } from './driver' ; import { URI } from 'vscode-uri' ; import * as kill from 'tree-kill' ; import { PageFunction } from 'playwright-core/types/structs' ; import { Logger , measureAndLog } from './logger' ; import type { LaunchOptions } from './code' ; const width = 1200 ; const height = 800 ; const root = join ( __dirname , '..' , '..' , '..' ) ; const logsPath = join ( root , '.build' , 'logs' , 'smoke-tests-browser' ) ; const vscodeToPlaywrightKey = { cmd : 'Meta' , ctrl : 'Control' , shift : 'Shift' , enter : 'Enter' , escape : 'Escape' , right : 'ArrowRight' , up : 'ArrowUp' , down : 'ArrowDown' , left : 'ArrowLeft' , home : 'Home' , esc : 'Escape' } ; let traceCounter = 1 ; class PlaywrightDriver implements IDriver { _serviceBrand : undefined ; constructor ( private readonly server , private readonly browser , private readonly context , private readonly page , private readonly logger ) { } async getWindowIds ( ) { return [ 1 ] ; } async capturePage ( ) { return '' ; } async startTracing ( windowId , name ) { try { await measureAndLog ( this . context . tracing . startChunk ( { title : name } ) , `startTracing for ${ name }` , this . logger ) ; } catch ( error ) { } } async stopTracing ( windowId , name , persist ) { try { let persistPath = undefined ; if ( persist ) { persistPath = join ( logsPath , `playwright-trace-${ traceCounter ++ }-${ name . replace ( /\s+/g , '-' ) }.zip` ) ; } await measureAndLog ( this . context . tracing . stopChunk ( { path : persistPath } ) , `stopTracing for ${ name }` , this . logger ) ; } catch ( error ) { } } async reloadWindow ( windowId ) { throw new Error ( 'Unsupported' ) ; } async exitApplication ( ) { try { await measureAndLog ( this . context . tracing . stop ( ) , 'stop tracing' , this . logger ) ; } catch ( error ) { } try { await measureAndLog ( this . browser . close ( ) , 'Browser.close()' , this . logger ) ; } catch ( error ) { } await measureAndLog ( teardown ( this . server , this . logger ) , 'teardown server' , this . logger ) ; return false ; } async dispatchKeybinding ( windowId , keybinding ) { const chords = keybinding . split ( ' ' ) ; for ( let i = 0 ; i < chords . length ; i ++ ) { const chord = chords [ i ] ; if ( i > 0 ) { await this . timeout ( 100 ) ; } if ( keybinding . startsWith ( 'Alt' ) || keybinding . startsWith ( 'Control' ) || keybinding . startsWith ( 'Backspace' ) ) { await this . page . keyboard . press ( keybinding ) ; return ; } const keys = chord . split ( '+' ) ; const keysDown = [ ] ; for ( let i = 0 ; i < keys . length ; i ++ ) { if ( keys [ i ] in vscodeToPlaywrightKey ) { keys [ i ] = vscodeToPlaywrightKey [ keys [ i ] ] ; } await this . page . keyboard . down ( keys [ i ] ) ; keysDown . push ( keys [ i ] ) ; } while ( keysDown . length > 0 ) { await this . page . keyboard . up ( keysDown . pop ( ) ! ) ; } } await this . timeout ( 100 ) ; } async click ( windowId , selector , xoffset ? , yoffset ? ) { const { x , y } = await this . getElementXY ( windowId , selector , xoffset , yoffset ) ; await this . page . mouse . click ( x + ( xoffset ? xoffset : 0 ) , y + ( yoffset ? yoffset : 0 ) ) ; } async doubleClick ( windowId , selector ) { throw new Error ( 'Unsupported' ) ; } async setValue ( windowId , selector , text ) { return this . page . evaluate ( ( [ driver , selector , text ] ) => driver . setValue ( selector , text ) , [ await this . _getDriverHandle ( ) , selector , text ] as const ) ; } async getTitle ( windowId ) { return this . _evaluateWithDriver ( ( [ driver ] ) => driver . getTitle ( ) ) ; } async isActiveElement ( windowId , selector ) { return this . page . evaluate ( ( [ driver , selector ] ) => driver . isActiveElement ( selector ) , [ await this . _getDriverHandle ( ) , selector ] as const ) ; } async getElements ( windowId , selector , recursive = false ) { return this . page . evaluate ( ( [ driver , selector , recursive ] ) => driver . getElements ( selector , recursive ) , [ await this . _getDriverHandle ( ) , selector , recursive ] as const ) ; } async getElementXY ( windowId , selector , xoffset ? , yoffset ? ) { return this . page . evaluate ( ( [ driver , selector , xoffset , yoffset ] ) => driver . getElementXY ( selector , xoffset , yoffset ) , [ await this . _getDriverHandle ( ) , selector , xoffset , yoffset ] as const ) ; } async typeInEditor ( windowId , selector , text ) { return this . page . evaluate ( ( [ driver , selector , text ] ) => driver . typeInEditor ( selector , text ) , [ await this . _getDriverHandle ( ) , selector , text ] as const ) ; } async getTerminalBuffer ( windowId , selector ) { return this . page . evaluate ( ( [ driver , selector ] ) => driver . getTerminalBuffer ( selector ) , [ await this . _getDriverHandle ( ) , selector ] as const ) ; } async writeInTerminal ( windowId , selector , text ) { return this . page . evaluate ( ( [ driver , selector , text ] ) => driver . writeInTerminal ( selector , text ) , [ await this . _getDriverHandle ( ) , selector , text ] as const ) ; } async getLocaleInfo ( windowId ) { return this . _evaluateWithDriver ( ( [ driver ] ) => driver . getLocaleInfo ( ) ) ; } async getLocalizedStrings ( windowId ) { return this . _evaluateWithDriver ( ( [ driver ] ) => driver . getLocalizedStrings ( ) ) ; } private async _evaluateWithDriver < T > ( pageFunction ) { return this . page . evaluate ( pageFunction , [ await this . _getDriverHandle ( ) ] ) ; } private timeout ( ms ) { return new Promise < void > ( resolve => setTimeout ( resolve , ms ) ) ; } private async _getDriverHandle ( ) { return this . page . evaluateHandle ( 'window.driver' ) ; } } let port = 9000 ; export async function launch ( options ) { const { serverProcess , endpoint } = await launchServer ( options ) ; const { browser , context , page } = await launchBrowser ( options , endpoint ) ; return { serverProcess , client : { dispose : ( ) => { } } , driver : new PlaywrightDriver ( serverProcess , browser , context , page , options . logger ) , kill : ( ) => teardown ( serverProcess , options . logger ) } ; } async function launchServer ( options ) { const { userDataDir , codePath , extensionsPath , logger } = options ; const codeServerPath = codePath ?? process . env . VSCODE_REMOTE_SERVER_PATH ; const agentFolder = userDataDir ; await measureAndLog ( promisify ( mkdir ) ( agentFolder ) , `mkdir(${ agentFolder })` , logger ) ; const env = { VSCODE_AGENT_FOLDER : agentFolder , VSCODE_REMOTE_SERVER_PATH : codeServerPath , ... process . env } ; const args = [ '--disable-telemetry' , '--port' , `${ port ++ }` , '--browser' , 'none' , '--driver' , 'web' , '--extensions-dir' , extensionsPath ] ; let serverLocation ; if ( codeServerPath ) { serverLocation = join ( codeServerPath , `server.${ process . platform === 'win32' ? 'cmd' : 'sh' }` ) ; args . push ( `--logsPath=${ logsPath }` ) ; logger . log ( `Starting built server from '${ serverLocation }'` ) ; logger . log ( `Storing log files into '${ logsPath }'` ) ; } else { serverLocation = join ( root , `resources/server/web.${ process . platform === 'win32' ? 'bat' : 'sh' }` ) ; args . push ( '--logsPath' , logsPath ) ; logger . log ( `Starting server out of sources from '${ serverLocation }'` ) ; logger . log ( `Storing log files into '${ logsPath }'` ) ; } const serverProcess = spawn ( serverLocation , args , { env } ) ; logger . log ( `Started server for browser smoke tests (pid: ${ serverProcess . pid })` ) ; return { serverProcess , endpoint : await measureAndLog ( waitForEndpoint ( serverProcess ) , 'waitForEndpoint(serverProcess)' , logger ) } ; } async function launchBrowser ( options , endpoint ) { const { logger , workspacePath } = options ; const browser = await measureAndLog ( playwright [ options . browser ?? 'chromium' ] . launch ( { headless : options . headless ?? false } ) , 'playwright#launch' , logger ) ; browser . on ( 'disconnected' , ( ) => logger . log ( `Playwright: browser disconnected` ) ) ; const context = await measureAndLog ( browser . newContext ( ) , 'browser.newContext' , logger ) ; try { await measureAndLog ( context . tracing . start ( { screenshots : true , snapshots : true , sources : true } ) , 'context.tracing.start()' , logger ) ; } catch ( error ) { logger . log ( `Failed to start playwright tracing: ${ error }` ) ; } const page = await measureAndLog ( context . newPage ( ) , 'context.newPage()' , logger ) ; await measureAndLog ( page . setViewportSize ( { width , height } ) , 'page.setViewportSize' , logger ) ; page . on ( 'pageerror' , async ( error ) => logger . log ( `Playwright ERROR: page error: ${ error }` ) ) ; page . on ( 'crash' , ( ) => logger . log ( 'Playwright ERROR: page crash' ) ) ; page . on ( 'close' , ( ) => logger . log ( 'Playwright: page close' ) ) ; page . on ( 'response' , async ( response ) => { if ( response . status ( ) >= 400 ) { logger . log ( `Playwright ERROR: HTTP status ${ response . status ( ) } for ${ response . url ( ) }` ) ; } } ) ; const payloadParam = `[["enableProposedApi",""],["webviewExternalEndpointCommit","d372f9187401bd145a0a6e15ba369e2d82d02005"],["skipWelcome","true"]]` ; await measureAndLog ( page . goto ( `${ endpoint }&folder=vscode-remote://localhost:9888${ URI . file ( workspacePath ! ) . path }&payload=${ payloadParam }` ) , 'page.goto()' , logger ) ; return { browser , context , page } ; } async function teardown ( server , logger ) { const serverPid = server . pid ; if ( typeof serverPid !== 'number' ) { return ; } let retries = 0 ; while ( retries < 3 ) { retries ++ ; try { return await promisify ( kill ) ( serverPid ) ; } catch ( error ) { try { process . kill ( serverPid , 0 ) ; logger . log ( `Error tearing down server (pid: ${ serverPid }, attempt: ${ retries }): ${ error }` ) ; } catch ( error ) { return ; } } } logger . log ( `Gave up tearing down server after ${ retries } attempts...` ) ; } function waitForEndpoint ( server ) { return new Promise < string > ( resolve => { server . stdout ?. on ( 'data' , ( d ) => { const matches = d . toString ( 'ascii' ) . match ( /Web UI available at (.+)/ ) ; if ( matches !== null ) { resolve ( matches [ 1 ] ) ; } } ) ; } ) ; }