UNPKG

appium-android-driver

Version:

Android UiAutomator and Chrome support for Appium

445 lines (411 loc) 15.1 kB
import {util} from '@appium/support'; import {Chromedriver} from 'appium-chromedriver'; import {errors, PROTOCOLS} from 'appium/driver'; import _ from 'lodash'; import { CHROMIUM_WIN, KNOWN_CHROME_PACKAGE_NAMES, NATIVE_WIN, WEBVIEW_BASE, WEBVIEW_WIN, dismissChromeWelcome, getWebViewsMapping, parseWebviewNames, setupExistingChromedriver, setupNewChromedriver, shouldDismissChromeWelcome, } from './helpers'; import {APP_STATE} from '../app-management'; import { BIDI_EVENT_NAME } from '../bidi/constants'; import { makeContextUpdatedEvent, makeObsoleteContextUpdatedEvent } from '../bidi/models'; // https://github.com/appium/appium/issues/20710 const DEFAULT_NATIVE_WINDOW_HANDLE = '1'; /** * @this {AndroidDriver} * @returns {Promise<string>} */ export async function getCurrentContext() { // if the current context is `null`, indicating no context // explicitly set, it is the default context return this.curContext || this.defaultContextName(); } /** * @this {AndroidDriver} * @returns {Promise<string[]>} */ export async function getContexts() { const webviewsMapping = await getWebViewsMapping.bind(this)(this.opts); return this.assignContexts(webviewsMapping); } /** * @this {AndroidDriver} * @param {string?} name * @returns {Promise<void>} */ export async function setContext(name) { let newContext = name; if (!util.hasValue(newContext)) { newContext = this.defaultContextName(); } else if (newContext === WEBVIEW_WIN) { // handle setContext "WEBVIEW" newContext = this.defaultWebviewName(); } // if we're already in the context we want, do nothing if (newContext === this.curContext) { return; } const webviewsMapping = await getWebViewsMapping.bind(this)(this.opts); const contexts = this.assignContexts(webviewsMapping); // if the context we want doesn't exist, fail if (!_.includes(contexts, newContext)) { throw new errors.NoSuchContextError(); } await this.switchContext(newContext, webviewsMapping); this.curContext = newContext; await this.notifyBiDiContextChange(); } /** * @this {AndroidDriver} * @param {number} [waitForWebviewMs] * @returns {Promise<import('../types').WebviewsMapping[]>} */ export async function mobileGetContexts(waitForWebviewMs) { const _opts = { androidDeviceSocket: this.opts.androidDeviceSocket, ensureWebviewsHavePages: true, webviewDevtoolsPort: this.opts.webviewDevtoolsPort, enableWebviewDetailsCollection: true, waitForWebviewMs: waitForWebviewMs || 0, }; return await getWebViewsMapping.bind(this)(_opts); } /** * @this {AndroidDriver} * @param {import('../types').WebviewsMapping[]} webviewsMapping * @returns {string[]} */ export function assignContexts(webviewsMapping) { const opts = { isChromeSession: this.isChromeSession, ...this.opts }; const webviews = parseWebviewNames.bind(this)(webviewsMapping, opts); this.contexts = [NATIVE_WIN, ...webviews]; this.log.debug(`Available contexts: ${JSON.stringify(this.contexts)}`); return this.contexts; } /** * @this {AndroidDriver} * @param {string} name * @param {import('../types').WebviewsMapping[]} webviewsMapping * @returns {Promise<void>} */ export async function switchContext(name, webviewsMapping) { // We have some options when it comes to webviews. If we want a // Chromedriver webview, we can only control one at a time. if (this.isChromedriverContext(name)) { // start proxying commands directly to chromedriver await this.startChromedriverProxy(name, webviewsMapping); } else if (this.isChromedriverContext(this.curContext)) { // if we're moving to a non-chromedriver webview, and our current context // _is_ a chromedriver webview, if caps recreateChromeDriverSessions is set // to true then kill chromedriver session using stopChromedriverProxies or // else simply suspend proxying to the latter if (this.opts.recreateChromeDriverSessions) { this.log.debug('recreateChromeDriverSessions set to true; killing existing chromedrivers'); await this.stopChromedriverProxies(); } else { this.suspendChromedriverProxy(); } } else { throw new Error(`Didn't know how to handle switching to context '${name}'`); } } /** * @this {AndroidDriver} * @returns {string} */ export function defaultContextName() { return NATIVE_WIN; } /** * @this {AndroidDriver} * @returns {string} */ export function defaultWebviewName() { return WEBVIEW_BASE + (this.opts.autoWebviewName || this.opts.appPackage); } /** * @this {AndroidDriver} * @returns {boolean} */ export function isWebContext() { return this.curContext !== null && this.curContext !== NATIVE_WIN; } /** * @this {AndroidDriver} * @returns {Promise<string>} */ export async function getWindowHandle() { if (!this.isWebContext()) { return DEFAULT_NATIVE_WINDOW_HANDLE; } const chromedriver = /** @type {Chromedriver} */ (this.chromedriver); const isJwp = chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP; const endpoint = isJwp ? '/window_handle' : '/window/handle'; return /** @type {string} */ (await chromedriver.jwproxy.command(endpoint, 'GET')); } /** * @this {AndroidDriver} * @returns {Promise<string[]>} */ export async function getWindowHandles() { if (!this.isWebContext()) { return [DEFAULT_NATIVE_WINDOW_HANDLE]; } const chromedriver = /** @type {Chromedriver} */ (this.chromedriver); const isJwp = chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP; const endpoint = isJwp ? '/window_handles' : '/window/handles'; return /** @type {string[]} */ (await chromedriver.jwproxy.command(endpoint, 'GET')); } /** * @this {AndroidDriver} * @param {string} handle * @returns {Promise<void>} */ export async function setWindow(handle) { if (!this.isWebContext()) { return; } const chromedriver = /** @type {Chromedriver} */ (this.chromedriver); const isJwp = chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP; const paramName = isJwp ? 'name' : 'handle'; await chromedriver.jwproxy.command('/window', 'POST', {[paramName]: handle}); } /** * Turn on proxying to an existing Chromedriver session or a new one * * @this {AndroidDriver} * @param {string} context * @param {import('../types').WebviewsMapping[]} webviewsMapping * @returns {Promise<void>} */ export async function startChromedriverProxy(context, webviewsMapping) { this.log.debug(`Connecting to chrome-backed webview context '${context}'`); let cd; if (this.sessionChromedrivers[context]) { // in the case where we've already set up a chromedriver for a context, // we want to reconnect to it, not create a whole new one this.log.debug(`Found existing Chromedriver for context '${context}'. Using it.`); cd = this.sessionChromedrivers[context]; await setupExistingChromedriver.bind(this)(cd); } else { // XXX: this suppresses errors about putting arbitrary stuff on opts const opts = /** @type {any} */ (_.cloneDeep(this.opts)); opts.chromeUseRunningApp = true; // if requested, tell chromedriver to attach to the android package we have // associated with the context name, rather than the package of the AUT. // And turn this on by default for chrome--if chrome pops up with a webview // and someone wants to switch to it, we should let chromedriver connect to // chrome rather than staying stuck on the AUT if (opts.extractChromeAndroidPackageFromContextName || context === `${WEBVIEW_BASE}chrome`) { let androidPackage = context.match(`${WEBVIEW_BASE}(.+)`); if (androidPackage && androidPackage.length > 0) { opts.chromeAndroidPackage = androidPackage[1]; } if (!opts.extractChromeAndroidPackageFromContextName) { if ( _.has(this.opts, 'enableWebviewDetailsCollection') && !this.opts.enableWebviewDetailsCollection ) { // When enableWebviewDetailsCollection capability is explicitly disabled, try to identify // chromeAndroidPackage based on contexts, known chrome variant packages and queryAppState result // since webviewsMapping does not have info object const contexts = webviewsMapping.map((wm) => wm.webviewName); for (const knownPackage of KNOWN_CHROME_PACKAGE_NAMES) { if (_.includes(contexts, `${WEBVIEW_BASE}${knownPackage}`)) { continue; } const appState = await this.queryAppState(knownPackage); if ( _.includes( [APP_STATE.RUNNING_IN_BACKGROUND, APP_STATE.RUNNING_IN_FOREGROUND], appState, ) ) { opts.chromeAndroidPackage = knownPackage; this.log.debug( `Identified chromeAndroidPackage as '${opts.chromeAndroidPackage}' ` + `for context '${context}' by querying states of Chrome app packages`, ); break; } } } else { for (const wm of webviewsMapping) { if (wm.webviewName === context && _.has(wm?.info, 'Android-Package')) { // XXX: should be a type guard here opts.chromeAndroidPackage = /** @type {NonNullable<import('../types').WebviewsMapping['info']>} */ (wm.info)[ 'Android-Package' ]; this.log.debug( `Identified chromeAndroidPackage as '${opts.chromeAndroidPackage}' ` + `for context '${context}' by CDP`, ); break; } } } } } cd = await setupNewChromedriver.bind(this)( opts, /** @type {string} */ (this.adb.curDeviceId), context, ); // bind our stop/exit handler, passing in context so we know which // one stopped unexpectedly cd.on(Chromedriver.EVENT_CHANGED, (msg) => { if (msg.state === Chromedriver.STATE_STOPPED) { this.onChromedriverStop(context); } }); // save the chromedriver object under the context this.sessionChromedrivers[context] = cd; } // hook up the local variables so we can proxy this biz this.chromedriver = cd; // @ts-ignore chromedriver is defined this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); this.proxyCommand = /** @type {import('@appium/types').ExternalDriver['proxyCommand']} */ ( // @ts-ignore chromedriver is defined this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy) ); this.jwpProxyActive = true; } /** * Stop proxying to any Chromedriver * * @this {AndroidDriver} * @returns {void} */ export function suspendChromedriverProxy() { this.chromedriver = undefined; this.proxyReqRes = undefined; this.proxyCommand = undefined; this.jwpProxyActive = false; } /** * Handle an out-of-band Chromedriver stop event * * @this {AndroidDriver} * @param {string} context * @returns {Promise<void>} */ export async function onChromedriverStop(context) { this.log.warn(`Chromedriver for context ${context} stopped unexpectedly`); if (context === this.curContext) { // we exited unexpectedly while automating the current context and so want // to shut down the session and respond with an error let err = new Error('Chromedriver quit unexpectedly during session'); await this.startUnexpectedShutdown(err); } else { // if a Chromedriver in the non-active context barfs, we don't really // care, we'll just make a new one next time we need the context. this.log.warn( "Chromedriver quit unexpectedly, but it wasn't the active " + 'context, ignoring', ); delete this.sessionChromedrivers[context]; } } /** * Intentionally stop all the chromedrivers currently active, and ignore * their exit events * * @this {AndroidDriver} * @returns {Promise<void>} */ export async function stopChromedriverProxies() { this.suspendChromedriverProxy(); // make sure we turn off the proxy flag for (let context of _.keys(this.sessionChromedrivers)) { let cd = this.sessionChromedrivers[context]; this.log.debug(`Stopping chromedriver for context ${context}`); // stop listening for the stopped state event cd.removeAllListeners(Chromedriver.EVENT_CHANGED); try { await cd.stop(); } catch (err) { this.log.warn(`Error stopping Chromedriver: ${/** @type {Error} */ (err).message}`); } delete this.sessionChromedrivers[context]; } } /** * @this {AndroidDriver} * @param {string} viewName * @returns {boolean} */ export function isChromedriverContext(viewName) { return _.includes(viewName, WEBVIEW_WIN) || viewName === CHROMIUM_WIN; } /** * https://github.com/appium/appium/issues/20741 * * @this {AndroidDriver} * @returns {Promise<void>} */ export async function notifyBiDiContextChange() { const name = await this.getCurrentContext(); this.eventEmitter.emit(BIDI_EVENT_NAME, makeContextUpdatedEvent(_.toLower(String(this.opts.automationName)), name)); this.eventEmitter.emit(BIDI_EVENT_NAME, makeObsoleteContextUpdatedEvent(name)); } /** * @this {AndroidDriver} * @returns {Promise<void>} */ export async function startChromeSession() { this.log.info('Starting a chrome-based browser session'); // XXX: this suppresses errors about putting arbitrary stuff on opts const opts = /** @type {any} */ (_.cloneDeep(this.opts)); const knownPackages = [ 'org.chromium.chrome.shell', 'com.android.chrome', 'com.chrome.beta', 'org.chromium.chrome', 'org.chromium.webview_shell', ]; if (_.includes(knownPackages, this.opts.appPackage)) { opts.chromeBundleId = this.opts.appPackage; } else { opts.chromeAndroidActivity = this.opts.appActivity; } this.chromedriver = await setupNewChromedriver.bind(this)( opts, /** @type {string} */ (this.adb.curDeviceId), ); // @ts-ignore chromedriver is defined this.chromedriver.on(Chromedriver.EVENT_CHANGED, (msg) => { if (msg.state === Chromedriver.STATE_STOPPED) { this.onChromedriverStop(CHROMIUM_WIN); } }); // Now that we have a Chrome session, we ensure that the context is // appropriately set and that this chromedriver is added to the list // of session chromedrivers so we can switch back and forth this.curContext = CHROMIUM_WIN; // @ts-ignore chromedriver is defined this.sessionChromedrivers[CHROMIUM_WIN] = this.chromedriver; // @ts-ignore chromedriver should be defined this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); this.proxyCommand = /** @type {import('@appium/types').ExternalDriver['proxyCommand']} */ ( // @ts-ignore chromedriver is defined this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy) ); this.jwpProxyActive = true; if (shouldDismissChromeWelcome.bind(this)()) { // dismiss Chrome welcome dialog await dismissChromeWelcome.bind(this)(); } } /** * @typedef {import('appium-adb').ADB} ADB * @typedef {import('../../driver').AndroidDriver} AndroidDriver */