feat(android): add Android.{launchServer,connect} (#18263)
Fixes https://github.com/microsoft/playwright/issues/17538
This commit is contained in:
Родитель
d3948d1308
Коммит
805312b722
|
@ -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
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
[browserServerImpl.ts]
|
||||
**
|
||||
|
||||
[androidServerImpl.ts]
|
||||
**
|
||||
|
||||
[inProcessFactory.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<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;
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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!);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
../client/
|
||||
../common/
|
||||
../server/
|
||||
../server/android/
|
||||
../server/dispatchers/
|
||||
../utils/
|
||||
../utilsBundle.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!;
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -512,6 +512,7 @@ export type PlaywrightInitializer = {
|
|||
}[],
|
||||
selectors: SelectorsChannel,
|
||||
preLaunchedBrowser?: BrowserChannel,
|
||||
preConnectedAndroidDevice?: AndroidDeviceChannel,
|
||||
socksSupport?: SocksSupportChannel,
|
||||
};
|
||||
export interface PlaywrightEventTarget {
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
});
|
|
@ -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"
|
||||
|
|
Загрузка…
Ссылка в новой задаче