2020-01-07 16:15:07 -08:00
/ * *
* Copyright 2017 Google Inc . All rights reserved .
* Modifications copyright ( c ) Microsoft Corporation .
*
* Licensed under the Apache License , Version 2.0 ( the "License" ) ;
* you may not use this file except in compliance with the License .
* You may obtain a copy of the License at
*
* http : //www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing , software
* distributed under the License is distributed on an "AS IS" BASIS ,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND , either express or implied .
* See the License for the specific language governing permissions and
* limitations under the License .
* /
2020-02-11 11:33:48 -08:00
import { BrowserFetcher , OnProgressCallback , BrowserFetcherOptions } from './browserFetcher' ;
2020-01-07 16:15:07 -08:00
import { DeviceDescriptors } from '../deviceDescriptors' ;
2020-01-09 14:49:22 -08:00
import { TimeoutError } from '../errors' ;
2020-01-07 16:15:07 -08:00
import * as types from '../types' ;
2020-01-22 17:42:10 -08:00
import { WKBrowser } from '../webkit/wkBrowser' ;
2020-01-23 14:40:37 -08:00
import { execSync } from 'child_process' ;
2020-01-07 16:15:07 -08:00
import { PipeTransport } from './pipeTransport' ;
2020-02-13 14:13:10 -08:00
import { launchProcess , waitForLine } from './processLauncher' ;
2020-01-16 22:11:14 -08:00
import * as fs from 'fs' ;
2020-01-07 16:15:07 -08:00
import * as path from 'path' ;
2020-01-16 22:11:14 -08:00
import * as platform from '../platform' ;
2020-01-07 16:15:07 -08:00
import * as util from 'util' ;
import * as os from 'os' ;
2020-02-13 13:54:01 -08:00
import { assert , helper } from '../helper' ;
2020-01-08 13:55:38 -08:00
import { kBrowserCloseMessageId } from '../webkit/wkConnection' ;
2020-01-24 14:49:47 -08:00
import { LaunchOptions , BrowserArgOptions , BrowserType } from './browserType' ;
2020-02-13 17:46:40 -08:00
import { ConnectionTransport , DeferWriteTransport } from '../transport' ;
2020-01-22 17:42:10 -08:00
import * as ws from 'ws' ;
import * as uuidv4 from 'uuid/v4' ;
2020-02-05 12:41:55 -08:00
import { ConnectOptions , LaunchType } from '../browser' ;
import { BrowserServer } from './browserServer' ;
2020-01-24 15:58:04 -08:00
import { Events } from '../events' ;
2020-02-05 12:41:55 -08:00
import { BrowserContext } from '../browserContext' ;
2020-01-07 16:15:07 -08:00
2020-01-24 14:49:47 -08:00
export class WebKit implements BrowserType {
2020-01-07 16:15:07 -08:00
private _projectRoot : string ;
readonly _revision : string ;
constructor ( projectRoot : string , preferredRevision : string ) {
this . _projectRoot = projectRoot ;
this . _revision = preferredRevision ;
}
2020-01-28 18:09:07 -08:00
name() {
return 'webkit' ;
}
2020-02-11 11:33:48 -08:00
async downloadBrowserIfNeeded ( onProgress? : OnProgressCallback ) {
const fetcher = this . _createBrowserFetcher ( ) ;
const revisionInfo = fetcher . revisionInfo ( ) ;
// Do nothing if the revision is already downloaded.
if ( revisionInfo . local )
return ;
await fetcher . download ( revisionInfo . revision , onProgress ) ;
}
2020-02-04 19:41:38 -08:00
async launch ( options? : LaunchOptions & { slowMo? : number } ) : Promise < WKBrowser > {
2020-02-12 19:32:23 -08:00
if ( options && ( options as any ) . userDataDir )
throw new Error ( 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistent` instead' ) ;
2020-02-05 16:36:36 -08:00
const { browserServer , transport } = await this . _launchServer ( options , 'local' ) ;
2020-02-04 19:41:38 -08:00
const browser = await WKBrowser . connect ( transport ! , options && options . slowMo ) ;
2020-01-23 08:51:43 -08:00
// Hack: for typical launch scenario, ensure that close waits for actual process termination.
2020-02-05 12:41:55 -08:00
browser . close = ( ) = > browserServer . close ( ) ;
( browser as any ) [ '__server__' ] = browserServer ;
2020-01-23 08:51:43 -08:00
return browser ;
2020-01-07 16:15:07 -08:00
}
2020-02-05 12:41:55 -08:00
async launchServer ( options? : LaunchOptions & { port? : number } ) : Promise < BrowserServer > {
return ( await this . _launchServer ( options , 'server' , undefined , options && options . port ) ) . browserServer ;
2020-02-04 19:41:38 -08:00
}
2020-02-05 16:36:36 -08:00
async launchPersistent ( userDataDir : string , options? : LaunchOptions ) : Promise < BrowserContext > {
2020-02-13 13:54:01 -08:00
const { timeout = 30000 } = options || { } ;
2020-02-05 16:36:36 -08:00
const { browserServer , transport } = await this . _launchServer ( options , 'persistent' , userDataDir ) ;
2020-02-05 12:41:55 -08:00
const browser = await WKBrowser . connect ( transport ! ) ;
2020-02-13 13:54:01 -08:00
await helper . waitWithTimeout ( browser . _waitForFirstPageTarget ( ) , 'first page' , timeout ) ;
2020-02-05 12:41:55 -08:00
// Hack: for typical launch scenario, ensure that close waits for actual process termination.
const browserContext = browser . _defaultContext ;
browserContext . close = ( ) = > browserServer . close ( ) ;
return browserContext ;
}
private async _launchServer ( options : LaunchOptions = { } , launchType : LaunchType , userDataDir? : string , port? : number ) : Promise < { browserServer : BrowserServer , transport? : ConnectionTransport } > {
2020-01-07 16:15:07 -08:00
const {
ignoreDefaultArgs = false ,
args = [ ] ,
dumpio = false ,
executablePath = null ,
env = process . env ,
handleSIGINT = true ,
handleSIGTERM = true ,
handleSIGHUP = true ,
2020-02-13 14:13:10 -08:00
timeout = 30000
2020-01-07 16:15:07 -08:00
} = options ;
2020-02-05 12:41:55 -08:00
let temporaryUserDataDir : string | null = null ;
if ( ! userDataDir ) {
2020-01-16 22:11:14 -08:00
userDataDir = await mkdtempAsync ( WEBKIT_PROFILE_PATH ) ;
2020-02-05 12:41:55 -08:00
temporaryUserDataDir = userDataDir ! ;
2020-01-16 22:11:14 -08:00
}
2020-02-05 16:36:36 -08:00
const webkitArguments = [ ] ;
if ( ! ignoreDefaultArgs )
webkitArguments . push ( . . . this . _defaultArgs ( options , userDataDir ! , port || 0 ) ) ;
else if ( Array . isArray ( ignoreDefaultArgs ) )
webkitArguments . push ( . . . this . _defaultArgs ( options , userDataDir ! , port || 0 ) . filter ( arg = > ignoreDefaultArgs . indexOf ( arg ) === - 1 ) ) ;
else
webkitArguments . push ( . . . args ) ;
2020-01-16 22:11:14 -08:00
2020-01-07 16:15:07 -08:00
let webkitExecutable = executablePath ;
if ( ! executablePath ) {
const { missingText , executablePath } = this . _resolveExecutablePath ( ) ;
if ( missingText )
throw new Error ( missingText ) ;
webkitExecutable = executablePath ;
}
2020-01-08 13:55:38 -08:00
2020-02-13 17:46:40 -08:00
let transport : ConnectionTransport | undefined = undefined ;
2020-02-05 12:41:55 -08:00
let browserServer : BrowserServer | undefined = undefined ;
2020-01-08 13:55:38 -08:00
const { launchedProcess , gracefullyClose } = await launchProcess ( {
2020-01-13 13:33:25 -08:00
executablePath : webkitExecutable ! ,
2020-01-07 16:15:07 -08:00
args : webkitArguments ,
2020-02-05 12:41:55 -08:00
env : { . . . env , CURL_COOKIE_JAR_PATH : path.join ( userDataDir ! , 'cookiejar.db' ) } ,
2020-01-07 16:15:07 -08:00
handleSIGINT ,
handleSIGTERM ,
handleSIGHUP ,
dumpio ,
pipe : true ,
2020-01-16 22:11:14 -08:00
tempDir : temporaryUserDataDir || undefined ,
2020-01-08 13:55:38 -08:00
attemptToGracefullyClose : async ( ) = > {
2020-01-22 17:42:10 -08:00
if ( ! transport )
2020-01-08 13:55:38 -08:00
return Promise . reject ( ) ;
// We try to gracefully close to prevent crash reporting and core dumps.
2020-01-23 17:45:31 -08:00
// Note that it's fine to reuse the pipe transport, since
// our connection ignores kBrowserCloseMessageId.
2020-01-08 13:55:38 -08:00
const message = JSON . stringify ( { method : 'Browser.close' , params : { } , id : kBrowserCloseMessageId } ) ;
transport . send ( message ) ;
} ,
2020-01-28 13:07:53 -08:00
onkill : ( exitCode , signal ) = > {
2020-02-05 12:41:55 -08:00
if ( browserServer )
browserServer . emit ( Events . BrowserServer . Close , exitCode , signal ) ;
2020-01-24 15:58:04 -08:00
} ,
2020-01-07 16:15:07 -08:00
} ) ;
2020-02-13 14:13:10 -08:00
const timeoutError = new TimeoutError ( ` Timed out after ${ timeout } ms while trying to connect to WebKit! The only WebKit revision guaranteed to work is r ${ this . _revision } ` ) ;
await waitForLine ( launchedProcess , launchedProcess . stdout , /^Web Inspector is reading from pipe #3$/ , timeout , timeoutError ) ;
2020-02-13 17:46:40 -08:00
transport = new DeferWriteTransport ( new PipeTransport ( launchedProcess . stdio [ 3 ] as NodeJS . WritableStream , launchedProcess . stdio [ 4 ] as NodeJS . ReadableStream ) ) ;
2020-02-07 17:39:32 -08:00
browserServer = new BrowserServer ( launchedProcess , gracefullyClose , launchType === 'server' ? await wrapTransportWithWebSocket ( transport , port || 0 ) : null ) ;
2020-02-05 12:41:55 -08:00
return { browserServer , transport } ;
2020-01-07 16:15:07 -08:00
}
2020-02-04 19:41:38 -08:00
async connect ( options : ConnectOptions ) : Promise < WKBrowser > {
2020-02-06 12:41:43 -08:00
const transport = new platform . WebSocketTransport ( options . wsEndpoint ) ;
2020-02-04 19:41:38 -08:00
return WKBrowser . connect ( transport , options . slowMo ) ;
2020-01-22 17:42:10 -08:00
}
2020-01-07 16:15:07 -08:00
executablePath ( ) : string {
return this . _resolveExecutablePath ( ) . executablePath ;
}
get devices ( ) : types . Devices {
return DeviceDescriptors ;
}
2020-01-09 14:49:22 -08:00
get errors ( ) : { TimeoutError : typeof TimeoutError } {
return { TimeoutError } ;
2020-01-07 16:15:07 -08:00
}
2020-02-05 16:36:36 -08:00
_defaultArgs ( options : BrowserArgOptions = { } , userDataDir : string , port : number ) : string [ ] {
2020-01-07 16:15:07 -08:00
const {
2020-01-24 14:49:47 -08:00
devtools = false ,
headless = ! devtools ,
2020-01-07 16:15:07 -08:00
args = [ ] ,
} = options ;
2020-01-24 14:49:47 -08:00
if ( devtools )
throw new Error ( 'Option "devtools" is not supported by WebKit' ) ;
2020-02-05 16:36:36 -08:00
const userDataDirArg = args . find ( arg = > arg . startsWith ( '--user-data-dir=' ) ) ;
if ( userDataDirArg )
throw new Error ( 'Pass userDataDir parameter instead of specifying --user-data-dir argument' ) ;
2020-01-23 12:18:41 -08:00
const webkitArguments = [ '--inspector-pipe' ] ;
if ( headless )
webkitArguments . push ( '--headless' ) ;
2020-02-05 16:36:36 -08:00
webkitArguments . push ( ` --user-data-dir= ${ userDataDir } ` ) ;
2020-01-07 16:15:07 -08:00
webkitArguments . push ( . . . args ) ;
return webkitArguments ;
}
2020-01-14 10:07:26 -08:00
_createBrowserFetcher ( options? : BrowserFetcherOptions ) : BrowserFetcher {
2020-01-07 16:15:07 -08:00
const downloadURLs = {
2020-01-17 16:30:19 -08:00
linux : '%s/builds/webkit/%s/minibrowser-gtk-wpe.zip' ,
2020-01-07 16:15:07 -08:00
mac : '%s/builds/webkit/%s/minibrowser-mac-%s.zip' ,
2020-01-16 19:43:39 -08:00
win64 : '%s/builds/webkit/%s/minibrowser-win64.zip' ,
2020-01-07 16:15:07 -08:00
} ;
const defaultOptions = {
path : path.join ( this . _projectRoot , '.local-webkit' ) ,
2020-01-23 16:00:55 -08:00
host : 'https://playwright.azureedge.net' ,
2020-01-07 16:15:07 -08:00
platform : ( ( ) = > {
const platform = os . platform ( ) ;
if ( platform === 'darwin' )
return 'mac' ;
if ( platform === 'linux' )
return 'linux' ;
if ( platform === 'win32' )
2020-01-16 19:43:39 -08:00
return 'win64' ;
2020-01-07 16:15:07 -08:00
return platform ;
} ) ( )
} ;
options = {
. . . defaultOptions ,
. . . options ,
} ;
2020-01-13 13:33:25 -08:00
assert ( ! ! ( downloadURLs as any ) [ options . platform ! ] , 'Unsupported platform: ' + options . platform ) ;
2020-01-07 16:15:07 -08:00
2020-01-13 13:33:25 -08:00
return new BrowserFetcher ( options . path ! , options . platform ! , this . _revision , ( platform : string , revision : string ) = > {
2020-01-07 16:15:07 -08:00
return {
downloadUrl : ( platform === 'mac' ) ?
2020-01-13 13:33:25 -08:00
util . format ( downloadURLs [ platform ] , options ! . host , revision , getMacVersion ( ) ) :
util . format ( ( downloadURLs as any ) [ platform ] , options ! . host , revision ) ,
2020-01-16 19:43:39 -08:00
executablePath : platform.startsWith ( 'win' ) ? 'MiniBrowser.exe' : 'pw_run.sh' ,
2020-01-07 16:15:07 -08:00
} ;
} ) ;
}
_resolveExecutablePath ( ) : { executablePath : string ; missingText : string | null ; } {
2020-01-14 10:07:26 -08:00
const browserFetcher = this . _createBrowserFetcher ( ) ;
2020-01-09 14:49:22 -08:00
const revisionInfo = browserFetcher . revisionInfo ( ) ;
2020-01-31 17:23:39 -08:00
const missingText = ! revisionInfo . local ? ` WebKit revision is not downloaded. Run "npm install" ` : null ;
2020-01-07 16:15:07 -08:00
return { executablePath : revisionInfo.executablePath , missingText } ;
}
}
2020-01-16 22:11:14 -08:00
const mkdtempAsync = platform . promisify ( fs . mkdtemp ) ;
const WEBKIT_PROFILE_PATH = path . join ( os . tmpdir ( ) , 'playwright_dev_profile-' ) ;
2020-01-07 16:15:07 -08:00
2020-01-13 13:33:25 -08:00
let cachedMacVersion : string | undefined = undefined ;
2020-01-27 09:37:33 -08:00
function getMacVersion ( ) : string {
2020-01-07 16:15:07 -08:00
if ( ! cachedMacVersion ) {
const [ major , minor ] = execSync ( 'sw_vers -productVersion' ) . toString ( 'utf8' ) . trim ( ) . split ( '.' ) ;
2020-01-27 09:37:33 -08:00
assert ( + major === 10 && + minor >= 14 , 'Error: unsupported macOS version, macOS 10.14 and newer are supported' ) ;
2020-01-07 16:15:07 -08:00
cachedMacVersion = major + '.' + minor ;
}
return cachedMacVersion ;
}
2020-02-06 12:41:43 -08:00
class SequenceNumberMixer < V > {
static _lastSequenceNumber = 1 ;
private _values = new Map < number , V > ( ) ;
generate ( value : V ) : number {
const sequenceNumber = ++ SequenceNumberMixer . _lastSequenceNumber ;
this . _values . set ( sequenceNumber , value ) ;
return sequenceNumber ;
}
take ( sequenceNumber : number ) : V | undefined {
const value = this . _values . get ( sequenceNumber ) ;
this . _values . delete ( sequenceNumber ) ;
return value ;
}
}
2020-02-07 17:39:32 -08:00
async function wrapTransportWithWebSocket ( transport : ConnectionTransport , port : number ) {
2020-02-05 12:41:55 -08:00
const server = new ws . Server ( { port } ) ;
2020-01-22 17:42:10 -08:00
const guid = uuidv4 ( ) ;
2020-02-06 12:41:43 -08:00
const idMixer = new SequenceNumberMixer < { id : number , socket : ws } > ( ) ;
const pendingBrowserContextCreations = new Set < number > ( ) ;
const pendingBrowserContextDeletions = new Map < number , string > ( ) ;
const browserContextIds = new Map < string , ws > ( ) ;
const pageProxyIds = new Map < string , ws > ( ) ;
const sockets = new Set < ws > ( ) ;
2020-01-22 17:42:10 -08:00
2020-02-06 12:41:43 -08:00
transport . onmessage = message = > {
const parsedMessage = JSON . parse ( message ) ;
if ( 'id' in parsedMessage ) {
if ( parsedMessage . id === - 9999 )
return ;
// Process command response.
const value = idMixer . take ( parsedMessage . id ) ;
if ( ! value )
return ;
const { id , socket } = value ;
if ( ! socket || socket . readyState === ws . CLOSING ) {
if ( pendingBrowserContextCreations . has ( id ) ) {
transport . send ( JSON . stringify ( {
id : ++ SequenceNumberMixer . _lastSequenceNumber ,
method : 'Browser.deleteContext' ,
params : { browserContextId : parsedMessage.result.browserContextId }
} ) ) ;
}
return ;
}
if ( pendingBrowserContextCreations . has ( parsedMessage . id ) ) {
// Browser.createContext response -> establish context attribution.
browserContextIds . set ( parsedMessage . result . browserContextId , socket ) ;
pendingBrowserContextCreations . delete ( parsedMessage . id ) ;
}
const deletedContextId = pendingBrowserContextDeletions . get ( parsedMessage . id ) ;
if ( deletedContextId ) {
// Browser.deleteContext response -> remove context attribution.
browserContextIds . delete ( deletedContextId ) ;
pendingBrowserContextDeletions . delete ( parsedMessage . id ) ;
}
parsedMessage . id = id ;
socket . send ( JSON . stringify ( parsedMessage ) ) ;
2020-01-22 17:42:10 -08:00
return ;
}
2020-02-06 12:41:43 -08:00
// Process notification response.
const { method , params , pageProxyId } = parsedMessage ;
if ( pageProxyId ) {
const socket = pageProxyIds . get ( pageProxyId ) ;
if ( ! socket || socket . readyState === ws . CLOSING ) {
// Drop unattributed messages on the floor.
return ;
}
socket . send ( message ) ;
2020-01-22 17:42:10 -08:00
return ;
}
2020-02-06 12:41:43 -08:00
if ( method === 'Browser.pageProxyCreated' ) {
const socket = browserContextIds . get ( params . pageProxyInfo . browserContextId ) ;
if ( ! socket || socket . readyState === ws . CLOSING ) {
// Drop unattributed messages on the floor.
return ;
}
pageProxyIds . set ( params . pageProxyInfo . pageProxyId , socket ) ;
socket . send ( message ) ;
return ;
}
if ( method === 'Browser.pageProxyDestroyed' ) {
const socket = pageProxyIds . get ( params . pageProxyId ) ;
pageProxyIds . delete ( params . pageProxyId ) ;
if ( socket && socket . readyState !== ws . CLOSING )
socket . send ( message ) ;
return ;
}
if ( method === 'Browser.provisionalLoadFailed' ) {
const socket = pageProxyIds . get ( params . pageProxyId ) ;
if ( socket && socket . readyState !== ws . CLOSING )
2020-02-07 13:38:50 -08:00
socket . send ( message ) ;
2020-02-06 12:41:43 -08:00
return ;
}
} ;
server . on ( 'connection' , ( socket : ws , req ) = > {
if ( req . url !== '/' + guid ) {
socket . close ( ) ;
return ;
}
sockets . add ( socket ) ;
// Following two messages are reporting the default browser context and the default page.
socket . send ( JSON . stringify ( {
method : 'Browser.pageProxyCreated' ,
params : { pageProxyInfo : { pageProxyId : '5' , browserContextId : '0000000000000002' } }
} ) ) ;
socket . send ( JSON . stringify ( {
method : 'Target.targetCreated' ,
params : {
targetInfo : { targetId : 'page-6' , type : 'page' , isPaused : false }
} ,
pageProxyId : '5'
} ) ) ;
socket . on ( 'message' , ( message : string ) = > {
const parsedMessage = JSON . parse ( Buffer . from ( message ) . toString ( ) ) ;
const { id , method , params } = parsedMessage ;
const seqNum = idMixer . generate ( { id , socket } ) ;
transport . send ( JSON . stringify ( { . . . parsedMessage , id : seqNum } ) ) ;
if ( method === 'Browser.createContext' )
pendingBrowserContextCreations . add ( seqNum ) ;
if ( method === 'Browser.deleteContext' )
pendingBrowserContextDeletions . set ( seqNum , params . browserContextId ) ;
} ) ;
socket . on ( 'close' , ( ) = > {
for ( const [ pageProxyId , s ] of pageProxyIds ) {
if ( s === socket )
pageProxyIds . delete ( pageProxyId ) ;
}
for ( const [ browserContextId , s ] of browserContextIds ) {
if ( s === socket ) {
transport . send ( JSON . stringify ( {
id : ++ SequenceNumberMixer . _lastSequenceNumber ,
method : 'Browser.deleteContext' ,
params : { browserContextId }
} ) ) ;
browserContextIds . delete ( browserContextId ) ;
}
}
sockets . delete ( socket ) ;
2020-01-22 17:42:10 -08:00
} ) ;
} ) ;
transport . onclose = ( ) = > {
2020-02-06 12:41:43 -08:00
for ( const socket of sockets )
2020-01-22 17:42:10 -08:00
socket . close ( undefined , 'Browser disconnected' ) ;
server . close ( ) ;
transport . onmessage = undefined ;
transport . onclose = undefined ;
} ;
const address = server . address ( ) ;
if ( typeof address === 'string' )
return address + '/' + guid ;
return 'ws://127.0.0.1:' + address . port + '/' + guid ;
}