diff --git a/docs/src/api/class-androiddevice.md b/docs/src/api/class-androiddevice.md index bd7e607784..ebca26ad66 100644 --- a/docs/src/api/class-androiddevice.md +++ b/docs/src/api/class-androiddevice.md @@ -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-%% diff --git a/docs/src/api/class-androidwebview.md b/docs/src/api/class-androidwebview.md index c1bcb16721..8a97bcfefe 100644 --- a/docs/src/api/class-androidwebview.md +++ b/docs/src/api/class-androidwebview.md @@ -20,4 +20,4 @@ WebView process PID. ## method: AndroidWebView.pkg - returns: <[string]> -WebView package identifier. +WebView package identifier. \ No newline at end of file diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index e754d3e903..895f466ceb 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -55,7 +55,7 @@ export class Android extends ChannelOwner implements ap export class AndroidDevice extends ChannelOwner implements api.AndroidDevice { readonly _timeoutSettings: TimeoutSettings; - private _webViews = new Map(); + private _webViews = new Map(); static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice { return (androidDevice as any)._object; @@ -68,18 +68,18 @@ export class AndroidDevice extends ChannelOwner 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 i return [...this._webViews.values()]; } - async webView(selector: { pkg: string }, options?: types.TimeoutOptions): Promise { - const webView = [...this._webViews.values()].find(v => v.pkg() === selector.pkg); + async webView(selector: { pkg?: string; socketName?: string; }, options?: types.TimeoutOptions): Promise { + 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 { if (!this._pagePromise) this._pagePromise = this._fetchPage(); @@ -341,7 +349,7 @@ export class AndroidWebView extends EventEmitter implements api.AndroidWebView { } private async _fetchPage(): Promise { - 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]; } } diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index 546d645ba4..28f2447e36 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -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 = { diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 8991944678..d50a9bc456 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -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: diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index bf21714e9d..46c4df28b0 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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), diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 25fc56372b..ae3a2ee569 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -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 void, reject: (error: Error) => void }>(); private _pollingWebViews: NodeJS.Timeout | undefined; readonly _timeoutSettings: TimeoutSettings; - private _webViews = new Map(); + private _webViews = new Map(); static Events = { WebViewAdded: 'webViewAdded', @@ -247,8 +247,7 @@ export class AndroidDevice extends SdkObject { 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 = '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 { - const webView = this._webViews.get(pid); + async connectToWebView(socketName: string): Promise { + 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 { @@ -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(); + const socketNames = 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)) + 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(); } } + + diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index 2fd3624650..af23d11eb0 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -57,7 +57,7 @@ export class AndroidDeviceDispatcher extends Dispatcher 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 { - return { context: new BrowserContextDispatcher(this._scope, await this._object.connectToWebView(params.pid)) }; + return { context: new BrowserContextDispatcher(this._scope, await this._object.connectToWebView(params.socketName)) }; } } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 6f5eb37831..1d2a22879f 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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 diff --git a/tests/android/webview.spec.ts b/tests/android/webview.spec.ts index ad810e1910..fdedccaecf 100644 --- a/tests/android/webview.spec.ts +++ b/tests/android/webview.spec.ts @@ -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(); +}); \ No newline at end of file