feat(android): add Android.{launchServer,connect} (#18263)

Fixes https://github.com/microsoft/playwright/issues/17538
This commit is contained in:
Max Schmitt 2022-10-24 17:23:11 -07:00 committed by GitHub
parent d3948d1308
commit 805312b722
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 552 additions and 26 deletions

View File

@ -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: '<deviceSerialNumber>',
});
const wsEndpoint = browserServer.wsEndpoint();
console.log(wsEndpoint);
})();
```
Client Side:
```js
const { _android } = require('playwright');
(async () => {
const device = await _android.connect('<wsEndpoint>');
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

View File

@ -1,6 +1,9 @@
[browserServerImpl.ts]
**
[androidServerImpl.ts]
**
[inProcessFactory.ts]
**

View File

@ -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<BrowserServer> {
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;
}
}

View File

@ -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 });

View File

@ -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<channels.AndroidChannel> 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<channels.AndroidChannel> implements ap
const { devices } = await this._channel.devices(options);
return devices.map(d => AndroidDevice.from(d));
}
async launchServer(options: types.LaunchServerOptions = {}): Promise<api.BrowserServer> {
if (!this._serverLauncher)
throw new Error('Launching server is not supported');
return this._serverLauncher.launchServer(options);
}
async connect(wsEndpoint: string, options: Parameters<api.Android['connect']>[1] = {}): Promise<api.AndroidDevice> {
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<channels.AndroidDeviceChannel> implements api.AndroidDevice {
readonly _timeoutSettings: TimeoutSettings;
private _webViews = new Map<string, AndroidWebView>();
_shouldCloseConnectionOnClose = false;
static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice {
return (androidDevice as any)._object;
@ -172,7 +234,20 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> 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);
}

View File

@ -145,7 +145,6 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> 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<channels.BrowserTypeChannel> 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<channels.BrowserTypeChannel> 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!);

View File

@ -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.

View File

@ -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.

View File

@ -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));

View File

@ -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({

View File

@ -2,6 +2,7 @@
../client/
../common/
../server/
../server/android/
../server/dispatchers/
../utils/
../utilsBundle.ts

View File

@ -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!;

View File

@ -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;
});

View File

@ -116,7 +116,7 @@ export class AndroidDevice extends SdkObject {
};
private _browserConnections = new Set<AndroidBrowser>();
private _android: Android;
readonly _android: Android;
private _isClosed = false;
constructor(android: Android, backend: DeviceBackend, model: string, options: channels.AndroidDevicesOptions) {

View File

@ -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<Buffer> {
if (this._closed)
throw new Error('Device is closed');
return runCommand(command, this.host, this.port, this.serial);
}
async open(command: string): Promise<SocketBackend> {
if (this._closed)
throw new Error('Device is closed');
const result = await open(command, this.host, this.port, this.serial);
result.becomeSocket();
return result;

View File

@ -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<Playwright, channels.PlaywrightChannel, RootDispatcher> 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;

View File

@ -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<AndroidDevice>;
/**
* Returns the list of detected Android devices.
* @param options
@ -12202,6 +12228,84 @@ export interface Android {
port?: number;
}): Promise<Array<AndroidDevice>>;
/**
* 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: '<deviceSerialNumber>',
* });
* const wsEndpoint = browserServer.wsEndpoint();
* console.log(wsEndpoint);
* })();
* ```
*
* Client Side:
*
* ```js
* const { _android } = require('playwright');
*
* (async () => {
* const device = await _android.connect('<wsEndpoint>');
*
* 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<BrowserServer>;
/**
* This setting will change the default maximum time for all the methods accepting `timeout` option.
* @param timeout Maximum time in milliseconds

View File

@ -512,6 +512,7 @@ export type PlaywrightInitializer = {
}[],
selectors: SelectorsChannel,
preLaunchedBrowser?: BrowserChannel,
preConnectedAndroidDevice?: AndroidDeviceChannel,
socksSupport?: SocksSupportChannel,
};
export interface PlaywrightEventTarget {

View File

@ -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?

View File

@ -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 }) {

View File

@ -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));
}
});

View File

@ -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"