UNPKG

@appium/base-driver

Version:

Base driver class for Appium drivers

510 lines 22.2 kB
"use strict"; 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