UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

373 lines 16.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.verifyApplicationPlatform = verifyApplicationPlatform; exports.onDownloadApp = onDownloadApp; exports.onPostConfigureApp = onPostConfigureApp; const utils_1 = require("../utils"); const node_path_1 = __importDefault(require("node:path")); const node_os_1 = __importDefault(require("node:os")); const node_assert_1 = __importDefault(require("node:assert")); const support_1 = require("appium/support"); const teen_process_1 = require("teen_process"); const helpers_1 = require("./helpers"); const constants_1 = require("./constants"); const ZIP_EXT = '.zip'; const SANITIZE_REPLACEMENT = '-'; const INTEL_ARCH = 'x86_64'; const MAX_ARCHIVE_SCAN_DEPTH = 1; /** * Verify whether the given application is compatible to the * platform where it is going to be installed and tested. * * @throws If bundle architecture does not match the expected device architecture. */ async function verifyApplicationPlatform() { this.log.debug('Verifying application platform'); if (!this.opts.app) { return; } const supportedPlatforms = await this.appInfosCache.extractAppPlatforms(this.opts.app); const isTvOS = (0, helpers_1.isTvOs)(this.opts.platformName); const prefix = isTvOS ? 'AppleTV' : 'iPhone'; const suffix = this.isSimulator() ? 'Simulator' : 'OS'; const dstPlatform = `${prefix}${suffix}`; if (!supportedPlatforms.includes(dstPlatform)) { throw new Error(`${this.isSimulator() ? 'Simulator' : 'Real device'} architecture is not supported by the ${this.opts.bundleId} application. ` + `Make sure the correct deployment target has been selected for its compilation in Xcode.`); } if (this.isRealDevice()) { return; } const executablePath = node_path_1.default.resolve(this.opts.app, await this.appInfosCache.extractExecutableName(this.opts.app)); const [resFile, resUname] = await Promise.all([ (0, teen_process_1.exec)('lipo', ['-info', executablePath]), (0, teen_process_1.exec)('uname', ['-m']), ]); const bundleExecutableInfo = resFile.stdout.trim(); this.log.debug(bundleExecutableInfo); const processArch = resUname.stdout.trim(); this.log.debug(`Current process architecture: ${processArch}`); const isAppleSiliconCpu = isAppleSilicon(); this.log.debug(`Is Apple Silicon CPU: ${isAppleSiliconCpu}`); if (isAppleSiliconCpu && processArch === INTEL_ARCH) { this.log.warn(`It looks like the Appium server process is running under Rosetta emulation. ` + `This might lead to various performance/compatibility issues while running tests on Simulator. ` + `Consider using binaries compiled natively for the ARM64 architecture to run Appium server ` + `with this driver.`); } if (bundleExecutableInfo.includes(processArch)) { return; } const hasRosetta = isAppleSiliconCpu && (await isRosettaInstalled()); const isIntelApp = bundleExecutableInfo.includes(INTEL_ARCH); // We cannot run Simulator builds compiled for arm64 on Intel machines // Rosetta allows only to run Intel ones on arm64 if ((isIntelApp && (!isAppleSiliconCpu || hasRosetta)) || (!isIntelApp && isAppleSiliconCpu)) { return; } const advice = isIntelApp && isAppleSiliconCpu && !hasRosetta ? `Please install Rosetta and try again.` : `Please rebuild your application to support the ${processArch} platform.`; throw new Error(`The ${this.opts.bundleId} application does not support the ${processArch} Simulator ` + `architecture:\n${bundleExecutableInfo}\n\n${advice}`); } /** * The callback invoked by configureApp helper * when it is necessary to download the remote application. * We assume the remote file could be anythingm, but only * .zip and .ipa formats are supported. * A .zip archive can contain one or more */ async function onDownloadApp(opts) { return this.isRealDevice() ? await downloadIpa.bind(this)(opts.stream, opts.headers) : await unzipApp.bind(this)(opts.stream); } /** Post-processes configured apps and reuses a valid cache entry when possible. */ async function onPostConfigureApp(opts) { // Pick the previously cached entry if its integrity has been preserved const appInfo = (0, utils_1.isPlainObject)(opts.cachedAppInfo) ? opts.cachedAppInfo : undefined; const cachedPath = appInfo ? appInfo.fullPath : undefined; const shouldUseCachedApp = async () => { if (!appInfo || !cachedPath || !(await support_1.fs.exists(cachedPath))) { return false; } const isCachedPathAFile = (await support_1.fs.stat(cachedPath)).isFile(); if (isCachedPathAFile) { return (await support_1.fs.hash(cachedPath)) === appInfo.integrity?.file; } // If the cached path is a folder then it is expected to be previously extracted from // an archive located under appPath whose hash is stored as `cachedAppInfo.packageHash` if (!isCachedPathAFile && opts.cachedAppInfo?.packageHash && opts.appPath && (await support_1.fs.exists(opts.appPath)) && (await support_1.fs.stat(opts.appPath)).isFile() && opts.cachedAppInfo.packageHash === (await support_1.fs.hash(opts.appPath))) { const nestedItemsCountInCache = appInfo.integrity?.folder; if (nestedItemsCountInCache !== undefined) { return (await support_1.fs.glob('**/*', { cwd: cachedPath })).length >= nestedItemsCountInCache; } } return false; }; if (await shouldUseCachedApp()) { if (!cachedPath) { return false; } this.log.info(`Using '${cachedPath}' which was cached from '${opts.appPath || 'unknown'}'`); return { appPath: cachedPath }; } if (!opts.appPath) { return false; } const isLocalIpa = await isIpaBundle(opts.appPath); const isLocalApp = !isLocalIpa && (await isAppBundle(opts.appPath)); const isPackageReadyForInstall = isLocalApp || (this.isRealDevice() && isLocalIpa); if (isPackageReadyForInstall) { await this.appInfosCache.put(opts.appPath); } // Only local .app bundles (real device/Simulator) // and .ipa packages for real devices should not be cached if (!opts.isUrl && isPackageReadyForInstall) { return false; } // Cache the app while unpacking the bundle if necessary return { appPath: isPackageReadyForInstall ? opts.appPath : await unzipApp.bind(this)(opts.appPath), }; } // Private functions /** * Check whether the given path on the file system points to the .app bundle root * * @param appPath Possible .app bundle root * @returns Whether the given path points to an .app bundle */ async function isAppBundle(appPath) { return (appPath.toLowerCase().endsWith(constants_1.APP_EXT) && (await support_1.fs.stat(appPath)).isDirectory() && (await support_1.fs.exists(node_path_1.default.join(appPath, 'Info.plist')))); } /** * Check whether the given path on the file system points to the .ipa file * * @param appPath Possible .ipa file * @returns Whether the given path points to an .ipa bundle */ async function isIpaBundle(appPath) { return appPath.toLowerCase().endsWith(constants_1.IPA_EXT) && (await support_1.fs.stat(appPath)).isFile(); } /** * Used to parse the file name value from response headers */ function parseFileName(headers) { const contentDisposition = headers['content-disposition']; if (typeof contentDisposition !== 'string') { return null; } if (/^attachment/i.test(contentDisposition)) { const match = /filename="([^"]+)/i.exec(contentDisposition); if (match) { return support_1.fs.sanitizeName(match[1], { replacement: SANITIZE_REPLACEMENT }); } } return null; } /** * Downloads and verifies remote applications for real devices */ async function downloadIpa(stream, headers) { const timer = new support_1.timing.Timer().start(); const logPerformance = (dstPath, fileSize, action) => { const secondsElapsed = timer.getDuration().asSeconds; this.log.info(`The remote file (${support_1.util.toReadableSizeString(fileSize)}) ` + `has been ${action} to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`); if (secondsElapsed >= 1) { const bytesPerSec = Math.floor(fileSize / secondsElapsed); this.log.debug(`Approximate speed: ${support_1.util.toReadableSizeString(bytesPerSec)}/s`); } }; // Check if the file to be downloaded is a .zip rather than .ipa const fileName = parseFileName(headers) ?? `appium-app-${new Date().getTime()}${constants_1.IPA_EXT}`; if (fileName.toLowerCase().endsWith(ZIP_EXT)) { const { rootDir, archiveSize } = await (0, helpers_1.unzipStream)(stream); logPerformance(rootDir, archiveSize, 'downloaded and unzipped'); try { const matchedPaths = await (0, helpers_1.findApps)(rootDir, [constants_1.IPA_EXT]); if (!(0, utils_1.isEmpty)(matchedPaths)) { this.log.debug(`Found ${support_1.util.pluralize(`${constants_1.IPA_EXT} application`, matchedPaths.length, true)} in ` + `'${node_path_1.default.basename(rootDir)}': ${matchedPaths}`); } for (const matchedPath of matchedPaths) { try { await this.appInfosCache.put(matchedPath); } catch (e) { this.log.info(e.message); continue; } this.log.debug(`Selecting the application at '${matchedPath}'`); const isolatedPath = node_path_1.default.join(await support_1.tempDir.openDir(), node_path_1.default.basename(matchedPath)); await support_1.fs.mv(matchedPath, isolatedPath); return isolatedPath; } throw new Error(`The remote archive does not contain any valid ${constants_1.IPA_EXT} applications`); } finally { await support_1.fs.rimraf(rootDir); } } const ipaPath = await support_1.tempDir.path({ prefix: fileName, suffix: fileName.toLowerCase().endsWith(constants_1.IPA_EXT) ? '' : constants_1.IPA_EXT, }); try { const writer = support_1.fs.createWriteStream(ipaPath); stream.pipe(writer); await new Promise((resolve, reject) => { stream.once('error', reject); writer.once('finish', resolve); writer.once('error', (e) => { stream.unpipe(writer); reject(e); }); }); } catch (err) { throw new Error(`Cannot fetch the remote file: ${err.message}`, { cause: err }); } const { size } = await support_1.fs.stat(ipaPath); logPerformance(ipaPath, size, 'downloaded'); try { await this.appInfosCache.put(ipaPath); } catch (e) { await support_1.fs.rimraf(ipaPath); throw e; } return ipaPath; } /** * Moves the application bundle to a newly created temporary folder * * @param appPath Full path to the .app or .ipa bundle * @returns The new path to the app bundle. * The name of the app bundle remains the same */ async function isolateApp(appPath) { const appFileName = node_path_1.default.basename(appPath); if ((await support_1.fs.stat(appPath)).isFile()) { const isolatedPath = await support_1.tempDir.path({ prefix: appFileName, suffix: '', }); await support_1.fs.mv(appPath, isolatedPath, { mkdirp: true }); return isolatedPath; } const tmpRoot = await support_1.tempDir.openDir(); const isolatedRoot = node_path_1.default.join(tmpRoot, appFileName); await support_1.fs.mv(appPath, isolatedRoot, { mkdirp: true }); return isolatedRoot; } /** * Unzip the given archive and find a matching .app bundle in it * * @param appPathOrZipStream The path to the archive. * @param depth [0] the current nesting depth. App bundles whose nesting level * is greater than 1 are not supported. * @returns Full path to the first matching .app bundle.. * @throws If no matching .app bundles were found in the provided archive. */ async function unzipApp(appPathOrZipStream, depth = 0) { const errMsg = `The archive did not have any matching ${constants_1.APP_EXT} or ${constants_1.IPA_EXT} ` + `bundles. Please make sure the provided package is valid and contains at least one matching ` + `application bundle which is not nested.`; if (depth > MAX_ARCHIVE_SCAN_DEPTH) { throw new Error(errMsg); } const timer = new support_1.timing.Timer().start(); let rootDir; let archiveSize; try { if (typeof appPathOrZipStream === 'string') { ({ rootDir, archiveSize } = await (0, helpers_1.unzipFile)(appPathOrZipStream)); } else { if (depth > 0) { node_assert_1.default.fail('Streaming unzip cannot be invoked for nested archive items'); } ({ rootDir, archiveSize } = await (0, helpers_1.unzipStream)(appPathOrZipStream)); } } catch (e) { this.log.debug(e.stack); throw new Error(`Cannot prepare the application for testing. Original error: ${e.message}`, { cause: e, }); } const secondsElapsed = timer.getDuration().asSeconds; this.log.info(`The file (${support_1.util.toReadableSizeString(archiveSize)}) ` + `has been ${typeof appPathOrZipStream === 'string' ? 'extracted' : 'downloaded and extracted'} ` + `to '${rootDir}' in ${secondsElapsed.toFixed(3)}s`); // it does not make much sense to approximate the speed for short downloads if (secondsElapsed >= 1) { const bytesPerSec = Math.floor(archiveSize / secondsElapsed); this.log.debug(`Approximate decompression speed: ${support_1.util.toReadableSizeString(bytesPerSec)}/s`); } const isCompatibleWithCurrentPlatform = async (appPath) => { let platforms; try { platforms = await this.appInfosCache.extractAppPlatforms(appPath); } catch (e) { this.log.info(e.message); return false; } if (this.isSimulator() && !platforms.some((p) => p.includes('Simulator'))) { this.log.info(`'${appPath}' does not have Simulator devices in the list of supported platforms ` + `(${platforms.join(',')}). Skipping it`); return false; } if (this.isRealDevice() && !platforms.some((p) => p.includes('OS'))) { this.log.info(`'${appPath}' does not have real devices in the list of supported platforms ` + `(${platforms.join(',')}). Skipping it`); return false; } return true; }; const matchedPaths = await (0, helpers_1.findApps)(rootDir, constants_1.SUPPORTED_EXTENSIONS); if ((0, utils_1.isEmpty)(matchedPaths)) { this.log.debug(`'${node_path_1.default.basename(rootDir)}' has no bundles`); } else { this.log.debug(`Found ${support_1.util.pluralize('bundle', matchedPaths.length, true)} in ` + `'${node_path_1.default.basename(rootDir)}': ${matchedPaths}`); } try { for (const matchedPath of matchedPaths) { const fullPath = node_path_1.default.join(rootDir, matchedPath); if (((await isAppBundle(fullPath)) || (this.isRealDevice() && (await isIpaBundle(fullPath)))) && (await isCompatibleWithCurrentPlatform(fullPath))) { this.log.debug(`Selecting the application at '${matchedPath}'`); return await isolateApp(fullPath); } } } finally { await support_1.fs.rimraf(rootDir); } throw new Error(errMsg); } async function isRosettaInstalled() { return await support_1.fs.exists('/Library/Apple/usr/share/rosetta/rosetta'); } function isAppleSilicon() { return node_os_1.default.cpus()[0].model.includes('Apple'); } //# sourceMappingURL=app-install.js.map