@appium/base-driver
Version:
Base driver class for Appium drivers
510 lines • 22.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BASEDRIVER_VER = void 0;
exports.configureApp = configureApp;
exports.isPackageOrBundle = isPackageOrBundle;
exports.duplicateKeys = duplicateKeys;
exports.parseCapsArray = parseCapsArray;
exports.generateDriverLogPrefix = generateDriverLogPrefix;
const lodash_1 = __importDefault(require("lodash"));
const node_path_1 = __importDefault(require("node:path"));
const logger_1 = require("./logger");
const support_1 = require("@appium/support");
const lru_cache_1 = require("lru-cache");
const async_lock_1 = __importDefault(require("async-lock"));
const axios_1 = __importDefault(require("axios"));
const bluebird_1 = __importDefault(require("bluebird"));
// for compat with running tests transpiled and in-place
exports.BASEDRIVER_VER = support_1.fs.readPackageJsonFrom(__dirname).version;
const CACHED_APPS_MAX_AGE_MS = 1000 * 60 * toNaturalNumber(60 * 24, 'APPIUM_APPS_CACHE_MAX_AGE');
const MAX_CACHED_APPS = toNaturalNumber(1024, 'APPIUM_APPS_CACHE_MAX_ITEMS');
const HTTP_STATUS_NOT_MODIFIED = 304;
const DEFAULT_REQ_HEADERS = Object.freeze({
'user-agent': `Appium (BaseDriver v${exports.BASEDRIVER_VER})`,
});
const AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC = 2;
const APPLICATIONS_CACHE = new lru_cache_1.LRUCache({
max: MAX_CACHED_APPS,
ttl: CACHED_APPS_MAX_AGE_MS, // expire after 24 hours
updateAgeOnGet: true,
dispose: ({ fullPath }, app) => {
logger_1.log.info(`The application '${app}' cached at '${fullPath}' has ` +
`expired after ${CACHED_APPS_MAX_AGE_MS}ms`);
if (fullPath) {
void support_1.fs.rimraf(fullPath);
}
},
noDisposeOnSet: true,
});
const APPLICATIONS_CACHE_GUARD = new async_lock_1.default();
const SANITIZE_REPLACEMENT = '-';
const DEFAULT_BASENAME = 'appium-app';
const APP_DOWNLOAD_TIMEOUT_MS = 120 * 1000;
process.on('exit', () => {
if (APPLICATIONS_CACHE.size === 0) {
return;
}
const appPaths = [...APPLICATIONS_CACHE.values()].map(({ fullPath }) => fullPath);
logger_1.log.debug(`Performing cleanup of ${support_1.util.pluralize('cached application', appPaths.length, true)}`);
for (const appPath of appPaths) {
if (!appPath) {
continue;
}
try {
support_1.fs.rimrafSync(appPath);
}
catch (e) {
logger_1.log.warn(e.message);
}
}
});
/**
* Performs initial application package configuration so the app is ready for driver use.
* Resolves local paths, downloads remote apps (http/https) with optional caching, and
* runs optional post-process or custom download hooks.
*
* @param app - Path to a local app or URL of a downloadable app (http/https).
* @param options - Supported extensions and optional hooks. Either a single extension
* string, an array of extension strings, or {@link ConfigureAppOptions} (e.g.
* `supportedExtensions`, `onPostProcess`, `onDownload`).
* @returns Resolved path to the application (local path or path to downloaded/cached app).
* @throws {Error} If supported extensions are missing, the app path/URL is invalid, or download fails.
*/
async function configureApp(app, options = {}) {
if (!lodash_1.default.isString(app)) {
// immediately shortcircuit if not given an app
return '';
}
let supportedAppExtensions;
const opts = !lodash_1.default.isString(options) && !lodash_1.default.isArray(options) ? options : undefined;
const onPostProcess = opts?.onPostProcess;
const onDownload = opts?.onDownload;
if (lodash_1.default.isString(options)) {
supportedAppExtensions = [options];
}
else if (lodash_1.default.isArray(options)) {
supportedAppExtensions = options;
}
else if (lodash_1.default.isPlainObject(options)) {
supportedAppExtensions = options.supportedExtensions ?? [];
}
else {
supportedAppExtensions = [];
}
if (lodash_1.default.isEmpty(supportedAppExtensions)) {
throw new Error(`One or more supported app extensions must be provided`);
}
let newApp = app;
const originalAppLink = app;
let packageHash = null;
let headers;
const remoteAppProps = {
lastModified: null,
immutable: false,
maxAge: null,
etag: null,
};
const { protocol, pathname } = parseAppLink(app);
const isUrl = isSupportedUrl(app);
if (!isUrl && !node_path_1.default.isAbsolute(newApp)) {
newApp = node_path_1.default.resolve(process.cwd(), newApp);
logger_1.log.warn(`The current application path '${app}' is not absolute ` +
`and has been rewritten to '${newApp}'. Consider using absolute paths rather than relative`);
app = newApp;
}
const appCacheKey = toCacheKey(app);
return await APPLICATIONS_CACHE_GUARD.acquire(appCacheKey, async () => {
const cachedAppInfo = APPLICATIONS_CACHE.get(appCacheKey);
if (cachedAppInfo) {
logger_1.log.debug(`Cached app data: ${JSON.stringify(cachedAppInfo, null, 2)}`);
}
if (isUrl) {
// Use the app from remote URL
logger_1.log.info(`Using downloadable app '${newApp}'`);
const reqHeaders = { ...DEFAULT_REQ_HEADERS };
if (cachedAppInfo?.etag) {
reqHeaders['if-none-match'] = cachedAppInfo.etag;
}
else if (cachedAppInfo?.lastModified) {
reqHeaders['if-modified-since'] = cachedAppInfo.lastModified.toUTCString();
}
logger_1.log.debug(`Request headers: ${JSON.stringify(reqHeaders)}`);
let result = await queryAppLink(newApp, reqHeaders);
headers = result.headers;
let { stream, status } = result;
logger_1.log.debug(`Response status: ${status}`);
try {
if (!lodash_1.default.isEmpty(headers)) {
if (headers.etag) {
logger_1.log.debug(`Etag: ${headers.etag}`);
remoteAppProps.etag = headers.etag;
}
if (headers['last-modified']) {
logger_1.log.debug(`Last-Modified: ${headers['last-modified']}`);
remoteAppProps.lastModified = new Date(headers['last-modified']);
}
if (headers['cache-control']) {
logger_1.log.debug(`Cache-Control: ${headers['cache-control']}`);
remoteAppProps.immutable = /\bimmutable\b/i.test(String(headers['cache-control']));
const maxAgeMatch = /\bmax-age=(\d+)\b/i.exec(String(headers['cache-control']));
if (maxAgeMatch) {
remoteAppProps.maxAge = parseInt(maxAgeMatch[1], 10);
}
}
}
if (cachedAppInfo && status === HTTP_STATUS_NOT_MODIFIED) {
const cachedPath = cachedAppInfo.fullPath ?? '';
if (cachedPath && (await isAppIntegrityOk(cachedPath, cachedAppInfo.integrity))) {
logger_1.log.info(`Reusing previously downloaded application at '${cachedPath}'`);
return verifyAppExtension(cachedPath, supportedAppExtensions);
}
logger_1.log.info(`The application at '${cachedAppInfo.fullPath}' does not exist anymore ` +
`or its integrity has been damaged. Deleting it from the internal cache`);
APPLICATIONS_CACHE.delete(appCacheKey);
if (!stream.closed) {
stream.destroy();
}
result = await queryAppLink(newApp, { ...DEFAULT_REQ_HEADERS });
stream = result.stream;
headers = result.headers;
status = result.status;
}
if (onDownload) {
newApp = await onDownload({
url: originalAppLink,
headers: lodash_1.default.clone(headers),
stream,
});
}
else {
const fileName = determineFilename(headers, pathname ?? '', supportedAppExtensions);
newApp = await fetchApp(stream, await support_1.tempDir.path({
prefix: fileName,
suffix: '',
}));
}
}
finally {
if (!stream.closed) {
stream.destroy();
}
}
}
else if (await support_1.fs.exists(newApp)) {
// Use the local app
logger_1.log.info(`Using local app '${newApp}'`);
}
else {
let errorMessage = `The application at '${newApp}' does not exist or is not accessible`;
// protocol value for 'C:\\temp' is 'c:', so we check the length as well
if (lodash_1.default.isString(protocol) && protocol.length > 2) {
errorMessage =
`The protocol '${protocol}' used in '${newApp}' is not supported. ` +
`Only http: and https: protocols are supported`;
}
throw new Error(errorMessage);
}
const isPackageAFile = (await support_1.fs.stat(newApp)).isFile();
if (isPackageAFile) {
packageHash = await calculateFileIntegrity(newApp);
}
const storeAppInCache = async (appPathToCache) => {
const cachedFullPath = cachedAppInfo?.fullPath;
if (cachedFullPath && cachedFullPath !== appPathToCache) {
await support_1.fs.rimraf(cachedFullPath);
}
const integrity = {};
if ((await support_1.fs.stat(appPathToCache)).isDirectory()) {
integrity.folder = await calculateFolderIntegrity(appPathToCache);
}
else {
integrity.file = await calculateFileIntegrity(appPathToCache);
}
APPLICATIONS_CACHE.set(appCacheKey, {
...remoteAppProps,
timestamp: Date.now(),
packageHash,
integrity,
fullPath: appPathToCache,
});
return appPathToCache;
};
if (lodash_1.default.isFunction(onPostProcess)) {
const postProcessArg = {
cachedAppInfo: lodash_1.default.clone(cachedAppInfo),
isUrl,
originalAppLink,
headers: lodash_1.default.clone(headers),
appPath: newApp,
};
const result = await onPostProcess(postProcessArg);
return !result?.appPath || app === result?.appPath || !(await support_1.fs.exists(result?.appPath))
? newApp
: await storeAppInCache(result.appPath);
}
verifyAppExtension(newApp, supportedAppExtensions);
return appCacheKey !== toCacheKey(newApp) && (packageHash || lodash_1.default.values(remoteAppProps).some(Boolean))
? await storeAppInCache(newApp)
: newApp;
});
}
/**
* Returns whether the given string looks like a package or bundle identifier
* (e.g. `com.example.app` or `org.company.AnotherApp`).
*
* @param app - Value to check (e.g. app path or bundle id).
* @returns `true` if the value matches a dot-separated identifier pattern.
*/
function isPackageOrBundle(app) {
return /^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/.test(app);
}
/**
* Recursively ensures both keys exist with the same value in objects and arrays.
* For each object, if `firstKey` exists its value is also set at `secondKey`, and vice versa.
*
* @param input - Object, array, or primitive to process (arrays/objects traversed recursively).
* @param firstKey - First key name to mirror.
* @param secondKey - Second key name to mirror.
* @returns A deep copy of `input` with both keys present where objects had either key.
*/
function duplicateKeys(input, firstKey, secondKey) {
// If array provided, recursively call on all elements
if (lodash_1.default.isArray(input)) {
return input.map((item) => duplicateKeys(item, firstKey, secondKey));
}
// If object, create duplicates for keys and then recursively call on values
if (lodash_1.default.isPlainObject(input)) {
const resultObj = {};
for (const [key, value] of lodash_1.default.toPairs(input)) {
const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey);
if (key === firstKey) {
resultObj[secondKey] = recursivelyCalledValue;
}
else if (key === secondKey) {
resultObj[firstKey] = recursivelyCalledValue;
}
resultObj[key] = recursivelyCalledValue;
}
return resultObj;
}
// Base case. Return primitives without doing anything.
return input;
}
/**
* Normalizes a capability value to a string array. If already an array, returns it;
* if a string, parses as JSON array when possible, otherwise returns a single-element array.
*
* @param capValue - Capability value: string (including JSON array like `"[\"a\",\"b\"]"`) or string[].
* @returns Array of strings.
* @throws {TypeError} If value is not a string/array or JSON parsing fails for array-like input.
*/
function parseCapsArray(capValue) {
if (lodash_1.default.isArray(capValue)) {
return capValue;
}
try {
const parsed = JSON.parse(capValue);
if (lodash_1.default.isArray(parsed)) {
return parsed;
}
}
catch (e) {
const message = `Failed to parse capability as JSON array: ${e.message}`;
if (lodash_1.default.isString(capValue) && lodash_1.default.startsWith(lodash_1.default.trimStart(capValue), '[')) {
throw new TypeError(message, { cause: e });
}
logger_1.log.warn(message);
}
if (lodash_1.default.isString(capValue)) {
return [capValue];
}
throw new TypeError(`Expected a string or a valid JSON array; received '${capValue}'`);
}
/**
* Builds a short log prefix for a driver instance (e.g. `UiAutomator2@a1b2`).
*
* @param obj - Driver or other object; its constructor name and a short id are used.
* @param _sessionId - Deprecated and unused; kept for {@link DriverHelpers} interface compatibility.
* @returns Prefix string like `DriverName@xxxx`, or `UnknownDriver@????` if `obj` is null.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- DriverHelpers interface
function generateDriverLogPrefix(obj, _sessionId) {
if (!obj) {
// This should not happen
return 'UnknownDriver@????';
}
return `${obj.constructor.name}@${support_1.node.getObjectId(obj).substring(0, 4)}`;
}
// #region Private helpers
function parseAppLink(appLink) {
try {
return new URL(appLink);
}
catch {
return {};
}
}
function isEnvOptionEnabled(optionName, defaultValue = null) {
const value = process.env[optionName];
if (!lodash_1.default.isNull(defaultValue) && lodash_1.default.isEmpty(value)) {
return defaultValue;
}
return !lodash_1.default.isEmpty(value) && !['0', 'false', 'no'].includes(lodash_1.default.toLower(value));
}
function isSupportedUrl(app) {
try {
const { protocol } = parseAppLink(app);
return ['http:', 'https:'].includes(protocol ?? '');
}
catch {
return false;
}
}
/**
* Transforms the given app link to the cache key.
* Necessary to properly cache apps having the same address but different query strings,
* e.g. ones stored in S3 using presigned URLs.
*/
function toCacheKey(app) {
if (!isEnvOptionEnabled('APPIUM_APPS_CACHE_IGNORE_URL_QUERY') || !isSupportedUrl(app)) {
return app;
}
try {
const parsed = parseAppLink(app);
const href = 'href' in parsed ? parsed.href : undefined;
const search = 'search' in parsed ? parsed.search : undefined;
if (href && search) {
return href.replace(search, '');
}
if (href) {
return href;
}
}
catch {
// ignore
}
return app;
}
async function queryAppLink(appLink, reqHeaders) {
const url = new URL(appLink);
// Extract credentials, then remove them from the URL for axios
const { username, password } = url;
url.username = '';
url.password = '';
const axiosUrl = url.href;
const axiosAuth = username ? { username, password } : undefined;
const requestOpts = {
url: axiosUrl,
auth: axiosAuth,
responseType: 'stream',
timeout: APP_DOWNLOAD_TIMEOUT_MS,
validateStatus: (status) => (status >= 200 && status < 300) || status === HTTP_STATUS_NOT_MODIFIED,
headers: reqHeaders,
};
try {
const { data: stream, headers, status } = await (0, axios_1.default)(requestOpts);
return { stream, headers, status };
}
catch (err) {
throw new Error(`Cannot download the app from ${axiosUrl}: ${err.message}`, { cause: err });
}
}
async function fetchApp(srcStream, dstPath) {
const timer = new support_1.timing.Timer().start();
try {
const writer = support_1.fs.createWriteStream(dstPath);
srcStream.pipe(writer);
await new bluebird_1.default((resolve, reject) => {
srcStream.once('error', reject);
writer.once('finish', () => resolve());
writer.once('error', (e) => {
srcStream.unpipe(writer);
reject(e);
});
});
}
catch (err) {
throw new Error(`Cannot fetch the application: ${err.message}`, { cause: err });
}
const secondsElapsed = timer.getDuration().asSeconds;
const { size } = await support_1.fs.stat(dstPath);
logger_1.log.debug(`The application (${support_1.util.toReadableSizeString(size)}) ` +
`has been downloaded to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`);
// it does not make much sense to approximate the speed for short downloads
if (secondsElapsed >= AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC) {
const bytesPerSec = Math.floor(size / secondsElapsed);
logger_1.log.debug(`Approximate download speed: ${support_1.util.toReadableSizeString(bytesPerSec)}/s`);
}
return dstPath;
}
function determineFilename(headers, pathname, supportedAppExtensions) {
const basename = support_1.fs.sanitizeName(node_path_1.default.basename(decodeURIComponent(pathname ?? '')), {
replacement: SANITIZE_REPLACEMENT,
});
const extname = node_path_1.default.extname(basename);
if (headers['content-disposition'] && /^attachment/i.test(String(headers['content-disposition']))) {
logger_1.log.debug(`Content-Disposition: ${headers['content-disposition']}`);
const match = /filename="([^"]+)/i.exec(String(headers['content-disposition']));
if (match) {
return support_1.fs.sanitizeName(match[1], { replacement: SANITIZE_REPLACEMENT });
}
}
// assign the default file name and the extension if none has been detected
const resultingName = basename
? basename.substring(0, basename.length - extname.length)
: DEFAULT_BASENAME;
let resultingExt = extname;
if (!supportedAppExtensions.map(lodash_1.default.toLower).includes(lodash_1.default.toLower(resultingExt))) {
logger_1.log.info(`The current file extension '${resultingExt}' is not supported. ` +
`Defaulting to '${lodash_1.default.first(supportedAppExtensions)}'`);
resultingExt = lodash_1.default.first(supportedAppExtensions);
}
return `${resultingName}${resultingExt}`;
}
function verifyAppExtension(app, supportedAppExtensions) {
if (supportedAppExtensions.map(lodash_1.default.toLower).includes(lodash_1.default.toLower(node_path_1.default.extname(app)))) {
return app;
}
throw new Error(`New app path '${app}' did not have ` +
`${support_1.util.pluralize('extension', supportedAppExtensions.length, false)}: ` +
supportedAppExtensions);
}
async function calculateFolderIntegrity(folderPath) {
return (await support_1.fs.glob('**/*', { cwd: folderPath })).length;
}
async function calculateFileIntegrity(filePath) {
return await support_1.fs.hash(filePath);
}
async function isAppIntegrityOk(currentPath, expectedIntegrity = {}) {
if (!(await support_1.fs.exists(currentPath))) {
return false;
}
// Folder integrity check is simple:
// Verify the previous amount of files is not greater than the current one.
// We don't want to use equality comparison because of an assumption that the OS might
// create some unwanted service files/cached inside of that folder or its subfolders.
// Ofc, validating the hash sum of each file (or at least of file path) would be much
// more precise, but we don't need to be very precise here and also don't want to
// overuse RAM and have a performance drop.
return (await support_1.fs.stat(currentPath)).isDirectory()
? (await calculateFolderIntegrity(currentPath)) >= (expectedIntegrity?.folder ?? 0)
: (await calculateFileIntegrity(currentPath)) === expectedIntegrity?.file;
}
function toNaturalNumber(defaultValue, envVarName) {
if (!envVarName || lodash_1.default.isUndefined(process.env[envVarName])) {
return defaultValue;
}
const num = parseInt(`${process.env[envVarName]}`, 10);
return num > 0 ? num : defaultValue;
}
exports.default = {
configureApp,
isPackageOrBundle,
duplicateKeys,
parseCapsArray,
generateDriverLogPrefix,
};
//# sourceMappingURL=helpers.js.map