appium-espresso-driver
Version:
Espresso integration for Appium
302 lines (270 loc) • 10.4 kB
text/typescript
import {SubProcess} from 'teen_process';
import {fs, system} from 'appium/support.js';
import path from 'node:path';
import {EOL} from 'node:os';
import {escapeRegExp} from '../../utils/index.js';
import type {AppiumLogger} from '@appium/types';
const GRADLE_VERSION_KEY = 'gradle';
const GRADLE_URL_PREFIX = 'distributionUrl=';
export const GRADLE_URL_TEMPLATE =
'https\\://services.gradle.org/distributions/gradle-VERSION-all.zip';
const DEPENDENCY_PROP_NAMES = [
'additionalAppDependencies',
'additionalAndroidTestDependencies',
] as const;
export const VERSION_KEYS = [
GRADLE_VERSION_KEY,
'androidGradlePlugin',
'compileSdk',
'buildTools',
'minSdk',
'targetSdk',
'kotlin',
'sourceCompatibility',
'targetCompatibility',
'jvmTarget',
'composeVersion',
'espressoVersion',
'annotationVersion',
] as const;
export interface ServerSigningConfig {
zipAlign: boolean;
keystoreFile: string;
keystorePassword: string;
keyAlias: string;
keyPassword: string;
}
export interface BuildServerSigningConfigArgs {
keystoreFile: string;
keystorePassword: string;
keyAlias: string;
keyPassword: string;
}
export interface EspressoBuildConfiguration {
toolsVersions?: Record<string, string>;
/**
* When false, the Espresso server is built without Jetpack Compose dependencies.
* Defaults to true when omitted.
*/
composeSupport?: boolean;
additionalAppDependencies?: string[];
additionalAndroidTestDependencies?: string[];
}
export interface ServerBuilderOptions {
serverPath: string;
showGradleLog?: boolean;
buildConfiguration?: EspressoBuildConfiguration;
testAppPackage?: string;
signingConfig?: ServerSigningConfig | null;
}
export class ServerBuilder {
private readonly log: AppiumLogger;
private readonly serverPath: string;
private readonly showGradleLog?: boolean;
private readonly serverVersions: Partial<Record<(typeof VERSION_KEYS)[number], string>>;
private readonly testAppPackage?: string;
private readonly signingConfig?: ServerSigningConfig | null;
private readonly additionalAppDependencies: string[] = [];
private readonly additionalAndroidTestDependencies: string[] = [];
private readonly composeSupport: boolean;
constructor(log: AppiumLogger, args: ServerBuilderOptions) {
this.log = log;
this.serverPath = args.serverPath;
this.showGradleLog = args.showGradleLog;
const buildConfiguration = args.buildConfiguration || {};
this.composeSupport = buildConfiguration.composeSupport !== false;
const versionConfiguration = buildConfiguration.toolsVersions || {};
this.serverVersions = {} as Partial<Record<(typeof VERSION_KEYS)[number], string>>;
for (const [key, value] of Object.entries(versionConfiguration)) {
if (VERSION_KEYS.includes(key as (typeof VERSION_KEYS)[number])) {
this.serverVersions[key as (typeof VERSION_KEYS)[number]] = value;
} else {
log.warn(`Got unexpected '${key}' in toolsVersion block of the build configuration`);
}
}
this.testAppPackage = args.testAppPackage;
this.signingConfig = args.signingConfig;
for (const propName of DEPENDENCY_PROP_NAMES) {
this[propName] = buildConfiguration[propName] || [];
}
}
async build(): Promise<void> {
const gradleVersion = this.serverVersions[GRADLE_VERSION_KEY];
if (gradleVersion) {
await this.setGradleWrapperVersion(gradleVersion);
}
await this.insertAdditionalDependencies();
await this.runBuildProcess();
}
private getCommand(): {cmd: string; args: string[]} {
const cmd = system.isWindows() ? 'gradlew.bat' : path.resolve(this.serverPath, 'gradlew');
const buildProperty = (key: string, value?: string): string | null =>
value ? `-P${key}=${value}` : null;
const args: string[] = VERSION_KEYS.filter((key) => key !== GRADLE_VERSION_KEY)
.map((key) => {
const serverVersion = this.serverVersions[key];
const gradleProperty = `appium${key.charAt(0).toUpperCase()}${key.slice(1)}`;
return buildProperty(gradleProperty, serverVersion);
})
.filter((arg): arg is string => typeof arg === 'string' && Boolean(arg));
const signingConfig = this.signingConfig;
if (signingConfig) {
args.push(
...Object.keys(signingConfig)
.map((key) => {
const propKey = key as keyof ServerSigningConfig;
const propValue = signingConfig[propKey];
const k = `appium${key.charAt(0).toUpperCase()}${key.slice(1)}`;
const v: string | undefined = propValue != null ? String(propValue) : undefined;
return buildProperty(k, v);
})
.filter((arg): arg is string => typeof arg === 'string' && Boolean(arg)),
);
}
if (this.testAppPackage) {
const targetPackageArg = buildProperty('appiumTargetPackage', this.testAppPackage);
if (targetPackageArg) {
args.push(targetPackageArg);
}
}
if (!this.composeSupport) {
const composeArg = buildProperty('appiumComposeSupport', 'false');
if (composeArg) {
args.push(composeArg);
}
}
args.push('app:assembleAndroidTest');
return {cmd, args};
}
private async setGradleWrapperVersion(version: string): Promise<void> {
const propertiesPath = path.resolve(
this.serverPath,
'gradle',
'wrapper',
'gradle-wrapper.properties',
);
const originalProperties = await fs.readFile(propertiesPath, 'utf8');
const newProperties = this.updateGradleDistUrl(originalProperties, version);
await fs.writeFile(propertiesPath, newProperties, 'utf8');
}
private updateGradleDistUrl(propertiesContent: string, version: string): string {
return propertiesContent.replace(
new RegExp(`^(${escapeRegExp(GRADLE_URL_PREFIX)}).+$`, 'gm'),
`$1${GRADLE_URL_TEMPLATE.replace('VERSION', version)}`,
);
}
private async insertAdditionalDependencies(): Promise<void> {
let hasAdditionalDeps = false;
for (const propName of DEPENDENCY_PROP_NAMES) {
const deps = this[propName];
if (!Array.isArray(deps)) {
throw new Error(`'${propName}' must be an array`);
}
if (deps.filter((line) => line.trim()).length === 0) {
continue;
}
for (const dep of deps) {
if (/[\s'\\$]/.test(dep)) {
throw new Error(
'Single quotes, dollar characters and whitespace characters' +
` are disallowed in additional dependencies: ${dep}`,
);
}
}
hasAdditionalDeps = true;
}
if (!hasAdditionalDeps) {
return;
}
const buildPath = path.resolve(this.serverPath, 'app', 'build.gradle.kts');
let configuration = await fs.readFile(buildPath, 'utf8');
for (const propName of DEPENDENCY_PROP_NAMES) {
const prefix = propName === DEPENDENCY_PROP_NAMES[0] ? 'api' : 'androidTestImplementation';
const deps = this[propName]
.filter((line) => line.trim())
.map((line) => `${prefix}("${line}")`);
if (deps.length === 0) {
continue;
}
this.log.info(`Adding the following ${propName} to build.gradle.kts: ${deps}`);
configuration = updateDependencyLines(configuration, propName, deps);
}
await fs.writeFile(buildPath, configuration, 'utf8');
}
private async runBuildProcess(): Promise<void> {
const {cmd, args} = this.getCommand();
this.log.debug(
`Beginning build with command '${cmd} ${args.join(' ')}' ` +
`in directory '${this.serverPath}'`,
);
const gradlebuild = new SubProcess(cmd, args, {
cwd: this.serverPath,
stdio: ['ignore', 'pipe', 'pipe'],
// https://github.com/nodejs/node/issues/52572
shell: system.isWindows(),
windowsVerbatimArguments: true,
});
const gradleError: string[] = [];
const logMsg = `Output from Gradle ${this.showGradleLog ? 'will' : 'will not'} be logged`;
this.log.debug(`${logMsg}. To change this, use 'showGradleLog' desired capability`);
gradlebuild.on('line-stderr', (line: string) => {
this.log.warn(`[Gradle] ${line}`);
gradleError.push(line);
});
gradlebuild.on('line-stdout', (line: string) => this.log.info(`[Gradle] ${line}`));
try {
await gradlebuild.start();
await gradlebuild.join();
} catch (err: any) {
const msg =
`Unable to build Espresso server - ${err.message}\n` +
`Gradle error message:${EOL}${gradleError.join('\n')}`;
throw this.log.errorWithException(msg);
} finally {
gradlebuild.removeAllListeners();
}
}
}
/**
* Build keystore options for signing the built Espresso server test APK.
*
* @param args - Paths and credentials for the keystore used when signing.
* @returns Configuration consumed by the Gradle signing step.
*/
export function buildServerSigningConfig(args: BuildServerSigningConfigArgs): ServerSigningConfig {
return {
zipAlign: true,
keystoreFile: args.keystoreFile,
keystorePassword: args.keystorePassword,
keyAlias: args.keyAlias,
keyPassword: args.keyPassword,
};
}
/**
* Insert Gradle dependency lines after a `// placeholder` marker in a Gradle file.
*
* @param originalContent - Full text of the Gradle configuration file.
* @param dependencyPlaceholder - Placeholder comment label to find (e.g. from build.gradle).
* @param dependencyLines - Dependency lines to insert (e.g. `implementation "..."`).
* @returns Updated file content, or the original string if the placeholder is missing.
*/
export function updateDependencyLines(
originalContent: string,
dependencyPlaceholder: string,
dependencyLines: string[],
): string {
const configurationLines = originalContent.split('\n');
const searchRe = new RegExp(`^\\s*//\\s*\\b${escapeRegExp(dependencyPlaceholder)}\\b`, 'm');
const placeholderIndex = configurationLines.findIndex((line) => searchRe.test(line));
if (placeholderIndex < 0) {
return originalContent;
}
const placeholderLine = configurationLines[placeholderIndex];
const indentLen = placeholderLine.length - placeholderLine.trimStart().length;
configurationLines.splice(
placeholderIndex + 1,
0,
...dependencyLines.map((line) => `${' '.repeat(indentLen)}${line}`),
);
return configurationLines.join('\n');
}