feat(adb): support webviews (#4657)

This commit is contained in:
Pavel Feldman 2020-12-09 17:15:24 -08:00 committed by GitHub
parent f939fdc1a1
commit 8fc49c98fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 533 additions and 209 deletions

View file

@ -14,6 +14,52 @@
* limitations under the License. * limitations under the License.
*/ */
import { EventEmitter } from 'events';
export interface AndroidDevice<BrowserContextOptions, BrowserContext, Page> extends EventEmitter {
input: AndroidInput;
setDefaultTimeout(timeout: number): void;
on(event: 'webview', handler: (webView: AndroidWebView<Page>) => void): this;
waitForEvent(event: string, predicate?: (data: any) => boolean): Promise<any>;
serial(): string;
model(): string;
webViews(): AndroidWebView<Page>[];
shell(command: string): Promise<string>;
launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise<BrowserContext>;
close(): Promise<void>;
wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise<void>;
fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise<void>;
press(selector: AndroidSelector, key: AndroidKey, options?: { duration?: number } & { timeout?: number }): Promise<void>;
tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise<void>;
drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise<void>;
fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise<void>;
longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise<void>;
pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
info(selector: AndroidSelector): Promise<AndroidElementInfo>;
}
export interface AndroidInput {
type(text: string): Promise<void>;
press(key: AndroidKey): Promise<void>;
tap(point: { x: number, y: number }): Promise<void>;
swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise<void>;
drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise<void>;
}
export interface AndroidWebView<Page> extends EventEmitter {
on(event: 'close', handler: () => void): this;
pid(): number;
pkg(): string;
page(): Promise<Page>;
}
export type AndroidElementInfo = { export type AndroidElementInfo = {
clazz: string; clazz: string;
desc: string; desc: string;
@ -52,37 +98,6 @@ export type AndroidSelector = {
text?: string | RegExp, text?: string | RegExp,
}; };
export interface AndroidDevice<BrowserContextOptions, BrowserContext> {
input: AndroidInput;
serial(): string;
model(): string;
shell(command: string): Promise<string>;
launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise<BrowserContext>;
close(): Promise<void>;
wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise<void>;
fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise<void>;
tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise<void>;
drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise<void>;
fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise<void>;
longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise<void>;
pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
info(selector: AndroidSelector): Promise<AndroidElementInfo>;
}
export interface AndroidInput {
type(text: string): Promise<void>;
press(key: AndroidKey): Promise<void>;
tap(point: { x: number, y: number }): Promise<void>;
swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise<void>;
drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise<void>;
}
export type AndroidKey = export type AndroidKey =
'Unknown' | 'Unknown' |
'SoftLeft' | 'SoftRight' | 'SoftLeft' | 'SoftRight' |

13
android-types.d.ts vendored
View file

@ -14,8 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
import { BrowserContext, BrowserContextOptions } from './types/types'; import { Page, BrowserContext, BrowserContextOptions } from './types/types';
import * as apiInternal from './android-types-internal'; import * as apiInternal from './android-types-internal';
import { EventEmitter } from 'events';
export * from './android-types-internal'; export { AndroidElementInfo, AndroidSelector } from './android-types-internal';
export type AndroidDevice = apiInternal.AndroidDevice<BrowserContext, BrowserContextOptions>; export type AndroidDevice = apiInternal.AndroidDevice<BrowserContextOptions, BrowserContext, Page>;
export type AndroidWebView = apiInternal.AndroidWebView<Page>;
export interface Android extends EventEmitter {
setDefaultTimeout(timeout: number): void;
devices(): Promise<AndroidDevice[]>;
}

View file

@ -28,7 +28,7 @@ const cpAsync = util.promisify(ncp);
const SCRIPT_NAME = path.basename(__filename); const SCRIPT_NAME = path.basename(__filename);
const ROOT_PATH = path.join(__dirname, '..'); const ROOT_PATH = path.join(__dirname, '..');
const PLAYWRIGHT_CORE_FILES = ['bin', 'lib', 'types', 'NOTICE', 'LICENSE']; const PLAYWRIGHT_CORE_FILES = ['bin/PrintDeps.exe', 'lib', 'types', 'NOTICE', 'LICENSE'];
const FFMPEG_FILES = ['third_party/ffmpeg']; const FFMPEG_FILES = ['third_party/ffmpeg'];
const PACKAGES = { const PACKAGES = {
@ -65,10 +65,10 @@ const PACKAGES = {
files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'electron-types.d.ts'], files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'electron-types.d.ts'],
}, },
'playwright-android': { 'playwright-android': {
version: '0.0.2', // Manually manage playwright-android version. version: '0.0.7', // Manually manage playwright-android version.
description: 'A high-level API to automate Chrome for Android', description: 'A high-level API to automate Chrome for Android',
browsers: [], browsers: [],
files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'android-types.d.ts', 'bin/android-driver.apk', 'bin/android-driver-target.apk'], files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'android-types.d.ts', 'android-types-internal.d.ts', 'bin/android-driver.apk', 'bin/android-driver-target.apk'],
}, },
}; };

View file

@ -18,6 +18,9 @@ lib/server/injected/
# Include generated types and entrypoint. # Include generated types and entrypoint.
!types/* !types/*
!index.d.ts !index.d.ts
# Include separate android types.
!android-types.d.ts
!android-types-internal.d.ts
# Include separate electron types. # Include separate electron types.
!electron-types.d.ts !electron-types.d.ts
# Include main entrypoint. # Include main entrypoint.

View file

@ -15,28 +15,32 @@ const { android } = require('playwright-android');
(async () => { (async () => {
const [device] = await android.devices(); const [device] = await android.devices();
// Android automation.
console.log(`Model: ${device.model()}`); console.log(`Model: ${device.model()}`);
console.log(`Serial: ${device.serial()}`); console.log(`Serial: ${device.serial()}`);
await device.tap({ desc: 'Home' }); await device.shell('am force-stop org.chromium.webview_shell');
console.log(await device.info({ text: 'Chrome' })); await device.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity');
await device.tap({ text: 'Chrome' });
await device.fill({ res: 'com.android.chrome:id/url_bar' }, 'www.chromium.org');
await device.input.press('Enter');
await new Promise(f => setTimeout(f, 1000));
await device.tap({ res: 'com.android.chrome:id/tab_switcher_button' }); await device.fill({ res: 'org.chromium.webview_shell:id/url_field' }, 'github.com/microsoft/playwright');
await device.tap({ desc: 'More options' });
await device.tap({ desc: 'Close all tabs' });
// Browser automation. let [webview] = device.webViews();
const context = await device.launchBrowser(); if (!webview)
const [page] = context.pages(); webview = await device.waitForEvent('webview');
await page.goto('https://webkit.org/');
console.log(await page.evaluate(() => window.location.href)); const page = await webview.page();
await context.close(); await Promise.all([
page.waitForNavigation(),
device.press({ res: 'org.chromium.webview_shell:id/url_field' }, 'Enter')
]);
console.log(await page.title());
{
const context = await device.launchBrowser();
const [page] = context.pages();
await page.goto('https://webkit.org/');
console.log(await page.evaluate(() => window.location.href));
await context.close();
}
await device.close(); await device.close();
})(); })();

View file

@ -15,21 +15,34 @@
*/ */
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { Events } from './events';
import { BrowserContext, validateBrowserContextOptions } from './browserContext'; import { BrowserContext, validateBrowserContextOptions } from './browserContext';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import * as apiInternal from '../../android-types-internal'; import * as apiInternal from '../../android-types-internal';
import * as types from './types'; import * as types from './types';
import { Page } from './page';
import { TimeoutSettings } from '../utils/timeoutSettings';
import { Waiter } from './waiter';
import { EventEmitter } from 'events';
type Direction = 'down' | 'up' | 'left' | 'right'; type Direction = 'down' | 'up' | 'left' | 'right';
type SpeedOptions = { speed?: number }; type SpeedOptions = { speed?: number };
export class Android extends ChannelOwner<channels.AndroidChannel, channels.AndroidInitializer> { export class Android extends ChannelOwner<channels.AndroidChannel, channels.AndroidInitializer> {
readonly _timeoutSettings: TimeoutSettings;
static from(android: channels.AndroidChannel): Android { static from(android: channels.AndroidChannel): Android {
return (android as any)._object; return (android as any)._object;
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this._timeoutSettings = new TimeoutSettings();
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
this._channel.setDefaultTimeoutNoReply({ timeout });
} }
async devices(): Promise<AndroidDevice[]> { async devices(): Promise<AndroidDevice[]> {
@ -41,6 +54,9 @@ export class Android extends ChannelOwner<channels.AndroidChannel, channels.Andr
} }
export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, channels.AndroidDeviceInitializer> { export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, channels.AndroidDeviceInitializer> {
readonly _timeoutSettings: TimeoutSettings;
private _webViews = new Map<number, AndroidWebView>();
static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice { static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice {
return (androidDevice as any)._object; return (androidDevice as any)._object;
} }
@ -50,6 +66,27 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidDeviceInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidDeviceInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this.input = new Input(this); this.input = new Input(this);
this._timeoutSettings = new TimeoutSettings((parent as Android)._timeoutSettings);
this._channel.on('webViewAdded', ({ webView }) => this._onWebViewAdded(webView));
this._channel.on('webViewRemoved', ({ pid }) => this._onWebViewRemoved(pid));
}
private _onWebViewAdded(webView: channels.AndroidWebView) {
const view = new AndroidWebView(this, webView);
this._webViews.set(webView.pid, view);
this.emit(Events.AndroidDevice.WebView, view);
}
private _onWebViewRemoved(pid: number) {
const view = this._webViews.get(pid);
this._webViews.delete(pid);
if (view)
view.emit(Events.AndroidWebView.Close);
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
this._channel.setDefaultTimeoutNoReply({ timeout });
} }
serial(): string { serial(): string {
@ -60,6 +97,10 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
return this._initializer.model; return this._initializer.model;
} }
webViews(): AndroidWebView[] {
return [...this._webViews.values()];
}
async wait(selector: apiInternal.AndroidSelector, options?: { state?: 'gone' } & types.TimeoutOptions) { async wait(selector: apiInternal.AndroidSelector, options?: { state?: 'gone' } & types.TimeoutOptions) {
await this._wrapApiCall('androidDevice.wait', async () => { await this._wrapApiCall('androidDevice.wait', async () => {
await this._channel.wait({ selector: toSelectorChannel(selector), ...options }); await this._channel.wait({ selector: toSelectorChannel(selector), ...options });
@ -72,6 +113,11 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
}); });
} }
async press(selector: apiInternal.AndroidSelector, key: apiInternal.AndroidKey, options?: types.TimeoutOptions) {
await this.tap(selector, options);
await this.input.press(key);
}
async tap(selector: apiInternal.AndroidSelector, options?: { duration?: number } & types.TimeoutOptions) { async tap(selector: apiInternal.AndroidSelector, options?: { duration?: number } & types.TimeoutOptions) {
await this._wrapApiCall('androidDevice.tap', async () => { await this._wrapApiCall('androidDevice.tap', async () => {
await this._channel.tap({ selector: toSelectorChannel(selector), ...options }); await this._channel.tap({ selector: toSelectorChannel(selector), ...options });
@ -129,6 +175,7 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
async close() { async close() {
return this._wrapApiCall('androidDevice.close', async () => { return this._wrapApiCall('androidDevice.close', async () => {
await this._channel.close(); await this._channel.close();
this.emit(Events.AndroidDevice.Close);
}); });
} }
@ -146,6 +193,18 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
return BrowserContext.from(context); return BrowserContext.from(context);
}); });
} }
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = new Waiter();
waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`);
if (event !== Events.AndroidDevice.Close)
waiter.rejectOnEvent(this, Events.AndroidDevice.Close, new Error('Device closed'));
const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose();
return result;
}
} }
class Input implements apiInternal.AndroidInput { class Input implements apiInternal.AndroidInput {
@ -235,3 +294,36 @@ function toSelectorChannel(selector: apiInternal.AndroidSelector): channels.Andr
selected, selected,
}; };
} }
export class AndroidWebView extends EventEmitter {
private _device: AndroidDevice;
private _data: channels.AndroidWebView;
private _pagePromise: Promise<Page> | undefined;
constructor(device: AndroidDevice, data: channels.AndroidWebView) {
super();
this._device = device;
this._data = data;
}
pid(): number {
return this._data.pid;
}
pkg(): string {
return this._data.pkg;
}
async page(): Promise<Page> {
if (!this._pagePromise)
this._pagePromise = this._fetchPage();
return this._pagePromise;
}
private async _fetchPage(): Promise<Page> {
return this._device._wrapApiCall('androidWebView.page', async () => {
const { context } = await this._device._channel.connectToWebView({ pid: this._data.pid });
return BrowserContext.from(context).pages()[0];
});
}
}

View file

@ -16,6 +16,15 @@
*/ */
export const Events = { export const Events = {
AndroidDevice: {
WebView: 'webview',
Close: 'close'
},
AndroidWebView: {
Close: 'close'
},
Browser: { Browser: {
Disconnected: 'disconnected' Disconnected: 'disconnected'
}, },

View file

@ -20,8 +20,8 @@ import * as channels from '../protocol/channels';
import { BrowserContextDispatcher } from './browserContextDispatcher'; import { BrowserContextDispatcher } from './browserContextDispatcher';
export class AndroidDispatcher extends Dispatcher<Android, channels.AndroidInitializer> implements channels.AndroidChannel { export class AndroidDispatcher extends Dispatcher<Android, channels.AndroidInitializer> implements channels.AndroidChannel {
constructor(scope: DispatcherScope, electron: Android) { constructor(scope: DispatcherScope, android: Android) {
super(scope, electron, 'Android', {}, true); super(scope, android, 'Android', {}, true);
} }
async devices(params: channels.AndroidDevicesParams): Promise<channels.AndroidDevicesResult> { async devices(params: channels.AndroidDevicesParams): Promise<channels.AndroidDevicesResult> {
@ -30,14 +30,22 @@ export class AndroidDispatcher extends Dispatcher<Android, channels.AndroidIniti
devices: devices.map(d => new AndroidDeviceDispatcher(this._scope, d)) devices: devices.map(d => new AndroidDeviceDispatcher(this._scope, d))
}; };
} }
async setDefaultTimeoutNoReply(params: channels.AndroidSetDefaultTimeoutNoReplyParams) {
this._object.setDefaultTimeout(params.timeout);
}
} }
export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.AndroidDeviceInitializer> implements channels.AndroidDeviceChannel { export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.AndroidDeviceInitializer> implements channels.AndroidDeviceChannel {
constructor(scope: DispatcherScope, device: AndroidDevice) { constructor(scope: DispatcherScope, device: AndroidDevice) {
super(scope, device, 'AndroidDevice', { super(scope, device, 'AndroidDevice', {
model: device.model, model: device.model,
serial: device.serial serial: device.serial,
}, true); }, true);
for (const webView of device.webViews())
this._dispatchEvent('webViewAdded', { webView });
device.on(AndroidDevice.Events.WebViewAdded, webView => this._dispatchEvent('webViewAdded', { webView }));
device.on(AndroidDevice.Events.WebViewRemoved, pid => this._dispatchEvent('webViewRemoved', { pid }));
} }
async wait(params: channels.AndroidDeviceWaitParams) { async wait(params: channels.AndroidDeviceWaitParams) {
@ -129,6 +137,14 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
async close(params: channels.AndroidDeviceCloseParams) { async close(params: channels.AndroidDeviceCloseParams) {
await this._object.close(); await this._object.close();
} }
async setDefaultTimeoutNoReply(params: channels.AndroidDeviceSetDefaultTimeoutNoReplyParams) {
this._object.setDefaultTimeout(params.timeout);
}
async connectToWebView(params: channels.AndroidDeviceConnectToWebViewParams): Promise<channels.AndroidDeviceConnectToWebViewResult> {
return { context: new BrowserContextDispatcher(this._scope, await this._object.connectToWebView(params.pid)) };
}
} }
const keyMap = new Map<string, number>([ const keyMap = new Map<string, number>([

View file

@ -2406,55 +2406,20 @@ export type ElectronApplicationCloseResult = void;
export type AndroidInitializer = {}; export type AndroidInitializer = {};
export interface AndroidChannel extends Channel { export interface AndroidChannel extends Channel {
devices(params?: AndroidDevicesParams, metadata?: Metadata): Promise<AndroidDevicesResult>; devices(params?: AndroidDevicesParams, metadata?: Metadata): Promise<AndroidDevicesResult>;
setDefaultTimeoutNoReply(params: AndroidSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise<AndroidSetDefaultTimeoutNoReplyResult>;
} }
export type AndroidDevicesParams = {}; export type AndroidDevicesParams = {};
export type AndroidDevicesOptions = {}; export type AndroidDevicesOptions = {};
export type AndroidDevicesResult = { export type AndroidDevicesResult = {
devices: AndroidDeviceChannel[], devices: AndroidDeviceChannel[],
}; };
export type AndroidSetDefaultTimeoutNoReplyParams = {
export type AndroidSelector = { timeout: number,
checkable?: boolean,
checked?: boolean,
clazz?: string,
clickable?: boolean,
depth?: number,
desc?: string,
enabled?: boolean,
focusable?: boolean,
focused?: boolean,
hasChild?: {
selector: AndroidSelector,
},
hasDescendant?: {
selector: AndroidSelector,
maxDepth?: number,
},
longClickable?: boolean,
pkg?: string,
res?: string,
scrollable?: boolean,
selected?: boolean,
text?: string,
}; };
export type AndroidSetDefaultTimeoutNoReplyOptions = {
export type AndroidElementInfo = {
clazz: string,
desc: string,
res: string,
pkg: string,
text: string,
bounds: Rect,
checkable: boolean,
checked: boolean,
clickable: boolean,
enabled: boolean,
focusable: boolean,
focused: boolean,
longClickable: boolean,
scrollable: boolean,
selected: boolean,
}; };
export type AndroidSetDefaultTimeoutNoReplyResult = void;
// ----------- AndroidDevice ----------- // ----------- AndroidDevice -----------
export type AndroidDeviceInitializer = { export type AndroidDeviceInitializer = {
@ -2462,6 +2427,8 @@ export type AndroidDeviceInitializer = {
serial: string, serial: string,
}; };
export interface AndroidDeviceChannel extends Channel { export interface AndroidDeviceChannel extends Channel {
on(event: 'webViewAdded', callback: (params: AndroidDeviceWebViewAddedEvent) => void): this;
on(event: 'webViewRemoved', callback: (params: AndroidDeviceWebViewRemovedEvent) => void): this;
wait(params: AndroidDeviceWaitParams, metadata?: Metadata): Promise<AndroidDeviceWaitResult>; wait(params: AndroidDeviceWaitParams, metadata?: Metadata): Promise<AndroidDeviceWaitResult>;
fill(params: AndroidDeviceFillParams, metadata?: Metadata): Promise<AndroidDeviceFillResult>; fill(params: AndroidDeviceFillParams, metadata?: Metadata): Promise<AndroidDeviceFillResult>;
tap(params: AndroidDeviceTapParams, metadata?: Metadata): Promise<AndroidDeviceTapResult>; tap(params: AndroidDeviceTapParams, metadata?: Metadata): Promise<AndroidDeviceTapResult>;
@ -2480,8 +2447,16 @@ export interface AndroidDeviceChannel extends Channel {
inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise<AndroidDeviceInputDragResult>; inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise<AndroidDeviceInputDragResult>;
launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise<AndroidDeviceLaunchBrowserResult>; launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise<AndroidDeviceLaunchBrowserResult>;
shell(params: AndroidDeviceShellParams, metadata?: Metadata): Promise<AndroidDeviceShellResult>; shell(params: AndroidDeviceShellParams, metadata?: Metadata): Promise<AndroidDeviceShellResult>;
setDefaultTimeoutNoReply(params: AndroidDeviceSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise<AndroidDeviceSetDefaultTimeoutNoReplyResult>;
connectToWebView(params: AndroidDeviceConnectToWebViewParams, metadata?: Metadata): Promise<AndroidDeviceConnectToWebViewResult>;
close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise<AndroidDeviceCloseResult>; close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise<AndroidDeviceCloseResult>;
} }
export type AndroidDeviceWebViewAddedEvent = {
webView: AndroidWebView,
};
export type AndroidDeviceWebViewRemovedEvent = {
pid: number,
};
export type AndroidDeviceWaitParams = { export type AndroidDeviceWaitParams = {
selector: AndroidSelector, selector: AndroidSelector,
state?: 'gone', state?: 'gone',
@ -2736,6 +2711,70 @@ export type AndroidDeviceShellOptions = {
export type AndroidDeviceShellResult = { export type AndroidDeviceShellResult = {
result: string, result: string,
}; };
export type AndroidDeviceSetDefaultTimeoutNoReplyParams = {
timeout: number,
};
export type AndroidDeviceSetDefaultTimeoutNoReplyOptions = {
};
export type AndroidDeviceSetDefaultTimeoutNoReplyResult = void;
export type AndroidDeviceConnectToWebViewParams = {
pid: number,
};
export type AndroidDeviceConnectToWebViewOptions = {
};
export type AndroidDeviceConnectToWebViewResult = {
context: BrowserContextChannel,
};
export type AndroidDeviceCloseParams = {}; export type AndroidDeviceCloseParams = {};
export type AndroidDeviceCloseOptions = {}; export type AndroidDeviceCloseOptions = {};
export type AndroidDeviceCloseResult = void; export type AndroidDeviceCloseResult = void;
export type AndroidWebView = {
pid: number,
pkg: string,
};
export type AndroidSelector = {
checkable?: boolean,
checked?: boolean,
clazz?: string,
clickable?: boolean,
depth?: number,
desc?: string,
enabled?: boolean,
focusable?: boolean,
focused?: boolean,
hasChild?: {
selector: AndroidSelector,
},
hasDescendant?: {
selector: AndroidSelector,
maxDepth?: number,
},
longClickable?: boolean,
pkg?: string,
res?: string,
scrollable?: boolean,
selected?: boolean,
text?: string,
};
export type AndroidElementInfo = {
clazz: string,
desc: string,
res: string,
pkg: string,
text: string,
bounds: Rect,
checkable: boolean,
checked: boolean,
clickable: boolean,
enabled: boolean,
focusable: boolean,
focused: boolean,
longClickable: boolean,
scrollable: boolean,
selected: boolean,
};

View file

@ -2079,54 +2079,9 @@ Android:
type: array type: array
items: AndroidDevice items: AndroidDevice
setDefaultTimeoutNoReply:
AndroidSelector: parameters:
type: object timeout: number
properties:
checkable: boolean?
checked: boolean?
clazz: string?
clickable: boolean?
depth: number?
desc: string?
enabled: boolean?
focusable: boolean?
focused: boolean?
hasChild:
type: object?
properties:
selector: AndroidSelector
hasDescendant:
type: object?
properties:
selector: AndroidSelector
maxDepth: number?
longClickable: boolean?
pkg: string?
res: string?
scrollable: boolean?
selected: boolean?
text: string?
AndroidElementInfo:
type: object
properties:
clazz: string
desc: string
res: string
pkg: string
text: string
bounds: Rect
checkable: boolean
checked: boolean
clickable: boolean
enabled: boolean
focusable: boolean
focused: boolean
longClickable: boolean
scrollable: boolean
selected: boolean
AndroidDevice: AndroidDevice:
@ -2326,4 +2281,79 @@ AndroidDevice:
returns: returns:
result: string result: string
setDefaultTimeoutNoReply:
parameters:
timeout: number
connectToWebView:
parameters:
pid: number
returns:
context: BrowserContext
close: close:
events:
webViewAdded:
parameters:
webView: AndroidWebView
webViewRemoved:
parameters:
pid: number
AndroidWebView:
type: object
properties:
pid: number
pkg: string
AndroidSelector:
type: object
properties:
checkable: boolean?
checked: boolean?
clazz: string?
clickable: boolean?
depth: number?
desc: string?
enabled: boolean?
focusable: boolean?
focused: boolean?
hasChild:
type: object?
properties:
selector: AndroidSelector
hasDescendant:
type: object?
properties:
selector: AndroidSelector
maxDepth: number?
longClickable: boolean?
pkg: string?
res: string?
scrollable: boolean?
selected: boolean?
text: string?
AndroidElementInfo:
type: object
properties:
clazz: string
desc: string
res: string
pkg: string
text: string
bounds: Rect
checkable: boolean
checked: boolean
clickable: boolean
enabled: boolean
focusable: boolean
focused: boolean
longClickable: boolean
scrollable: boolean
selected: boolean

View file

@ -898,46 +898,8 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
}); });
scheme.ElectronApplicationCloseParams = tOptional(tObject({})); scheme.ElectronApplicationCloseParams = tOptional(tObject({}));
scheme.AndroidDevicesParams = tOptional(tObject({})); scheme.AndroidDevicesParams = tOptional(tObject({}));
scheme.AndroidSelector = tObject({ scheme.AndroidSetDefaultTimeoutNoReplyParams = tObject({
checkable: tOptional(tBoolean), timeout: tNumber,
checked: tOptional(tBoolean),
clazz: tOptional(tString),
clickable: tOptional(tBoolean),
depth: tOptional(tNumber),
desc: tOptional(tString),
enabled: tOptional(tBoolean),
focusable: tOptional(tBoolean),
focused: tOptional(tBoolean),
hasChild: tOptional(tObject({
selector: tType('AndroidSelector'),
})),
hasDescendant: tOptional(tObject({
selector: tType('AndroidSelector'),
maxDepth: tOptional(tNumber),
})),
longClickable: tOptional(tBoolean),
pkg: tOptional(tString),
res: tOptional(tString),
scrollable: tOptional(tBoolean),
selected: tOptional(tBoolean),
text: tOptional(tString),
});
scheme.AndroidElementInfo = tObject({
clazz: tString,
desc: tString,
res: tString,
pkg: tString,
text: tString,
bounds: tType('Rect'),
checkable: tBoolean,
checked: tBoolean,
clickable: tBoolean,
enabled: tBoolean,
focusable: tBoolean,
focused: tBoolean,
longClickable: tBoolean,
scrollable: tBoolean,
selected: tBoolean,
}); });
scheme.AndroidDeviceWaitParams = tObject({ scheme.AndroidDeviceWaitParams = tObject({
selector: tType('AndroidSelector'), selector: tType('AndroidSelector'),
@ -1065,7 +1027,58 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
scheme.AndroidDeviceShellParams = tObject({ scheme.AndroidDeviceShellParams = tObject({
command: tString, command: tString,
}); });
scheme.AndroidDeviceSetDefaultTimeoutNoReplyParams = tObject({
timeout: tNumber,
});
scheme.AndroidDeviceConnectToWebViewParams = tObject({
pid: tNumber,
});
scheme.AndroidDeviceCloseParams = tOptional(tObject({})); scheme.AndroidDeviceCloseParams = tOptional(tObject({}));
scheme.AndroidWebView = tObject({
pid: tNumber,
pkg: tString,
});
scheme.AndroidSelector = tObject({
checkable: tOptional(tBoolean),
checked: tOptional(tBoolean),
clazz: tOptional(tString),
clickable: tOptional(tBoolean),
depth: tOptional(tNumber),
desc: tOptional(tString),
enabled: tOptional(tBoolean),
focusable: tOptional(tBoolean),
focused: tOptional(tBoolean),
hasChild: tOptional(tObject({
selector: tType('AndroidSelector'),
})),
hasDescendant: tOptional(tObject({
selector: tType('AndroidSelector'),
maxDepth: tOptional(tNumber),
})),
longClickable: tOptional(tBoolean),
pkg: tOptional(tString),
res: tOptional(tString),
scrollable: tOptional(tBoolean),
selected: tOptional(tBoolean),
text: tOptional(tString),
});
scheme.AndroidElementInfo = tObject({
clazz: tString,
desc: tString,
res: tString,
pkg: tString,
text: tString,
bounds: tType('Rect'),
checkable: tBoolean,
checked: tBoolean,
clickable: tBoolean,
enabled: tBoolean,
focusable: tBoolean,
focused: tBoolean,
longClickable: tBoolean,
scrollable: tBoolean,
selected: tBoolean,
});
return scheme; return scheme;
} }

View file

@ -29,6 +29,8 @@ import { CRBrowser } from '../chromium/crBrowser';
import { helper } from '../helper'; import { helper } from '../helper';
import { Transport } from '../../protocol/transport'; import { Transport } from '../../protocol/transport';
import { RecentLogsCollector } from '../../utils/debugLogger'; import { RecentLogsCollector } from '../../utils/debugLogger';
import { TimeoutSettings } from '../../utils/timeoutSettings';
import { AndroidWebView } from '../../protocol/channels';
const readFileAsync = util.promisify(fs.readFile); const readFileAsync = util.promisify(fs.readFile);
@ -51,35 +53,67 @@ export interface SocketBackend extends EventEmitter {
export class Android { export class Android {
private _backend: Backend; private _backend: Backend;
readonly _timeoutSettings: TimeoutSettings;
constructor(backend: Backend) { constructor(backend: Backend) {
this._backend = backend; this._backend = backend;
this._timeoutSettings = new TimeoutSettings();
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
} }
async devices(): Promise<AndroidDevice[]> { async devices(): Promise<AndroidDevice[]> {
const devices = await this._backend.devices(); const devices = await this._backend.devices();
return await Promise.all(devices.map(d => AndroidDevice.create(d))); return await Promise.all(devices.map(d => AndroidDevice.create(this, d)));
} }
} }
export class AndroidDevice { export class AndroidDevice extends EventEmitter {
readonly _backend: DeviceBackend; readonly _backend: DeviceBackend;
readonly model: string; readonly model: string;
readonly serial: string; readonly serial: string;
private _driverPromise: Promise<Transport> | undefined; private _driverPromise: Promise<Transport> | undefined;
private _lastId = 0; private _lastId = 0;
private _callbacks = new Map<number, { fulfill: (result: any) => void, reject: (error: Error) => void }>(); private _callbacks = new Map<number, { fulfill: (result: any) => void, reject: (error: Error) => void }>();
private _pollingWebViews: NodeJS.Timeout | undefined;
readonly _timeoutSettings: TimeoutSettings;
private _webViews = new Map<number, AndroidWebView>();
constructor(backend: DeviceBackend, model: string) { static Events = {
WebViewAdded: 'webViewAdded',
WebViewRemoved: 'webViewRemoved',
};
private _browserConnections = new Set<AndroidBrowser>();
constructor(android: Android, backend: DeviceBackend, model: string) {
super();
this._backend = backend; this._backend = backend;
this.model = model; this.model = model;
this.serial = backend.serial; this.serial = backend.serial;
this._timeoutSettings = new TimeoutSettings(android._timeoutSettings);
} }
static async create(backend: DeviceBackend): Promise<AndroidDevice> { static async create(android: Android, backend: DeviceBackend): Promise<AndroidDevice> {
await backend.init(); await backend.init();
const model = await backend.runCommand('shell:getprop ro.product.model'); const model = await backend.runCommand('shell:getprop ro.product.model');
return new AndroidDevice(backend, model); const device = new AndroidDevice(android, backend, model.trim());
await device._init();
return device;
}
async _init() {
await this._refreshWebViews();
const poll = () => {
this._pollingWebViews = setTimeout(() => this._refreshWebViews().then(poll).catch(() => {}), 500);
};
poll();
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
} }
async shell(command: string): Promise<string> { async shell(command: string): Promise<string> {
@ -101,7 +135,9 @@ export class AndroidDevice {
debug('pw:android')('Installing the new driver'); debug('pw:android')('Installing the new driver');
for (const file of ['android-driver.apk', 'android-driver-target.apk']) { 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}`)); 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}`); const installSocket = await this._backend.open(`shell:cmd package install -r -t -S ${driverFile.length}`);
debug('pw:android')('Writing driver bytes: ' + driverFile.length); debug('pw:android')('Writing driver bytes: ' + driverFile.length);
await installSocket.write(driverFile); await installSocket.write(driverFile);
@ -150,20 +186,26 @@ export class AndroidDevice {
} }
async close() { async close() {
const driver = await this._driver(); if (this._pollingWebViews)
driver.close(); clearTimeout(this._pollingWebViews);
for (const connection of this._browserConnections)
await connection.close();
if (this._driverPromise) {
const driver = await this._driver();
driver.close();
}
await this._backend.close(); await this._backend.close();
} }
async launchBrowser(packageName: string = 'com.android.chrome', options: types.BrowserContextOptions = {}): Promise<BrowserContext> { async launchBrowser(pkg: string = 'com.android.chrome', options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
debug('pw:android')('Force-stopping', packageName); debug('pw:android')('Force-stopping', pkg);
await this._backend.runCommand(`shell:am force-stop ${packageName}`); await this._backend.runCommand(`shell:am force-stop ${pkg}`);
const socketName = createGuid(); const socketName = createGuid();
const commandLine = `_ --disable-fre --no-default-browser-check --no-first-run --remote-debugging-socket-name=${socketName}`; const commandLine = `_ --disable-fre --no-default-browser-check --no-first-run --remote-debugging-socket-name=${socketName}`;
debug('pw:android')('Starting', packageName, commandLine); debug('pw:android')('Starting', pkg, commandLine);
await this._backend.runCommand(`shell:echo "${commandLine}" > /data/local/tmp/chrome-command-line`); await this._backend.runCommand(`shell:echo "${commandLine}" > /data/local/tmp/chrome-command-line`);
await this._backend.runCommand(`shell:am start -n ${packageName}/com.google.android.apps.chrome.Main about:blank`); await this._backend.runCommand(`shell:am start -n ${pkg}/com.google.android.apps.chrome.Main about:blank`);
debug('pw:android')('Polling for socket', socketName); debug('pw:android')('Polling for socket', socketName);
while (true) { while (true) {
@ -173,8 +215,20 @@ export class AndroidDevice {
await new Promise(f => setTimeout(f, 100)); await new Promise(f => setTimeout(f, 100));
} }
debug('pw:android')('Got the socket, connecting'); debug('pw:android')('Got the socket, connecting');
const androidBrowser = new AndroidBrowser(this, packageName, socketName); return await this._connectToBrowser(socketName, options);
}
connectToWebView(pid: number): Promise<BrowserContext> {
const webView = this._webViews.get(pid);
if (!webView)
throw new Error('WebView has been closed');
return this._connectToBrowser(`webview_devtools_remote_${pid}`);
}
private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
const androidBrowser = new AndroidBrowser(this, socketName);
await androidBrowser._open(); await androidBrowser._open();
this._browserConnections.add(androidBrowser);
const browserOptions: BrowserOptions = { const browserOptions: BrowserOptions = {
name: 'clank', name: 'clank',
@ -195,6 +249,49 @@ export class AndroidDevice {
}); });
return browser._defaultContext!; return browser._defaultContext!;
} }
webViews(): AndroidWebView[] {
return [...this._webViews.values()];
}
private async _refreshWebViews() {
const sockets = (await this._backend.runCommand(`shell:cat /proc/net/unix | grep webview_devtools_remote`)).split('\n');
const newPids = new Set<number>();
for (const line of sockets) {
const match = line.match(/[^@]+@webview_devtools_remote_(\d+)/);
if (!match)
continue;
const pid = +match[1];
newPids.add(pid);
}
for (const pid of newPids) {
if (this._webViews.has(pid))
continue;
const procs = (await this._backend.runCommand(`shell:ps -A | grep ${pid}`)).split('\n');
let pkg = '';
for (const proc of procs) {
const match = proc.match(/[^\s]+\s+(\d+).*$/);
if (!match)
continue;
const p = match[1];
if (+p !== pid)
continue;
pkg = proc.substring(proc.lastIndexOf(' '));
}
const webView = { pid, pkg };
this._webViews.set(pid, webView);
this.emit(AndroidDevice.Events.WebViewAdded, webView);
}
for (const p of this._webViews.keys()) {
if (!newPids.has(p)) {
this._webViews.delete(p);
this.emit(AndroidDevice.Events.WebViewRemoved, p);
}
}
}
} }
class AndroidBrowser extends EventEmitter { class AndroidBrowser extends EventEmitter {
@ -205,11 +302,9 @@ class AndroidBrowser extends EventEmitter {
private _waitForNextTask = makeWaitForNextTask(); private _waitForNextTask = makeWaitForNextTask();
onmessage?: (message: any) => void; onmessage?: (message: any) => void;
onclose?: () => void; onclose?: () => void;
private _packageName: string;
constructor(device: AndroidDevice, packageName: string, socketName: string) { constructor(device: AndroidDevice, socketName: string) {
super(); super();
this._packageName = packageName;
this.device = device; this.device = device;
this.socketName = socketName; this.socketName = socketName;
this._receiver = new (ws as any).Receiver() as stream.Writable; this._receiver = new (ws as any).Receiver() as stream.Writable;
@ -249,7 +344,6 @@ Sec-WebSocket-Version: 13\r
async close() { async close() {
await this._socket!.close(); await this._socket!.close();
await this.device._backend.runCommand(`shell:am force-stop ${this._packageName}`);
} }
} }

View file

@ -55,7 +55,7 @@ class AdbDevice implements DeviceBackend {
async function runCommand(command: string, serial?: string): Promise<string> { async function runCommand(command: string, serial?: string): Promise<string> {
debug('pw:adb:runCommand')(command, serial); debug('pw:adb:runCommand')(command, serial);
const socket = new BufferedSocketWrapper(net.createConnection({ port: 5037 })); const socket = new BufferedSocketWrapper(command, net.createConnection({ port: 5037 }));
if (serial) { if (serial) {
await socket.write(encodeMessage(`host:transport:${serial}`)); await socket.write(encodeMessage(`host:transport:${serial}`));
const status = await socket.read(4); const status = await socket.read(4);
@ -72,7 +72,7 @@ async function runCommand(command: string, serial?: string): Promise<string> {
} }
async function open(command: string, serial?: string): Promise<BufferedSocketWrapper> { async function open(command: string, serial?: string): Promise<BufferedSocketWrapper> {
const socket = new BufferedSocketWrapper(net.createConnection({ port: 5037 })); const socket = new BufferedSocketWrapper(command, net.createConnection({ port: 5037 }));
if (serial) { if (serial) {
await socket.write(encodeMessage(`host:transport:${serial}`)); await socket.write(encodeMessage(`host:transport:${serial}`));
const status = await socket.read(4); const status = await socket.read(4);
@ -97,13 +97,15 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
private _notifyReader: (() => void) | undefined; private _notifyReader: (() => void) | undefined;
private _connectPromise: Promise<void>; private _connectPromise: Promise<void>;
private _isClosed = false; private _isClosed = false;
private _command: string;
constructor(socket: net.Socket) { constructor(command: string, socket: net.Socket) {
super(); super();
this._command = command;
this._socket = socket; this._socket = socket;
this._connectPromise = new Promise(f => this._socket.on('connect', f)); this._connectPromise = new Promise(f => this._socket.on('connect', f));
this._socket.on('data', data => { this._socket.on('data', data => {
debug('pw:android:adb:data')(data.toString()); debug('pw:adb:data')(data.toString());
if (this._isSocket) { if (this._isSocket) {
this.emit('data', data); this.emit('data', data);
return; return;
@ -122,13 +124,13 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
} }
async write(data: Buffer) { async write(data: Buffer) {
debug('pw:android:adb:send')(data.toString()); debug('pw:adb:send')(data.toString().substring(0, 100) + '...');
await this._connectPromise; await this._connectPromise;
await new Promise(f => this._socket.write(data, f)); await new Promise(f => this._socket.write(data, f));
} }
async close() { async close() {
debug('pw:android:adb')('Close'); debug('pw:adb')('Close ' + this._command);
this._socket.destroy(); this._socket.destroy();
} }
@ -139,7 +141,7 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
await new Promise(f => this._notifyReader = f); await new Promise(f => this._notifyReader = f);
const result = this._buffer.slice(0, length); const result = this._buffer.slice(0, length);
this._buffer = this._buffer.slice(length); this._buffer = this._buffer.slice(length);
debug('pw:android:adb:recv')(result.toString()); debug('pw:adb:recv')(result.toString().substring(0, 100) + '...');
return result; return result;
} }