diff --git a/docs/src/api/class-android.md b/docs/src/api/class-android.md index 4a122063a8..16c9e31f44 100644 --- a/docs/src/api/class-android.md +++ b/docs/src/api/class-android.md @@ -78,6 +78,39 @@ Note that since you don't need Playwright to install web browsers when testing A PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i -D playwright ``` +## async method: Android.connect +* since: v1.28 +- returns: <[AndroidDevice]> + +This methods attaches Playwright to an existing Android device. +Use [`method: Android.launchServer`] to launch a new Android server instance. + +### param: Android.connect.wsEndpoint +* since: v1.28 +- `wsEndpoint` <[string]> + +A browser websocket endpoint to connect to. + +### option: Android.connect.headers +* since: v1.28 +- `headers` <[Object]<[string], [string]>> + +Additional HTTP headers to be sent with web socket connect request. Optional. + +### option: Android.connect.slowMo +* since: v1.28 +- `slowMo` <[float]> + +Slows down Playwright operations by the specified amount of milliseconds. Useful so that you +can see what is going on. Defaults to `0`. + +### option: Android.connect.timeout +* since: v1.28 +- `timeout` <[float]> + +Maximum time in milliseconds to wait for the connection to be established. Defaults to +`30000` (30 seconds). Pass `0` to disable timeout. + ## async method: Android.devices * since: v1.9 - returns: <[Array]<[AndroidDevice]>> @@ -102,6 +135,94 @@ Optional port to establish ADB server connection. Default to `5037`. Prevents automatic playwright driver installation on attach. Assumes that the drivers have been installed already. +## async method: Android.launchServer +* since: v1.28 +* langs: js +- returns: <[BrowserServer]> + +Launches Playwright Android server that clients can connect to. See the following example: + +Server Side: + +```js +const { _android } = require('playwright'); + +(async () => { + const browserServer = await _android.launchServer({ + // If you have multiple devices connected and want to use a specific one. + // deviceSerialNumber: '', + }); + const wsEndpoint = browserServer.wsEndpoint(); + console.log(wsEndpoint); +})(); +``` + +Client Side: + +```js +const { _android } = require('playwright'); + +(async () => { + const device = await _android.connect(''); + + console.log(device.model()); + console.log(device.serial()); + await device.shell('am force-stop com.android.chrome'); + const context = await device.launchBrowser(); + + const page = await context.newPage(); + await page.goto('https://webkit.org/'); + console.log(await page.evaluate(() => window.location.href)); + await page.screenshot({ path: 'page-chrome-1.png' }); + + await context.close(); +})(); +``` + +### option: Android.launchServer.adbHost +* since: v1.28 +- `adbHost` <[string]> + +Optional host to establish ADB server connection. Default to `127.0.0.1`. + +### option: Android.launchServer.adbPort +* since: v1.28 +- `adbPort` <[int]> + +Optional port to establish ADB server connection. Default to `5037`. + +### option: Android.launchServer.omitDriverInstall +* since: v1.28 +- `omitDriverInstall` <[boolean]> + +Prevents automatic playwright driver installation on attach. Assumes that the drivers have been installed already. + +### option: Android.launchServer.deviceSerialNumber +* since: v1.28 +- `deviceSerialNumber` <[string]> + +Optional device serial number to launch the browser on. If not specified, it will +throw if multiple devices are connected. + +### option: Android.launchServer.port +* since: v1.28 +- `port` <[int]> + +Port to use for the web socket. Defaults to 0 that picks any available port. + +### option: Android.launchServer.wsPath +* since: v1.28 +- `wsPath` <[string]> + +Path at which to serve the Android Server. For security, this defaults to an +unguessable string. + +:::warning +Any process or web page (including those running in Playwright) with knowledge +of the `wsPath` can take control of the OS user. For this reason, you should +use an unguessable token when using this option. +::: + ## method: Android.setDefaultTimeout * since: v1.9 diff --git a/packages/playwright-core/src/DEPS.list b/packages/playwright-core/src/DEPS.list index 6b9ffb8d3c..2f7995ab62 100644 --- a/packages/playwright-core/src/DEPS.list +++ b/packages/playwright-core/src/DEPS.list @@ -1,6 +1,9 @@ [browserServerImpl.ts] ** +[androidServerImpl.ts] +** + [inProcessFactory.ts] ** diff --git a/packages/playwright-core/src/androidServerImpl.ts b/packages/playwright-core/src/androidServerImpl.ts new file mode 100644 index 0000000000..65643ae53a --- /dev/null +++ b/packages/playwright-core/src/androidServerImpl.ts @@ -0,0 +1,62 @@ +/** + * 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. + */ + +import type { LaunchAndroidServerOptions } from './client/types'; +import { ws } from './utilsBundle'; +import type { WebSocketEventEmitter } from './utilsBundle'; +import type { BrowserServer } from './client/browserType'; +import { createGuid } from './utils'; +import { createPlaywright } from './server/playwright'; +import { PlaywrightServer } from './remote/playwrightServer'; + +export class AndroidServerLauncherImpl { + async launchServer(options: LaunchAndroidServerOptions = {}): Promise { + const playwright = createPlaywright('javascript'); + // 1. Pre-connect to the device + let devices = await playwright.android.devices({ + host: options.adbHost, + port: options.adbPort, + omitDriverInstall: options.omitDriverInstall, + }); + + if (devices.length === 0) + throw new Error('No devices found'); + + if (options.deviceSerialNumber) { + devices = devices.filter(d => d.serial === options.deviceSerialNumber); + if (devices.length === 0) + throw new Error(`No device with serial number '${options.deviceSerialNumber}' not found`); + } + + if (devices.length > 1) + throw new Error(`More than one device found. Please specify deviceSerialNumber`); + + const device = devices[0]; + + const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`; + + // 2. Start the server + const server = new PlaywrightServer({ path, maxConnections: 1, enableSocksProxy: false, preLaunchedAndroidDevice: device }); + const wsEndpoint = await server.listen(options.port); + + // 3. Return the BrowserServer interface + const browserServer = new ws.EventEmitter() as (BrowserServer & WebSocketEventEmitter); + browserServer.wsEndpoint = () => wsEndpoint; + browserServer.close = () => device.close(); + browserServer.kill = () => device.close(); + return browserServer; + } +} diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index 8b6d4314ff..a602bda7a8 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -49,9 +49,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { throw e; }); - let path = `/${createGuid()}`; - if (options.wsPath) - path = options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`; + const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`; // 2. Start the server const server = new PlaywrightServer({ path, maxConnections: Infinity, enableSocksProxy: false, preLaunchedBrowser: browser }); diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index d13bc30c3e..3d8e83b831 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -15,7 +15,7 @@ */ import fs from 'fs'; -import { isString, isRegExp } from '../utils'; +import { isString, isRegExp, monotonicTime } from '../utils'; import type * as channels from '@protocol/channels'; import { Events } from './events'; import { BrowserContext, prepareBrowserContextParams } from './browserContext'; @@ -26,12 +26,17 @@ import type { Page } from './page'; import { TimeoutSettings } from '../common/timeoutSettings'; import { Waiter } from './waiter'; import { EventEmitter } from 'events'; +import { Connection } from './connection'; +import { isSafeCloseError, kBrowserClosedError } from '../common/errors'; +import { raceAgainstTimeout } from '../utils/timeoutRunner'; +import type { AndroidServerLauncherImpl } from '../androidServerImpl'; type Direction = 'down' | 'up' | 'left' | 'right'; type SpeedOptions = { speed?: number }; export class Android extends ChannelOwner implements api.Android { readonly _timeoutSettings: TimeoutSettings; + _serverLauncher?: AndroidServerLauncherImpl; static from(android: channels.AndroidChannel): Android { return (android as any)._object; @@ -51,11 +56,68 @@ export class Android extends ChannelOwner implements ap const { devices } = await this._channel.devices(options); return devices.map(d => AndroidDevice.from(d)); } + + async launchServer(options: types.LaunchServerOptions = {}): Promise { + if (!this._serverLauncher) + throw new Error('Launching server is not supported'); + return this._serverLauncher.launchServer(options); + } + + async connect(wsEndpoint: string, options: Parameters[1] = {}): Promise { + return await this._wrapApiCall(async () => { + const deadline = options.timeout ? monotonicTime() + options.timeout : 0; + const headers = { 'x-playwright-browser': 'android', ...options.headers }; + const localUtils = this._connection.localUtils(); + const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: options.slowMo, timeout: options.timeout }; + const { pipe } = await localUtils._channel.connect(connectParams); + const closePipe = () => pipe.close().catch(() => {}); + const connection = new Connection(localUtils); + connection.markAsRemote(); + connection.on('close', closePipe); + + let device: AndroidDevice; + let closeError: string | undefined; + const onPipeClosed = () => { + device?._didClose(); + connection.close(closeError || kBrowserClosedError); + }; + pipe.on('closed', onPipeClosed); + connection.onmessage = message => pipe.send({ message }).catch(onPipeClosed); + + pipe.on('message', ({ message }) => { + try { + connection!.dispatch(message); + } catch (e) { + closeError = e.toString(); + closePipe(); + } + }); + + const result = await raceAgainstTimeout(async () => { + const playwright = await connection!.initializePlaywright(); + if (!playwright._initializer.preConnectedAndroidDevice) { + closePipe(); + throw new Error('Malformed endpoint. Did you use Android.launchServer method?'); + } + device = AndroidDevice.from(playwright._initializer.preConnectedAndroidDevice!); + device._shouldCloseConnectionOnClose = true; + device.on(Events.AndroidDevice.Close, closePipe); + return device; + }, deadline ? deadline - monotonicTime() : 0); + if (!result.timedOut) { + return result.result; + } else { + closePipe(); + throw new Error(`Timeout ${options.timeout}ms exceeded`); + } + }); + } } export class AndroidDevice extends ChannelOwner implements api.AndroidDevice { readonly _timeoutSettings: TimeoutSettings; private _webViews = new Map(); + _shouldCloseConnectionOnClose = false; static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice { return (androidDevice as any)._object; @@ -172,7 +234,20 @@ export class AndroidDevice extends ChannelOwner i } async close() { - await this._channel.close(); + try { + this._didClose(); + if (this._shouldCloseConnectionOnClose) + this._connection.close(kBrowserClosedError); + else + await this._channel.close(); + } catch (e) { + if (isSafeCloseError(e)) + return; + throw e; + } + } + + _didClose() { this.emit(Events.AndroidDevice.Close); } diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 7b02456c4d..27dd6aa5cf 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -145,7 +145,6 @@ export class BrowserType extends ChannelOwner imple const logger = params.logger; return await this._wrapApiCall(async () => { const deadline = params.timeout ? monotonicTime() + params.timeout : 0; - let browser: Browser; const headers = { 'x-playwright-browser': this.name(), ...params.headers }; const localUtils = this._connection.localUtils(); const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: params.slowMo, timeout: params.timeout }; @@ -153,10 +152,11 @@ export class BrowserType extends ChannelOwner imple connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; const { pipe } = await localUtils._channel.connect(connectParams); const closePipe = () => pipe.close().catch(() => {}); - const connection = new Connection(this._connection.localUtils()); + const connection = new Connection(localUtils); connection.markAsRemote(); connection.on('close', closePipe); + let browser: Browser; let closeError: string | undefined; const onPipeClosed = () => { // Emulate all pages, contexts and the browser closing upon disconnect. @@ -188,7 +188,7 @@ export class BrowserType extends ChannelOwner imple const playwright = await connection!.initializePlaywright(); if (!playwright._initializer.preLaunchedBrowser) { closePipe(); - throw new Error('Malformed endpoint. Did you use launchServer method?'); + throw new Error('Malformed endpoint. Did you use BrowserType.launchServer method?'); } playwright._setSelectors(this._playwright.selectors); browser = Browser.from(playwright._initializer.preLaunchedBrowser!); diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index ceada909b1..517c52c683 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -112,6 +112,15 @@ export type LaunchServerOptions = { logger?: Logger, } & FirefoxUserPrefs; +export type LaunchAndroidServerOptions = { + deviceSerialNumber?: string, + adbHost?: string, + adbPort?: number, + omitDriverInstall?: boolean, + port?: number, + wsPath?: string, +}; + export type SelectorEngine = { /** * Returns the first element matching given selector in the root's subtree. diff --git a/packages/playwright-core/src/grid/gridBrowserWorker.ts b/packages/playwright-core/src/grid/gridBrowserWorker.ts index 19d92d190c..23a9f5c9bf 100644 --- a/packages/playwright-core/src/grid/gridBrowserWorker.ts +++ b/packages/playwright-core/src/grid/gridBrowserWorker.ts @@ -23,7 +23,7 @@ function launchGridBrowserWorker(gridURL: string, agentId: string, workerId: str const log = debug(`pw:grid:worker:${workerId}`); log('created'); const ws = new WebSocket(gridURL.replace('http://', 'ws://') + `/registerWorker?agentId=${agentId}&workerId=${workerId}`); - new PlaywrightConnection(Promise.resolve(), 'launch-browser', ws, { enableSocksProxy: true, browserName, launchOptions: {} }, { playwright: null, browser: null }, log, async () => { + new PlaywrightConnection(Promise.resolve(), 'launch-browser', ws, { enableSocksProxy: true, browserName, launchOptions: {} }, { }, log, async () => { log('exiting process'); setTimeout(() => process.exit(0), 30000); // Meanwhile, try to gracefully close all browsers. diff --git a/packages/playwright-core/src/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index 3ee2cacae9..2f86598fc6 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -18,6 +18,7 @@ import type { Playwright as PlaywrightAPI } from './client/playwright'; import { createPlaywright, DispatcherConnection, RootDispatcher, PlaywrightDispatcher } from './server'; import { Connection } from './client/connection'; import { BrowserServerLauncherImpl } from './browserServerImpl'; +import { AndroidServerLauncherImpl } from './androidServerImpl'; export function createInProcessPlaywright(): PlaywrightAPI { const playwright = createPlaywright('javascript'); @@ -37,6 +38,7 @@ export function createInProcessPlaywright(): PlaywrightAPI { playwrightAPI.chromium._serverLauncher = new BrowserServerLauncherImpl('chromium'); playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox'); playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit'); + playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl(); // Switch to async dispatch after we got Playwright object. dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message)); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 79981d7fc0..626f918531 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -289,6 +289,7 @@ scheme.PlaywrightInitializer = tObject({ })), selectors: tChannel(['Selectors']), preLaunchedBrowser: tOptional(tChannel(['Browser'])), + preConnectedAndroidDevice: tOptional(tChannel(['AndroidDevice'])), socksSupport: tOptional(tChannel(['SocksSupport'])), }); scheme.PlaywrightNewRequestParams = tObject({ diff --git a/packages/playwright-core/src/remote/DEPS.list b/packages/playwright-core/src/remote/DEPS.list index cb14447c93..69b2850494 100644 --- a/packages/playwright-core/src/remote/DEPS.list +++ b/packages/playwright-core/src/remote/DEPS.list @@ -2,6 +2,7 @@ ../client/ ../common/ ../server/ +../server/android/ ../server/dispatchers/ ../utils/ ../utilsBundle.ts diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 8ec0a64f34..e3220748b6 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -23,6 +23,7 @@ import { gracefullyCloseAll } from '../utils/processLauncher'; import { SocksProxy } from '../common/socksProxy'; import { assert } from '../utils'; import type { LaunchOptions } from '../server/types'; +import { AndroidDevice } from '../server/android/android'; import { DebugControllerDispatcher } from '../server/dispatchers/debugControllerDispatcher'; export type ClientType = 'controller' | 'playwright' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser'; @@ -34,8 +35,9 @@ type Options = { }; type PreLaunched = { - playwright: Playwright | null; - browser: Browser | null; + playwright?: Playwright | undefined; + browser?: Browser | undefined; + androidDevice?: AndroidDevice | undefined; }; export class PlaywrightConnection { @@ -56,7 +58,7 @@ export class PlaywrightConnection { if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser') assert(preLaunched.playwright); if (clientType === 'pre-launched-browser') - assert(preLaunched.browser); + assert(preLaunched.browser || preLaunched.androidDevice); this._onClose = onClose; this._debugLog = log; @@ -72,7 +74,7 @@ export class PlaywrightConnection { }); ws.on('close', () => this._onDisconnect()); - ws.on('error', error => this._onDisconnect(error)); + ws.on('error', (error: Error) => this._onDisconnect(error)); if (clientType === 'controller') { this._root = this._initDebugControllerMode(); @@ -83,7 +85,7 @@ export class PlaywrightConnection { if (clientType === 'reuse-browser') return await this._initReuseBrowsersMode(scope); if (clientType === 'pre-launched-browser') - return await this._initPreLaunchedBrowserMode(scope); + return this._preLaunched.browser ? await this._initPreLaunchedBrowserMode(scope) : await this._initPreLaunchedAndroidMode(scope); if (clientType === 'launch-browser') return await this._initLaunchBrowserMode(scope); if (clientType === 'playwright') @@ -122,7 +124,7 @@ export class PlaywrightConnection { } private async _initPreLaunchedBrowserMode(scope: RootDispatcher) { - this._debugLog(`engaged pre-launched mode`); + this._debugLog(`engaged pre-launched (browser) mode`); const playwright = this._preLaunched.playwright!; const browser = this._preLaunched.browser!; browser.on(Browser.Events.Disconnected, () => { @@ -139,6 +141,19 @@ export class PlaywrightConnection { return playwrightDispatcher; } + private async _initPreLaunchedAndroidMode(scope: RootDispatcher) { + this._debugLog(`engaged pre-launched (Android) mode`); + const playwright = this._preLaunched.playwright!; + const androidDevice = this._preLaunched.androidDevice!; + androidDevice.on(AndroidDevice.Events.Closed, () => { + // Underlying browser did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Android device disconnected' }); + }); + const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, undefined, undefined, androidDevice); + this._cleanups.push(() => playwrightDispatcher.cleanup()); + return playwrightDispatcher; + } + private _initDebugControllerMode(): DebugControllerDispatcher { this._debugLog(`engaged reuse controller mode`); const playwright = this._preLaunched.playwright!; diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index c0d34f061b..0354ea08c2 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -24,6 +24,7 @@ import { PlaywrightConnection } from './playwrightConnection'; import type { ClientType } from './playwrightConnection'; import type { LaunchOptions } from '../server/types'; import { ManualPromise } from '../utils/manualPromise'; +import type { AndroidDevice } from '../server/android/android'; const debugLog = debug('pw:server'); @@ -40,10 +41,11 @@ type ServerOptions = { maxConnections: number; enableSocksProxy: boolean; preLaunchedBrowser?: Browser + preLaunchedAndroidDevice?: AndroidDevice }; export class PlaywrightServer { - private _preLaunchedPlaywright: Playwright | null = null; + private _preLaunchedPlaywright: Playwright | undefined; private _wsServer: WebSocketServer | undefined; private _options: ServerOptions; @@ -51,6 +53,8 @@ export class PlaywrightServer { this._options = options; if (options.preLaunchedBrowser) this._preLaunchedPlaywright = options.preLaunchedBrowser.options.rootSdkObject as Playwright; + if (options.preLaunchedAndroidDevice) + this._preLaunchedPlaywright = options.preLaunchedAndroidDevice._android._playwrightOptions.rootSdkObject as Playwright; } preLaunchedPlaywright(): Playwright { @@ -121,7 +125,7 @@ export class PlaywrightServer { clientType = 'controller'; else if (shouldReuseBrowser) clientType = 'reuse-browser'; - else if (this._options.preLaunchedBrowser) + else if (this._options.preLaunchedBrowser || this._options.preLaunchedAndroidDevice) clientType = 'pre-launched-browser'; else if (browserName) clientType = 'launch-browser'; @@ -130,7 +134,7 @@ export class PlaywrightServer { semaphore.aquire(), clientType, ws, { enableSocksProxy, browserName, launchOptions }, - { playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser || null }, + { playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser, androidDevice: this._options.preLaunchedAndroidDevice }, log, () => semaphore.release()); (ws as any)[kConnectionSymbol] = connection; }); diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 72a826a275..10b8c5f68c 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -116,7 +116,7 @@ export class AndroidDevice extends SdkObject { }; private _browserConnections = new Set(); - private _android: Android; + readonly _android: Android; private _isClosed = false; constructor(android: Android, backend: DeviceBackend, model: string, options: channels.AndroidDevicesOptions) { diff --git a/packages/playwright-core/src/server/android/backendAdb.ts b/packages/playwright-core/src/server/android/backendAdb.ts index ba0a4a40af..29e3834482 100644 --- a/packages/playwright-core/src/server/android/backendAdb.ts +++ b/packages/playwright-core/src/server/android/backendAdb.ts @@ -37,6 +37,7 @@ class AdbDevice implements DeviceBackend { status: string; host: string | undefined; port: number | undefined; + private _closed = false; constructor(serial: string, status: string, host?: string, port?: number) { this.serial = serial; @@ -49,13 +50,18 @@ class AdbDevice implements DeviceBackend { } async close() { + this._closed = true; } runCommand(command: string): Promise { + if (this._closed) + throw new Error('Device is closed'); return runCommand(command, this.host, this.port, this.serial); } async open(command: string): Promise { + if (this._closed) + throw new Error('Device is closed'); const result = await open(command, this.host, this.port, this.serial); result.becomeSocket(); return result; diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 3e330a74ab..6a49ed9693 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -31,26 +31,31 @@ import { APIRequestContextDispatcher } from './networkDispatchers'; import { SelectorsDispatcher } from './selectorsDispatcher'; import { ConnectedBrowserDispatcher } from './browserDispatcher'; import { createGuid } from '../../utils'; +import type { AndroidDevice } from '../android/android'; +import { AndroidDeviceDispatcher } from './androidDispatcher'; export class PlaywrightDispatcher extends Dispatcher implements channels.PlaywrightChannel { _type_Playwright; private _browserDispatcher: ConnectedBrowserDispatcher | undefined; - constructor(scope: RootDispatcher, playwright: Playwright, socksProxy?: SocksProxy, preLaunchedBrowser?: Browser) { + constructor(scope: RootDispatcher, playwright: Playwright, socksProxy?: SocksProxy, preLaunchedBrowser?: Browser, prelaunchedAndroidDevice?: AndroidDevice) { const descriptors = require('../deviceDescriptors') as types.Devices; const deviceDescriptors = Object.entries(descriptors) .map(([name, descriptor]) => ({ name, descriptor })); const browserDispatcher = preLaunchedBrowser ? new ConnectedBrowserDispatcher(scope, preLaunchedBrowser) : undefined; + const android = new AndroidDispatcher(scope, playwright.android); + const prelaunchedAndroidDeviceDispatcher = prelaunchedAndroidDevice ? new AndroidDeviceDispatcher(android, prelaunchedAndroidDevice) : undefined; super(scope, playwright, 'Playwright', { chromium: new BrowserTypeDispatcher(scope, playwright.chromium), firefox: new BrowserTypeDispatcher(scope, playwright.firefox), webkit: new BrowserTypeDispatcher(scope, playwright.webkit), - android: new AndroidDispatcher(scope, playwright.android), + android, electron: new ElectronDispatcher(scope, playwright.electron), utils: new LocalUtilsDispatcher(scope, playwright), deviceDescriptors, selectors: new SelectorsDispatcher(scope, browserDispatcher?.selectors || playwright.selectors), preLaunchedBrowser: browserDispatcher, + preConnectedAndroidDevice: prelaunchedAndroidDeviceDispatcher, socksSupport: socksProxy ? new SocksSupportDispatcher(scope, socksProxy) : undefined, }); this._type_Playwright = true; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 426b6209ee..dd0fdcbd84 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12181,6 +12181,32 @@ export {}; * */ export interface Android { + /** + * This methods attaches Playwright to an existing Android device. Use + * [android.launchServer([options])](https://playwright.dev/docs/api/class-android#android-launch-server) to launch a new + * Android server instance. + * @param wsEndpoint A browser websocket endpoint to connect to. + * @param options + */ + connect(wsEndpoint: string, options?: { + /** + * Additional HTTP headers to be sent with web socket connect request. Optional. + */ + headers?: { [key: string]: string; }; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going on. + * Defaults to `0`. + */ + slowMo?: number; + + /** + * Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass `0` to + * disable timeout. + */ + timeout?: number; + }): Promise; + /** * Returns the list of detected Android devices. * @param options @@ -12202,6 +12228,84 @@ export interface Android { port?: number; }): Promise>; + /** + * Launches Playwright Android server that clients can connect to. See the following example: + * + * Server Side: + * + * ```js + * const { _android } = require('playwright'); + * + * (async () => { + * const browserServer = await _android.launchServer({ + * // If you have multiple devices connected and want to use a specific one. + * // deviceSerialNumber: '', + * }); + * const wsEndpoint = browserServer.wsEndpoint(); + * console.log(wsEndpoint); + * })(); + * ``` + * + * Client Side: + * + * ```js + * const { _android } = require('playwright'); + * + * (async () => { + * const device = await _android.connect(''); + * + * console.log(device.model()); + * console.log(device.serial()); + * await device.shell('am force-stop com.android.chrome'); + * const context = await device.launchBrowser(); + * + * const page = await context.newPage(); + * await page.goto('https://webkit.org/'); + * console.log(await page.evaluate(() => window.location.href)); + * await page.screenshot({ path: 'page-chrome-1.png' }); + * + * await context.close(); + * })(); + * ``` + * + * @param options + */ + launchServer(options?: { + /** + * Optional host to establish ADB server connection. Default to `127.0.0.1`. + */ + adbHost?: string; + + /** + * Optional port to establish ADB server connection. Default to `5037`. + */ + adbPort?: number; + + /** + * Optional device serial number to launch the browser on. If not specified, it will throw if multiple devices are + * connected. + */ + deviceSerialNumber?: string; + + /** + * Prevents automatic playwright driver installation on attach. Assumes that the drivers have been installed already. + */ + omitDriverInstall?: boolean; + + /** + * Port to use for the web socket. Defaults to 0 that picks any available port. + */ + port?: number; + + /** + * Path at which to serve the Android Server. For security, this defaults to an unguessable string. + * + * > NOTE: Any process or web page (including those running in Playwright) with knowledge of the `wsPath` can take control + * of the OS user. For this reason, you should use an unguessable token when using this option. + */ + wsPath?: string; + }): Promise; + /** * This setting will change the default maximum time for all the methods accepting `timeout` option. * @param timeout Maximum time in milliseconds diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 97d3ef5e0b..650c158326 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -512,6 +512,7 @@ export type PlaywrightInitializer = { }[], selectors: SelectorsChannel, preLaunchedBrowser?: BrowserChannel, + preConnectedAndroidDevice?: AndroidDeviceChannel, socksSupport?: SocksSupportChannel, }; export interface PlaywrightEventTarget { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index de0c075bf9..3286f217ab 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -597,6 +597,8 @@ Playwright: selectors: Selectors # Only present when connecting remotely via BrowserType.connect() method. preLaunchedBrowser: Browser? + # Only present when connecting remotely via Android.connect() method. + preConnectedAndroidDevice: AndroidDevice? # Only present when socks proxy is supported. socksSupport: SocksSupport? diff --git a/tests/android/device.spec.ts b/tests/android/device.spec.ts index 7cc43f51f4..6ae375a987 100644 --- a/tests/android/device.spec.ts +++ b/tests/android/device.spec.ts @@ -45,10 +45,13 @@ test('androidDevice.screenshot', async function({ androidDevice }, testInfo) { }); test('androidDevice.push', async function({ androidDevice }) { - await androidDevice.shell('rm /data/local/tmp/hello-world'); - await androidDevice.push(Buffer.from('hello world'), '/data/local/tmp/hello-world'); - const data = await androidDevice.shell('cat /data/local/tmp/hello-world'); - expect(data).toEqual(Buffer.from('hello world')); + try { + await androidDevice.push(Buffer.from('hello world'), '/data/local/tmp/hello-world'); + const data = await androidDevice.shell('cat /data/local/tmp/hello-world'); + expect(data).toEqual(Buffer.from('hello world')); + } finally { + await androidDevice.shell('rm /data/local/tmp/hello-world'); + } }); test('androidDevice.fill', async function({ androidDevice }) { diff --git a/tests/android/launch-server.spec.ts b/tests/android/launch-server.spec.ts new file mode 100644 index 0000000000..6ebd6dbc0e --- /dev/null +++ b/tests/android/launch-server.spec.ts @@ -0,0 +1,114 @@ +/** + * Copyright 2020 Microsoft Corporation. All rights reserved. + * + * 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. + */ + +import ws from 'ws'; +import { androidTest as test, expect } from './androidTest'; + +test('android.launchServer should connect to a device', async ({ playwright }) => { + const browserServer = await playwright._android.launchServer(); + const device = await playwright._android.connect(browserServer.wsEndpoint()); + const output = await device.shell('echo 123'); + expect(output.toString()).toBe('123\n'); + await device.close(); + await browserServer.close(); +}); + +test('android.launchServer should be abe to reconnect to a device', async ({ playwright }) => { + const browserServer = await playwright._android.launchServer(); + try { + { + const device = await playwright._android.connect(browserServer.wsEndpoint()); + await device.push(Buffer.from('hello world'), '/data/local/tmp/hello-world'); + await device.close(); + } + { + const device = await playwright._android.connect(browserServer.wsEndpoint()); + const data = await device.shell('cat /data/local/tmp/hello-world'); + expect(data).toEqual(Buffer.from('hello world')); + await device.close(); + } + } finally { + // Cleanup + const device = await playwright._android.connect(browserServer.wsEndpoint()); + await device.shell('rm /data/local/tmp/hello-world'); + await device.close(); + await browserServer.close(); + } +}); + +test('android.launchServer should throw if there is no device with a specified serial number', async ({ playwright }) => { + await expect(playwright._android.launchServer({ + deviceSerialNumber: 'does-not-exist', + })).rejects.toThrow(`No device with serial number 'does-not-exist'`); +}); + +test('android.launchServer should not allow multiple connections', async ({ playwright }) => { + const browserServer = await playwright._android.launchServer(); + try { + await playwright._android.connect(browserServer.wsEndpoint()); + await expect(playwright._android.connect(browserServer.wsEndpoint(), { timeout: 2_000 })).rejects.toThrow('android.connect: Timeout 2000ms exceeded'); + } finally { + await browserServer.close(); + } +}); + +test('android.launchServer BrowserServer.close() will disconnect the device', async ({ playwright }) => { + const browserServer = await playwright._android.launchServer(); + try { + const device = await playwright._android.connect(browserServer.wsEndpoint()); + await browserServer.close(); + await expect(device.shell('echo 123')).rejects.toThrow('androidDevice.shell: Browser has been closed'); + } finally { + await browserServer.close(); + } +}); + +test('android.launchServer BrowserServer.kill() will disconnect the device', async ({ playwright }) => { + const browserServer = await playwright._android.launchServer(); + try { + const device = await playwright._android.connect(browserServer.wsEndpoint()); + await browserServer.kill(); + await expect(device.shell('echo 123')).rejects.toThrow('androidDevice.shell: Browser has been closed'); + } finally { + await browserServer.close(); + } +}); + +test('android.launchServer should terminate WS connection when device gets disconnected', async ({ playwright }) => { + const browserServer = await playwright._android.launchServer(); + const forwardingServer = new ws.Server({ port: 0, path: '/connect' }); + let receivedConnection: ws.WebSocket; + forwardingServer.on('connection', connection => { + receivedConnection = connection; + const actualConnection = new ws.WebSocket(browserServer.wsEndpoint()); + actualConnection.on('message', message => connection.send(message)); + connection.on('message', message => actualConnection.send(message)); + connection.on('close', () => actualConnection.close()); + actualConnection.on('close', () => connection.close()); + }); + try { + const device = await playwright._android.connect(`ws://localhost:${(forwardingServer.address() as ws.AddressInfo).port}/connect`); + expect((await device.shell('echo 123')).toString()).toBe('123\n'); + expect(receivedConnection.readyState).toBe(ws.OPEN); + const waitToClose = new Promise(f => receivedConnection.on('close', f)); + await device.close(); + await waitToClose; + expect(receivedConnection.readyState).toBe(ws.CLOSED); + } finally { + await browserServer.close(); + await new Promise(f => forwardingServer.close(f)); + } +}); diff --git a/utils/avd_start.sh b/utils/avd_start.sh index 58ed28b879..0cc0a59ada 100755 --- a/utils/avd_start.sh +++ b/utils/avd_start.sh @@ -13,7 +13,7 @@ bash $PWD/utils/avd_stop.sh echo "Starting emulator" # On normal macOS GitHub Action runners, the host GPU is not available. So 'swiftshader_indirect' would have to be used. # Since we (Playwright) run our tests on a selfhosted mac, the host GPU is available, so we use it. -nohup ${ANDROID_HOME}/emulator/emulator -avd android33 -no-audio -no-window -gpu host -no-boot-anim & +nohup ${ANDROID_HOME}/emulator/emulator -avd android33 -no-audio -no-window -gpu host -no-boot-anim -no-snapshot & ${ANDROID_HOME}/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do sleep 1; done; input keyevent 82' ${ANDROID_HOME}/platform-tools/adb devices echo "Emulator started"