feat(android): expose installAPK(path) and ADB socket (#4689)

This commit is contained in:
Max Schmitt 2020-12-12 18:36:38 +01:00 committed by GitHub
parent 6cc695d92a
commit 1b7fb7d56a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 217 additions and 17 deletions

View File

@ -28,6 +28,8 @@ export interface AndroidDevice<BrowserContextOptions, BrowserContext, Page> exte
webViews(): AndroidWebView<Page>[];
webView(selector: { pkg: string }, options?: { timeout?: number }): Promise<AndroidWebView<Page>>;
shell(command: string): Promise<string>;
open(command: string): Promise<AndroidSocket>;
installApk(file: string | Buffer, options?: { args?: string[] }): Promise<void>;
launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise<BrowserContext>;
close(): Promise<void>;
@ -46,6 +48,12 @@ export interface AndroidDevice<BrowserContextOptions, BrowserContext, Page> exte
info(selector: AndroidSelector): Promise<AndroidElementInfo>;
}
export interface AndroidSocket extends EventEmitter {
on(event: 'data', handler: (data: Buffer) => void): this;
write(data: Buffer): Promise<void>
close(): Promise<void>
}
export interface AndroidInput {
type(text: string): Promise<void>;
press(key: AndroidKey): Promise<void>;

View File

@ -14,6 +14,9 @@
* limitations under the License.
*/
import * as fs from 'fs';
import * as util from 'util';
import { isString } from '../utils/utils';
import * as channels from '../protocol/channels';
import { Events } from './events';
import { BrowserContext, validateBrowserContextOptions } from './browserContext';
@ -196,6 +199,18 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
});
}
async open(command: string): Promise<AndroidSocket> {
return this._wrapApiCall('androidDevice.open', async () => {
return AndroidSocket.from((await this._channel.open({ command })).socket);
});
}
async installApk(file: string | Buffer, options?: { args: string[] }): Promise<void> {
return this._wrapApiCall('androidDevice.installApk', async () => {
await this._channel.installApk({ file: await readApkFile(file), args: options && options.args });
});
}
async launchBrowser(options: types.BrowserContextOptions & { packageName?: string } = {}): Promise<BrowserContext> {
return this._wrapApiCall('androidDevice.launchBrowser', async () => {
const contextOptions = validateBrowserContextOptions(options);
@ -217,6 +232,35 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
}
}
export class AndroidSocket extends ChannelOwner<channels.AndroidSocketChannel, channels.AndroidSocketInitializer> {
static from(androidDevice: channels.AndroidSocketChannel): AndroidSocket {
return (androidDevice as any)._object;
}
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidSocketInitializer) {
super(parent, type, guid, initializer);
this._channel.on('data', ({ data }) => this.emit(Events.AndroidSocket.Data, Buffer.from(data, 'base64')));
}
async write(data: Buffer): Promise<void> {
return this._wrapApiCall('androidDevice.write', async () => {
await this._channel.write({ data: data.toString('base64') });
});
}
async close(): Promise<void> {
return this._wrapApiCall('androidDevice.close', async () => {
await this._channel.close();
});
}
}
async function readApkFile(file: string | Buffer): Promise<string> {
if (isString(file))
return (await util.promisify(fs.readFile)(file)).toString('base64');
return file.toString('base64');
}
class Input implements apiInternal.AndroidInput {
private _device: AndroidDevice;

View File

@ -40,7 +40,7 @@ import { FirefoxBrowser } from './firefoxBrowser';
import { debugLogger } from '../utils/debugLogger';
import { SelectorsOwner } from './selectors';
import { isUnderTest } from '../utils/utils';
import { Android, AndroidDevice } from './android';
import { Android, AndroidSocket, AndroidDevice } from './android';
class Root extends ChannelOwner<channels.Channel, {}> {
constructor(connection: Connection) {
@ -151,6 +151,9 @@ export class Connection {
case 'Android':
result = new Android(parent, type, guid, initializer);
break;
case 'AndroidSocket':
result = new AndroidSocket(parent, type, guid, initializer);
break;
case 'AndroidDevice':
result = new AndroidDevice(parent, type, guid, initializer);
break;

View File

@ -21,6 +21,10 @@ export const Events = {
Close: 'close'
},
AndroidSocket: {
Data: 'data'
},
AndroidWebView: {
Close: 'close'
},

View File

@ -15,9 +15,10 @@
*/
import { Dispatcher, DispatcherScope, existingDispatcher } from './dispatcher';
import { Android, AndroidDevice } from '../server/android/android';
import { Android, AndroidDevice, SocketBackend } from '../server/android/android';
import * as channels from '../protocol/channels';
import { BrowserContextDispatcher } from './browserContextDispatcher';
import { Events } from '../client/events';
export class AndroidDispatcher extends Dispatcher<Android, channels.AndroidInitializer> implements channels.AndroidChannel {
constructor(scope: DispatcherScope, android: Android) {
@ -131,10 +132,19 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
await this._object.send('inputDrag', params);
}
async shell(params: channels.AndroidDeviceShellParams) {
async shell(params: channels.AndroidDeviceShellParams): Promise<channels.AndroidDeviceShellResult> {
return { result: await this._object.shell(params.command) };
}
async open(params: channels.AndroidDeviceOpenParams, metadata?: channels.Metadata): Promise<channels.AndroidDeviceOpenResult> {
const socket = await this._object.open(params.command);
return { socket: new AndroidSocketDispatcher(this._scope, socket) };
}
async installApk(params: channels.AndroidDeviceInstallApkParams) {
await this._object.installApk(Buffer.from(params.file, 'base64'), { args: params.args });
}
async launchBrowser(params: channels.AndroidDeviceLaunchBrowserParams): Promise<channels.AndroidDeviceLaunchBrowserResult> {
const context = await this._object.launchBrowser(params.packageName, params);
return { context: new BrowserContextDispatcher(this._scope, context) };
@ -153,6 +163,21 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
}
}
export class AndroidSocketDispatcher extends Dispatcher<SocketBackend, channels.AndroidSocketInitializer> implements channels.AndroidSocketChannel {
constructor(scope: DispatcherScope, socket: SocketBackend) {
super(scope, socket, 'AndroidSocket', {}, true);
socket.on(Events.AndroidSocket.Data, (data: Buffer) => this._dispatchEvent('data', { data: data.toString('base64') }));
}
async write(params: channels.AndroidSocketWriteParams, metadata?: channels.Metadata): Promise<void> {
await this._object.write(Buffer.from(params.data, 'base64'));
}
async close(params: channels.AndroidSocketCloseParams, metadata?: channels.Metadata): Promise<void> {
await this._object.close();
}
}
const keyMap = new Map<string, number>([
['Unknown', 0],
['SoftLeft', 1],

View File

@ -2421,6 +2421,27 @@ export type AndroidSetDefaultTimeoutNoReplyOptions = {
};
export type AndroidSetDefaultTimeoutNoReplyResult = void;
// ----------- AndroidSocket -----------
export type AndroidSocketInitializer = {};
export interface AndroidSocketChannel extends Channel {
on(event: 'data', callback: (params: AndroidSocketDataEvent) => void): this;
write(params: AndroidSocketWriteParams, metadata?: Metadata): Promise<AndroidSocketWriteResult>;
close(params?: AndroidSocketCloseParams, metadata?: Metadata): Promise<AndroidSocketCloseResult>;
}
export type AndroidSocketDataEvent = {
data: Binary,
};
export type AndroidSocketWriteParams = {
data: Binary,
};
export type AndroidSocketWriteOptions = {
};
export type AndroidSocketWriteResult = void;
export type AndroidSocketCloseParams = {};
export type AndroidSocketCloseOptions = {};
export type AndroidSocketCloseResult = void;
// ----------- AndroidDevice -----------
export type AndroidDeviceInitializer = {
model: string,
@ -2446,7 +2467,9 @@ export interface AndroidDeviceChannel extends Channel {
inputSwipe(params: AndroidDeviceInputSwipeParams, metadata?: Metadata): Promise<AndroidDeviceInputSwipeResult>;
inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise<AndroidDeviceInputDragResult>;
launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise<AndroidDeviceLaunchBrowserResult>;
open(params: AndroidDeviceOpenParams, metadata?: Metadata): Promise<AndroidDeviceOpenResult>;
shell(params: AndroidDeviceShellParams, metadata?: Metadata): Promise<AndroidDeviceShellResult>;
installApk(params: AndroidDeviceInstallApkParams, metadata?: Metadata): Promise<AndroidDeviceInstallApkResult>;
setDefaultTimeoutNoReply(params: AndroidDeviceSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise<AndroidDeviceSetDefaultTimeoutNoReplyResult>;
connectToWebView(params: AndroidDeviceConnectToWebViewParams, metadata?: Metadata): Promise<AndroidDeviceConnectToWebViewResult>;
close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise<AndroidDeviceCloseResult>;
@ -2702,6 +2725,15 @@ export type AndroidDeviceLaunchBrowserOptions = {
export type AndroidDeviceLaunchBrowserResult = {
context: BrowserContextChannel,
};
export type AndroidDeviceOpenParams = {
command: string,
};
export type AndroidDeviceOpenOptions = {
};
export type AndroidDeviceOpenResult = {
socket: AndroidSocketChannel,
};
export type AndroidDeviceShellParams = {
command: string,
};
@ -2711,6 +2743,14 @@ export type AndroidDeviceShellOptions = {
export type AndroidDeviceShellResult = {
result: string,
};
export type AndroidDeviceInstallApkParams = {
file: Binary,
args?: string[],
};
export type AndroidDeviceInstallApkOptions = {
args?: string[],
};
export type AndroidDeviceInstallApkResult = void;
export type AndroidDeviceSetDefaultTimeoutNoReplyParams = {
timeout: number,
};

View File

@ -2083,6 +2083,20 @@ Android:
parameters:
timeout: number
AndroidSocket:
type: interface
commands:
write:
parameters:
data: binary
close:
events:
data:
parameters:
data: binary
AndroidDevice:
type: interface
@ -2119,7 +2133,7 @@ AndroidDevice:
dest: Point
speed: number?
timeout: number?
fling:
parameters:
selector: AndroidSelector
@ -2132,12 +2146,12 @@ AndroidDevice:
- right
speed: number?
timeout: number?
longTap:
parameters:
selector: AndroidSelector
timeout: number?
pinchClose:
parameters:
selector: AndroidSelector
@ -2275,12 +2289,25 @@ AndroidDevice:
returns:
context: BrowserContext
open:
parameters:
command: string
returns:
socket: AndroidSocket
shell:
parameters:
command: string
returns:
result: string
installApk:
parameters:
file: binary
args:
type: array?
items: string
setDefaultTimeoutNoReply:
parameters:
timeout: number

View File

@ -901,6 +901,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
scheme.AndroidSetDefaultTimeoutNoReplyParams = tObject({
timeout: tNumber,
});
scheme.AndroidSocketWriteParams = tObject({
data: tBinary,
});
scheme.AndroidSocketCloseParams = tOptional(tObject({}));
scheme.AndroidDeviceWaitParams = tObject({
selector: tType('AndroidSelector'),
state: tOptional(tEnum(['gone'])),
@ -1024,9 +1028,16 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
password: tOptional(tString),
})),
});
scheme.AndroidDeviceOpenParams = tObject({
command: tString,
});
scheme.AndroidDeviceShellParams = tObject({
command: tString,
});
scheme.AndroidDeviceInstallApkParams = tObject({
file: tBinary,
args: tOptional(tArray(tString)),
});
scheme.AndroidDeviceSetDefaultTimeoutNoReplyParams = tObject({
timeout: tNumber,
});

View File

@ -144,6 +144,10 @@ export class AndroidDevice extends EventEmitter {
return result;
}
async open(command: string): Promise<SocketBackend> {
return await this._backend.open(`shell:${command}`);
}
private async _driver(): Promise<Transport> {
if (this._driverPromise)
return this._driverPromise;
@ -158,16 +162,8 @@ export class AndroidDevice extends EventEmitter {
await this.shell(`cmd package uninstall com.microsoft.playwright.androiddriver.test`);
debug('pw:android')('Installing the new driver');
for (const file of ['android-driver.apk', 'android-driver-target.apk']) {
debug('pw:android')('Reading ' + require.resolve(`../../../bin/${file}`));
const driverFile = await readFileAsync(require.resolve(`../../../bin/${file}`));
debug('pw:android')('Opening install socket');
const installSocket = await this._backend.open(`shell:cmd package install -r -t -S ${driverFile.length}`);
debug('pw:android')('Writing driver bytes: ' + driverFile.length);
await installSocket.write(driverFile);
const success = await new Promise(f => installSocket.on('data', f));
debug('pw:android')('Written driver bytes: ' + success);
}
for (const file of ['android-driver.apk', 'android-driver-target.apk'])
await this.installApk(await readFileAsync(require.resolve(`../../../bin/${file}`)));
debug('pw:android')('Starting the new driver');
this.shell(`am instrument -w com.microsoft.playwright.androiddriver.test/androidx.test.runner.AndroidJUnitRunner`);
@ -177,7 +173,7 @@ export class AndroidDevice extends EventEmitter {
while (!socket) {
try {
socket = await this._backend.open(`localabstract:playwright_android_driver_socket`);
} catch (e) {
} catch (e) {
await new Promise(f => setTimeout(f, 100));
}
}
@ -281,6 +277,16 @@ export class AndroidDevice extends EventEmitter {
return [...this._webViews.values()];
}
async installApk(content: Buffer, options?: { args?: string[] }): Promise<void> {
const args = options && options.args ? options.args : ['-r', '-t', '-S'];
debug('pw:android')('Opening install socket');
const installSocket = await this._backend.open(`shell:cmd package install ${args.join(' ')} ${content.length}`);
debug('pw:android')('Writing driver bytes: ' + content.length);
await installSocket.write(content);
const success = await new Promise(f => installSocket.on('data', f));
debug('pw:android')('Written driver bytes: ' + success);
}
private async _refreshWebViews() {
const sockets = (await this._backend.runCommand(`shell:cat /proc/net/unix | grep webview_devtools_remote`)).split('\n');
if (this._isClosed)

View File

@ -0,0 +1,32 @@
/**
* 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 { folio } from './android.fixtures';
const { it, expect } = folio;
if (process.env.PW_ANDROID_TESTS) {
it('should run ADB shell commands', async function({ device }) {
const output = await device.shell('echo 123');
expect(output).toBe('123\n');
});
it('should open a ADB socket', async function({ device }) {
const socket = await device.open('/bin/cat');
await socket.write(Buffer.from('321\n'));
const output = await new Promise(resolve => socket.on('data', resolve));
expect(output.toString()).toBe('321\n');
});
}