appium-espresso-driver
Version:
Espresso integration for Appium
581 lines (536 loc) • 20.4 kB
text/typescript
import {JWProxy, errors} from 'appium/driver.js';
import {sleep, waitForCondition} from 'asyncbox';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {fs, node, util, timing} from 'appium/support.js';
import {getPackageInfoSync, getPackageInfo, isPlainObject} from '../../utils/index.js';
import {ServerBuilder, buildServerSigningConfig, type ServerSigningConfig} from './builder.js';
import {
ESPRESSO_SERVER_LAUNCH_TIMEOUT_MS,
TARGET_PACKAGE_CONTAINER,
TEST_APK_PKG,
} from './constants.js';
import axios from 'axios';
import * as semver from 'semver';
import type {
AppiumLogger,
StringRecord,
HTTPMethod,
HTTPBody,
ProxyResponse,
ProxyOptions,
} from '@appium/types';
import type {ADB} from 'appium-adb';
import type {SubProcess} from 'teen_process';
export interface EspressoRunnerOptions {
adb: ADB;
tmpDir: string;
host: string;
systemPort: number;
devicePort: number;
appPackage: string;
forceEspressoRebuild: boolean;
reqBasePath?: string;
showGradleLog?: boolean;
espressoBuildConfig?: string;
serverLaunchTimeout?: number;
androidInstallTimeout?: number;
disableSuppressAccessibilityService?: boolean;
useKeystore?: boolean;
keystorePath?: string;
keystorePassword?: string;
keyAlias?: string;
keyPassword?: string;
}
interface InstrumentationState {
crashed: boolean;
exited: boolean;
}
interface ServerStatus {
build: {
version: string;
packageName?: string;
};
}
interface SessionInfo {
id: string;
}
interface SessionsResponse {
value: SessionInfo[];
}
class EspressoProxy extends JWProxy {
instrumentationState!: InstrumentationState;
override async proxyCommand(
url: string,
method: HTTPMethod,
body: HTTPBody = null,
): Promise<[ProxyResponse, HTTPBody]> {
const {crashed, exited} = this.instrumentationState;
if (exited) {
throw new errors.InvalidContextError(
`'${method} ${url}' cannot be proxied to Espresso server because ` +
`the instrumentation process has ${crashed ? 'crashed' : 'been unexpectedly terminated'}. ` +
`Check the Appium server log and the logcat output for more details`,
);
}
return await super.proxyCommand(url, method, body);
}
}
export class EspressoRunner {
public readonly host: string;
public readonly systemPort: number;
public readonly appPackage: string;
public readonly adb: ADB;
public readonly tmpDir: string;
public readonly forceEspressoRebuild: boolean;
public readonly devicePort: number;
public readonly jwproxy: EspressoProxy;
public readonly proxyReqRes: typeof EspressoProxy.prototype.proxyReqRes;
public readonly proxyCommand: typeof EspressoProxy.prototype.command;
public readonly modServerPath: string;
public readonly showGradleLog?: boolean;
public readonly espressoBuildConfig?: string;
public readonly serverLaunchTimeout: number;
public readonly androidInstallTimeout?: number;
public readonly disableSuppressAccessibilityService?: boolean;
public readonly signingConfig: ServerSigningConfig | null;
public instProcess: SubProcess | null = null;
private readonly log: AppiumLogger;
constructor(log: AppiumLogger, opts: EspressoRunnerOptions) {
this.adb = requireOption(opts, 'adb');
this.tmpDir = requireOption(opts, 'tmpDir');
this.host = requireOption(opts, 'host');
this.systemPort = requireOption(opts, 'systemPort');
this.devicePort = requireOption(opts, 'devicePort');
this.appPackage = requireOption(opts, 'appPackage');
this.forceEspressoRebuild = requireOption(opts, 'forceEspressoRebuild');
this.log = log;
const proxyOpts: ProxyOptions = {
log,
server: this.host,
port: this.systemPort,
keepAlive: true,
};
if (opts.reqBasePath) {
proxyOpts.reqBasePath = opts.reqBasePath;
}
this.jwproxy = new EspressoProxy(proxyOpts);
this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy);
this.proxyCommand = this.jwproxy.command.bind(this.jwproxy);
this.jwproxy.instrumentationState = {
exited: false,
crashed: false,
};
const {manifestPayload} = getPackageInfoSync();
const modServerName = fs.sanitizeName(
`${TEST_APK_PKG}_${manifestPayload.version}_${this.appPackage}_${this.adb.curDeviceId}.apk`,
{replacement: '-'},
);
this.modServerPath = path.resolve(this.tmpDir, modServerName);
this.showGradleLog = opts.showGradleLog;
this.espressoBuildConfig = opts.espressoBuildConfig;
this.serverLaunchTimeout = opts.serverLaunchTimeout || ESPRESSO_SERVER_LAUNCH_TIMEOUT_MS;
this.androidInstallTimeout = opts.androidInstallTimeout;
this.disableSuppressAccessibilityService = opts.disableSuppressAccessibilityService;
// Espresso Server app needs to be signed with same keyStore as appPackage
if (
opts.useKeystore &&
opts.keystorePath &&
opts.keystorePassword &&
opts.keyAlias &&
opts.keyPassword
) {
this.signingConfig = buildServerSigningConfig({
keystoreFile: opts.keystorePath,
keystorePassword: opts.keystorePassword,
keyAlias: opts.keyAlias,
keyPassword: opts.keyPassword,
});
} else {
this.signingConfig = null;
}
}
async isAppPackageChanged(): Promise<boolean> {
if (!(await this.adb.fileExists(TARGET_PACKAGE_CONTAINER))) {
this.log.debug('The previous target application package is unknown');
return true;
}
const previousAppPackage = (await this.adb.shell(['cat', TARGET_PACKAGE_CONTAINER])).trim();
this.log.debug(
`The previous target application package was '${previousAppPackage}'. ` +
`The current package is '${this.appPackage}'`,
);
return previousAppPackage !== this.appPackage;
}
/**
* Installs Espresso server apk on to the device or emulator.
* Each adb command uses default timeout by them.
*/
async installServer(): Promise<void> {
const appState = await this.adb.getApplicationInstallState(this.modServerPath, TEST_APK_PKG);
const shouldUninstallApp = [
this.adb.APP_INSTALL_STATE.OLDER_VERSION_INSTALLED,
this.adb.APP_INSTALL_STATE.NEWER_VERSION_INSTALLED,
].includes(appState);
const shouldInstallApp =
shouldUninstallApp || [this.adb.APP_INSTALL_STATE.NOT_INSTALLED].includes(appState);
if (shouldUninstallApp) {
this.log.info(
`Uninstalling Espresso Test Server apk from the target device (pkg: '${TEST_APK_PKG}')`,
);
try {
await this.adb.uninstallApk(TEST_APK_PKG);
} catch (err: any) {
this.log.warn(`Error uninstalling '${TEST_APK_PKG}': ${err.message}`);
}
}
if (shouldInstallApp) {
this.log.info(
`Installing Espresso Test Server apk from the target device (path: '${this.modServerPath}')`,
);
try {
await this.adb.install(this.modServerPath, {
replace: false,
timeout: this.androidInstallTimeout,
});
this.log.info(
`Installed Espresso Test Server apk '${this.modServerPath}' (pkg: '${TEST_APK_PKG}')`,
);
} catch (err: any) {
throw this.log.errorWithException(
`Cannot install '${this.modServerPath}' because of '${err.message}'`,
);
}
}
}
async installTestApk(): Promise<void> {
let rebuild = this.forceEspressoRebuild;
if (rebuild) {
this.log.debug(`'forceEspressoRebuild' capability is enabled`);
} else if (await this.isAppPackageChanged()) {
this.log.info(`Forcing Espresso server rebuild because of changed application package`);
rebuild = true;
}
if (rebuild && (await fs.exists(this.modServerPath))) {
this.log.debug(`Deleting the obsolete Espresso server package '${this.modServerPath}'`);
await fs.unlink(this.modServerPath);
}
if (!(await fs.exists(this.modServerPath))) {
await this.buildNewModServer();
}
const wasSigned = await this.adb.checkApkCert(this.modServerPath);
if (!wasSigned) {
await this.adb.sign(this.modServerPath);
}
if ((rebuild || !wasSigned) && (await this.adb.uninstallApk(TEST_APK_PKG))) {
this.log.info('Uninstalled the obsolete Espresso server package from the device under test');
}
await this.installServer();
}
async buildNewModServer(): Promise<void> {
let buildConfiguration: Record<string, any> = {};
if (this.espressoBuildConfig) {
let buildConfigurationStr: string;
if (await fs.exists(this.espressoBuildConfig)) {
this.log.info(`Loading the build configuration from '${this.espressoBuildConfig}'`);
buildConfigurationStr = await fs.readFile(this.espressoBuildConfig, 'utf8');
} else {
this.log.info(`Loading the build configuration from 'espressoBuildConfig' capability`);
buildConfigurationStr = this.espressoBuildConfig;
}
try {
buildConfiguration = JSON.parse(buildConfigurationStr);
} catch (e: any) {
this.log.error('Cannot parse the build configuration JSON', e);
throw e;
}
}
const dirName = fs.sanitizeName(`espresso-server-${this.adb.curDeviceId}`, {
replacement: '-',
});
const serverPath = path.resolve(this.tmpDir, dirName);
this.log.info(`Building espresso server in '${serverPath}'`);
this.log.debug(`The build folder root could be customized by changing the 'tmpDir' capability`);
await fs.rimraf(serverPath);
await fs.mkdirp(serverPath);
this.log.debug(
`Copying espresso server template from ('${getTestServerRoot()}' to '${serverPath}')`,
);
await copyGradleProjectRecursively(getTestServerRoot(), serverPath);
this.log.debug('Bulding espresso server');
await new ServerBuilder(this.log, {
serverPath,
buildConfiguration,
showGradleLog: this.showGradleLog,
testAppPackage: this.appPackage,
signingConfig: this.signingConfig,
}).build();
const apkPath = path.resolve(
serverPath,
'app',
'build',
'outputs',
'apk',
'androidTest',
'debug',
'app-debug-androidTest.apk',
);
this.log.debug(`Copying built apk from '${apkPath}' to '${this.modServerPath}'`);
await fs.copyFile(apkPath, this.modServerPath);
}
async startSession(caps: StringRecord): Promise<void> {
await this.cleanupSessionLeftovers();
const cmd: string[] = [
'shell',
'am',
'instrument',
'-w',
'-e',
'debug',
String(process.env.ESPRESSO_JAVA_DEBUG === 'true'),
'-e',
'disableAnalytics',
'true', // To avoid unexpected error by google analytics
];
if (typeof this.disableSuppressAccessibilityService === 'boolean') {
cmd.push(
'-e',
'DISABLE_SUPPRESS_ACCESSIBILITY_SERVICES',
String(this.disableSuppressAccessibilityService),
);
}
cmd.push(`${TEST_APK_PKG}/androidx.test.runner.AndroidJUnitRunner`);
const {manifestPayload} = await getPackageInfo();
this.log.info(
`Starting Espresso Server v${manifestPayload.version} with cmd: adb ${cmd.join(' ')}`,
);
let hasSocketError = false;
// start the instrumentation process
this.jwproxy.instrumentationState = {
exited: false,
crashed: false,
};
this.instProcess = this.adb.createSubProcess(cmd);
this.instProcess.on('exit', (code: number | null, signal: string | null) => {
this.log.info(`Instrumentation process exited with code ${code} from signal ${signal}`);
this.jwproxy.instrumentationState.exited = true;
});
this.instProcess.on('output', (stdout: string, stderr: string) => {
const line = stdout || stderr;
if (!line.trim()) {
// Do not print empty lines into the system log
return;
}
this.log.debug(`[Instrumentation] ${line.trim()}`);
// A 'SocketException' indicates that we couldn't connect to the Espresso server,
// because the INTERNET permission is not set
if (line.toLowerCase().includes('java.net.socketexception')) {
hasSocketError = true;
} else if (line.includes('Process crashed')) {
this.jwproxy.instrumentationState.crashed = true;
}
});
const timer = new timing.Timer().start();
await this.instProcess.start(0);
this.log.info(`Waiting up to ${this.serverLaunchTimeout}ms for Espresso server to be online`);
try {
await waitForCondition(
async () => {
if (hasSocketError) {
throw this.log.errorWithException(
`Espresso server has failed to start due to an unexpected exception. ` +
`Make sure the 'INTERNET' permission is requested in the Android manifest of your ` +
`application under test (<uses-permission android:name="android.permission.INTERNET" />)`,
);
} else if (this.jwproxy.instrumentationState.exited) {
throw this.log.errorWithException(
`Espresso server process has been unexpectedly terminated. ` +
`Check the Appium server log and the logcat output for more details`,
);
}
let serverStatus: ServerStatus;
try {
serverStatus = (await this.jwproxy.command('/status', 'GET')) as ServerStatus;
} catch {
return false;
}
return await this._verifyServerStatus(manifestPayload.version, serverStatus);
},
{
waitMs: this.serverLaunchTimeout,
intervalMs: 500,
},
);
} catch (e: any) {
if (/Condition unmet/.test(e.message)) {
throw this.log.errorWithException(
`Timed out waiting for Espresso server to be ` +
`online within ${this.serverLaunchTimeout}ms. The timeout value could be ` +
`customized using 'espressoServerLaunchTimeout' capability`,
);
}
throw e;
}
this.log.info(
`Espresso server is online. ` +
`The initialization process took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
this.log.info('Starting the session');
await this.jwproxy.command('/session', 'POST', {
capabilities: {
firstMatch: [caps],
alwaysMatch: {},
},
});
await this.recordTargetAppPackage();
}
async deleteSession(): Promise<void> {
this.log.debug('Deleting Espresso server session');
// rely on jwproxy's intelligence to know what we're talking about and
// delete the current session
try {
await this.jwproxy.command('/', 'DELETE');
} catch (err: any) {
this.log.warn(
`Did not get confirmation Espresso deleteSession worked; ` + `Error was: ${err}`,
);
}
if (this.instProcess?.isRunning) {
await this.instProcess.stop();
}
}
private async cleanupSessionLeftovers(): Promise<void> {
this.log.debug('Performing cleanup of automation leftovers');
try {
const {value} = (
await axios({
url: `http://${this.host}:${this.systemPort}/sessions`,
timeout: 500,
})
).data as SessionsResponse;
const activeSessionIds = value.map((sess) => sess.id);
if (activeSessionIds.length) {
this.log.debug(
`The following obsolete sessions are still running: ${JSON.stringify(activeSessionIds)}`,
);
this.log.debug('Cleaning up the obsolete sessions');
await Promise.all(
activeSessionIds.map((id) =>
axios({
url: `http://${this.host}:${this.systemPort}/session/${id}`,
method: 'DELETE',
}),
),
);
// Let all sessions to be properly terminated before continuing
await sleep(1000);
} else {
this.log.debug('No obsolete sessions have been detected');
}
} catch (e: any) {
this.log.debug(`No obsolete sessions have been detected (${e.message})`);
}
}
private async recordTargetAppPackage(): Promise<void> {
await this.adb.shell([`echo "${this.appPackage}" > "${TARGET_PACKAGE_CONTAINER}"`]);
this.log.info(
`Recorded the target application package '${this.appPackage}' to ${TARGET_PACKAGE_CONTAINER}`,
);
}
private async _verifyServerStatus(
driverVersion: string,
serverStatus: ServerStatus,
): Promise<boolean> {
if (!isPlainObject(serverStatus) || !isPlainObject(serverStatus.build)) {
throw this.log.errorWithException(
`The Espresso server version integrated with the application under test is not compatible ` +
`with the current driver version '${driverVersion}'.`,
);
}
const {
build: {version: serverVersion, packageName: serverPackageName},
} = serverStatus;
const appLabel = serverPackageName
? `'${serverPackageName}' application`
: 'application under test';
const parsedServerVersion = semver.coerce(serverVersion);
const parsedDriverVersion = semver.coerce(driverVersion);
if (parsedServerVersion && parsedDriverVersion) {
if (parsedServerVersion.major !== parsedDriverVersion.major) {
throw this.log.errorWithException(
`The Espresso server version '${serverVersion}' integrated with the ${appLabel} is not compatible ` +
`with the current driver version '${driverVersion}'.`,
);
} else if (parsedServerVersion.minor < parsedDriverVersion.minor) {
this.log.warn(
`The Espresso server version integrated with the ${appLabel} might not be compatible ` +
`with the current driver version (${serverVersion} < ${driverVersion})'.`,
);
}
} else {
const warnMessage = parsedServerVersion
? `The Espresso driver version '${driverVersion}' ` +
`cannot be parsed. It might be incompatible with the current server '${serverVersion}' ` +
`integrated with the ${appLabel}.`
: `The Espresso server version '${serverVersion}' integrated with the ${appLabel} ` +
`cannot be parsed. It might be incompatible with the current driver ` +
`version '${driverVersion}'.`;
this.log.warn(warnMessage);
}
if (this.appPackage && serverPackageName && this.appPackage !== serverPackageName) {
throw this.log.errorWithException(
`The Espresso server that is listening on the device under tests is built for a different ` +
`application package (${serverPackageName} !== ${this.appPackage}).`,
);
}
return true;
}
}
function requireOption<K extends keyof EspressoRunnerOptions>(
opts: EspressoRunnerOptions,
key: K,
): NonNullable<EspressoRunnerOptions[K]> {
if (!util.hasValue(opts[key])) {
throw new Error(`Option '${key}' is required!`);
}
return opts[key] as NonNullable<EspressoRunnerOptions[K]>;
}
const MODULE_NAME = 'appium-espresso-driver';
const FILENAME = fileURLToPath(import.meta.url);
let testServerRoot: string | undefined;
/**
* Recursively copy all files except build directories contents
* @param sourceBaseDir directory to copy files from
* @param targetBaseDir directory to copy files to
*/
export async function copyGradleProjectRecursively(
sourceBaseDir: string,
targetBaseDir: string,
): Promise<void> {
// @ts-ignore it is ok to have the async callback
await fs.walkDir(sourceBaseDir, true, async (itemPath: string, isDirectory: boolean) => {
const relativePath = path.relative(sourceBaseDir, itemPath);
const targetPath = path.resolve(targetBaseDir, relativePath);
const isInGradleBuildDir = `${path.sep}${itemPath}`.includes(`${path.sep}build${path.sep}`);
if (isInGradleBuildDir) {
return false;
}
if (isDirectory) {
await fs.mkdirp(targetPath);
} else {
await fs.copyFile(itemPath, targetPath);
}
return false;
});
}
/** Root path of the bundled Espresso server Gradle project. */
function getTestServerRoot(): string {
if (testServerRoot === undefined) {
const moduleRoot = node.getModuleRootSync(MODULE_NAME, FILENAME);
if (!moduleRoot) {
throw new Error(`Cannot find the root folder of the ${MODULE_NAME} Node.js module`);
}
testServerRoot = path.join(moduleRoot, 'espresso-server');
}
return testServerRoot;
}