diff --git a/android-types-internal.d.ts b/android-types-internal.d.ts index 8f4ee655fc..1c1947abd2 100644 --- a/android-types-internal.d.ts +++ b/android-types-internal.d.ts @@ -14,6 +14,52 @@ * limitations under the License. */ +import { EventEmitter } from 'events'; + +export interface AndroidDevice extends EventEmitter { + input: AndroidInput; + + setDefaultTimeout(timeout: number): void; + on(event: 'webview', handler: (webView: AndroidWebView) => void): this; + waitForEvent(event: string, predicate?: (data: any) => boolean): Promise; + + serial(): string; + model(): string; + webViews(): AndroidWebView[]; + shell(command: string): Promise; + launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise; + close(): Promise; + + wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise; + fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise; + press(selector: AndroidSelector, key: AndroidKey, options?: { duration?: number } & { timeout?: number }): Promise; + tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise; + drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise; + fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise; + longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise; + pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + + info(selector: AndroidSelector): Promise; +} + +export interface AndroidInput { + type(text: string): Promise; + press(key: AndroidKey): Promise; + tap(point: { x: number, y: number }): Promise; + swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise; + drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise; +} + +export interface AndroidWebView extends EventEmitter { + on(event: 'close', handler: () => void): this; + pid(): number; + pkg(): string; + page(): Promise; +} + export type AndroidElementInfo = { clazz: string; desc: string; @@ -52,37 +98,6 @@ export type AndroidSelector = { text?: string | RegExp, }; -export interface AndroidDevice { - input: AndroidInput; - - serial(): string; - model(): string; - shell(command: string): Promise; - launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise; - close(): Promise; - - wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise; - fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise; - tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise; - drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise; - fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise; - longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise; - pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise; - pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise; - scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise; - swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise; - - info(selector: AndroidSelector): Promise; -} - -export interface AndroidInput { - type(text: string): Promise; - press(key: AndroidKey): Promise; - tap(point: { x: number, y: number }): Promise; - swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise; - drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise; -} - export type AndroidKey = 'Unknown' | 'SoftLeft' | 'SoftRight' | diff --git a/android-types.d.ts b/android-types.d.ts index f13b3eb565..c646b5cd6c 100644 --- a/android-types.d.ts +++ b/android-types.d.ts @@ -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; +export { AndroidElementInfo, AndroidSelector } from './android-types-internal'; +export type AndroidDevice = apiInternal.AndroidDevice; +export type AndroidWebView = apiInternal.AndroidWebView; + +export interface Android extends EventEmitter { + setDefaultTimeout(timeout: number): void; + devices(): Promise; +} diff --git a/packages/build_package.js b/packages/build_package.js index d8bc1fe0c5..50d360594e 100755 --- a/packages/build_package.js +++ b/packages/build_package.js @@ -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'], }, }; diff --git a/packages/common/.npmignore b/packages/common/.npmignore index 60eec66784..3344ccf792 100644 --- a/packages/common/.npmignore +++ b/packages/common/.npmignore @@ -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. diff --git a/packages/playwright-android/README.md b/packages/playwright-android/README.md index 4d13f93202..9c8c39e835 100644 --- a/packages/playwright-android/README.md +++ b/packages/playwright-android/README.md @@ -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(); })(); diff --git a/src/client/android.ts b/src/client/android.ts index c3600cf18e..e1b9ec6e28 100644 --- a/src/client/android.ts +++ b/src/client/android.ts @@ -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 { + 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 { @@ -41,6 +54,9 @@ export class Android extends ChannelOwner { + readonly _timeoutSettings: TimeoutSettings; + private _webViews = new Map(); + static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice { return (androidDevice as any)._object; } @@ -50,6 +66,27 @@ export class AndroidDevice extends ChannelOwner 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 { await this._channel.wait({ selector: toSelectorChannel(selector), ...options }); @@ -72,6 +113,11 @@ export class AndroidDevice extends ChannelOwner { await this._channel.tap({ selector: toSelectorChannel(selector), ...options }); @@ -129,6 +175,7 @@ export class AndroidDevice extends ChannelOwner { await this._channel.close(); + this.emit(Events.AndroidDevice.Close); }); } @@ -146,6 +193,18 @@ export class AndroidDevice extends ChannelOwner { + 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 | 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 { + if (!this._pagePromise) + this._pagePromise = this._fetchPage(); + return this._pagePromise; + } + + private async _fetchPage(): Promise { + return this._device._wrapApiCall('androidWebView.page', async () => { + const { context } = await this._device._channel.connectToWebView({ pid: this._data.pid }); + return BrowserContext.from(context).pages()[0]; + }); + } +} diff --git a/src/client/events.ts b/src/client/events.ts index f689b19e2c..cda80f5a84 100644 --- a/src/client/events.ts +++ b/src/client/events.ts @@ -16,6 +16,15 @@ */ export const Events = { + AndroidDevice: { + WebView: 'webview', + Close: 'close' + }, + + AndroidWebView: { + Close: 'close' + }, + Browser: { Disconnected: 'disconnected' }, diff --git a/src/dispatchers/androidDispatcher.ts b/src/dispatchers/androidDispatcher.ts index aed6eb819c..a6f40ec308 100644 --- a/src/dispatchers/androidDispatcher.ts +++ b/src/dispatchers/androidDispatcher.ts @@ -20,8 +20,8 @@ import * as channels from '../protocol/channels'; import { BrowserContextDispatcher } from './browserContextDispatcher'; export class AndroidDispatcher extends Dispatcher 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 { @@ -30,14 +30,22 @@ export class AndroidDispatcher extends Dispatcher new AndroidDeviceDispatcher(this._scope, d)) }; } + + async setDefaultTimeoutNoReply(params: channels.AndroidSetDefaultTimeoutNoReplyParams) { + this._object.setDefaultTimeout(params.timeout); + } } export class AndroidDeviceDispatcher extends Dispatcher 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 { + return { context: new BrowserContextDispatcher(this._scope, await this._object.connectToWebView(params.pid)) }; + } } const keyMap = new Map([ diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index f2de8b6f17..ee9c98b0fa 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -2406,55 +2406,20 @@ export type ElectronApplicationCloseResult = void; export type AndroidInitializer = {}; export interface AndroidChannel extends Channel { devices(params?: AndroidDevicesParams, metadata?: Metadata): Promise; + setDefaultTimeoutNoReply(params: AndroidSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise; } 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; fill(params: AndroidDeviceFillParams, metadata?: Metadata): Promise; tap(params: AndroidDeviceTapParams, metadata?: Metadata): Promise; @@ -2480,8 +2447,16 @@ export interface AndroidDeviceChannel extends Channel { inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise; launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise; shell(params: AndroidDeviceShellParams, metadata?: Metadata): Promise; + setDefaultTimeoutNoReply(params: AndroidDeviceSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise; + connectToWebView(params: AndroidDeviceConnectToWebViewParams, metadata?: Metadata): Promise; close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise; } +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, +}; diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 67e07755e3..986979cb06 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -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 diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index c995a70163..ef8997b74a 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -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; } diff --git a/src/server/android/android.ts b/src/server/android/android.ts index 6218bd5dcb..91406b2cd3 100644 --- a/src/server/android/android.ts +++ b/src/server/android/android.ts @@ -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 { 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 | undefined; private _lastId = 0; private _callbacks = new Map void, reject: (error: Error) => void }>(); + private _pollingWebViews: NodeJS.Timeout | undefined; + readonly _timeoutSettings: TimeoutSettings; + private _webViews = new Map(); - constructor(backend: DeviceBackend, model: string) { + static Events = { + WebViewAdded: 'webViewAdded', + WebViewRemoved: 'webViewRemoved', + }; + + private _browserConnections = new Set(); + + 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 { + static async create(android: Android, backend: DeviceBackend): Promise { 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 { @@ -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 { - 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 { + 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 { + 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 { + 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(); + 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}`); } } diff --git a/src/server/android/backendAdb.ts b/src/server/android/backendAdb.ts index 6eeef2a46f..f482a636f6 100644 --- a/src/server/android/backendAdb.ts +++ b/src/server/android/backendAdb.ts @@ -55,7 +55,7 @@ class AdbDevice implements DeviceBackend { async function runCommand(command: string, serial?: string): Promise { 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 { } async function open(command: string, serial?: string): Promise { - 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; 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; }