mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(android): expose installAPK(path) and ADB socket (#4689)
This commit is contained in:
parent
6cc695d92a
commit
1b7fb7d56a
8
android-types-internal.d.ts
vendored
8
android-types-internal.d.ts
vendored
@ -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>;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -21,6 +21,10 @@ export const Events = {
|
||||
Close: 'close'
|
||||
},
|
||||
|
||||
AndroidSocket: {
|
||||
Data: 'data'
|
||||
},
|
||||
|
||||
AndroidWebView: {
|
||||
Close: 'close'
|
||||
},
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
32
test/android/device.spec.ts
Normal file
32
test/android/device.spec.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user