feat(adb): support webviews (#4657)
This commit is contained in:
parent
f939fdc1a1
commit
8fc49c98fa
77
android-types-internal.d.ts
vendored
77
android-types-internal.d.ts
vendored
|
|
@ -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
13
android-types.d.ts
vendored
|
|
@ -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[]>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const Events = {
|
export const Events = {
|
||||||
|
AndroidDevice: {
|
||||||
|
WebView: 'webview',
|
||||||
|
Close: 'close'
|
||||||
|
},
|
||||||
|
|
||||||
|
AndroidWebView: {
|
||||||
|
Close: 'close'
|
||||||
|
},
|
||||||
|
|
||||||
Browser: {
|
Browser: {
|
||||||
Disconnected: 'disconnected'
|
Disconnected: 'disconnected'
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>([
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue