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.
*/
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 = {
clazz: string;
desc: string;
@ -52,37 +98,6 @@ export type AndroidSelector = {
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 =
'Unknown' |
'SoftLeft' | 'SoftRight' |

13
android-types.d.ts vendored
View File

@ -14,8 +14,15 @@
* 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 { EventEmitter } from 'events';
export * from './android-types-internal';
export type AndroidDevice = apiInternal.AndroidDevice<BrowserContext, BrowserContextOptions>;
export { AndroidElementInfo, AndroidSelector } from './android-types-internal';
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 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 PACKAGES = {
@ -65,10 +65,10 @@ const PACKAGES = {
files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'electron-types.d.ts'],
},
'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',
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.
!types/*
!index.d.ts
# Include separate android types.
!android-types.d.ts
!android-types-internal.d.ts
# Include separate electron types.
!electron-types.d.ts
# Include main entrypoint.

View File

@ -15,28 +15,32 @@ const { android } = require('playwright-android');
(async () => {
const [device] = await android.devices();
// Android automation.
console.log(`Model: ${device.model()}`);
console.log(`Serial: ${device.serial()}`);
await device.tap({ desc: 'Home' });
console.log(await device.info({ text: 'Chrome' }));
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.shell('am force-stop org.chromium.webview_shell');
await device.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity');
await device.tap({ res: 'com.android.chrome:id/tab_switcher_button' });
await device.tap({ desc: 'More options' });
await device.tap({ desc: 'Close all tabs' });
await device.fill({ res: 'org.chromium.webview_shell:id/url_field' }, 'github.com/microsoft/playwright');
// Browser automation.
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();
let [webview] = device.webViews();
if (!webview)
webview = await device.waitForEvent('webview');
const page = await webview.page();
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();
})();

View File

@ -15,21 +15,34 @@
*/
import * as channels from '../protocol/channels';
import { Events } from './events';
import { BrowserContext, validateBrowserContextOptions } from './browserContext';
import { ChannelOwner } from './channelOwner';
import * as apiInternal from '../../android-types-internal';
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 SpeedOptions = { speed?: number };
export class Android extends ChannelOwner<channels.AndroidChannel, channels.AndroidInitializer> {
readonly _timeoutSettings: TimeoutSettings;
static from(android: channels.AndroidChannel): Android {
return (android as any)._object;
}
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidInitializer) {
super(parent, type, guid, initializer);
this._timeoutSettings = new TimeoutSettings();
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
this._channel.setDefaultTimeoutNoReply({ timeout });
}
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> {
readonly _timeoutSettings: TimeoutSettings;
private _webViews = new Map<number, AndroidWebView>();
static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice {
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) {
super(parent, type, guid, initializer);
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 {
@ -60,6 +97,10 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
return this._initializer.model;
}
webViews(): AndroidWebView[] {
return [...this._webViews.values()];
}
async wait(selector: apiInternal.AndroidSelector, options?: { state?: 'gone' } & types.TimeoutOptions) {
await this._wrapApiCall('androidDevice.wait', async () => {
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) {
await this._wrapApiCall('androidDevice.tap', async () => {
await this._channel.tap({ selector: toSelectorChannel(selector), ...options });
@ -129,6 +175,7 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
async close() {
return this._wrapApiCall('androidDevice.close', async () => {
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);
});
}
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 {
@ -235,3 +294,36 @@ function toSelectorChannel(selector: apiInternal.AndroidSelector): channels.Andr
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 = {
AndroidDevice: {
WebView: 'webview',
Close: 'close'
},
AndroidWebView: {
Close: 'close'
},
Browser: {
Disconnected: 'disconnected'
},

View File

@ -20,8 +20,8 @@ import * as channels from '../protocol/channels';
import { BrowserContextDispatcher } from './browserContextDispatcher';
export class AndroidDispatcher extends Dispatcher<Android, channels.AndroidInitializer> implements channels.AndroidChannel {
constructor(scope: DispatcherScope, electron: Android) {
super(scope, electron, 'Android', {}, true);
constructor(scope: DispatcherScope, android: Android) {
super(scope, android, 'Android', {}, true);
}
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))
};
}
async setDefaultTimeoutNoReply(params: channels.AndroidSetDefaultTimeoutNoReplyParams) {
this._object.setDefaultTimeout(params.timeout);
}
}
export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.AndroidDeviceInitializer> implements channels.AndroidDeviceChannel {
constructor(scope: DispatcherScope, device: AndroidDevice) {
super(scope, device, 'AndroidDevice', {
model: device.model,
serial: device.serial
serial: device.serial,
}, 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) {
@ -129,6 +137,14 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
async close(params: channels.AndroidDeviceCloseParams) {
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>([

View File

@ -2406,55 +2406,20 @@ export type ElectronApplicationCloseResult = void;
export type AndroidInitializer = {};
export interface AndroidChannel extends Channel {
devices(params?: AndroidDevicesParams, metadata?: Metadata): Promise<AndroidDevicesResult>;
setDefaultTimeoutNoReply(params: AndroidSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise<AndroidSetDefaultTimeoutNoReplyResult>;
}
export type AndroidDevicesParams = {};
export type AndroidDevicesOptions = {};
export type AndroidDevicesResult = {
devices: AndroidDeviceChannel[],
};
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 AndroidSetDefaultTimeoutNoReplyParams = {
timeout: number,
};
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 -----------
export type AndroidDeviceInitializer = {
@ -2462,6 +2427,8 @@ export type AndroidDeviceInitializer = {
serial: string,
};
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>;
fill(params: AndroidDeviceFillParams, metadata?: Metadata): Promise<AndroidDeviceFillResult>;
tap(params: AndroidDeviceTapParams, metadata?: Metadata): Promise<AndroidDeviceTapResult>;
@ -2480,8 +2447,16 @@ export interface AndroidDeviceChannel extends Channel {
inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise<AndroidDeviceInputDragResult>;
launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise<AndroidDeviceLaunchBrowserResult>;
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>;
}
export type AndroidDeviceWebViewAddedEvent = {
webView: AndroidWebView,
};
export type AndroidDeviceWebViewRemovedEvent = {
pid: number,
};
export type AndroidDeviceWaitParams = {
selector: AndroidSelector,
state?: 'gone',
@ -2736,6 +2711,70 @@ export type AndroidDeviceShellOptions = {
export type AndroidDeviceShellResult = {
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 AndroidDeviceCloseOptions = {};
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
items: AndroidDevice
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
setDefaultTimeoutNoReply:
parameters:
timeout: number
AndroidDevice:
@ -2326,4 +2281,79 @@ AndroidDevice:
returns:
result: string
setDefaultTimeoutNoReply:
parameters:
timeout: number
connectToWebView:
parameters:
pid: number
returns:
context: BrowserContext
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.AndroidDevicesParams = tOptional(tObject({}));
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,
scheme.AndroidSetDefaultTimeoutNoReplyParams = tObject({
timeout: tNumber,
});
scheme.AndroidDeviceWaitParams = tObject({
selector: tType('AndroidSelector'),
@ -1065,7 +1027,58 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
scheme.AndroidDeviceShellParams = tObject({
command: tString,
});
scheme.AndroidDeviceSetDefaultTimeoutNoReplyParams = tObject({
timeout: tNumber,
});
scheme.AndroidDeviceConnectToWebViewParams = tObject({
pid: tNumber,
});
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;
}

View File

@ -29,6 +29,8 @@ import { CRBrowser } from '../chromium/crBrowser';
import { helper } from '../helper';
import { Transport } from '../../protocol/transport';
import { RecentLogsCollector } from '../../utils/debugLogger';
import { TimeoutSettings } from '../../utils/timeoutSettings';
import { AndroidWebView } from '../../protocol/channels';
const readFileAsync = util.promisify(fs.readFile);
@ -51,35 +53,67 @@ export interface SocketBackend extends EventEmitter {
export class Android {
private _backend: Backend;
readonly _timeoutSettings: TimeoutSettings;
constructor(backend: Backend) {
this._backend = backend;
this._timeoutSettings = new TimeoutSettings();
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
}
async devices(): Promise<AndroidDevice[]> {
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 model: string;
readonly serial: string;
private _driverPromise: Promise<Transport> | undefined;
private _lastId = 0;
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.model = model;
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();
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> {
@ -101,7 +135,9 @@ export class AndroidDevice {
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);
@ -150,20 +186,26 @@ export class AndroidDevice {
}
async close() {
const driver = await this._driver();
driver.close();
if (this._pollingWebViews)
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();
}
async launchBrowser(packageName: string = 'com.android.chrome', options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
debug('pw:android')('Force-stopping', packageName);
await this._backend.runCommand(`shell:am force-stop ${packageName}`);
async launchBrowser(pkg: string = 'com.android.chrome', options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
debug('pw:android')('Force-stopping', pkg);
await this._backend.runCommand(`shell:am force-stop ${pkg}`);
const socketName = createGuid();
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: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);
while (true) {
@ -173,8 +215,20 @@ export class AndroidDevice {
await new Promise(f => setTimeout(f, 100));
}
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();
this._browserConnections.add(androidBrowser);
const browserOptions: BrowserOptions = {
name: 'clank',
@ -195,6 +249,49 @@ export class AndroidDevice {
});
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 {
@ -205,11 +302,9 @@ class AndroidBrowser extends EventEmitter {
private _waitForNextTask = makeWaitForNextTask();
onmessage?: (message: any) => void;
onclose?: () => void;
private _packageName: string;
constructor(device: AndroidDevice, packageName: string, socketName: string) {
constructor(device: AndroidDevice, socketName: string) {
super();
this._packageName = packageName;
this.device = device;
this.socketName = socketName;
this._receiver = new (ws as any).Receiver() as stream.Writable;
@ -249,7 +344,6 @@ Sec-WebSocket-Version: 13\r
async 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> {
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) {
await socket.write(encodeMessage(`host:transport:${serial}`));
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> {
const socket = new BufferedSocketWrapper(net.createConnection({ port: 5037 }));
const socket = new BufferedSocketWrapper(command, net.createConnection({ port: 5037 }));
if (serial) {
await socket.write(encodeMessage(`host:transport:${serial}`));
const status = await socket.read(4);
@ -97,13 +97,15 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
private _notifyReader: (() => void) | undefined;
private _connectPromise: Promise<void>;
private _isClosed = false;
private _command: string;
constructor(socket: net.Socket) {
constructor(command: string, socket: net.Socket) {
super();
this._command = command;
this._socket = socket;
this._connectPromise = new Promise(f => this._socket.on('connect', f));
this._socket.on('data', data => {
debug('pw:android:adb:data')(data.toString());
debug('pw:adb:data')(data.toString());
if (this._isSocket) {
this.emit('data', data);
return;
@ -122,13 +124,13 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
}
async write(data: Buffer) {
debug('pw:android:adb:send')(data.toString());
debug('pw:adb:send')(data.toString().substring(0, 100) + '...');
await this._connectPromise;
await new Promise(f => this._socket.write(data, f));
}
async close() {
debug('pw:android:adb')('Close');
debug('pw:adb')('Close ' + this._command);
this._socket.destroy();
}
@ -139,7 +141,7 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
await new Promise(f => this._notifyReader = f);
const result = this._buffer.slice(0, 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;
}