appium-android-driver
Version:
Android UiAutomator and Chrome support for Appium
872 lines (807 loc) • 30.2 kB
text/typescript
import {util, timing} from '@appium/support';
import _ from 'lodash';
import axios from 'axios';
import net from 'node:net';
import {findAPortNotInUse} from 'portscanner';
import {sleep} from 'asyncbox';
import B from 'bluebird';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import {Chromedriver} from 'appium-chromedriver';
import type {ChromedriverOpts} from 'appium-chromedriver';
import {toDetailsCacheKey, getWebviewDetails, WEBVIEWS_DETAILS_CACHE} from './cache';
import dns from 'node:dns/promises';
import type {AndroidDriver, AndroidDriverOpts} from '../../driver';
import type {
GetWebviewsOpts,
WebviewsMapping,
WebviewProps,
WebViewDetails,
PortSpec,
WebviewProc,
DetailCollectionOptions,
} from '../types';
import type {StringRecord} from '@appium/types';
// https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc
export const CHROME_BROWSER_PACKAGE_ACTIVITY = {
chrome: {
pkg: 'com.android.chrome',
activity: 'com.google.android.apps.chrome.Main',
},
chromium: {
pkg: 'org.chromium.chrome.shell',
activity: '.ChromeShellActivity',
},
chromebeta: {
pkg: 'com.chrome.beta',
activity: 'com.google.android.apps.chrome.Main',
},
browser: {
pkg: 'com.android.browser',
activity: 'com.android.browser.BrowserActivity',
},
'chromium-browser': {
pkg: 'org.chromium.chrome',
activity: 'com.google.android.apps.chrome.Main',
},
'chromium-webview': {
pkg: 'org.chromium.webview_shell',
activity: 'org.chromium.webview_shell.WebViewBrowserActivity',
},
default: {
pkg: 'com.android.chrome',
activity: 'com.google.android.apps.chrome.Main',
},
} as const;
export const CHROME_PACKAGE_NAME = 'com.android.chrome';
export const KNOWN_CHROME_PACKAGE_NAMES = [
CHROME_PACKAGE_NAME,
'com.chrome.beta',
'com.chrome.dev',
'com.chrome.canary',
] as const;
const CHROMEDRIVER_AUTODOWNLOAD_FEATURE = 'chromedriver_autodownload';
const CROSSWALK_SOCKET_PATTERN = /@([\w.]+)_devtools_remote\b/;
const CHROMIUM_DEVTOOLS_SOCKET = 'chrome_devtools_remote';
export const NATIVE_WIN = 'NATIVE_APP';
export const WEBVIEW_WIN = 'WEBVIEW';
export const CHROMIUM_WIN = 'CHROMIUM';
export const WEBVIEW_BASE = `${WEBVIEW_WIN}_`;
export const DEVTOOLS_SOCKET_PATTERN = /@[\w.]+_devtools_remote_?([\w.]+_)?(\d+)?\b/;
const WEBVIEW_PID_PATTERN = new RegExp(`^${WEBVIEW_BASE}(\\d+)`);
const WEBVIEW_PKG_PATTERN = new RegExp(`^${WEBVIEW_BASE}([^\\d\\s][\\w.]*)`);
const WEBVIEW_WAIT_INTERVAL_MS = 200;
const CDP_REQ_TIMEOUT = 2000; // ms
const DEVTOOLS_PORTS_RANGE = [10900, 11000] as const;
const DEVTOOLS_PORT_ALLOCATION_GUARD = util.getLockFileGuard(
path.resolve(os.tmpdir(), 'android_devtools_port_guard'),
{timeout: 7, tryRecovery: true},
);
// #region Exported Functions
/**
* Gets the Chrome browser package and activity information for the specified browser name.
*
* @param browser - The browser name (e.g., 'chrome', 'chromium', 'chromebeta')
* @returns The package and activity information for the browser, or the default Chrome configuration
*/
export function getChromePkg(
browser: string,
): (typeof CHROME_BROWSER_PACKAGE_ACTIVITY)[keyof typeof CHROME_BROWSER_PACKAGE_ACTIVITY] {
return (
CHROME_BROWSER_PACKAGE_ACTIVITY[browser.toLowerCase() as keyof typeof CHROME_BROWSER_PACKAGE_ACTIVITY] ||
CHROME_BROWSER_PACKAGE_ACTIVITY.default
);
}
/**
* Parses webview names from the webviews mapping for use in getContexts.
* Filters out webviews that don't have pages if ensureWebviewsHavePages is enabled.
*
* @param webviewsMapping - Array of webview mapping objects
* @param options - Options including ensureWebviewsHavePages and isChromeSession flags
* @returns An array of webview context names
*/
export function parseWebviewNames(
this: AndroidDriver,
webviewsMapping: WebviewsMapping[],
options: GetWebviewsOpts & {isChromeSession?: boolean} = {},
): string[] {
const {ensureWebviewsHavePages = true, isChromeSession = false} = options;
if (isChromeSession) {
return [CHROMIUM_WIN];
}
const result: string[] = [];
for (const {webview, pages, proc, webviewName} of webviewsMapping) {
if (ensureWebviewsHavePages && !pages?.length) {
this.log.info(
`Skipping the webview '${webview}' at '${proc}' ` +
`since it has reported having zero pages`,
);
continue;
}
if (webviewName) {
result.push(webviewName);
}
}
this.log.debug(
`Found ${util.pluralize('webview', result.length, true)}: ${JSON.stringify(result)}`,
);
return result;
}
/**
* Get a list of available webviews mapping by introspecting processes with adb,
* where webviews are listed. It's possible to pass in a 'deviceSocket' arg, which
* limits the webview possibilities to the one running on the Chromium devtools
* socket we're interested in (see note on webviewsFromProcs). We can also
* direct this method to verify whether a particular webview process actually
* has any pages (if a process exists but no pages are found, Chromedriver will
* not actually be able to connect to it, so this serves as a guard for that
* strange failure mode). The strategy for checking whether any pages are
* active involves sending a request to the remote debug server on the device,
* hence it is also possible to specify the port on the host machine which
* should be used for this communication.
*
* @param opts - Options for webview discovery including device socket, port, and collection settings
* @returns An array of webview mapping objects
*/
export async function getWebViewsMapping(
this: AndroidDriver,
opts: GetWebviewsOpts = {},
): Promise<WebviewsMapping[]> {
const {
androidDeviceSocket = null,
ensureWebviewsHavePages = true,
webviewDevtoolsPort = null,
enableWebviewDetailsCollection = true,
waitForWebviewMs = 0,
} = opts;
this.log.debug(`Getting a list of available webviews`);
let waitMs = waitForWebviewMs;
if (!_.isNumber(waitMs)) {
waitMs = parseInt(`${waitMs}`, 10) || 0;
}
let webviewsMapping: WebviewsMapping[];
const timer = new timing.Timer().start();
do {
webviewsMapping = await webviewsFromProcs.bind(this)(androidDeviceSocket);
if (webviewsMapping.length > 0) {
break;
}
this.log.debug(`No webviews found in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
await sleep(WEBVIEW_WAIT_INTERVAL_MS);
} while (timer.getDuration().asMilliSeconds < waitMs);
await collectWebviewsDetails.bind(this)(webviewsMapping, {
ensureWebviewsHavePages,
enableWebviewDetailsCollection,
webviewDevtoolsPort,
});
for (const webviewMapping of webviewsMapping) {
const {webview, info} = webviewMapping;
webviewMapping.webviewName = null;
let wvName = webview;
let process: {name: string; id: string | null} | undefined;
if (!androidDeviceSocket) {
const pkgMatch = WEBVIEW_PKG_PATTERN.exec(webview);
try {
// web view name could either be suffixed with PID or the package name
// package names could not start with a digit
const pkg = pkgMatch ? pkgMatch[1] : await procFromWebview.bind(this)(webview);
wvName = `${WEBVIEW_BASE}${pkg}`;
const pidMatch = WEBVIEW_PID_PATTERN.exec(webview);
process = {
name: pkg,
id: pidMatch ? pidMatch[1] : null,
};
} catch (e) {
const err = e as Error;
this.log.debug(err.stack);
this.log.warn(err.message);
continue;
}
}
webviewMapping.webviewName = wvName;
const key = toDetailsCacheKey(this.adb, wvName);
if (info || process) {
WEBVIEWS_DETAILS_CACHE.set(key, {info, process});
} else if (WEBVIEWS_DETAILS_CACHE.has(key)) {
WEBVIEWS_DETAILS_CACHE.delete(key);
}
}
return webviewsMapping;
}
/**
* Sets up a new Chromedriver instance for the specified context.
*
* @param opts - Driver options with Chrome-specific settings
* @param curDeviceId - The current device ID
* @param context - Optional context name for webview sessions
* @returns A configured Chromedriver instance
*/
export async function setupNewChromedriver(
this: AndroidDriver,
opts: AndroidDriverOpts & Record<string, any>,
curDeviceId: string,
context?: string,
): Promise<Chromedriver> {
// TODO: Remove the legacy
if ((opts as any).chromeDriverPort) {
this.log.warn(
`The 'chromeDriverPort' capability is deprecated. Please use 'chromedriverPort' instead`,
);
opts.chromedriverPort = (opts as any).chromeDriverPort;
}
if (opts.chromedriverPort) {
this.log.debug(`Using user-specified port ${opts.chromedriverPort} for chromedriver`);
} else {
// if a single port wasn't given, we'll look for a free one
opts.chromedriverPort = await getChromedriverPort.bind(this)(opts.chromedriverPorts);
}
const details = context ? getWebviewDetails(this.adb, context) : undefined;
if (!_.isEmpty(details)) {
this.log.debug(
'Passing web view details to the Chromedriver constructor: ' +
JSON.stringify(details, null, 2),
);
}
const chromedriverOpts: ChromedriverOpts & {details?: WebViewDetails} = {
port: _.isNil(opts.chromedriverPort) ? undefined : String(opts.chromedriverPort),
executable: opts.chromedriverExecutable,
adb: this.adb,
cmdArgs: opts.chromedriverArgs as string[] | undefined,
verbose: !!opts.showChromedriverLog,
executableDir: opts.chromedriverExecutableDir,
mappingPath: opts.chromedriverChromeMappingFile,
bundleId: (opts as any).chromeBundleId,
useSystemExecutable: opts.chromedriverUseSystemExecutable,
disableBuildCheck: opts.chromedriverDisableBuildCheck,
details: details as any,
isAutodownloadEnabled: isChromedriverAutodownloadEnabled.bind(this)(),
};
if (this.basePath) {
chromedriverOpts.reqBasePath = this.basePath;
}
const chromedriver = new Chromedriver(chromedriverOpts);
// make sure there are chromeOptions
opts.chromeOptions = opts.chromeOptions || {};
// try out any prefixed chromeOptions,
// and strip the prefix
for (const opt of _.keys(opts)) {
if (opt.endsWith(':chromeOptions')) {
this?.log?.warn(`Merging '${opt}' into 'chromeOptions'. This may cause unexpected behavior`);
_.merge(opts.chromeOptions, (opts as any)[opt]);
}
}
// Ensure there are logging preferences
opts.chromeLoggingPrefs = opts.chromeLoggingPrefs ?? {};
// Strip the prefix and store it
for (const opt of _.keys(opts)) {
if (opt.endsWith(':loggingPrefs')) {
this.log.warn(`Merging '${opt}' into 'chromeLoggingPrefs'. This may cause unexpected behavior`);
_.merge(opts.chromeLoggingPrefs, (opts as any)[opt]);
}
}
const caps = createChromedriverCaps.bind(this)(opts, curDeviceId, details) as any;
this.log.debug(
`Before starting chromedriver, androidPackage is '${caps.chromeOptions.androidPackage}'`,
);
const sessionCaps = await chromedriver.start(caps);
cacheChromedriverCaps.bind(this)(sessionCaps, context);
return chromedriver;
}
/**
* Sets up an existing Chromedriver instance, checking if it's still working.
* If not, restarts the session.
*
* @param chromedriver - The existing Chromedriver instance
* @param context - The context name associated with this Chromedriver
* @returns The Chromedriver instance (possibly restarted)
*/
export async function setupExistingChromedriver<T extends Chromedriver>(
this: AndroidDriver,
chromedriver: T,
context: string,
): Promise<T> {
// check the status by sending a simple window-based command to ChromeDriver
// if there is an error, we want to recreate the ChromeDriver session
if (await chromedriver.hasWorkingWebview()) {
const cachedCaps = this._chromedriverCapsCache.get(context);
if (cachedCaps) {
cacheChromedriverCaps.bind(this)(cachedCaps, context);
}
} else {
this.log.debug('ChromeDriver is not associated with a window. Re-initializing the session.');
const sessionCaps = await chromedriver.restart();
cacheChromedriverCaps.bind(this)(sessionCaps, context);
}
return chromedriver;
}
/**
* Determines if the Chrome welcome dialog should be dismissed based on Chrome options.
*
* @returns True if the '--no-first-run' argument is present in chromeOptions
*/
export function shouldDismissChromeWelcome(this: AndroidDriver): boolean {
return (
!!this.opts.chromeOptions &&
_.isArray(this.opts.chromeOptions.args) &&
this.opts.chromeOptions.args.includes('--no-first-run')
);
}
/**
* Dismisses the Chrome welcome dialog if it appears.
* Handles both the terms acceptance and sign-in dialog.
*/
export async function dismissChromeWelcome(this: AndroidDriver): Promise<void> {
this.log.info('Trying to dismiss Chrome welcome');
const activity = await this.getCurrentActivity();
if (activity !== 'org.chromium.chrome.browser.firstrun.FirstRunActivity') {
this.log.info('Chrome welcome dialog never showed up! Continuing');
return;
}
const el = await this.findElOrEls('id', 'com.android.chrome:id/terms_accept', false);
await this.click((el as any).ELEMENT as string);
try {
const el2 = await this.findElOrEls('id', 'com.android.chrome:id/negative_button', false);
await this.click((el2 as any).ELEMENT as string);
} catch (e) {
// DO NOTHING, THIS DEVICE DIDNT LAUNCH THE SIGNIN DIALOG
// IT MUST BE A NON GMS DEVICE
this.log.warn(`This device did not show Chrome SignIn dialog, ${(e as Error).message}`);
}
}
// #endregion
// #region Internal Helper Functions
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const srv = net.createServer();
srv.listen(0, () => {
const address = srv.address();
let port: number;
if (address && typeof address === 'object' && 'port' in address) {
port = address.port;
} else {
reject(new Error('Cannot determine any free port number'));
return;
}
srv.close(() => resolve(port));
});
});
}
/**
* https://chromedevtools.github.io/devtools-protocol/
*/
async function cdpGetRequest(host: string, port: number, endpoint: string): Promise<any[]> {
// Workaround for https://github.com/puppeteer/puppeteer/issues/2242, https://github.com/appium/appium/issues/20782
const compatibleHost = isCompatibleCdpHost(host) ? host : (await dns.lookup(host)).address;
return (
await axios({
url: `http://${compatibleHost}:${port}${endpoint}`,
timeout: CDP_REQ_TIMEOUT,
// We need to set this from Node.js v19 onwards.
// Otherwise, in situation with multiple webviews,
// the preceding webview pages will be incorrectly retrieved as the current ones.
// https://nodejs.org/en/blog/announcements/v19-release-announce#https11-keepalive-by-default
httpAgent: new http.Agent({keepAlive: false}),
})
).data;
}
/**
*/
async function cdpList(host: string, port: number): Promise<any[]> {
return cdpGetRequest(host, port, '/json/list');
}
/**
*/
async function cdpInfo(host: string, port: number): Promise<any> {
return cdpGetRequest(host, port, '/json/version');
}
/**
* Creates Chromedriver capabilities based on the provided Appium capabilities.
*/
function createChromedriverCaps(
this: AndroidDriver,
opts: any,
deviceId: string,
webViewDetails?: WebViewDetails | null,
): StringRecord {
const caps: any = {chromeOptions: {}};
const androidPackage =
opts.chromeOptions?.androidPackage ||
opts.appPackage ||
webViewDetails?.info?.['Android-Package'];
if (androidPackage) {
// chromedriver raises an invalid argument error when androidPackage is 'null'
caps.chromeOptions.androidPackage = androidPackage;
}
if (_.isBoolean(opts.chromeUseRunningApp)) {
caps.chromeOptions.androidUseRunningApp = opts.chromeUseRunningApp;
}
if (opts.chromeAndroidPackage) {
caps.chromeOptions.androidPackage = opts.chromeAndroidPackage;
}
if (opts.chromeAndroidActivity) {
caps.chromeOptions.androidActivity = opts.chromeAndroidActivity;
}
if (opts.chromeAndroidProcess) {
caps.chromeOptions.androidProcess = opts.chromeAndroidProcess;
} else if (webViewDetails?.process?.name && webViewDetails?.process?.id) {
caps.chromeOptions.androidProcess = webViewDetails.process.name;
}
if (_.toLower(opts.browserName) === 'chromium-webview') {
caps.chromeOptions.androidActivity = opts.appActivity;
}
if (opts.pageLoadStrategy) {
caps.pageLoadStrategy = opts.pageLoadStrategy;
}
const isChrome = _.toLower(caps.chromeOptions.androidPackage) === 'chrome';
if (_.includes(KNOWN_CHROME_PACKAGE_NAMES, caps.chromeOptions.androidPackage) || isChrome) {
// if we have extracted package from context name, it could come in as bare
// "chrome", and so we should make sure the details are correct, including
// not using an activity or process id
if (isChrome) {
caps.chromeOptions.androidPackage = CHROME_PACKAGE_NAME;
}
delete caps.chromeOptions.androidActivity;
delete caps.chromeOptions.androidProcess;
}
// add device id from adb
caps.chromeOptions.androidDeviceSerial = deviceId;
if (_.isPlainObject(opts.loggingPrefs) || _.isPlainObject(opts.chromeLoggingPrefs)) {
if (opts.loggingPrefs) {
this.log.warn(
`The 'loggingPrefs' cap is deprecated; use the 'chromeLoggingPrefs' cap instead`,
);
}
caps.loggingPrefs = opts.chromeLoggingPrefs || opts.loggingPrefs;
}
if (opts.enablePerformanceLogging) {
this.log.warn(
`The 'enablePerformanceLogging' cap is deprecated; simply use ` +
`the 'chromeLoggingPrefs' cap instead, with a 'performance' key set to 'ALL'`,
);
const newPref = {performance: 'ALL'};
// don't overwrite other logging prefs that have been sent in if they exist
caps.loggingPrefs = caps.loggingPrefs ? {...caps.loggingPrefs, ...newPref} : newPref;
}
if (opts.chromeOptions?.Arguments) {
// merge `Arguments` and `args`
opts.chromeOptions.args = [...(opts.chromeOptions.args || []), ...opts.chromeOptions.Arguments];
delete opts.chromeOptions.Arguments;
}
if (opts.webSocketUrl && _.isNil(caps.webSocketUrl) && this.opts.chromedriverForwardBiDi) {
caps.webSocketUrl = opts.webSocketUrl;
}
this.log.debug(
'Precalculated Chromedriver capabilities: ' + JSON.stringify(caps.chromeOptions, null, 2),
);
const protectedCapNames: string[] = [];
for (const [opt, val] of _.toPairs(opts.chromeOptions)) {
if (_.isUndefined(caps.chromeOptions[opt])) {
caps.chromeOptions[opt] = val;
} else {
protectedCapNames.push(opt);
}
}
if (!_.isEmpty(protectedCapNames)) {
this.log.info(
'The following Chromedriver capabilities cannot be overridden ' +
'by the provided chromeOptions:',
);
for (const optName of protectedCapNames) {
this.log.info(` ${optName} (${JSON.stringify(opts.chromeOptions[optName])})`);
}
}
return caps;
}
/**
* Allocates a local port for devtools communication.
*
* @param socketName - The remote Unix socket name
* @param webviewDevtoolsPort - The local port number or null to apply autodetection
* @returns The host name and the port number to connect to if the remote socket has been forwarded successfully
* @throws {Error} If there was an error while allocating the local port
*/
async function allocateDevtoolsChannel(
this: AndroidDriver,
socketName: string,
webviewDevtoolsPort: number | null = null,
): Promise<[string, number]> {
// socket names come with '@', but this should not be a part of the abstract
// remote port, so remove it
const remotePort = socketName.replace(/^@/, '');
let startPort: number = DEVTOOLS_PORTS_RANGE[0];
let endPort: number = DEVTOOLS_PORTS_RANGE[1];
if (webviewDevtoolsPort) {
endPort = webviewDevtoolsPort + (endPort - startPort);
startPort = webviewDevtoolsPort;
}
this.log.debug(
`Forwarding remote port ${remotePort} to a local ` + `port in range ${startPort}..${endPort}`,
);
if (!webviewDevtoolsPort) {
this.log.debug(
`You could use the 'webviewDevtoolsPort' capability to customize ` +
`the starting port number`,
);
}
const port = await DEVTOOLS_PORT_ALLOCATION_GUARD(async () => {
let localPort: number;
try {
localPort = await findAPortNotInUse(startPort, endPort);
} catch {
throw new Error(
`Cannot find any free port to forward the Devtools socket ` +
`in range ${startPort}..${endPort}. You could set the starting port number ` +
`manually by providing the 'webviewDevtoolsPort' capability`,
);
}
await this.adb.adbExec(['forward', `tcp:${localPort}`, `localabstract:${remotePort}`]);
return localPort as number;
});
return [this.adb.adbHost ?? '127.0.0.1', port as number];
}
/**
* Wrapper for Chrome Debugger Protocol data collection.
* No error is thrown if CDP request fails - in such case no data will be
* recorded into the corresponding `webviewsMapping` item.
*
* @param webviewsMapping - The current webviews mapping. Each item of this array gets mutated
* (`info`/`pages` properties get added based on the provided `opts`)
* if the requested details have been successfully retrieved for it
* @param opts - If both `ensureWebviewsHavePages` and `enableWebviewDetailsCollection`
* properties are falsy then no details collection is performed
*/
async function collectWebviewsDetails(
this: AndroidDriver,
webviewsMapping: WebviewProps[],
opts: DetailCollectionOptions = {},
): Promise<void> {
if (_.isEmpty(webviewsMapping)) {
return;
}
const {
webviewDevtoolsPort = null,
ensureWebviewsHavePages = null,
enableWebviewDetailsCollection = null,
} = opts;
if (!ensureWebviewsHavePages) {
this.log.info(
`Not checking whether webviews have active pages; use the ` +
`'ensureWebviewsHavePages' cap to turn this check on`,
);
}
if (!enableWebviewDetailsCollection) {
this.log.info(
`Not collecting web view details. Details collection might help ` +
`to make Chromedriver initialization more precise. Use the 'enableWebviewDetailsCollection' ` +
`cap to turn it on`,
);
}
if (!ensureWebviewsHavePages && !enableWebviewDetailsCollection) {
return;
}
// Connect to each devtools socket and retrieve web view details
this.log.debug(
`Collecting CDP data of ${util.pluralize('webview', webviewsMapping.length, true)}`,
);
const detailCollectors: Promise<void>[] = [];
for (const item of webviewsMapping) {
detailCollectors.push(
(async (): Promise<void> => {
let port: number | undefined;
let host: string | undefined;
try {
[host, port] = (await allocateDevtoolsChannel.bind(this)(
item.proc,
webviewDevtoolsPort,
)) as [string, number];
if (enableWebviewDetailsCollection) {
item.info = await cdpInfo(host, port);
}
if (ensureWebviewsHavePages) {
item.pages = await cdpList(host, port);
}
} catch (e) {
const err = e as Error;
this.log.info(
`CDP data for '${item.webview}' cannot be collected. Original error: ${err.message}`,
);
} finally {
if (port) {
try {
await this.adb.removePortForward(port);
} catch (e) {
this.log.debug(e);
}
}
}
})(),
);
}
await B.all(detailCollectors);
this.log.debug(`CDP data collection completed`);
}
/**
* Takes a webview name like WEBVIEW_4296 and uses 'adb shell ps' to figure out
* which app package is associated with that webview.
*/
async function procFromWebview(this: AndroidDriver, webview: string): Promise<string> {
const pidMatch = WEBVIEW_PID_PATTERN.exec(webview);
if (!pidMatch) {
throw new Error(`Could not find PID for webview '${webview}'`);
}
const pid = pidMatch[1];
this.log.debug(`${webview} mapped to pid ${pid}`);
this.log.debug(`Getting process name for webview '${webview}'`);
const pkg = await this.adb.getProcessNameById(pid);
this.log.debug(`Got process name: '${pkg}'`);
return pkg;
}
/**
* Gets a list of Android system processes that look like webviews.
* See https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc
* for more details.
*/
async function getPotentialWebviewProcs(this: AndroidDriver): Promise<string[]> {
const out = await this.adb.shell(['cat', '/proc/net/unix']);
const names: string[] = [];
const allMatches: string[] = [];
for (const line of out.split('\n')) {
// Num RefCount Protocol Flags Type St Inode Path
const [, , , flags, , st, , sockPath] = line.trim().split(/\s+/);
if (!sockPath) {
continue;
}
if (sockPath.startsWith('@')) {
allMatches.push(line.trim());
}
if (flags !== '00010000' || st !== '01') {
continue;
}
if (!DEVTOOLS_SOCKET_PATTERN.test(sockPath)) {
continue;
}
names.push(sockPath);
}
if (_.isEmpty(names)) {
this.log.debug('Found no active devtools sockets');
if (!_.isEmpty(allMatches)) {
this.log.debug(`Other sockets are: ${JSON.stringify(allMatches, null, 2)}`);
}
} else {
this.log.debug(
`Parsed ${names.length} active devtools ${util.pluralize('socket', names.length, false)}: ` +
JSON.stringify(names),
);
}
// sometimes the webview process shows up multiple times per app
return _.uniq(names);
}
/**
* Retrieves a list of system processes that look like webviews,
* and returns them along with the webview context name appropriate for it.
* If a deviceSocket is provided, only attempts to find webviews which match
* that socket name (for apps which embed Chromium, which isn't the
* same as chrome-backed webviews).
*
* @param deviceSocket - The explicitly-named device socket to use, or null to find all webviews
* @returns An array of webview process objects with proc and webview properties
*/
async function webviewsFromProcs(
this: AndroidDriver,
deviceSocket: string | null = null,
): Promise<WebviewProc[]> {
const socketNames = await getPotentialWebviewProcs.bind(this)();
const webviews: WebviewProc[] = [];
for (const socketName of socketNames) {
if (deviceSocket === CHROMIUM_DEVTOOLS_SOCKET && socketName === `@${deviceSocket}`) {
webviews.push({
proc: socketName,
webview: CHROMIUM_WIN,
});
continue;
}
const socketNameMatch = DEVTOOLS_SOCKET_PATTERN.exec(socketName);
if (!socketNameMatch) {
continue;
}
const matchedSocketName = socketNameMatch[2];
const crosswalkMatch = CROSSWALK_SOCKET_PATTERN.exec(socketName);
if (!matchedSocketName && !crosswalkMatch) {
continue;
}
if ((deviceSocket && socketName === `@${deviceSocket}`) || !deviceSocket) {
webviews.push({
proc: socketName,
webview: matchedSocketName
? `${WEBVIEW_BASE}${matchedSocketName}`
: `${WEBVIEW_BASE}${crosswalkMatch?.[1] ?? ''}`,
});
}
}
return webviews;
}
/**
* Finds a free port for Chromedriver based on the provided port specification.
* If no specification is provided, finds any available free port.
*/
async function getChromedriverPort(
this: AndroidDriver,
portSpec?: PortSpec,
): Promise<number> {
// if the user didn't give us any specific information about chromedriver
// port ranges, just find any free port
if (!portSpec) {
const port = await getFreePort();
this.log.debug(`A port was not given, using random free port: ${port}`);
return port;
}
// otherwise find the free port based on a list or range provided by the user
this.log.debug(`Finding a free port for chromedriver using spec ${JSON.stringify(portSpec)}`);
let foundPort: number | null = null;
for (const potentialPort of portSpec) {
let port: number;
let stopPort: number;
if (Array.isArray(potentialPort)) {
[port, stopPort] = potentialPort.map((p) => parseInt(String(p), 10));
} else {
port = parseInt(String(potentialPort), 10); // ensure we have a number and not a string
stopPort = port;
}
this.log.debug(`Checking port range ${port}:${stopPort}`);
try {
foundPort = await findAPortNotInUse(port, stopPort);
break;
} catch {
this.log.debug(`Nothing in port range ${port}:${stopPort} was available`);
}
}
if (foundPort === null) {
throw new Error(
`Could not find a free port for chromedriver using ` +
`chromedriverPorts spec ${JSON.stringify(portSpec)}`,
);
}
this.log.debug(`Using free port ${foundPort} for chromedriver`);
return foundPort;
}
function isChromedriverAutodownloadEnabled(this: AndroidDriver): boolean {
if (this.isFeatureEnabled(CHROMEDRIVER_AUTODOWNLOAD_FEATURE)) {
return true;
}
this.log.debug(
`Automated Chromedriver download is disabled. ` +
`Use '${CHROMEDRIVER_AUTODOWNLOAD_FEATURE}' server feature to enable it`,
);
return false;
}
function cacheChromedriverCaps(
this: AndroidDriver,
sessionCaps: Record<string, any>,
context?: string,
): void {
if (!context) {
return;
}
// Store full session capabilities in cache
this._chromedriverCapsCache.set(context, sessionCaps);
if (
this.opts?.chromedriverForwardBiDi &&
sessionCaps?.webSocketUrl &&
this._bidiProxyUrl !== sessionCaps.webSocketUrl
) {
this._bidiProxyUrl = sessionCaps.webSocketUrl;
this.log.debug(`Updated Bidi Proxy URL to ${this._bidiProxyUrl}`);
}
}
/**
* https://github.com/puppeteer/puppeteer/issues/2242#issuecomment-544219536
*/
function isCompatibleCdpHost(host: string): boolean {
return (
['localhost', 'localhost.localdomain'].includes(host) ||
host.endsWith('.localhost') ||
Boolean(net.isIP(host))
);
}
// #endregion