appium-uiautomator2-driver
Version:
UiAutomator2 integration for Appium
680 lines (616 loc) • 23.9 kB
text/typescript
import type {
DefaultCreateSessionResult,
DriverData,
ExternalDriver,
InitialOpts,
RouteMatcher,
SingularSessionData,
StringRecord,
SessionCapabilities,
} from '@appium/types';
import {DEFAULT_ADB_PORT, type ADB} from 'appium-adb';
import {AndroidDriver, utils} from 'appium-android-driver';
import {BaseDriver, DeviceSettings} from 'appium/driver';
import {mjpeg, util} from 'appium/support';
import UIAUTOMATOR2_CONSTRAINTS, {type Uiautomator2Constraints} from './constraints';
import {newMethodMap} from './method-map';
import {assignDefaults, memoize} from './utils';
import type {
Uiautomator2Settings,
Uiautomator2DeviceDetails,
Uiautomator2DriverCaps,
Uiautomator2DriverOpts,
Uiautomator2StartSessionOpts,
W3CUiautomator2DriverCaps,
} from './types';
import type {UiAutomator2Server} from './uiautomator2-server';
import {
allocateMjpegServerPort,
allocateSystemPort,
initServer,
performExecution,
performPostExecSetup,
performPreExecSetup,
releaseMjpegServerPort,
releaseSystemPort,
requireServer,
startSession,
} from './uiautomator2-server';
import {
mobileGetActionHistory,
mobileScheduleAction,
mobileUnscheduleAction,
performActions,
releaseActions,
} from './commands/actions';
import {
getAlertText,
mobileAcceptAlert,
mobileDismissAlert,
postAcceptAlert,
postDismissAlert,
} from './commands/alert';
import {mobileInstallMultipleApks} from './commands/app-management';
import {checkAppPresent, ensureAppStarts, initAUT, prepareSessionApp} from './commands/aut';
import {mobileGetBatteryInfo} from './commands/battery';
import {getClipboard, setClipboard} from './commands/clipboard';
import {
active,
getAttribute,
elementEnabled,
elementDisplayed,
elementSelected,
getName,
getLocation,
getSize,
getElementRect,
getElementScreenshot,
getText,
setValueImmediate,
doSetElementValue,
click,
clear,
mobileReplaceElementValue,
} from './commands/element';
import {doFindElementOrEls} from './commands/find';
import {
mobileClickGesture,
mobileDoubleClickGesture,
mobileDragGesture,
mobileFlingGesture,
mobileLongClickGesture,
mobilePinchCloseGesture,
mobilePinchOpenGesture,
mobileScroll,
mobileScrollBackTo,
mobileScrollGesture,
mobileSwipeGesture,
} from './commands/gestures';
import {
pressKeyCode,
longPressKeyCode,
mobilePressKey,
mobileType,
doSendKeys,
keyevent,
} from './commands/keyboard';
import {
getPageSource,
getOrientation,
setOrientation,
openNotifications,
suspendChromedriverProxy,
mobileGetDeviceInfo,
mobileResetAccessibilityCache,
} from './commands/misc';
import {mobileListWindows, mobileListDisplays} from './commands/windows';
import {setUrl, mobileDeepLink, back} from './commands/navigation';
import {
mobileScreenshots,
mobileViewportScreenshot,
getScreenshot,
getViewportScreenshot,
} from './commands/screenshot';
import {
getStatusBarHeight,
getDevicePixelRatio,
getDisplayDensity,
getViewPortRect,
getWindowRect,
getWindowSize,
mobileViewPortRect,
} from './commands/viewport';
import {executeMethodMap} from './execute-method-map';
// NO_PROXY contains the paths that we never want to proxy to UiAutomator2 server.
// TODO: Add the list of paths that we never want to proxy to UiAutomator2 server.
// TODO: Need to segregate the paths better way using regular expressions wherever applicable.
// (Not segregating right away because more paths to be added in the NO_PROXY list)
const NO_PROXY: RouteMatcher[] = [
['DELETE', new RegExp('^/session/[^/]+/actions')],
['GET', new RegExp('^/session/(?!.*/)')],
['GET', new RegExp('^/session/[^/]+/alert_[^/]+')],
['GET', new RegExp('^/session/[^/]+/alert/[^/]+')],
['GET', new RegExp('^/session/[^/]+/appium/[^/]+/current_activity')],
['GET', new RegExp('^/session/[^/]+/appium/[^/]+/current_package')],
['GET', new RegExp('^/session/[^/]+/appium/app/[^/]+')],
['GET', new RegExp('^/session/[^/]+/appium/capabilities')],
['GET', new RegExp('^/session/[^/]+/appium/commands')],
['GET', new RegExp('^/session/[^/]+/appium/device/[^/]+')],
['GET', new RegExp('^/session/[^/]+/appium/extensions')],
['GET', new RegExp('^/session/[^/]+/appium/settings')],
['GET', new RegExp('^/session/[^/]+/context')],
['GET', new RegExp('^/session/[^/]+/contexts')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/attribute')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/displayed')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/enabled')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/location_in_view')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/name')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/screenshot')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/selected')],
['GET', new RegExp('^/session/[^/]+/ime/[^/]+')],
['GET', new RegExp('^/session/[^/]+/location')],
['GET', new RegExp('^/session/[^/]+/network_connection')],
['GET', new RegExp('^/session/[^/]+/screenshot')],
['GET', new RegExp('^/session/[^/]+/timeouts')],
['GET', new RegExp('^/session/[^/]+/url')],
['POST', new RegExp('^/session/[^/]+/[^/]+_alert$')],
['POST', new RegExp('^/session/[^/]+/actions')],
['POST', new RegExp('^/session/[^/]+/alert/[^/]+')],
['POST', new RegExp('^/session/[^/]+/app/[^/]')],
['POST', new RegExp('^/session/[^/]+/appium/[^/]+/start_activity')],
['POST', new RegExp('^/session/[^/]+/appium/app/[^/]+')],
['POST', new RegExp('^/session/[^/]+/appium/compare_images')],
['POST', new RegExp('^/session/[^/]+/appium/device/(?!set_clipboard)[^/]+')],
['POST', new RegExp('^/session/[^/]+/appium/element/[^/]+/replace_value')],
['POST', new RegExp('^/session/[^/]+/appium/element/[^/]+/value')],
['POST', new RegExp('^/session/[^/]+/appium/getPerformanceData')],
['POST', new RegExp('^/session/[^/]+/appium/performanceData/types')],
['POST', new RegExp('^/session/[^/]+/appium/settings')],
['POST', new RegExp('^/session/[^/]+/appium/execute_driver')],
['POST', new RegExp('^/session/[^/]+/appium/start_recording_screen')],
['POST', new RegExp('^/session/[^/]+/appium/stop_recording_screen')],
['POST', new RegExp('^/session/[^/]+/appium/.*event')],
['POST', new RegExp('^/session/[^/]+/context')],
['POST', new RegExp('^/session/[^/]+/element')],
['POST', new RegExp('^/session/[^/]+/ime/[^/]+')],
['POST', new RegExp('^/session/[^/]+/keys')],
['POST', new RegExp('^/session/[^/]+/location')],
['POST', new RegExp('^/session/[^/]+/network_connection')],
['POST', new RegExp('^/session/[^/]+/timeouts')],
['POST', new RegExp('^/session/[^/]+/url')],
// MJSONWP commands
['GET', new RegExp('^/session/[^/]+/log/types')],
['POST', new RegExp('^/session/[^/]+/execute')],
['POST', new RegExp('^/session/[^/]+/execute_async')],
['POST', new RegExp('^/session/[^/]+/log')],
// W3C commands
// For Selenium v4 (W3C does not have this route)
['GET', new RegExp('^/session/[^/]+/se/log/types')],
['GET', new RegExp('^/session/[^/]+/window/rect')],
['POST', new RegExp('^/session/[^/]+/execute/async')],
['POST', new RegExp('^/session/[^/]+/execute/sync')],
// For Selenium v4 (W3C does not have this route)
['POST', new RegExp('^/session/[^/]+/se/log')],
];
// This is a set of methods and paths that we never want to proxy to Chromedriver.
const CHROME_NO_PROXY: RouteMatcher[] = [
['GET', new RegExp('^/session/[^/]+/appium')],
['GET', new RegExp('^/session/[^/]+/context')],
['GET', new RegExp('^/session/[^/]+/element/[^/]+/rect')],
['GET', new RegExp('^/session/[^/]+/orientation')],
['POST', new RegExp('^/session/[^/]+/appium')],
['POST', new RegExp('^/session/[^/]+/context')],
['POST', new RegExp('^/session/[^/]+/orientation')],
// this is needed to make the mobile: commands working in web context
['POST', new RegExp('^/session/[^/]+/execute$')],
['POST', new RegExp('^/session/[^/]+/execute/sync')],
// MJSONWP commands
['GET', new RegExp('^/session/[^/]+/log/types$')],
['POST', new RegExp('^/session/[^/]+/log$')],
// W3C commands
// For Selenium v4 (W3C does not have this route)
['GET', new RegExp('^/session/[^/]+/se/log/types$')],
// For Selenium v4 (W3C does not have this route)
['POST', new RegExp('^/session/[^/]+/se/log$')],
];
class AndroidUiautomator2Driver
extends AndroidDriver
implements ExternalDriver<Uiautomator2Constraints, string, StringRecord>
{
static newMethodMap = newMethodMap;
static executeMethodMap = executeMethodMap;
uiautomator2!: UiAutomator2Server;
systemPort: number | undefined;
_originalIme: string | null;
mjpegStream?: mjpeg.MJpegStream;
override caps: Uiautomator2DriverCaps;
override opts: Uiautomator2DriverOpts;
override desiredCapConstraints: Uiautomator2Constraints;
mobileGetActionHistory = mobileGetActionHistory;
mobileScheduleAction = mobileScheduleAction;
mobileUnscheduleAction = mobileUnscheduleAction;
performActions = performActions as AndroidDriver['performActions'];
releaseActions = releaseActions;
getAlertText = getAlertText;
mobileAcceptAlert = mobileAcceptAlert;
mobileDismissAlert = mobileDismissAlert;
postAcceptAlert = postAcceptAlert;
postDismissAlert = postDismissAlert;
mobileInstallMultipleApks = mobileInstallMultipleApks;
mobileGetBatteryInfo = mobileGetBatteryInfo;
active = active;
getAttribute = getAttribute as AndroidDriver['getAttribute'];
elementEnabled = elementEnabled as AndroidDriver['elementEnabled'];
elementDisplayed = elementDisplayed as AndroidDriver['elementDisplayed'];
elementSelected = elementSelected as AndroidDriver['elementSelected'];
getName = getName as AndroidDriver['getName'];
getLocation = getLocation as AndroidDriver['getLocation'];
getSize = getSize as AndroidDriver['getSize'];
getElementRect = getElementRect;
getElementScreenshot = getElementScreenshot;
getText = getText as AndroidDriver['getText'];
setValueImmediate = setValueImmediate as AndroidDriver['setValueImmediate'];
doSetElementValue = doSetElementValue as AndroidDriver['doSetElementValue'];
click = click as AndroidDriver['click'];
clear = clear;
mobileReplaceElementValue = mobileReplaceElementValue;
doFindElementOrEls = doFindElementOrEls as AndroidDriver['doFindElementOrEls'];
mobileClickGesture = mobileClickGesture;
mobileDoubleClickGesture = mobileDoubleClickGesture;
mobileDragGesture = mobileDragGesture;
mobileFlingGesture = mobileFlingGesture;
mobileLongClickGesture = mobileLongClickGesture;
mobilePinchCloseGesture = mobilePinchCloseGesture;
mobilePinchOpenGesture = mobilePinchOpenGesture;
mobileScroll = mobileScroll;
mobileScrollBackTo = mobileScrollBackTo;
mobileScrollGesture = mobileScrollGesture;
mobileSwipeGesture = mobileSwipeGesture;
pressKeyCode = pressKeyCode as AndroidDriver['pressKeyCode'];
longPressKeyCode = longPressKeyCode as AndroidDriver['longPressKeyCode'];
mobilePressKey = mobilePressKey;
mobileType = mobileType;
doSendKeys = doSendKeys as AndroidDriver['doSendKeys'];
keyevent = keyevent;
getPageSource = getPageSource;
getOrientation = getOrientation;
setOrientation = setOrientation;
openNotifications = openNotifications as AndroidDriver['openNotifications'];
suspendChromedriverProxy = suspendChromedriverProxy as AndroidDriver['suspendChromedriverProxy'];
mobileGetDeviceInfo = mobileGetDeviceInfo;
mobileResetAccessibilityCache = mobileResetAccessibilityCache;
mobileListWindows = mobileListWindows;
mobileListDisplays = mobileListDisplays;
getClipboard = getClipboard;
setClipboard = setClipboard;
setUrl = setUrl as AndroidDriver['setUrl'];
mobileDeepLink = mobileDeepLink;
back = back;
mobileScreenshots = mobileScreenshots;
mobileViewportScreenshot = mobileViewportScreenshot;
getScreenshot = getScreenshot;
getViewportScreenshot = getViewportScreenshot;
getStatusBarHeight = getStatusBarHeight;
getDevicePixelRatio = getDevicePixelRatio;
getDisplayDensity = getDisplayDensity as AndroidDriver['getDisplayDensity'];
getViewPortRect = getViewPortRect;
getWindowRect = getWindowRect as AndroidDriver['getWindowRect'];
getWindowSize = getWindowSize as AndroidDriver['getWindowSize'];
mobileViewPortRect = mobileViewPortRect;
prepareSessionApp = prepareSessionApp;
checkAppPresent = checkAppPresent;
initAUT = initAUT;
ensureAppStarts = ensureAppStarts;
allocateSystemPort = allocateSystemPort;
releaseSystemPort = releaseSystemPort;
allocateMjpegServerPort = allocateMjpegServerPort;
releaseMjpegServerPort = releaseMjpegServerPort;
performSessionPreExecSetup = performPreExecSetup;
performSessionExecution = performExecution;
performSessionPostExecSetup = performPostExecSetup;
startUiAutomator2Session = startSession;
initUiAutomator2Server = initServer;
requireUiautomator2 = requireServer;
constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) {
// `shell` overwrites adb.shell, so remove
// @ts-expect-error FIXME: what is this?
delete opts.shell;
super(opts, shouldValidateCaps);
this.locatorStrategies = [
'xpath',
'id',
'class name',
'accessibility id',
'css selector',
'-android uiautomator',
];
this.desiredCapConstraints = structuredClone(UIAUTOMATOR2_CONSTRAINTS);
this.jwpProxyActive = false;
this.jwpProxyAvoid = NO_PROXY;
this._originalIme = null;
this.settings = new DeviceSettings(
{ignoreUnimportantViews: false, allowInvisibleElements: false},
this.onSettingsUpdate.bind(this),
);
// handle webview mechanics from AndroidDriver
this.sessionChromedrivers = {};
this.caps = {} as Uiautomator2DriverCaps;
this.opts = opts as Uiautomator2DriverOpts;
// memoize functions here, so that they are done on a per-instance basis
this.getStatusBarHeight = memoize(this.getStatusBarHeight);
this.getDevicePixelRatio = memoize(this.getDevicePixelRatio);
}
override get driverData() {
// TODO fill out resource info here
return {};
}
override validateDesiredCaps(caps: any): caps is Uiautomator2DriverCaps {
return super.validateDesiredCaps(caps);
}
async createSession(
w3cCaps1: W3CUiautomator2DriverCaps,
w3cCaps2?: W3CUiautomator2DriverCaps,
w3cCaps3?: W3CUiautomator2DriverCaps,
driverData?: DriverData[],
): Promise<any> {
try {
// TODO handle otherSessionData for multiple sessions
const [sessionId, caps] = (await BaseDriver.prototype.createSession.call(
this,
w3cCaps1,
w3cCaps2,
w3cCaps3,
driverData,
)) as DefaultCreateSessionResult<Uiautomator2Constraints>;
const startSessionOpts: Uiautomator2StartSessionOpts = {
...caps,
platform: 'LINUX',
webStorageEnabled: false,
takesScreenshot: true,
javascriptEnabled: true,
databaseEnabled: false,
networkConnectionEnabled: true,
locationContextEnabled: false,
warnings: {},
desired: caps,
};
const defaultOpts = {
fullReset: false,
autoLaunch: true,
adbPort: DEFAULT_ADB_PORT,
androidInstallTimeout: 90000,
};
assignDefaults(this.opts as Record<string, unknown>, defaultOpts);
this.opts.adbPort = this.opts.adbPort || DEFAULT_ADB_PORT;
// get device udid for this session
const {udid, emPort} = await this.getDeviceInfoFromCaps();
this.opts.udid = udid;
// @ts-expect-error do not put random stuff on opts
this.opts.emPort = emPort;
// now that we know our java version and device info, we can create our
// ADB instance
this.adb = await this.createADB();
if (this.isChromeSession && this.opts.browserName) {
this.log.info(`We're going to run a Chrome-based session`);
const {pkg, activity: defaultActivity} = utils.getChromePkg(this.opts.browserName);
let activity: string = defaultActivity;
try {
activity = await this.adb.resolveLaunchableActivity(pkg);
} catch (e) {
this.log.warn(
`Using the default ${pkg} activity ${activity}. Original error: ${(e as Error).message}`,
);
}
this.opts.appPackage = this.caps.appPackage = pkg;
this.opts.appActivity = this.caps.appActivity = activity;
this.log.info(`Chrome-type package and activity are ${pkg} and ${activity}`);
}
await this.prepareSessionApp();
const result = await this.startUiAutomator2Session(startSessionOpts);
if (this.opts.mjpegScreenshotUrl) {
this.log.info(`Starting MJPEG stream reading URL: '${this.opts.mjpegScreenshotUrl}'`);
this.mjpegStream = new mjpeg.MJpegStream(this.opts.mjpegScreenshotUrl);
await this.mjpegStream.start();
}
return [sessionId, result];
} catch (e) {
await this.deleteSession();
throw e;
}
}
async getDeviceDetails(): Promise<Uiautomator2DeviceDetails> {
const [
pixelRatio,
statBarHeight,
viewportRect,
{apiVersion, platformVersion, manufacturer, model, realDisplaySize, displayDensity},
] = await Promise.all([
this.getDevicePixelRatio(),
this.getStatusBarHeight(),
this.getViewPortRect(),
this.mobileGetDeviceInfo(),
]);
return {
pixelRatio,
statBarHeight,
viewportRect,
deviceApiLevel: Number.parseInt(String(apiVersion), 10),
platformVersion,
deviceManufacturer: manufacturer,
deviceModel: model,
deviceScreenSize: realDisplaySize,
deviceScreenDensity: displayDensity,
};
}
override async getSession(): Promise<SingularSessionData<Uiautomator2Constraints>> {
const sessionData = await BaseDriver.prototype.getSession.call(this);
this.log.debug('Getting session details from server to mix in');
const uia2Data = (await this.requireUiautomator2().jwproxy.command(
'/',
'GET',
{},
)) as StringRecord;
return {...sessionData, ...uia2Data};
}
override async deleteSession() {
this.log.debug('Deleting UiAutomator2 session');
const screenRecordingStopTasks = [
async () => {
if (this._screenRecordingProperties) {
await this.stopRecordingScreen();
}
},
async () => {
if (await this.mobileIsMediaProjectionRecordingRunning()) {
await this.mobileStopMediaProjectionRecording();
}
},
async () => {
if (this._screenStreamingProps) {
await this.mobileStopScreenStreaming();
}
},
];
try {
await this.stopChromedriverProxies();
} catch (err) {
this.log.warn(`Unable to stop ChromeDriver proxies: ${(err as Error).message}`);
}
if (this.jwpProxyActive) {
try {
await this.uiautomator2.deleteSession();
} catch (err) {
this.log.warn(`Unable to proxy deleteSession to UiAutomator2: ${(err as Error).message}`);
}
this.jwpProxyActive = false;
}
if (this.adb) {
await Promise.all(
screenRecordingStopTasks.map((task) =>
(async () => {
try {
await task();
} catch {}
})(),
),
);
if (this.opts.appPackage) {
if (
!this.isChromeSession &&
((!this.opts.dontStopAppOnReset && !this.opts.noReset) ||
(this.opts.noReset && this.opts.shouldTerminateApp))
) {
try {
await this.adb.forceStop(this.opts.appPackage);
} catch (err) {
this.log.warn(`Unable to force stop app: ${(err as Error).message}`);
}
}
if (this.opts.fullReset && !this.opts.skipUninstall) {
this.log.debug(
`Capability 'fullReset' set to 'true', Uninstalling '${this.opts.appPackage}'`,
);
try {
await this.adb.uninstallApk(this.opts.appPackage);
} catch (err) {
this.log.warn(`Unable to uninstall app: ${(err as Error).message}`);
}
}
}
// This value can be true if test target device is <= 26
if (this._wasWindowAnimationDisabled) {
this.log.info('Restoring window animation state');
await this.settingsApp.setAnimationState(true);
}
if (this._originalIme) {
try {
await this.adb.setIME(this._originalIme);
} catch (e) {
this.log.warn(`Cannot restore the original IME: ${(e as Error).message}`);
}
}
try {
await this.releaseSystemPort();
} catch (error) {
this.log.warn(`Unable to remove system port forward: ${(error as Error).message}`);
// Ignore, this block will also be called when we fall in catch block
// and before even port forward.
}
try {
await this.releaseMjpegServerPort();
} catch (error) {
this.log.warn(`Unable to remove MJPEG server port forward: ${(error as Error).message}`);
// Ignore, this block will also be called when we fall in catch block
// and before even port forward.
}
if ((await this.adb.getApiLevel()) >= 28) {
// Android P
this.log.info('Restoring hidden api policy to the device default configuration');
await this.adb.setDefaultHiddenApiPolicy(!!this.opts.ignoreHiddenApiPolicyError);
}
}
if (this.mjpegStream) {
this.log.info('Closing MJPEG stream');
this.mjpegStream.stop();
}
await super.deleteSession();
}
async onSettingsUpdate() {
// intentionally do nothing here, since commands.updateSettings proxies
// settings to the uiauto2 server already
}
override proxyActive(sessionId: string): boolean {
void sessionId;
// we always have an active proxy to the UiAutomator2 server
return true;
}
override canProxy(sessionId: string): boolean {
void sessionId;
// we can always proxy to the uiautomator2 server
return true;
}
override getProxyAvoidList(): RouteMatcher[] {
// we are maintaining two sets of NO_PROXY lists, one for chromedriver(CHROME_NO_PROXY)
// and one for uiautomator2(NO_PROXY), based on current context will return related NO_PROXY list
if (util.hasValue(this.chromedriver)) {
// if the current context is webview(chromedriver), then return CHROME_NO_PROXY list
this.jwpProxyAvoid = CHROME_NO_PROXY;
} else {
this.jwpProxyAvoid = NO_PROXY;
}
if (this.opts.nativeWebScreenshot) {
this.jwpProxyAvoid = [
...this.jwpProxyAvoid,
['GET', new RegExp('^/session/[^/]+/screenshot')],
];
}
return this.jwpProxyAvoid;
}
// @ts-expect-error narrower parameter type than the base class override allows
override async updateSettings(settings: Uiautomator2Settings) {
await this.settings.update(settings);
await this.requireUiautomator2().jwproxy.command('/appium/settings', 'POST', {settings});
}
override async getSettings(): Promise<StringRecord> {
const driverSettings = this.settings.getSettings();
const serverSettings = (await this.requireUiautomator2().jwproxy.command(
'/appium/settings',
'GET',
)) as Partial<Uiautomator2Settings>;
return {...driverSettings, ...serverSettings};
}
// needed to make the typechecker happy
override async getAppiumSessionCapabilities(): Promise<
SessionCapabilities<Uiautomator2Constraints>
> {
return (await super.getAppiumSessionCapabilities()) as SessionCapabilities<Uiautomator2Constraints>;
}
requireAdb(): ADB {
const adb = this.adb;
if (!adb) {
throw this.log.errorWithException('ADB must be initialized before this operation');
}
return adb;
}
}
export {AndroidUiautomator2Driver};