appium-android-driver
Version:
Android UiAutomator and Chrome support for Appium
838 lines (781 loc) • 28.4 kB
JavaScript
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 {toDetailsCacheKey, getWebviewDetails, WEBVIEWS_DETAILS_CACHE} from './cache';
import dns from 'node:dns/promises';
// https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc
export const CHROME_BROWSER_PACKAGE_ACTIVITY = /** @type {const} */ ({
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',
},
});
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',
];
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];
const DEVTOOLS_PORT_ALLOCATION_GUARD = util.getLockFileGuard(
path.resolve(os.tmpdir(), 'android_devtools_port_guard'),
{timeout: 7, tryRecovery: true},
);
/**
* @returns {Promise<number>}
*/
async function getFreePort() {
return await new Promise((resolve, reject) => {
const srv = net.createServer();
srv.listen(0, () => {
const address = srv.address();
let port;
if (_.has(address, 'port')) {
// @ts-ignore The above condition covers possible errors
port = address.port;
} else {
reject(new Error('Cannot determine any free port number'));
}
srv.close(() => resolve(port));
});
});
}
/**
* https://chromedevtools.github.io/devtools-protocol/
*
* @param {string} host
* @param {number} port
* @param {string} endpoint
* @returns {Promise<object[]>}
*/
async function cdpGetRequest(host, port, endpoint) {
// 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;
}
/**
* @param {string} host
* @param {number} port
* @returns {Promise<object[]>}
*/
async function cdpList(host, port) {
return cdpGetRequest(host, port, '/json/list');
}
/**
* @param {string} host
* @param {number} port
* @returns {Promise<object[]>}
*/
async function cdpInfo(host, port) {
return cdpGetRequest(host, port, '/json/version');
}
/**
*
* @param {string} browser
* @returns {CHROME_BROWSER_PACKAGE_ACTIVITY[keyof CHROME_BROWSER_PACKAGE_ACTIVITY]}
*/
export function getChromePkg(browser) {
return (
CHROME_BROWSER_PACKAGE_ACTIVITY[browser.toLowerCase()] ||
CHROME_BROWSER_PACKAGE_ACTIVITY.default
);
}
/**
* Create Chromedriver capabilities based on the provided
* Appium capabilities
*
* @this {import('../../driver').AndroidDriver}
* @param {any} opts
* @param {string} deviceId
* @param {import('../types').WebViewDetails | null} [webViewDetails]
* @returns {import('@appium/types').StringRecord}
*/
function createChromedriverCaps(opts, deviceId, webViewDetails) {
const caps = {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;
}
this.log.debug(
'Precalculated Chromedriver capabilities: ' + JSON.stringify(caps.chromeOptions, null, 2),
);
/** @type {string[]} */
const protectedCapNames = [];
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;
}
/**
* Parse webview names for getContexts
*
* @this {import('../../driver').AndroidDriver}
* @param {import('../types').WebviewsMapping[]} webviewsMapping
* @param {import('../types').GetWebviewsOpts} options
* @returns {string[]}
*/
export function parseWebviewNames(
webviewsMapping,
{ensureWebviewsHavePages = true, isChromeSession = false} = {},
) {
if (isChromeSession) {
return [CHROMIUM_WIN];
}
/** @type {string[]} */
const result = [];
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;
}
/**
* Allocates a local port for devtools communication
*
* @this {import('../../driver').AndroidDriver}
* @param {string} socketName - The remote Unix socket name
* @param {number?} [webviewDevtoolsPort=null] - The local port number or null to apply
* autodetection
* @returns {Promise<[string, number]>} 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(socketName, webviewDevtoolsPort = null) {
// 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, endPort] = DEVTOOLS_PORTS_RANGE;
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;
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;
});
return [this.adb.adbHost ?? '127.0.0.1', port];
}
/**
* This is a 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.
*
* @this {import('../../driver').AndroidDriver}
* @param {import('../types').WebviewProps[]} 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 {import('../types').DetailCollectionOptions} [opts={}] If both `ensureWebviewsHavePages` and
* `enableWebviewDetailsCollection` properties are falsy then no details collection
* is performed
* @returns {Promise<void>}
*/
async function collectWebviewsDetails(webviewsMapping, opts = {}) {
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 = [];
for (const item of webviewsMapping) {
detailCollectors.push(
(async () => {
let port;
let host;
try {
[host, port] = /** @type {[string, number]} */ (
await allocateDevtoolsChannel.bind(this)(item.proc, webviewDevtoolsPort)
);
if (enableWebviewDetailsCollection) {
item.info = await cdpInfo(host, port);
}
if (ensureWebviewsHavePages) {
item.pages = await cdpList(host, port);
}
} catch (e) {
this.log.info(
`CDP data for '${item.webview}' cannot be collected. Original error: ${e.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`);
}
/**
* 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.
*
* @this {import('../../driver').AndroidDriver}
* @param {import('../types').GetWebviewsOpts} [opts={}]
* @returns {Promise<import('../types').WebviewsMapping[]>}
*/
export async function getWebViewsMapping({
androidDeviceSocket = null,
ensureWebviewsHavePages = true,
webviewDevtoolsPort = null,
enableWebviewDetailsCollection = true,
waitForWebviewMs = 0,
} = {}) {
this.log.debug(`Getting a list of available webviews`);
if (!_.isNumber(waitForWebviewMs)) {
waitForWebviewMs = parseInt(`${waitForWebviewMs}`, 10) || 0;
}
/** @type {import('../types').WebviewsMapping[]} */
let 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 < waitForWebviewMs);
await collectWebviewsDetails.bind(this)(webviewsMapping, {
ensureWebviewsHavePages,
enableWebviewDetailsCollection,
webviewDevtoolsPort,
});
for (const webviewMapping of webviewsMapping) {
const {webview, info} = webviewMapping;
webviewMapping.webviewName = null;
let wvName = webview;
/** @type {{name: string; id: string | null} | undefined} */
let process;
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) {
this.log.debug(e.stack);
this.log.warn(e.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;
}
/**
* Take a webview name like WEBVIEW_4296 and use 'adb shell ps' to figure out
* which app package is associated with that webview. One of the reasons we
* want to do this is to make sure we're listing webviews for the actual AUT,
* not some other running app
*
* @this {import('../../driver').AndroidDriver}
* @param {string} webview
* @returns {Promise<string>}
*/
async function procFromWebview(webview) {
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.getNameByPid(pid);
this.log.debug(`Got process name: '${pkg}'`);
return pkg;
}
/**
* This function gets a list of android system processes and returns ones
* that look like webviews
* See https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc
* for more details
*
* @this {import('../../driver').AndroidDriver}
* @returns {Promise<string[]>} a list of matching webview socket names (including the leading '@')
*/
async function getPotentialWebviewProcs() {
const out = await this.adb.shell(['cat', '/proc/net/unix']);
/** @type {string[]} */
const names = [];
/** @type {string[]} */
const allMatches = [];
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);
}
/**
* This function retrieves a list of system processes that look like webviews,
* and returns them along with the webview context name appropriate for it.
* If we pass in a deviceSocket, we only attempt to find webviews which match
* that socket name (this is for apps which embed Chromium, which isn't the
* same as chrome-backed webviews).
*
* @this {import('../../driver').AndroidDriver}
* @param {string?} [deviceSocket=null] - the explictly-named device socket to use
* @returns {Promise<import('../types').WebviewProc[]>}
*/
async function webviewsFromProcs(deviceSocket = null) {
const socketNames = await getPotentialWebviewProcs.bind(this)();
/** @type {{proc: string; webview: string}[]} */
const webviews = [];
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}`
: // @ts-expect-error: XXX crosswalkMatch can absolutely be null
`${WEBVIEW_BASE}${crosswalkMatch[1]}`,
});
}
}
return webviews;
}
/**
* @this {import('../../driver').AndroidDriver}
* @param {import('../types').PortSpec} [portSpec]
* @returns {Promise<number>}
*/
async function getChromedriverPort(portSpec) {
// 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 = null;
for (const potentialPort of portSpec) {
/** @type {number} */
let port;
/** @type {number} */
let stopPort;
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;
}
/**
* @this {import('../../driver').AndroidDriver}
* @returns {boolean}
*/
function isChromedriverAutodownloadEnabled() {
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;
}
/**
* @this {import('../../driver').AndroidDriver}
* @param {import('../../driver').AndroidDriverOpts} opts
* @param {string} curDeviceId
* @param {string} [context]
* @returns {Promise<Chromedriver>}
*/
export async function setupNewChromedriver(opts, curDeviceId, context) {
// @ts-ignore TODO: Remove the legacy
if (opts.chromeDriverPort) {
this.log.warn(
`The 'chromeDriverPort' capability is deprecated. Please use 'chromedriverPort' instead`,
);
// @ts-ignore TODO: Remove the legacy
opts.chromedriverPort = opts.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),
);
}
/** @type {import('appium-chromedriver').ChromedriverOpts} */
const chromedriverOpts = {
port: _.isNil(opts.chromedriverPort) ? undefined : String(opts.chromedriverPort),
executable: opts.chromedriverExecutable,
adb: this.adb,
cmdArgs: /** @type {string[] | undefined} */ (opts.chromedriverArgs),
verbose: !!opts.showChromedriverLog,
executableDir: opts.chromedriverExecutableDir,
mappingPath: opts.chromedriverChromeMappingFile,
// @ts-ignore this property exists
bundleId: opts.chromeBundleId,
useSystemExecutable: opts.chromedriverUseSystemExecutable,
disableBuildCheck: opts.chromedriverDisableBuildCheck,
// @ts-ignore this is ok
details,
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[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[opt]);
}
}
const caps = /** @type {any} */ (createChromedriverCaps.bind(this)(opts, curDeviceId, details));
this.log.debug(
`Before starting chromedriver, androidPackage is '${caps.chromeOptions.androidPackage}'`,
);
await chromedriver.start(caps);
return chromedriver;
}
/**
* @this {import('../../driver').AndroidDriver}
* @template {Chromedriver} T
* @param {T} chromedriver
* @returns {Promise<T>}
*/
export async function setupExistingChromedriver(chromedriver) {
// 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())) {
this.log.debug('ChromeDriver is not associated with a window. Re-initializing the session.');
await chromedriver.restart();
}
return chromedriver;
}
/**
* @this {import('../../driver').AndroidDriver}
* @returns {boolean}
*/
export function shouldDismissChromeWelcome() {
return (
!!this.opts.chromeOptions &&
_.isArray(this.opts.chromeOptions.args) &&
this.opts.chromeOptions.args.includes('--no-first-run')
);
}
/**
* @this {import('../../driver').AndroidDriver}
* @returns {Promise<void>}
*/
export async function dismissChromeWelcome() {
this.log.info('Trying to dismiss Chrome welcome');
let activity = await this.getCurrentActivity();
if (activity !== 'org.chromium.chrome.browser.firstrun.FirstRunActivity') {
this.log.info('Chrome welcome dialog never showed up! Continuing');
return;
}
let el = await this.findElOrEls('id', 'com.android.chrome:id/terms_accept', false);
await this.click(/** @type {string} */ (el.ELEMENT));
try {
let el = await this.findElOrEls('id', 'com.android.chrome:id/negative_button', false);
await this.click(/** @type {string} */ (el.ELEMENT));
} 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, ${/** @type {Error} */ (e).message}`,
);
}
}
/**
* https://github.com/puppeteer/puppeteer/issues/2242#issuecomment-544219536
*
* @param {string} host
* @returns {boolean}
*/
function isCompatibleCdpHost (host) {
return ['localhost', 'localhost.localdomain'].includes(host)
|| host.endsWith('.localhost')
|| Boolean(net.isIP(host));
}
/**
* @typedef {import('appium-adb').ADB} ADB
*/