appium-uiautomator2-driver
Version:
UiAutomator2 integration for Appium
524 lines (487 loc) • 18.2 kB
text/typescript
import {JWProxy, errors} from 'appium/driver';
import {sleep, waitForCondition} from 'asyncbox';
import {
SERVER_APK_PATH as apkPath,
TEST_APK_PATH as testApkPath,
version as serverVersion,
} from 'appium-uiautomator2-server';
import {util, timing} from 'appium/support';
import type {
AppiumLogger,
StringRecord,
HTTPMethod,
HTTPBody,
ProxyResponse,
ProxyOptions,
} from '@appium/types';
import axios from 'axios';
import type {ADB, InstallState} from 'appium-adb';
import type {SubProcess} from 'teen_process';
import {SERVER_PACKAGE_ID, SERVER_TEST_PACKAGE_ID} from './packages';
const SERVER_LAUNCH_TIMEOUT_MS = 30000;
const SERVER_INSTALL_RETRIES = 20;
const SERVICES_LAUNCH_TIMEOUT_MS = 30000;
const SERVER_SHUTDOWN_TIMEOUT_MS = 5000;
const SERVER_REQUEST_TIMEOUT_MS = 500;
export const INSTRUMENTATION_TARGET = `${SERVER_TEST_PACKAGE_ID}/androidx.test.runner.AndroidJUnitRunner`;
export interface PackageInfo {
installState: InstallState;
appPath: string;
appId: string;
}
export interface UiAutomator2ServerOptions {
adb: ADB;
host: string;
systemPort: number;
disableWindowAnimation: boolean;
readTimeout?: number;
disableSuppressAccessibilityService?: boolean;
basePath?: string;
}
// Type helper to extract required (non-optional) keys from UiAutomator2ServerOptions
type RequiredKeysOf<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
interface SessionInfo {
id: string;
}
interface SessionsResponse {
value: SessionInfo[];
}
const REQUIRED_OPTIONS: RequiredKeysOf<UiAutomator2ServerOptions>[] = [
'adb',
'host',
'systemPort',
'disableWindowAnimation',
] as const;
class UIA2Proxy extends JWProxy {
public didInstrumentationExit: boolean = false;
override async proxyCommand(
url: string,
method: HTTPMethod,
body: HTTPBody = null,
): Promise<[ProxyResponse, HTTPBody]> {
if (this.didInstrumentationExit) {
throw new errors.InvalidContextError(
`'${method} ${url}' cannot be proxied to UiAutomator2 server because ` +
'the instrumentation process is not running (probably crashed). ' +
'Check the server log and/or the logcat output for more details',
);
}
return await super.proxyCommand(url, method, body);
}
}
export class UiAutomator2Server {
public readonly jwproxy: UIA2Proxy;
public readonly proxyReqRes: typeof UIA2Proxy.prototype.proxyReqRes;
public readonly proxyCommand: typeof UIA2Proxy.prototype.command;
private readonly host!: string;
private readonly systemPort!: number;
private readonly adb!: ADB;
private readonly disableWindowAnimation!: boolean;
private readonly disableSuppressAccessibilityService?: boolean;
private readonly log: AppiumLogger;
private instrumentationProcess: SubProcess | null = null;
constructor(log: AppiumLogger, opts: UiAutomator2ServerOptions) {
// Validate and assign required properties from UiAutomator2ServerOptions
// The keys are typed to match only the required (non-optional) properties of the interface
for (const key of REQUIRED_OPTIONS) {
if (!opts || !util.hasValue(opts[key])) {
throw new Error(`Option '${key}' is required!`);
}
(this as any)[key] = opts[key];
}
this.log = log;
this.disableSuppressAccessibilityService = opts.disableSuppressAccessibilityService;
const proxyOpts: ProxyOptions = {
log,
server: this.host,
port: this.systemPort,
keepAlive: true,
};
if (opts.basePath) {
proxyOpts.reqBasePath = opts.basePath;
}
if (opts.readTimeout && opts.readTimeout > 0) {
proxyOpts.timeout = opts.readTimeout;
}
this.jwproxy = new UIA2Proxy(proxyOpts);
this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy);
this.proxyCommand = this.jwproxy.command.bind(this.jwproxy);
this.jwproxy.didInstrumentationExit = false;
this.instrumentationProcess = null;
}
/**
* Installs the apks on to the device or emulator.
*
* @param installTimeout - Installation timeout
*/
async installServerApk(installTimeout: number = SERVER_INSTALL_RETRIES * 1000): Promise<void> {
const packagesInfo = await Promise.all(
[
{
appPath: apkPath,
appId: SERVER_PACKAGE_ID,
},
{
appPath: testApkPath,
appId: SERVER_TEST_PACKAGE_ID,
},
].map(({appPath, appId}) => this.prepareServerPackage(appPath, appId)),
);
this.log.debug(`Server packages status: ${JSON.stringify(packagesInfo)}`);
const shouldUninstallServerPackages = this.shouldUninstallServerPackages(packagesInfo);
// Install must always follow uninstall. Also, perform the install if
// any of server packages is not installed or is outdated
const shouldInstallServerPackages =
shouldUninstallServerPackages || this.shouldInstallServerPackages(packagesInfo);
this.log.info(
`Server packages are ${shouldInstallServerPackages ? '' : 'not '}going to be (re)installed`,
);
if (shouldInstallServerPackages && shouldUninstallServerPackages) {
this.log.info('Full packages reinstall is going to be performed');
}
if (shouldUninstallServerPackages) {
const silentUninstallPkg = async (pkgId: string): Promise<void> => {
try {
await this.adb.uninstallApk(pkgId);
} catch (err: any) {
this.log.info(`Cannot uninstall '${pkgId}': ${err.message}`);
}
};
await Promise.all(packagesInfo.map(({appId}) => silentUninstallPkg(appId)));
}
if (shouldInstallServerPackages) {
const installPkg = async (pkgPath: string): Promise<void> => {
await this.adb.install(pkgPath, {
noIncremental: true,
replace: true,
timeout: installTimeout,
timeoutCapName: 'uiautomator2ServerInstallTimeout',
});
};
await Promise.all(packagesInfo.map(({appPath}) => installPkg(appPath)));
}
await this.verifyServicesAvailability();
}
async startSession(caps: StringRecord): Promise<void> {
await this.cleanupAutomationLeftovers();
if (caps.skipServerInstallation) {
this.log.info(
`'skipServerInstallation' is set. Attempting to use UIAutomator2 server from the device`,
);
} else {
this.log.info(`Starting UIAutomator2 server ${serverVersion}`);
this.log.info(`Using UIAutomator2 server from '${apkPath}' and test from '${testApkPath}'`);
}
const timeout = (caps.uiautomator2ServerLaunchTimeout as number) || SERVER_LAUNCH_TIMEOUT_MS;
const timer = new timing.Timer().start();
let retries = 0;
const maxRetries = 2;
const delayBetweenRetries = 3000;
while (retries < maxRetries) {
this.log.info(`Waiting up to ${timeout}ms for UiAutomator2 to be online...`);
this.jwproxy.didInstrumentationExit = false;
try {
await this.stopInstrumentationProcess();
} catch {}
await this.startInstrumentationProcess();
if (!this.jwproxy.didInstrumentationExit) {
try {
await waitForCondition(
async () => {
try {
await this.jwproxy.command('/status', 'GET');
return true;
} catch {
// short circuit to retry or fail fast
return this.jwproxy.didInstrumentationExit;
}
},
{
waitMs: timeout,
intervalMs: 1000,
},
);
} catch {
throw this.log.errorWithException(
`The instrumentation process cannot be initialized within ${timeout}ms timeout. ` +
'Make sure the application under test does not crash and investigate the logcat output. ' +
`You could also try to increase the value of 'uiautomator2ServerLaunchTimeout' capability`,
);
}
}
if (!this.jwproxy.didInstrumentationExit) {
break;
}
retries++;
if (retries >= maxRetries) {
throw this.log.errorWithException(
'The instrumentation process cannot be initialized. ' +
'Make sure the application under test does not crash and investigate the logcat output.',
);
}
this.log.warn(
`The instrumentation process has been unexpectedly terminated. ` +
`Retrying UiAutomator2 startup (#${retries} of ${maxRetries - 1})`,
);
await this.cleanupAutomationLeftovers(true);
await sleep(delayBetweenRetries);
}
this.log.debug(
`The initialization of the instrumentation process took ` +
`${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
await this.jwproxy.command('/session', 'POST', {
capabilities: {
firstMatch: [caps],
alwaysMatch: {},
},
});
}
async deleteSession(): Promise<void> {
this.log.debug('Deleting UiAutomator2 server session');
try {
await this.jwproxy.command('/', 'DELETE');
} catch (err: any) {
this.log.warn(
`Did not get the confirmation of UiAutomator2 server session deletion. ` +
`Original error: ${err.message}`,
);
}
// Theoretically we could also force kill instumentation and server processes
// without waiting for them to properly quit on their own.
// This may cause unexpected error reports in device logs though.
await this._waitForTermination();
try {
await this.stopInstrumentationProcess();
} catch (err: any) {
this.log.warn(`Could not stop the instrumentation process. Original error: ${err.message}`);
}
try {
await Promise.all([
this.adb.forceStop(SERVER_PACKAGE_ID),
this.adb.forceStop(SERVER_TEST_PACKAGE_ID),
]);
} catch {}
}
private async prepareServerPackage(appPath: string, appId: string): Promise<PackageInfo> {
const resultInfo: PackageInfo = {
installState: this.adb.APP_INSTALL_STATE.NOT_INSTALLED,
appPath,
appId,
};
if (appId === SERVER_TEST_PACKAGE_ID && (await this.adb.isAppInstalled(appId))) {
// There is no point in getting the state for the test server,
// since it does not contain any version info
resultInfo.installState = this.adb.APP_INSTALL_STATE.SAME_VERSION_INSTALLED;
} else if (appId === SERVER_PACKAGE_ID) {
resultInfo.installState = await this.adb.getApplicationInstallState(
resultInfo.appPath,
appId,
);
}
return resultInfo;
}
/**
* Checks if server components must be installed from the device under test
* in scope of the current driver session.
*
* For example, if one of servers on the device under test was newer than servers current UIA2 driver wants to
* use for the session, the UIA2 driver should uninstall the installed ones in order to avoid
* version mismatch between the UIA2 drier and servers on the device under test.
* Also, if the device under test has missing servers, current UIA2 driver should uninstall all
* servers once in order to install proper servers freshly.
*
* @param packagesInfo
* @returns true if any of components is already installed and the other is not installed
* or the installed one has a newer version.
*/
private shouldUninstallServerPackages(packagesInfo: PackageInfo[] = []): boolean {
const isAnyComponentInstalled = packagesInfo.some(
({installState}) => installState !== this.adb.APP_INSTALL_STATE.NOT_INSTALLED,
);
const isAnyComponentNotInstalledOrNewer = packagesInfo.some(({installState}) =>
[
this.adb.APP_INSTALL_STATE.NOT_INSTALLED,
this.adb.APP_INSTALL_STATE.NEWER_VERSION_INSTALLED,
].includes(installState),
);
return isAnyComponentInstalled && isAnyComponentNotInstalledOrNewer;
}
/**
* Checks if server components should be installed on the device under test in scope of the current driver session.
*
* @param packagesInfo
* @returns true if any of components is not installed or older than currently installed in order to
* install or upgrade the servers on the device under test.
*/
private shouldInstallServerPackages(packagesInfo: PackageInfo[] = []): boolean {
return packagesInfo.some(({installState}) =>
[
this.adb.APP_INSTALL_STATE.NOT_INSTALLED,
this.adb.APP_INSTALL_STATE.OLDER_VERSION_INSTALLED,
].includes(installState),
);
}
private async verifyServicesAvailability(): Promise<void> {
this.log.debug(`Waiting up to ${SERVICES_LAUNCH_TIMEOUT_MS}ms for services to be available`);
let isPmServiceAvailable = false;
let pmOutput = '';
let pmError: Error | null = null;
try {
await waitForCondition(
async () => {
if (!isPmServiceAvailable) {
pmError = null;
pmOutput = '';
try {
pmOutput = await this.adb.shell(['pm', 'list', 'instrumentation']);
} catch (e: any) {
pmError = e;
}
if (pmOutput.includes('Could not access the Package Manager')) {
pmError = new Error(`Problem running Package Manager: ${pmOutput}`);
pmOutput = ''; // remove output, so it is not printed below
} else if (pmOutput.includes(INSTRUMENTATION_TARGET)) {
pmOutput = ''; // remove output, so it is not printed below
this.log.debug(`Instrumentation target '${INSTRUMENTATION_TARGET}' is available`);
isPmServiceAvailable = true;
} else if (!pmError) {
pmError = new Error('The instrumentation target is not listed by Package Manager');
}
}
return isPmServiceAvailable;
},
{
waitMs: SERVICES_LAUNCH_TIMEOUT_MS,
intervalMs: 1000,
},
);
} catch {
const errorMessage = (pmError as any)?.message || 'Unknown error';
this.log.error(
`Unable to find instrumentation target '${INSTRUMENTATION_TARGET}': ${errorMessage}`,
);
if (pmOutput) {
this.log.debug('Available targets:');
for (const line of pmOutput.split('\n')) {
this.log.debug(` ${line.replace('instrumentation:', '')}`);
}
}
}
}
private async startInstrumentationProcess(): Promise<void> {
const cmd = ['am', 'instrument', '-w'];
if (this.disableWindowAnimation) {
cmd.push('--no-window-animation');
}
if (typeof this.disableSuppressAccessibilityService === 'boolean') {
cmd.push(
'-e',
'DISABLE_SUPPRESS_ACCESSIBILITY_SERVICES',
`${this.disableSuppressAccessibilityService}`,
);
}
// Disable Google analytics to prevent possible fatal exception
cmd.push('-e', 'disableAnalytics', 'true');
cmd.push(INSTRUMENTATION_TARGET);
this.instrumentationProcess = this.adb.createSubProcess(['shell', ...cmd]);
for (const streamName of ['stderr', 'stdout'] as const) {
this.instrumentationProcess.on(`line-${streamName}`, (line: string) =>
this.log.debug(`[Instrumentation] ${line}`),
);
}
this.instrumentationProcess.once('exit', (code: number | null, signal: string | null) => {
this.log.debug(
`[Instrumentation] The process has exited with code ${code}, signal ${signal}`,
);
this.jwproxy.didInstrumentationExit = true;
});
await this.instrumentationProcess.start(0);
}
private async stopInstrumentationProcess(): Promise<void> {
try {
if (this.instrumentationProcess?.isRunning) {
await this.instrumentationProcess.stop();
}
} finally {
this.instrumentationProcess?.removeAllListeners();
this.instrumentationProcess = null;
}
}
private async cleanupAutomationLeftovers(strictCleanup: boolean = false): Promise<void> {
this.log.debug(
`Performing ${strictCleanup ? 'strict' : 'shallow'} cleanup of automation leftovers`,
);
const serverBase = `http://${this.host}:${this.systemPort}`;
try {
const {value} = (
await axios({
url: `${serverBase}/sessions`,
timeout: SERVER_REQUEST_TIMEOUT_MS,
})
).data as SessionsResponse;
const activeSessionIds = value.map(({id}) => id).filter(Boolean);
if (activeSessionIds.length) {
this.log.debug(`The following obsolete sessions are still running: ${activeSessionIds}`);
this.log.debug(
`Cleaning up ${util.pluralize('obsolete session', activeSessionIds.length, true)}`,
);
await Promise.all(
activeSessionIds.map((id: string) =>
axios.delete(`${serverBase}/session/${id}`, {
timeout: SERVER_REQUEST_TIMEOUT_MS,
}),
),
);
// Let the server to be properly terminated before continuing
await this._waitForTermination();
} else {
this.log.debug('No obsolete sessions have been detected');
}
} catch (e: any) {
this.log.debug(`No obsolete sessions have been detected (${e.message})`);
}
try {
await Promise.all([
this.adb.forceStop(SERVER_PACKAGE_ID),
this.adb.forceStop(SERVER_TEST_PACKAGE_ID),
]);
} catch {}
if (strictCleanup) {
// https://github.com/appium/appium/issues/10749
try {
await this.adb.killProcessesByName('uiautomator');
} catch {}
}
}
/**
* Blocks until UIA2 server stops running
* or SERVER_SHUTDOWN_TIMEOUT_MS expires
*
* @returns {Promise<void>}
*/
private async _waitForTermination(): Promise<void> {
try {
await waitForCondition(
async () => {
try {
return !(await this.adb.processExists(SERVER_PACKAGE_ID));
} catch {
return true;
}
},
{
waitMs: SERVER_SHUTDOWN_TIMEOUT_MS,
intervalMs: 300,
},
);
} catch {
this.log.warn(
`The UIA2 server has not been terminated within ${SERVER_SHUTDOWN_TIMEOUT_MS}ms timeout. ` +
`Continuing anyway`,
);
}
}
}