gst-atom-xcuitest-driver
Version:
ATOM driver for iOS using XCUITest for backend
440 lines (394 loc) • 15.2 kB
JavaScript
import B from 'bluebird';
import { utilities } from 'gst-atom-ios-device';
import { fs, util, net, plist } from 'appium-support';
import path from 'path';
import { utils as iosUtils } from 'gst-atom-ios-driver';
import { exec } from 'teen_process';
import xcode from 'appium-xcode';
import _ from 'lodash';
import log from './logger';
import iosGenericSimulators from './ios-generic-simulators';
import url from 'url';
import os from 'os';
import semver from 'semver';
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';
async function detectUdid (usbmuxdRemoteHost, usbmuxdRemotePort) {
log.debug('Auto-detecting real device udid...');
var options = {
usbmuxdRemoteHost: usbmuxdRemoteHost,
usbmuxdRemotePort: usbmuxdRemotePort
};
const udids = await utilities.getConnectedDevices(null, options);
if (_.isEmpty(udids)) {
throw new Error('No device is connected to the host');
}
const udid = _.last(udids);
if (udids.length > 1) {
log.warn(`Multiple devices found: ${udids.join(', ')}`);
log.warn(`Choosing '${udid}'. If this is wrong, manually set with 'udid' desired capability`);
}
log.debug(`Detected real device udid: '${udid}'`);
return udid;
}
async function getAndCheckXcodeVersion () {
let version;
try {
version = await xcode.getVersion(true);
} catch (err) {
log.debug(err);
log.errorAndThrow(`Could not determine Xcode version: ${err.message}`);
}
// we do not support Xcodes < 7.3,
if (version.versionFloat < 7.3) {
log.errorAndThrow(`Xcode version '${version.versionString}'. Support for ` +
`Xcode ${version.versionString} is not supported. ` +
`Please upgrade to version 7.3 or higher`);
}
return version;
}
async function getAndCheckIosSdkVersion () {
try {
return await xcode.getMaxIOSSDK();
} catch (err) {
log.errorAndThrow(`Could not determine iOS SDK version: ${err.message}`);
}
}
/**
* Get the generic simulator for a given IOS version and device type (iPhone, iPad)
*
* @param {string|number} platformVersion IOS version. e.g.) 13.0
* @param {string} deviceName Type of IOS device. Can be iPhone, iPad (possibly more in the future)
*
* @returns {string} Generic iPhone or iPad simulator (if applicable)
*/
function getGenericSimulatorForIosVersion (platformVersion, deviceName) {
let genericSimulators = iosGenericSimulators[deviceName];
if (genericSimulators) {
genericSimulators = genericSimulators.sort(([simOne], [simTwo]) => util.compareVersions(simOne, '<', simTwo) ? -1 : 1);
// Find the highest iOS version in the list that is below the provided version
let genericIosSimulator;
for (const [platformVersionFromList, iosSimulator] of genericSimulators) {
if (util.compareVersions(platformVersionFromList, '>', platformVersion)) {
break;
}
genericIosSimulator = iosSimulator;
}
return genericIosSimulator;
}
}
function translateDeviceName (platformVersion, deviceName = '') {
const deviceNameTranslated = getGenericSimulatorForIosVersion(platformVersion, deviceName.toLowerCase().trim());
if (deviceNameTranslated) {
log.debug(`Changing deviceName from '${deviceName}' to '${deviceNameTranslated}'`);
return deviceNameTranslated;
}
return deviceName;
}
// 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();
async function markSystemFilesForCleanup (wda) {
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;
if (derivedDataCleanupMarkers.has(logsRoot)) {
markersCount = derivedDataCleanupMarkers.get(logsRoot);
}
derivedDataCleanupMarkers.set(logsRoot, ++markersCount);
}
async function clearSystemFiles (wda) {
// 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');
if (derivedDataCleanupMarkers.has(logsRoot)) {
let markersCount = derivedDataCleanupMarkers.get(logsRoot);
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
for (const dstFolder of dstFolders) {
let scheduledFilesCount = 0;
B.resolve(fs.walkDir(dstFolder, true, (itemPath, isDir) => {
if (isDir) {
return;
}
const fileName = path.basename(itemPath);
if (!XCTEST_LOG_FILES_PATTERNS.some((p) => p.test(fileName))) {
return;
}
// delete the file asynchronously
fs.unlink(itemPath).catch((e) => {
log.info(e.message);
});
scheduledFilesCount++;
})).finally(() => {
if (scheduledFilesCount > 0) {
log.info(`Scheduled ${scheduledFilesCount} temporary XCTest log ` +
`${util.pluralize('file', scheduledFilesCount)} for cleanup in '${dstFolder}'`);
}
}).catch((e) => {
log.info(e.message);
});
}
log.debug(`Started background XCTest logs cleanup in '${dstFolders}'`);
}
if (await fs.exists(logsRoot)) {
log.info(`Cleaning test logs in '${logsRoot}' folder`);
await iosUtils.clearLogs([logsRoot]);
return;
}
log.info(`There is no ${logsRoot} folder, so not cleaning files`);
}
async function checkAppPresent (app) {
log.debug(`Checking whether app '${app}' is actually present on file system`);
if (!(await fs.exists(app))) {
log.errorAndThrow(`Could not find app at '${app}'`);
}
log.debug('App is present');
}
async function getDriverInfo () {
const stat = await fs.stat(path.resolve(__dirname, '..'));
const built = stat.mtime.getTime();
// get the package.json and the version from it
const pkg = require(__filename.includes('build/lib/utils') ? '../../package.json' : '../package.json');
const version = pkg.version;
return {
built,
version,
};
}
function normalizeCommandTimeouts (value) {
// The value is normalized already
if (typeof value !== 'string') {
return value;
}
let result = {};
// Use as default timeout for all commands if a single integer value is provided
if (!isNaN(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 (err) {
log.errorAndThrow(`"commandTimeouts" capability should be a valid JSON object. "${value}" was given instead`);
}
for (let [cmd, timeout] of _.toPairs(result)) {
if (!_.isInteger(timeout) || timeout <= 0) {
log.errorAndThrow(`The timeout for "${cmd}" should be a valid natural number of milliseconds. "${timeout}" was given instead`);
}
}
return result;
}
async function printUser () {
try {
let {stdout} = await exec('whoami');
log.debug(`Current user: '${stdout.trim()}'`);
} catch (err) {
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 {string|number} port - The port number.
* @param {?Function} 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 {Array<string>} - the list of matched process ids.
*/
async function getPIDsListeningOnPort (port, filteringFunc = null) {
const result = [];
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 (e) {
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 {?string} method - The http multipart upload method name. The 'PUT' one is used by default.
* Only works if `remotePath` is provided.
*/
/**
* 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 {string} localPath - The path to an existing local file
* @param {?string} remotePath - The path to the remote location, where
* this file should be uploaded
* @param {?UploadOptions} uploadOptions - Set of upload options
* @returns {string} Either an empty string if the upload was successful or
* base64-encoded file representation if `remotePath` is falsy
*/
async function encodeBase64OrUpload (localPath, remotePath = null, uploadOptions = {}) {
if (!await fs.exists(localPath)) {
log.errorAndThrow(`The file at '${localPath}' does not exist or is not accessible`);
}
const {size} = await fs.stat(localPath);
log.debug(`The size of the file is ${util.toReadableSizeString(size)}`);
if (_.isEmpty(remotePath)) {
return (await util.toInMemoryBase64(localPath)).toString();
}
const {
user,
pass,
method,
} = uploadOptions;
const remoteUrl = url.parse(remotePath);
let options = {};
if (remoteUrl.protocol.startsWith('http')) {
options = {
url: remoteUrl.href,
method: method || 'PUT',
multipart: [{ body: fs.createReadStream(localPath) }],
};
if (user && pass) {
options.auth = {user, pass};
}
} else if (remoteUrl.protocol === 'ftp:') {
options = {
host: remoteUrl.hostname,
port: remoteUrl.port || 21,
};
if (user && pass) {
options.user = user;
options.pass = pass;
}
}
await net.uploadFile(localPath, remotePath, options);
return '';
}
/**
* Stops and removes all web socket handlers that are listening
* in scope of the currect session.
*
* @param {Object} server - The instance of NodeJs HTTP server,
* which hosts Appium
* @param {string} sessionId - The id of the current session
*/
async function removeAllSessionWebSocketHandlers (server, sessionId) {
if (!server || !_.isFunction(server.getWebSocketHandlers)) {
return;
}
const activeHandlers = await server.getWebSocketHandlers(sessionId);
for (const pathname of _.keys(activeHandlers)) {
await server.removeWebSocketHandler(pathname);
}
}
/**
* @typedef {Object} PlatformOpts
*
* @property {boolean} isSimulator - Whether the destination platform is a Simulator
* @property {boolean} isTvOS - Whether the destination platform is a Simulator
*/
/**
* Verify whether the given application is compatible to the
* platform where it is going to be installed and tested.
*
* @param {string} app - The actual path to the application bundle
* @param {PlatformOpts} expectedPlatform
* @throws {Error} If bundle architecture does not match the expected device architecture.
*/
async function verifyApplicationPlatform (app, expectedPlatform) {
log.debug('Verifying application platform');
const infoPlist = path.resolve(app, 'Info.plist');
if (!await fs.exists(infoPlist)) {
log.debug(`'${infoPlist}' does not exist`);
return;
}
const {CFBundleSupportedPlatforms} = await plist.parsePlistFile(infoPlist);
log.debug(`CFBundleSupportedPlatforms: ${JSON.stringify(CFBundleSupportedPlatforms)}`);
if (!_.isArray(CFBundleSupportedPlatforms)) {
log.debug(`CFBundleSupportedPlatforms key does not exist in '${infoPlist}'`);
return;
}
const {
isSimulator,
isTvOS,
} = expectedPlatform;
const prefix = isTvOS ? 'AppleTV' : 'iPhone';
const suffix = isSimulator ? 'Simulator' : 'OS';
const dstPlatform = `${prefix}${suffix}`;
if (!CFBundleSupportedPlatforms.includes(dstPlatform)) {
throw new Error(`${isSimulator ? 'Simulator' : 'Real device'} architecture is unsupported by the '${app}' application. ` +
`Make sure the correct deployment target has been selected for its compilation in Xcode.`);
}
}
/**
* Returns true if the urlString is localhost
* @param {?string} urlString
* @returns {boolean} Return true if the urlString is localhost
*/
function isLocalHost (urlString) {
try {
const {hostname} = url.parse(urlString);
return ['localhost', '127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(hostname);
} catch (ign) {
log.warn(`'${urlString}' cannot be parsed as a valid URL`);
}
return false;
}
/**
* Normalizes platformVersion to a valid iOS version string
*
* @param {string} originalVersion - Loose version number, that can be parsed by semver
* @return {string} iOS version number in <major>.<minor> format
* @throws {Error} if the version number cannot be parsed
*/
function normalizePlatformVersion (originalVersion) {
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}`;
}
export { detectUdid, getAndCheckXcodeVersion, getAndCheckIosSdkVersion, getGenericSimulatorForIosVersion,
checkAppPresent, getDriverInfo,
clearSystemFiles, translateDeviceName, normalizeCommandTimeouts,
DEFAULT_TIMEOUT_KEY, markSystemFilesForCleanup, printUser,
getPIDsListeningOnPort, encodeBase64OrUpload, removeAllSessionWebSocketHandlers,
verifyApplicationPlatform, isLocalHost, normalizePlatformVersion };