UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

464 lines (424 loc) 15.7 kB
import * as xcode from 'appium-xcode'; import {errors} from 'appium/driver'; import {fs, net, util} from 'appium/support'; import B from 'bluebird'; import _ from 'lodash'; import os from 'node:os'; import path from 'node:path'; import url from 'node:url'; import * as semver from 'semver'; import {exec} from 'teen_process'; import {log} from './logger'; import {PLATFORM_NAME_TVOS, PLATFORM_NAME_IOS} from './desired-caps'; import type {XCUITestDriverOpts, XCUITestDriver} from './driver'; import type {XcodeVersion} from 'appium-xcode'; import type {Simulator} from 'appium-ios-simulator'; import type {HTTPHeaders} from '@appium/types'; import type {Method} from 'axios'; export const UDID_AUTO = 'auto'; const MODULE_NAME = 'appium-xcuitest-driver'; export const DEFAULT_TIMEOUT_KEY = 'default'; const XCTEST_LOG_FILES_PATTERNS = [ /^Session-WebDriverAgentRunner.*\.log$/i, /^StandardOutputAndStandardError\.txt$/i, ]; const XCTEST_LOGS_CACHE_FOLDER_PREFIX = 'com.apple.dt.XCTest'; export const NATIVE_WIN = 'NATIVE_APP'; export async function getAndCheckXcodeVersion(): Promise<XcodeVersion> { try { return await xcode.getVersion(true); } catch (err: any) { throw log.errorWithException(`Could not determine Xcode version: ${err.message}`); } } export async function getAndCheckIosSdkVersion(): Promise<string | null> { try { return await xcode.getMaxIOSSDK(); } catch (err: any) { throw log.errorWithException(`Could not determine iOS SDK version: ${err.message}`); } } export async function clearLogs(locations: string[]): Promise<void> { log.debug('Clearing log files'); const cleanupPromises: Promise<void>[] = []; for (const location of locations) { if (!(await fs.exists(location))) { continue; } cleanupPromises.push( (async () => { let size: string | undefined; try { const {stdout} = await exec('du', ['-sh', location]); size = stdout.trim().split(/\s+/)[0]; } catch {} try { log.debug(`Deleting '${location}'. ${size ? `Freeing ${size}.` : ''}`); await fs.rimraf(location); } catch (err: any) { log.warn(`Unable to delete '${location}': ${err.message}`); } })(), ); } if (!_.isEmpty(cleanupPromises)) { await B.all(cleanupPromises); } log.debug('Finished clearing log files'); } // This map contains derived data logs folders as keys // and values are the count of times the particular // folder has been scheduled for removal const derivedDataCleanupMarkers = new Map<string, number>(); export async function markSystemFilesForCleanup(wda: any): Promise<void> { if (!wda || !(await wda.retrieveDerivedDataPath())) { log.warn( 'No WebDriverAgent derived data available, so unable to mark system files for cleanup', ); return; } const logsRoot = path.resolve(await wda.retrieveDerivedDataPath(), 'Logs'); let markersCount = 0; const existingCount = derivedDataCleanupMarkers.get(logsRoot); if (existingCount !== undefined) { markersCount = existingCount; } derivedDataCleanupMarkers.set(logsRoot, ++markersCount); } export async function clearSystemFiles(wda: any): Promise<void> { // only want to clear the system files for the particular WDA xcode run if (!wda || !(await wda.retrieveDerivedDataPath())) { log.warn('No WebDriverAgent derived data available, so unable to clear system files'); return; } const logsRoot = path.resolve(await wda.retrieveDerivedDataPath(), 'Logs'); const existingCount = derivedDataCleanupMarkers.get(logsRoot); if (existingCount !== undefined) { let markersCount = existingCount; derivedDataCleanupMarkers.set(logsRoot, --markersCount); if (markersCount > 0) { log.info( `Not cleaning '${logsRoot}' folder, because the other session does not expect it to be cleaned`, ); return; } } derivedDataCleanupMarkers.set(logsRoot, 0); // Cleaning up big temporary files created by XCTest: https://github.com/appium/appium/issues/9410 const globPattern = `${os.tmpdir()}/${XCTEST_LOGS_CACHE_FOLDER_PREFIX}*/`; const dstFolders = await fs.glob(globPattern); if (_.isEmpty(dstFolders)) { log.debug(`Did not find the temporary XCTest logs root at '${globPattern}'`); } else { // perform the cleanup asynchronously const promises: Promise<void>[] = []; for (const dstFolder of dstFolders) { const promise = (async () => { try { await fs.walkDir(dstFolder, true, (itemPath, isDir) => { if (isDir) { return; } const fileName = path.basename(itemPath); if (XCTEST_LOG_FILES_PATTERNS.some((p) => p.test(fileName))) { fs.rimraf(itemPath); } }); } catch (e: any) { log.debug(e.stack); log.info(e.message); } })(); promises.push(promise); } log.debug(`Started XCTest logs cleanup in '${dstFolders}'`); if (promises.length) { await B.all(promises); } } if (await fs.exists(logsRoot)) { log.info(`Cleaning test logs in '${logsRoot}' folder`); await clearLogs([logsRoot]); return; } log.info(`There is no ${logsRoot} folder, so not cleaning files`); } export async function checkAppPresent(app: string): Promise<void> { log.debug(`Checking whether app '${app}' is actually present on file system`); if (!(await fs.exists(app))) { throw log.errorWithException(`Could not find app at '${app}'`); } log.debug('App is present'); } /** * Reads the content to the current module's package.json * * @returns The full path to module root * @throws If the current module's package.json cannot be determined */ const getModuleManifest = _.memoize(async function getModuleManifest(): Promise<Record<string, any>> { // Start from the directory containing the compiled output (build/lib) or source (lib) // and walk up to find package.json let currentDir = path.resolve(__dirname, '..'); let isAtFsRoot = false; while (!isAtFsRoot) { const manifestPath = path.join(currentDir, 'package.json'); try { if (await fs.exists(manifestPath)) { const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); if (manifest.name === MODULE_NAME) { return manifest; } } } catch {} const parentDir = path.dirname(currentDir); isAtFsRoot = currentDir.length <= parentDir.length; currentDir = parentDir; } throw new Error(`Cannot find the package manifest of the ${MODULE_NAME} Node.js module`); }); /** * @typedef {Object} DriverInfo * @property {string} version Driver version string * @property {string} built Driver build timestamp */ export interface DriverInfo { version: string; built: string; } /** * @returns */ export const getDriverInfo = _.memoize(async function getDriverInfo(): Promise<DriverInfo> { const [stat, manifest] = await B.all([ fs.stat(path.resolve(__dirname, '..')), getModuleManifest(), ]); return { built: stat.mtime.toString(), version: manifest.version, }; }); export function normalizeCommandTimeouts(value: string | Record<string, number>): Record<string, number> { // The value is normalized already if (typeof value !== 'string') { return value; } let result: Record<string, number> = {}; // Use as default timeout for all commands if a single integer value is provided if (!isNaN(Number(value))) { result[DEFAULT_TIMEOUT_KEY] = _.toInteger(value); return result; } // JSON object has been provided. Let's parse it try { result = JSON.parse(value); if (!_.isPlainObject(result)) { throw new Error(); } } catch { throw log.errorWithException( `"commandTimeouts" capability should be a valid JSON object. "${value}" was given instead`, ); } for (const [cmd, timeout] of _.toPairs(result)) { if (!_.isInteger(timeout) || timeout <= 0) { throw log.errorWithException( `The timeout for "${cmd}" should be a valid natural number of milliseconds. "${timeout}" was given instead`, ); } } return result; } export async function printUser(): Promise<void> { try { const {stdout} = await exec('whoami'); log.debug(`Current user: '${stdout.trim()}'`); } catch (err: any) { log.debug(`Unable to get username running server: ${err.message}`); } } /** * Get the IDs of processes listening on the particular system port. * It is also possible to apply additional filtering based on the * process command line. * * @param port - The port number. * @param filteringFunc - Optional lambda function, which * receives command line string of the particular process * listening on given port, and is expected to return * either true or false to include/exclude the corresponding PID * from the resulting array. * @returns - the list of matched process ids. */ export async function getPIDsListeningOnPort( port: string | number, filteringFunc: ((cmdLine: string) => boolean | Promise<boolean>) | null = null ): Promise<string[]> { const result: string[] = []; try { // This only works since Mac OS X El Capitan const {stdout} = await exec('lsof', ['-ti', `tcp:${port}`]); result.push(...stdout.trim().split(/\n+/)); } catch { return result; } if (!_.isFunction(filteringFunc)) { return result; } return await B.filter(result, async (x) => { const {stdout} = await exec('ps', ['-p', x, '-o', 'command']); return await filteringFunc(stdout); }); } /** * @typedef {Object} UploadOptions * * @property {string} [user] - The name of the user for the remote authentication. Only works if `remotePath` is provided. * @property {string} [pass] - The password for the remote authentication. Only works if `remotePath` is provided. * @property {import('axios').Method} [method] - The http multipart upload method name. The 'PUT' one is used by default. * Only works if `remotePath` is provided. * @property {import('@appium/types').HTTPHeaders} [headers] - Additional headers mapping for multipart http(s) uploads * @property {string} [fileFieldName] [file] - The name of the form field, where the file content BLOB should be stored for * http(s) uploads * @property {Record<string, any> | [string, any][]} [formFields] - Additional form fields for multipart http(s) uploads */ export interface UploadOptions { user?: string; pass?: string; method?: Method; headers?: HTTPHeaders; fileFieldName?: string; formFields?: Record<string, any> | [string, any][]; } /** * Encodes the given local file to base64 and returns the resulting string * or uploads it to a remote server using http/https or ftp protocols * if `remotePath` is set * * @param localPath - The path to an existing local file * @param remotePath - The path to the remote location, where * this file should be uploaded * @param uploadOptions - Set of upload options * @returns Either an empty string if the upload was successful or * base64-encoded file representation if `remotePath` is falsy */ export async function encodeBase64OrUpload( localPath: string, remotePath: string | null = null, uploadOptions: UploadOptions = {} ): Promise<string> { if (!(await fs.exists(localPath))) { throw log.errorWithException(`The file at '${localPath}' does not exist or is not accessible`); } if (_.isEmpty(remotePath)) { const {size} = await fs.stat(localPath); log.debug(`The size of the file is ${util.toReadableSizeString(size)}`); return (await util.toInMemoryBase64(localPath)).toString(); } const {user, pass, method, headers, fileFieldName, formFields} = uploadOptions; const options: net.HttpUploadOptions & net.NetOptions = { method: method || 'PUT', headers, fileFieldName, formFields, }; if (user && pass) { options.auth = {user, pass}; } await net.uploadFile(localPath, remotePath as string, options); return ''; } /** * Stops and removes all web socket handlers that are listening * in scope of the current session. * * @this {XCUITestDriver} * @returns */ export async function removeAllSessionWebSocketHandlers(this: XCUITestDriver): Promise<void> { if (!this.sessionId || !_.isFunction(this.server?.getWebSocketHandlers)) { return; } const activeHandlers = await this.server.getWebSocketHandlers(this.sessionId); for (const pathname of _.keys(activeHandlers)) { await this.server.removeWebSocketHandler(pathname); } } /** * Returns true if the urlString is localhost * @param urlString * @returns Return true if the urlString is localhost */ export function isLocalHost(urlString: string): boolean { try { const hostname = url.parse(urlString).hostname as string; return ['localhost', '127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(hostname); } catch { log.warn(`'${urlString}' cannot be parsed as a valid URL`); } return false; } /** * Normalizes platformVersion to a valid iOS version string * * @param originalVersion - Loose version number, that can be parsed by semver * @return iOS version number in <major>.<minor> format * @throws if the version number cannot be parsed */ export function normalizePlatformVersion(originalVersion: string): string { const normalizedVersion = semver.coerce(originalVersion); if (!normalizedVersion) { throw new Error(`The platform version '${originalVersion}' should be a valid version number`); } return `${normalizedVersion.major}.${normalizedVersion.minor}`; } /** * Assert the presence of particular keys in the given object * * @param argNames one or more key names * @param opts the object to check * @returns the same given object */ export function requireArgs(argNames: string | string[], opts: Record<string, any> = {}): Record<string, any> { for (const argName of _.isArray(argNames) ? argNames : [argNames]) { if (!_.has(opts, argName)) { throw new errors.InvalidArgumentError(`'${argName}' argument must be provided`); } } return opts; } /** * Asserts that the given driver is running on a Simulator and return * the simlator instance. * * @param action - Description of action */ export function assertSimulator(this: XCUITestDriver, action: string): Simulator { if (!this.isSimulator()) { throw new Error(`${_.upperFirst(action)} can only be performed on Simulator`); } return this.device as Simulator; } /** * Check if platform name is the TV OS one. */ export function isTvOs(platformName: string | null | undefined): boolean { return _.toLower(platformName ?? '') === _.toLower(PLATFORM_NAME_TVOS); } /** * Return normalized platform name. */ export function normalizePlatformName(platformName: string | null | undefined): string { return isTvOs(platformName) ? PLATFORM_NAME_TVOS : PLATFORM_NAME_IOS; } export function shouldSetInitialSafariUrl(opts: XCUITestDriverOpts): boolean { return !(opts.safariInitialUrl === '' || (opts.noReset && _.isNil(opts.safariInitialUrl))) && !opts.initialDeeplinkUrl; } export function isIos17OrNewer(opts: XCUITestDriverOpts): boolean { return !!opts.platformVersion && util.compareVersions(opts.platformVersion, '>=', '17.0'); } export function isIos18OrNewer(opts: XCUITestDriverOpts): boolean { return !!opts.platformVersion && util.compareVersions(opts.platformVersion, '>=', '18.0'); }