feat(android): allow getting webviews by socket name (#13248)

This commit is contained in:
kaivean 2022-04-09 02:52:16 +08:00 committed by GitHub
parent d91349f22a
commit d65263f151
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 102 additions and 55 deletions

View file

@ -358,7 +358,8 @@ This method waits until [AndroidWebView] matching the [`option: selector`] is op
### param: AndroidDevice.webView.selector
- `selector` <[Object]>
- `pkg` <[string]> Package identifier.
- `pkg` ?<[string]> Optional Package identifier.
- `socketName` ?<[string]> Optional webview socket name.
### option: AndroidDevice.webView.timeout = %%-android-timeout-%%

View file

@ -20,4 +20,4 @@ WebView process PID.
## method: AndroidWebView.pkg
- returns: <[string]>
WebView package identifier.
WebView package identifier.

View file

@ -55,7 +55,7 @@ export class Android extends ChannelOwner<channels.AndroidChannel> implements ap
export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> implements api.AndroidDevice {
readonly _timeoutSettings: TimeoutSettings;
private _webViews = new Map<number, AndroidWebView>();
private _webViews = new Map<string, AndroidWebView>();
static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice {
return (androidDevice as any)._object;
@ -68,18 +68,18 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
this.input = new AndroidInput(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));
this._channel.on('webViewRemoved', ({ socketName }) => this._onWebViewRemoved(socketName));
}
private _onWebViewAdded(webView: channels.AndroidWebView) {
const view = new AndroidWebView(this, webView);
this._webViews.set(webView.pid, view);
this._webViews.set(webView.socketName, view);
this.emit(Events.AndroidDevice.WebView, view);
}
private _onWebViewRemoved(pid: number) {
const view = this._webViews.get(pid);
this._webViews.delete(pid);
private _onWebViewRemoved(socketName: string) {
const view = this._webViews.get(socketName);
this._webViews.delete(socketName);
if (view)
view.emit(Events.AndroidWebView.Close);
}
@ -101,14 +101,18 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
return [...this._webViews.values()];
}
async webView(selector: { pkg: string }, options?: types.TimeoutOptions): Promise<AndroidWebView> {
const webView = [...this._webViews.values()].find(v => v.pkg() === selector.pkg);
async webView(selector: { pkg?: string; socketName?: string; }, options?: types.TimeoutOptions): Promise<AndroidWebView> {
const predicate = (v: AndroidWebView) => {
if (selector.pkg)
return v.pkg() === selector.pkg;
if (selector.socketName)
return v._socketName() === selector.socketName;
return false;
};
const webView = [...this._webViews.values()].find(predicate);
if (webView)
return webView;
return this.waitForEvent('webview', {
...options,
predicate: (view: AndroidWebView) => view.pkg() === selector.pkg
});
return this.waitForEvent('webview', { ...options, predicate });
}
async wait(selector: api.AndroidSelector, options?: { state?: 'gone' } & types.TimeoutOptions) {
@ -334,6 +338,10 @@ export class AndroidWebView extends EventEmitter implements api.AndroidWebView {
return this._data.pkg;
}
_socketName(): string {
return this._data.socketName;
}
async page(): Promise<Page> {
if (!this._pagePromise)
this._pagePromise = this._fetchPage();
@ -341,7 +349,7 @@ export class AndroidWebView extends EventEmitter implements api.AndroidWebView {
}
private async _fetchPage(): Promise<Page> {
const { context } = await this._device._channel.connectToWebView({ pid: this._data.pid });
const { context } = await this._device._channel.connectToWebView({ socketName: this._data.socketName });
return BrowserContext.from(context).pages()[0];
}
}

View file

@ -3790,7 +3790,7 @@ export type AndroidDeviceWebViewAddedEvent = {
webView: AndroidWebView,
};
export type AndroidDeviceWebViewRemovedEvent = {
pid: number,
socketName: string,
};
export type AndroidDeviceWaitParams = {
selector: AndroidSelector,
@ -4107,7 +4107,7 @@ export type AndroidDeviceSetDefaultTimeoutNoReplyOptions = {
};
export type AndroidDeviceSetDefaultTimeoutNoReplyResult = void;
export type AndroidDeviceConnectToWebViewParams = {
pid: number,
socketName: string,
};
export type AndroidDeviceConnectToWebViewOptions = {
@ -4127,6 +4127,7 @@ export interface AndroidDeviceEvents {
export type AndroidWebView = {
pid: number,
pkg: string,
socketName: string,
};
export type AndroidSelector = {

View file

@ -3058,7 +3058,7 @@ AndroidDevice:
connectToWebView:
parameters:
pid: number
socketName: string
returns:
context: BrowserContext
@ -3071,7 +3071,7 @@ AndroidDevice:
webViewRemoved:
parameters:
pid: number
socketName: string
AndroidWebView:
@ -3079,6 +3079,7 @@ AndroidWebView:
properties:
pid: number
pkg: string
socketName: string
AndroidSelector:

View file

@ -1472,12 +1472,13 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
timeout: tNumber,
});
scheme.AndroidDeviceConnectToWebViewParams = tObject({
pid: tNumber,
socketName: tString,
});
scheme.AndroidDeviceCloseParams = tOptional(tObject({}));
scheme.AndroidWebView = tObject({
pid: tNumber,
pkg: tString,
socketName: tString,
});
scheme.AndroidSelector = tObject({
checkable: tOptional(tBoolean),

View file

@ -22,7 +22,7 @@ import os from 'os';
import path from 'path';
import type * as stream from 'stream';
import * as ws from 'ws';
import { createGuid, makeWaitForNextTask } from '../../utils';
import { createGuid, makeWaitForNextTask, isUnderTest } from '../../utils';
import { removeFolders } from '../../utils/fileUtils';
import type { BrowserOptions, BrowserProcess, PlaywrightOptions } from '../browser';
import type { BrowserContext } from '../browserContext';
@ -107,7 +107,7 @@ export class AndroidDevice extends SdkObject {
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>();
private _webViews = new Map<string, AndroidWebView>();
static Events = {
WebViewAdded: 'webViewAdded',
@ -247,8 +247,7 @@ export class AndroidDevice extends SdkObject {
async launchBrowser(pkg: string = 'com.android.chrome', options: types.BrowserContextOptions): Promise<BrowserContext> {
debug('pw:android')('Force-stopping', pkg);
await this._backend.runCommand(`shell:am force-stop ${pkg}`);
const socketName = 'playwright-' + createGuid();
const socketName = isUnderTest() ? 'webview_devtools_remote_playwright_test' : ('playwright-' + createGuid());
const commandLine = `_ --disable-fre --no-default-browser-check --no-first-run --remote-debugging-socket-name=${socketName}`;
debug('pw:android')('Starting', pkg, commandLine);
await this._backend.runCommand(`shell:echo "${commandLine}" > /data/local/tmp/chrome-command-line`);
@ -256,11 +255,11 @@ export class AndroidDevice extends SdkObject {
return await this._connectToBrowser(socketName, options);
}
async connectToWebView(pid: number): Promise<BrowserContext> {
const webView = this._webViews.get(pid);
async connectToWebView(socketName: string): Promise<BrowserContext> {
const webView = this._webViews.get(socketName);
if (!webView)
throw new Error('WebView has been closed');
return await this._connectToBrowser(`webview_devtools_remote_${pid}`);
return await this._connectToBrowser(socketName);
}
private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
@ -345,47 +344,59 @@ export class AndroidDevice extends SdkObject {
}
private async _refreshWebViews() {
// possible socketName, eg: webview_devtools_remote_32327, webview_devtools_remote_32327_zeus, webview_devtools_remote_zeus
const sockets = (await this._backend.runCommand(`shell:cat /proc/net/unix | grep webview_devtools_remote`)).toString().split('\n');
if (this._isClosed)
return;
const newPids = new Set<number>();
const socketNames = new Set<string>();
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))
const matchSocketName = line.match(/[^@]+@(.*?webview_devtools_remote_?.*)/);
if (!matchSocketName)
continue;
const procs = (await this._backend.runCommand(`shell:ps -A | grep ${pid}`)).toString().split('\n');
const socketName = matchSocketName[1];
socketNames.add(socketName);
if (this._webViews.has(socketName))
continue;
// possible line: 0000000000000000: 00000002 00000000 00010000 0001 01 5841881 @webview_devtools_remote_zeus
// the result: match[1] = ''
const match = line.match(/[^@]+@.*?webview_devtools_remote_?(\d*)/);
let pid = -1;
if (match && match[1])
pid = +match[1];
const pkg = await this._extractPkg(pid);
if (this._isClosed)
return;
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(' ') + 1);
}
const webView = { pid, pkg };
this._webViews.set(pid, webView);
const webView = { pid, pkg, socketName };
this._webViews.set(socketName, webView);
this.emit(AndroidDevice.Events.WebViewAdded, webView);
}
for (const p of this._webViews.keys()) {
if (!newPids.has(p)) {
if (!socketNames.has(p)) {
this._webViews.delete(p);
this.emit(AndroidDevice.Events.WebViewRemoved, p);
}
}
}
private async _extractPkg(pid: number) {
let pkg = '';
if (pid === -1)
return pkg;
const procs = (await this._backend.runCommand(`shell:ps -A | grep ${pid}`)).toString().split('\n');
for (const proc of procs) {
const match = proc.match(/[^\s]+\s+(\d+).*$/);
if (!match)
continue;
pkg = proc.substring(proc.lastIndexOf(' ') + 1);
}
return pkg;
}
}
class AndroidBrowser extends EventEmitter {
@ -465,3 +476,5 @@ class ClankBrowserProcess implements BrowserProcess {
await this._browser.close();
}
}

View file

@ -57,7 +57,7 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
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 }));
device.on(AndroidDevice.Events.WebViewRemoved, socketName => this._dispatchEvent('webViewRemoved', { socketName }));
}
async wait(params: channels.AndroidDeviceWaitParams) {
@ -170,7 +170,7 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
}
async connectToWebView(params: channels.AndroidDeviceConnectToWebViewParams): Promise<channels.AndroidDeviceConnectToWebViewResult> {
return { context: new BrowserContextDispatcher(this._scope, await this._object.connectToWebView(params.pid)) };
return { context: new BrowserContextDispatcher(this._scope, await this._object.connectToWebView(params.socketName)) };
}
}

View file

@ -11727,9 +11727,14 @@ export interface AndroidDevice {
*/
webView(selector: {
/**
* Package identifier.
* Optional Package identifier.
*/
pkg: string;
pkg?: string;
/**
* Optional webview socket name.
*/
socketName?: string;
}, options?: {
/**
* Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by

View file

@ -18,6 +18,7 @@ import { androidTest as test, expect } from './androidTest';
test.afterEach(async ({ androidDevice }) => {
await androidDevice.shell('am force-stop org.chromium.webview_shell');
await androidDevice.shell('am force-stop com.android.chrome');
});
test('androidDevice.webView', async function({ androidDevice }) {
@ -61,3 +62,19 @@ test('should navigate page externally', async function({ androidDevice }) {
]);
expect(await page.title()).toBe('Hello world!');
});
test('select webview from socketName', async function({ androidDevice }) {
test.slow();
const context = await androidDevice.launchBrowser();
const newPage = await context.newPage();
newPage.goto('about:blank');
const webview = await androidDevice.webView({ socketName: 'webview_devtools_remote_playwright_test' });
expect(webview.pkg()).toBe('');
expect(webview.pid()).toBe(-1);
const page = await webview.page();
expect(page.url()).toBe('about:blank');
await newPage.close();
await context.close();
});