Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 6 commits into from
Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/src/api/class-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,46 @@ 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 instance.
mxschmitt marked this conversation as resolved.
Show resolved Hide resolved
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.logger
* since: v1.28
* langs: js
- `logger` <[Logger]>

Logger sink for Playwright logging. Optional.

### 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 +142,96 @@ 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]>

Returns the Android browser instance.

Launches android server that client can connect to. 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.
// By default it will use the first 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, the browser will be launched on the first available device.

### 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 Browser Server. For security, this defaults to an
mxschmitt marked this conversation as resolved.
Show resolved Hide resolved
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
63 changes: 63 additions & 0 deletions packages/playwright-core/src/androidServerImpl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* 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');
let devices = await playwright.android.devices({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd avoid a long-living connection here and would create a device upon the connection. I.e. instead of preLaunchedAndroidDevice: device pass preLaunchedAndroidDevice: deviceFactory.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea but this makes it harder when a device gets disconnected during launchServer and connect(). With the current impl the ws server will shut down when the adb connection drops.

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];

let path = `/${createGuid()}`;
if (options.wsPath)
path = options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`;

// 2. Start the server
const server = new PlaywrightServer({ path, maxConnections: Infinity, 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 as any)._disconnectForTest = () => server.close();
return browserServer;
}
}
76 changes: 75 additions & 1 deletion 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 { 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,76 @@ 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, params: Parameters<api.Android['connect']>[1] = {}): Promise<api.AndroidDevice> {
const logger = params.logger;
return await this._wrapApiCall(async () => {
const deadline = params.timeout ? monotonicTime() + params.timeout : 0;
const headers = { 'x-playwright-browser': 'android', ...params.headers };
const localUtils = this._connection.localUtils();
const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: params.slowMo, timeout: params.timeout };
if ((params as any).__testHookRedirectPortForwarding)
mxschmitt marked this conversation as resolved.
Show resolved Hide resolved
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
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 () => {
// For tests.
if ((params as any).__testHookBeforeCreateBrowser)
await (params as any).__testHookBeforeCreateBrowser();

const playwright = await connection!.initializePlaywright();
if (!playwright._initializer.preConnectedAndroidDevice) {
closePipe();
throw new Error('Malformed endpoint. Did you use launchServer method?');
}
device = AndroidDevice.from(playwright._initializer.preConnectedAndroidDevice!);
device._logger = logger;
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 ${params.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 @@ -173,6 +243,10 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i

async close() {
await this._channel.close();
this._didClose();
}

_didClose() {
this.emit(Events.AndroidDevice.Close);
}

Expand Down
4 changes: 2 additions & 2 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
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
2 changes: 2 additions & 0 deletions packages/playwright-core/src/inProcessFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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));
Expand Down
Loading