From 1b7fb7d56a26e9b6633ca1758ca674ff1af87a72 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Sat, 12 Dec 2020 18:36:38 +0100 Subject: [PATCH] feat(android): expose installAPK(path) and ADB socket (#4689) --- android-types-internal.d.ts | 8 +++++ src/client/android.ts | 44 ++++++++++++++++++++++++++++ src/client/connection.ts | 5 +++- src/client/events.ts | 4 +++ src/dispatchers/androidDispatcher.ts | 29 ++++++++++++++++-- src/protocol/channels.ts | 40 +++++++++++++++++++++++++ src/protocol/protocol.yml | 33 +++++++++++++++++++-- src/protocol/validator.ts | 11 +++++++ src/server/android/android.ts | 28 +++++++++++------- test/android/device.spec.ts | 32 ++++++++++++++++++++ 10 files changed, 217 insertions(+), 17 deletions(-) create mode 100644 test/android/device.spec.ts diff --git a/android-types-internal.d.ts b/android-types-internal.d.ts index 9f81843d7b..a1bd5b253a 100644 --- a/android-types-internal.d.ts +++ b/android-types-internal.d.ts @@ -28,6 +28,8 @@ export interface AndroidDevice exte webViews(): AndroidWebView[]; webView(selector: { pkg: string }, options?: { timeout?: number }): Promise>; shell(command: string): Promise; + open(command: string): Promise; + installApk(file: string | Buffer, options?: { args?: string[] }): Promise; launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise; close(): Promise; @@ -46,6 +48,12 @@ export interface AndroidDevice exte info(selector: AndroidSelector): Promise; } +export interface AndroidSocket extends EventEmitter { + on(event: 'data', handler: (data: Buffer) => void): this; + write(data: Buffer): Promise + close(): Promise +} + export interface AndroidInput { type(text: string): Promise; press(key: AndroidKey): Promise; diff --git a/src/client/android.ts b/src/client/android.ts index 55939fbdc0..6957511ddd 100644 --- a/src/client/android.ts +++ b/src/client/android.ts @@ -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 { + return this._wrapApiCall('androidDevice.open', async () => { + return AndroidSocket.from((await this._channel.open({ command })).socket); + }); + } + + async installApk(file: string | Buffer, options?: { args: string[] }): Promise { + 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 { return this._wrapApiCall('androidDevice.launchBrowser', async () => { const contextOptions = validateBrowserContextOptions(options); @@ -217,6 +232,35 @@ export class AndroidDevice extends ChannelOwner { + 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 { + return this._wrapApiCall('androidDevice.write', async () => { + await this._channel.write({ data: data.toString('base64') }); + }); + } + + async close(): Promise { + return this._wrapApiCall('androidDevice.close', async () => { + await this._channel.close(); + }); + } +} + +async function readApkFile(file: string | Buffer): Promise { + if (isString(file)) + return (await util.promisify(fs.readFile)(file)).toString('base64'); + return file.toString('base64'); +} + class Input implements apiInternal.AndroidInput { private _device: AndroidDevice; diff --git a/src/client/connection.ts b/src/client/connection.ts index 0aed67b8db..d902a19431 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -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 { 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; diff --git a/src/client/events.ts b/src/client/events.ts index cda80f5a84..813b3ed79b 100644 --- a/src/client/events.ts +++ b/src/client/events.ts @@ -21,6 +21,10 @@ export const Events = { Close: 'close' }, + AndroidSocket: { + Data: 'data' + }, + AndroidWebView: { Close: 'close' }, diff --git a/src/dispatchers/androidDispatcher.ts b/src/dispatchers/androidDispatcher.ts index 5cedf60e31..ecb1083f2d 100644 --- a/src/dispatchers/androidDispatcher.ts +++ b/src/dispatchers/androidDispatcher.ts @@ -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 implements channels.AndroidChannel { constructor(scope: DispatcherScope, android: Android) { @@ -131,10 +132,19 @@ export class AndroidDeviceDispatcher extends Dispatcher { return { result: await this._object.shell(params.command) }; } + async open(params: channels.AndroidDeviceOpenParams, metadata?: channels.Metadata): Promise { + 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 { 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 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 { + await this._object.write(Buffer.from(params.data, 'base64')); + } + + async close(params: channels.AndroidSocketCloseParams, metadata?: channels.Metadata): Promise { + await this._object.close(); + } +} + const keyMap = new Map([ ['Unknown', 0], ['SoftLeft', 1], diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index ee9c98b0fa..9c3b040019 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -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; + close(params?: AndroidSocketCloseParams, metadata?: Metadata): Promise; +} +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; inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise; launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise; + open(params: AndroidDeviceOpenParams, metadata?: Metadata): Promise; shell(params: AndroidDeviceShellParams, metadata?: Metadata): Promise; + installApk(params: AndroidDeviceInstallApkParams, metadata?: Metadata): Promise; setDefaultTimeoutNoReply(params: AndroidDeviceSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise; connectToWebView(params: AndroidDeviceConnectToWebViewParams, metadata?: Metadata): Promise; close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise; @@ -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, }; diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 986979cb06..c243c87b0d 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -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 diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index ef8997b74a..013598a0a8 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -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, }); diff --git a/src/server/android/android.ts b/src/server/android/android.ts index 19c19e1ecc..197861cfbd 100644 --- a/src/server/android/android.ts +++ b/src/server/android/android.ts @@ -144,6 +144,10 @@ export class AndroidDevice extends EventEmitter { return result; } + async open(command: string): Promise { + return await this._backend.open(`shell:${command}`); + } + private async _driver(): Promise { 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 { + 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) diff --git a/test/android/device.spec.ts b/test/android/device.spec.ts new file mode 100644 index 0000000000..056690403b --- /dev/null +++ b/test/android/device.spec.ts @@ -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'); + }); +}