UNPKG

appium-android-driver

Version:

Android UiAutomator and Chrome support for Appium

482 lines (446 loc) 17.8 kB
import {util} from '@appium/support'; import {Chromedriver} from 'appium-chromedriver'; import {errors, PROTOCOLS} from 'appium/driver'; import type {StringRecord} from '@appium/types'; 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'; import type {AndroidDriver} from '../../driver'; import type {WebviewsMapping} from '../types'; // https://github.com/appium/appium/issues/20710 const DEFAULT_NATIVE_WINDOW_HANDLE = '1'; /** * Gets the current context name. Returns the default context if no context is explicitly set. * * @returns The current context name */ export async function getCurrentContext(this: AndroidDriver): Promise<string> { // if the current context is `null`, indicating no context // explicitly set, it is the default context return this.curContext || this.defaultContextName(); } /** * Gets a list of all available contexts (native and webviews). * * @returns An array of context names */ export async function getContexts(this: AndroidDriver): Promise<string[]> { const webviewsMapping = await getWebViewsMapping.bind(this)(this.opts); return this.assignContexts(webviewsMapping); } /** * Sets the current context to the specified context name. * * @param name - The context name to switch to. If not provided or null, defaults to the native context. * If "WEBVIEW", uses the default webview name. * @throws {errors.NoSuchContextError} If the specified context does not exist */ export async function setContext(this: AndroidDriver, name?: string | null): Promise<void> { 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(); } /** * Gets a detailed list of all available webview contexts with their mapping information. * * @param waitForWebviewMs - Optional timeout in milliseconds to wait for webviews to appear * @returns An array of webview mapping objects containing detailed information about each webview */ export async function mobileGetContexts( this: AndroidDriver, waitForWebviewMs?: number, ): Promise<WebviewsMapping[]> { const _opts = { androidDeviceSocket: this.opts.androidDeviceSocket, ensureWebviewsHavePages: true, webviewDevtoolsPort: this.opts.webviewDevtoolsPort, enableWebviewDetailsCollection: true, waitForWebviewMs: waitForWebviewMs || 0, }; return await getWebViewsMapping.bind(this)(_opts); } /** * Assigns and returns a list of available contexts based on the webviews mapping. * * @param webviewsMapping - Array of webview mapping objects * @returns An array of context names (always includes NATIVE_APP as the first element) */ export function assignContexts( this: AndroidDriver, webviewsMapping: WebviewsMapping[], ): string[] { 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; } /** * Switches to the specified context. Handles Chromedriver proxy setup/teardown as needed. * * @param name - The context name to switch to * @param webviewsMapping - Array of webview mapping objects * @throws {Error} If the context cannot be handled */ export async function switchContext( this: AndroidDriver, name: string, webviewsMapping: WebviewsMapping[], ): Promise<void> { this._bidiProxyUrl = null; // 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}'`); } } /** * Returns the default native context name. * * @returns The native context name ("NATIVE_APP") */ export function defaultContextName(this: AndroidDriver): string { return NATIVE_WIN; } /** * Returns the default webview name based on the app package or auto webview name option. * * @returns The default webview name */ export function defaultWebviewName(this: AndroidDriver): string { return WEBVIEW_BASE + (this.opts.autoWebviewName || this.opts.appPackage); } /** * Checks if the current context is a web context (not native). * * @returns True if the current context is a webview, false otherwise */ export function isWebContext(this: AndroidDriver): boolean { return this.curContext !== null && this.curContext !== NATIVE_WIN; } /** * Gets the current window handle. Returns a default handle for native contexts. * * @returns The current window handle */ export async function getWindowHandle(this: AndroidDriver): Promise<string> { if (!this.isWebContext()) { return DEFAULT_NATIVE_WINDOW_HANDLE; } const chromedriver = this.chromedriver as Chromedriver; const isJwp = chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP; const endpoint = isJwp ? '/window_handle' : '/window/handle'; return (await chromedriver.jwproxy.command(endpoint, 'GET')) as string; } /** * Gets all available window handles. Returns a default handle for native contexts. * * @returns An array of window handles */ export async function getWindowHandles(this: AndroidDriver): Promise<string[]> { if (!this.isWebContext()) { return [DEFAULT_NATIVE_WINDOW_HANDLE]; } const chromedriver = this.chromedriver as Chromedriver; const isJwp = chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP; const endpoint = isJwp ? '/window_handles' : '/window/handles'; return (await chromedriver.jwproxy.command(endpoint, 'GET')) as string[]; } /** * Sets the current window to the specified handle. Does nothing for native contexts. * * @param handle - The window handle to switch to */ export async function setWindow(this: AndroidDriver, handle: string): Promise<void> { if (!this.isWebContext()) { return; } const chromedriver = this.chromedriver as Chromedriver; const isJwp = chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP; const paramName = isJwp ? 'name' : 'handle'; await chromedriver.jwproxy.command('/window', 'POST', {[paramName]: handle}); } /** * Turns on proxying to an existing Chromedriver session or creates a new one. * * @param context - The context name to connect to * @param webviewsMapping - Array of webview mapping objects */ export async function startChromedriverProxy( this: AndroidDriver, context: string, webviewsMapping: WebviewsMapping[], ): Promise<void> { this.log.debug(`Connecting to chrome-backed webview context '${context}'`); let cd: Chromedriver; 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, context); } else { // XXX: this suppresses errors about putting arbitrary stuff on opts const opts = _.cloneDeep(this.opts) as any; 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`) { const 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 = (wm.info as NonNullable<WebviewsMapping['info']>)[ 'Android-Package' ]; this.log.debug( `Identified chromeAndroidPackage as '${opts.chromeAndroidPackage}' ` + `for context '${context}' by CDP`, ); break; } } } } } cd = await setupNewChromedriver.bind(this)( opts, this.adb.curDeviceId as string, 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; this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); this.proxyCommand = this.chromedriver.jwproxy.command.bind( this.chromedriver.jwproxy, ) as typeof this.proxyCommand; this.jwpProxyActive = true; } /** * Stops proxying to any Chromedriver and clears the proxy state. */ export function suspendChromedriverProxy(this: AndroidDriver): void { this.chromedriver = undefined; this.proxyReqRes = undefined; this.proxyCommand = undefined; this.jwpProxyActive = false; } /** * Handles an out-of-band Chromedriver stop event. * If the stopped context is the current context, triggers an unexpected shutdown. * Otherwise, logs a warning and removes the context from the session. * * @param context - The context name where Chromedriver stopped */ export async function onChromedriverStop(this: AndroidDriver, context: string): Promise<void> { 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 const 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 stops all the chromedrivers currently active and ignores their exit events. */ export async function stopChromedriverProxies(this: AndroidDriver): Promise<void> { this.suspendChromedriverProxy(); // make sure we turn off the proxy flag for (const context of _.keys(this.sessionChromedrivers)) { const 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: ${(err as Error).message}`); } delete this.sessionChromedrivers[context]; } } /** * Checks if a context name represents a Chromedriver-backed webview context. * * @param viewName - The context name to check * @returns True if the context is a Chromedriver context, false otherwise */ export function isChromedriverContext(this: AndroidDriver, viewName: string): boolean { return _.includes(viewName, WEBVIEW_WIN) || viewName === CHROMIUM_WIN; } /** * Notifies BiDi clients about a context change event. * See https://github.com/appium/appium/issues/20741 */ export async function notifyBiDiContextChange(this: AndroidDriver): Promise<void> { 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)); } /** * Gets the ChromeDriver session capabilities for the current webview context. * * @returns The ChromeDriver session capabilities * @throws {errors.InvalidContextError} If not in a webview context * @throws {Error} If no ChromeDriver session capabilities are found */ export async function mobileGetChromeCapabilities(this: AndroidDriver): Promise<StringRecord> { if (!this.isWebContext()) { throw new errors.InvalidContextError( 'mobile: getChromeCapabilities can only be called in a webview context', ); } const currentContext = await this.getCurrentContext(); const sessionCaps = this._chromedriverCapsCache.get(currentContext); if (!sessionCaps) { throw new Error( `No ChromeDriver session capabilities found for context '${currentContext}'. ` + 'The ChromeDriver session may not have been initialized yet.', ); } return sessionCaps; } /** * Starts a Chrome-based browser session and sets up the Chromedriver proxy. */ export async function startChromeSession(this: AndroidDriver): Promise<void> { this.log.info('Starting a chrome-based browser session'); // XXX: this suppresses errors about putting arbitrary stuff on opts const opts = _.cloneDeep(this.opts) as any; 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; } const chromedriver = await setupNewChromedriver.bind(this)( opts, this.adb.curDeviceId as string, ); this.chromedriver = chromedriver; 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; this.sessionChromedrivers[CHROMIUM_WIN] = chromedriver; this.proxyReqRes = chromedriver.proxyReq.bind(chromedriver); this.proxyCommand = chromedriver.jwproxy.command.bind( chromedriver.jwproxy, ) as typeof this.proxyCommand; this.jwpProxyActive = true; if (shouldDismissChromeWelcome.bind(this)()) { // dismiss Chrome welcome dialog await dismissChromeWelcome.bind(this)(); } }