Skip to content

Commit

Permalink
feat(android): add Android.{launchServer,connect} (#18263)
Browse files Browse the repository at this point in the history
Fixes #17538
  • Loading branch information
mxschmitt committed Oct 25, 2022
1 parent d3948d1 commit 805312b
Show file tree
Hide file tree
Showing 22 changed files with 552 additions and 26 deletions.
121 changes: 121 additions & 0 deletions docs/src/api/class-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]>>
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/DEPS.list
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
[browserServerImpl.ts]
**

[androidServerImpl.ts]
**

[inProcessFactory.ts]
**

Expand Down
62 changes: 62 additions & 0 deletions packages/playwright-core/src/androidServerImpl.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 1 addition & 3 deletions packages/playwright-core/src/browserServerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
79 changes: 77 additions & 2 deletions packages/playwright-core/src/client/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
6 changes: 3 additions & 3 deletions packages/playwright-core/src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,18 @@ 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 };
if ((params as any).__testHookRedirectPortForwarding)
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.
Expand Down Expand Up @@ -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!);
Expand Down
9 changes: 9 additions & 0 deletions packages/playwright-core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/grid/gridBrowserWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 805312b

Please sign in to comment.