Skip to content

Commit 8fc49c9

Browse files
authored
feat(adb): support webviews (#4657)
1 parent f939fdc commit 8fc49c9

File tree

13 files changed

+535
-211
lines changed

13 files changed

+535
-211
lines changed

android-types-internal.d.ts

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,52 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { EventEmitter } from 'events';
18+
19+
export interface AndroidDevice<BrowserContextOptions, BrowserContext, Page> extends EventEmitter {
20+
input: AndroidInput;
21+
22+
setDefaultTimeout(timeout: number): void;
23+
on(event: 'webview', handler: (webView: AndroidWebView<Page>) => void): this;
24+
waitForEvent(event: string, predicate?: (data: any) => boolean): Promise<any>;
25+
26+
serial(): string;
27+
model(): string;
28+
webViews(): AndroidWebView<Page>[];
29+
shell(command: string): Promise<string>;
30+
launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise<BrowserContext>;
31+
close(): Promise<void>;
32+
33+
wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise<void>;
34+
fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise<void>;
35+
press(selector: AndroidSelector, key: AndroidKey, options?: { duration?: number } & { timeout?: number }): Promise<void>;
36+
tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise<void>;
37+
drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise<void>;
38+
fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise<void>;
39+
longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise<void>;
40+
pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
41+
pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
42+
scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
43+
swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
44+
45+
info(selector: AndroidSelector): Promise<AndroidElementInfo>;
46+
}
47+
48+
export interface AndroidInput {
49+
type(text: string): Promise<void>;
50+
press(key: AndroidKey): Promise<void>;
51+
tap(point: { x: number, y: number }): Promise<void>;
52+
swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise<void>;
53+
drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise<void>;
54+
}
55+
56+
export interface AndroidWebView<Page> extends EventEmitter {
57+
on(event: 'close', handler: () => void): this;
58+
pid(): number;
59+
pkg(): string;
60+
page(): Promise<Page>;
61+
}
62+
1763
export type AndroidElementInfo = {
1864
clazz: string;
1965
desc: string;
@@ -52,37 +98,6 @@ export type AndroidSelector = {
5298
text?: string | RegExp,
5399
};
54100

55-
export interface AndroidDevice<BrowserContextOptions, BrowserContext> {
56-
input: AndroidInput;
57-
58-
serial(): string;
59-
model(): string;
60-
shell(command: string): Promise<string>;
61-
launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise<BrowserContext>;
62-
close(): Promise<void>;
63-
64-
wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise<void>;
65-
fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise<void>;
66-
tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise<void>;
67-
drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise<void>;
68-
fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise<void>;
69-
longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise<void>;
70-
pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
71-
pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
72-
scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
73-
swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
74-
75-
info(selector: AndroidSelector): Promise<AndroidElementInfo>;
76-
}
77-
78-
export interface AndroidInput {
79-
type(text: string): Promise<void>;
80-
press(key: AndroidKey): Promise<void>;
81-
tap(point: { x: number, y: number }): Promise<void>;
82-
swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise<void>;
83-
drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise<void>;
84-
}
85-
86101
export type AndroidKey =
87102
'Unknown' |
88103
'SoftLeft' | 'SoftRight' |

android-types.d.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,15 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { BrowserContext, BrowserContextOptions } from './types/types';
17+
import { Page, BrowserContext, BrowserContextOptions } from './types/types';
1818
import * as apiInternal from './android-types-internal';
19+
import { EventEmitter } from 'events';
1920

20-
export * from './android-types-internal';
21-
export type AndroidDevice = apiInternal.AndroidDevice<BrowserContext, BrowserContextOptions>;
21+
export { AndroidElementInfo, AndroidSelector } from './android-types-internal';
22+
export type AndroidDevice = apiInternal.AndroidDevice<BrowserContextOptions, BrowserContext, Page>;
23+
export type AndroidWebView = apiInternal.AndroidWebView<Page>;
24+
25+
export interface Android extends EventEmitter {
26+
setDefaultTimeout(timeout: number): void;
27+
devices(): Promise<AndroidDevice[]>;
28+
}

packages/build_package.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const cpAsync = util.promisify(ncp);
2828
const SCRIPT_NAME = path.basename(__filename);
2929
const ROOT_PATH = path.join(__dirname, '..');
3030

31-
const PLAYWRIGHT_CORE_FILES = ['bin', 'lib', 'types', 'NOTICE', 'LICENSE'];
31+
const PLAYWRIGHT_CORE_FILES = ['bin/PrintDeps.exe', 'lib', 'types', 'NOTICE', 'LICENSE'];
3232
const FFMPEG_FILES = ['third_party/ffmpeg'];
3333

3434
const PACKAGES = {
@@ -65,10 +65,10 @@ const PACKAGES = {
6565
files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'electron-types.d.ts'],
6666
},
6767
'playwright-android': {
68-
version: '0.0.2', // Manually manage playwright-android version.
68+
version: '0.0.7', // Manually manage playwright-android version.
6969
description: 'A high-level API to automate Chrome for Android',
7070
browsers: [],
71-
files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'android-types.d.ts', 'bin/android-driver.apk', 'bin/android-driver-target.apk'],
71+
files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'android-types.d.ts', 'android-types-internal.d.ts', 'bin/android-driver.apk', 'bin/android-driver-target.apk'],
7272
},
7373
};
7474

packages/common/.npmignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ lib/server/injected/
1818
# Include generated types and entrypoint.
1919
!types/*
2020
!index.d.ts
21+
# Include separate android types.
22+
!android-types.d.ts
23+
!android-types-internal.d.ts
2124
# Include separate electron types.
2225
!electron-types.d.ts
2326
# Include main entrypoint.

packages/playwright-android/README.md

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,32 @@ const { android } = require('playwright-android');
1515

1616
(async () => {
1717
const [device] = await android.devices();
18-
19-
// Android automation.
2018
console.log(`Model: ${device.model()}`);
2119
console.log(`Serial: ${device.serial()}`);
2220

23-
await device.tap({ desc: 'Home' });
24-
console.log(await device.info({ text: 'Chrome' }));
25-
await device.tap({ text: 'Chrome' });
26-
await device.fill({ res: 'com.android.chrome:id/url_bar' }, 'www.chromium.org');
27-
await device.input.press('Enter');
28-
await new Promise(f => setTimeout(f, 1000));
29-
30-
await device.tap({ res: 'com.android.chrome:id/tab_switcher_button' });
31-
await device.tap({ desc: 'More options' });
32-
await device.tap({ desc: 'Close all tabs' });
33-
34-
// Browser automation.
35-
const context = await device.launchBrowser();
36-
const [page] = context.pages();
37-
await page.goto('https://webkit.org/');
38-
console.log(await page.evaluate(() => window.location.href));
39-
await context.close();
21+
await device.shell('am force-stop org.chromium.webview_shell');
22+
await device.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity');
23+
24+
await device.fill({ res: 'org.chromium.webview_shell:id/url_field' }, 'github.com/microsoft/playwright');
25+
26+
let [webview] = device.webViews();
27+
if (!webview)
28+
webview = await device.waitForEvent('webview');
29+
30+
const page = await webview.page();
31+
await Promise.all([
32+
page.waitForNavigation(),
33+
device.press({ res: 'org.chromium.webview_shell:id/url_field' }, 'Enter')
34+
]);
35+
console.log(await page.title());
36+
37+
{
38+
const context = await device.launchBrowser();
39+
const [page] = context.pages();
40+
await page.goto('https://webkit.org/');
41+
console.log(await page.evaluate(() => window.location.href));
42+
await context.close();
43+
}
4044

4145
await device.close();
4246
})();

src/client/android.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,34 @@
1515
*/
1616

1717
import * as channels from '../protocol/channels';
18+
import { Events } from './events';
1819
import { BrowserContext, validateBrowserContextOptions } from './browserContext';
1920
import { ChannelOwner } from './channelOwner';
2021
import * as apiInternal from '../../android-types-internal';
2122
import * as types from './types';
23+
import { Page } from './page';
24+
import { TimeoutSettings } from '../utils/timeoutSettings';
25+
import { Waiter } from './waiter';
26+
import { EventEmitter } from 'events';
2227

2328
type Direction = 'down' | 'up' | 'left' | 'right';
2429
type SpeedOptions = { speed?: number };
2530

2631
export class Android extends ChannelOwner<channels.AndroidChannel, channels.AndroidInitializer> {
32+
readonly _timeoutSettings: TimeoutSettings;
33+
2734
static from(android: channels.AndroidChannel): Android {
2835
return (android as any)._object;
2936
}
3037

3138
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidInitializer) {
3239
super(parent, type, guid, initializer);
40+
this._timeoutSettings = new TimeoutSettings();
41+
}
42+
43+
setDefaultTimeout(timeout: number) {
44+
this._timeoutSettings.setDefaultTimeout(timeout);
45+
this._channel.setDefaultTimeoutNoReply({ timeout });
3346
}
3447

3548
async devices(): Promise<AndroidDevice[]> {
@@ -41,6 +54,9 @@ export class Android extends ChannelOwner<channels.AndroidChannel, channels.Andr
4154
}
4255

4356
export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, channels.AndroidDeviceInitializer> {
57+
readonly _timeoutSettings: TimeoutSettings;
58+
private _webViews = new Map<number, AndroidWebView>();
59+
4460
static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice {
4561
return (androidDevice as any)._object;
4662
}
@@ -50,6 +66,27 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
5066
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidDeviceInitializer) {
5167
super(parent, type, guid, initializer);
5268
this.input = new Input(this);
69+
this._timeoutSettings = new TimeoutSettings((parent as Android)._timeoutSettings);
70+
this._channel.on('webViewAdded', ({ webView }) => this._onWebViewAdded(webView));
71+
this._channel.on('webViewRemoved', ({ pid }) => this._onWebViewRemoved(pid));
72+
}
73+
74+
private _onWebViewAdded(webView: channels.AndroidWebView) {
75+
const view = new AndroidWebView(this, webView);
76+
this._webViews.set(webView.pid, view);
77+
this.emit(Events.AndroidDevice.WebView, view);
78+
}
79+
80+
private _onWebViewRemoved(pid: number) {
81+
const view = this._webViews.get(pid);
82+
this._webViews.delete(pid);
83+
if (view)
84+
view.emit(Events.AndroidWebView.Close);
85+
}
86+
87+
setDefaultTimeout(timeout: number) {
88+
this._timeoutSettings.setDefaultTimeout(timeout);
89+
this._channel.setDefaultTimeoutNoReply({ timeout });
5390
}
5491

5592
serial(): string {
@@ -60,6 +97,10 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
6097
return this._initializer.model;
6198
}
6299

100+
webViews(): AndroidWebView[] {
101+
return [...this._webViews.values()];
102+
}
103+
63104
async wait(selector: apiInternal.AndroidSelector, options?: { state?: 'gone' } & types.TimeoutOptions) {
64105
await this._wrapApiCall('androidDevice.wait', async () => {
65106
await this._channel.wait({ selector: toSelectorChannel(selector), ...options });
@@ -72,6 +113,11 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
72113
});
73114
}
74115

116+
async press(selector: apiInternal.AndroidSelector, key: apiInternal.AndroidKey, options?: types.TimeoutOptions) {
117+
await this.tap(selector, options);
118+
await this.input.press(key);
119+
}
120+
75121
async tap(selector: apiInternal.AndroidSelector, options?: { duration?: number } & types.TimeoutOptions) {
76122
await this._wrapApiCall('androidDevice.tap', async () => {
77123
await this._channel.tap({ selector: toSelectorChannel(selector), ...options });
@@ -129,6 +175,7 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
129175
async close() {
130176
return this._wrapApiCall('androidDevice.close', async () => {
131177
await this._channel.close();
178+
this.emit(Events.AndroidDevice.Close);
132179
});
133180
}
134181

@@ -146,6 +193,18 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
146193
return BrowserContext.from(context);
147194
});
148195
}
196+
197+
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> {
198+
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
199+
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
200+
const waiter = new Waiter();
201+
waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`);
202+
if (event !== Events.AndroidDevice.Close)
203+
waiter.rejectOnEvent(this, Events.AndroidDevice.Close, new Error('Device closed'));
204+
const result = await waiter.waitForEvent(this, event, predicate as any);
205+
waiter.dispose();
206+
return result;
207+
}
149208
}
150209

151210
class Input implements apiInternal.AndroidInput {
@@ -235,3 +294,36 @@ function toSelectorChannel(selector: apiInternal.AndroidSelector): channels.Andr
235294
selected,
236295
};
237296
}
297+
298+
export class AndroidWebView extends EventEmitter {
299+
private _device: AndroidDevice;
300+
private _data: channels.AndroidWebView;
301+
private _pagePromise: Promise<Page> | undefined;
302+
303+
constructor(device: AndroidDevice, data: channels.AndroidWebView) {
304+
super();
305+
this._device = device;
306+
this._data = data;
307+
}
308+
309+
pid(): number {
310+
return this._data.pid;
311+
}
312+
313+
pkg(): string {
314+
return this._data.pkg;
315+
}
316+
317+
async page(): Promise<Page> {
318+
if (!this._pagePromise)
319+
this._pagePromise = this._fetchPage();
320+
return this._pagePromise;
321+
}
322+
323+
private async _fetchPage(): Promise<Page> {
324+
return this._device._wrapApiCall('androidWebView.page', async () => {
325+
const { context } = await this._device._channel.connectToWebView({ pid: this._data.pid });
326+
return BrowserContext.from(context).pages()[0];
327+
});
328+
}
329+
}

src/client/events.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
*/
1717

1818
export const Events = {
19+
AndroidDevice: {
20+
WebView: 'webview',
21+
Close: 'close'
22+
},
23+
24+
AndroidWebView: {
25+
Close: 'close'
26+
},
27+
1928
Browser: {
2029
Disconnected: 'disconnected'
2130
},

0 commit comments

Comments
 (0)