kuben-appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
498 lines (451 loc) • 17.8 kB
JavaScript
import B from 'bluebird';
import { fs, util, net, plist } from 'appium-support';
import path from 'path';
import { utils as iosUtils } from 'kuben-appium-ios-driver';
import { SubProcess, exec } from 'teen_process';
import xcode from 'kuben-appium-xcode';
import _ from 'lodash';
import log from './logger';
import _fs from 'fs';
import url from 'url';
import v8 from 'v8';
const DEFAULT_TIMEOUT_KEY = 'default';
async function detectUdid () {
log.debug('Auto-detecting real device udid...');
let cmd, args = [];
try {
cmd = await fs.which('idevice_id');
args.push('-l');
log.debug('Using idevice_id');
} catch (err) {
log.debug('Using udidetect');
cmd = require.resolve('udidetect');
}
let udid;
try {
let {stdout} = await exec(cmd, args, {timeout: 3000});
let udids = _.uniq(_.filter(stdout.split('\n'), Boolean));
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`);
}
} catch (err) {
log.errorAndThrow(`Error detecting udid: ${err.message}`);
}
if (!udid || udid.length <= 2) {
throw new Error('Could not detect udid.');
}
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}`);
}
if (!version.toolsVersion) {
try {
version.toolsVersion = await xcode.getCommandLineToolsVersion();
} catch (ign) {}
}
// 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 () {
let versionNumber;
try {
versionNumber = await xcode.getMaxIOSSDK();
} catch (err) {
log.errorAndThrow(`Could not determine iOS SDK version: ${err.message}`);
}
return versionNumber;
}
function translateDeviceName (platformVersion, devName = '') {
let deviceName = devName;
switch (devName.toLowerCase().trim()) {
case 'iphone simulator':
deviceName = 'iPhone 6';
break;
case 'ipad simulator':
// no need to worry about floating point comparison because of the
// nature of the numbers being compared
// iPad Retina is no longer available for ios 10.3
// so we pick another iPad to use as default
deviceName = (parseFloat(platformVersion) < 10.3) ? 'iPad Retina' : 'iPad Air';
break;
}
if (deviceName !== devName) {
log.debug(`Changing deviceName from '${devName}' to '${deviceName}'`);
}
return deviceName;
}
// This map contains derived data attachment folders as keys
// and values are stacks of permssion masks
// It is used to synchronize permissions change
// on shared folders
const derivedDataPermissionsStacks = new Map();
async function adjustWDAAttachmentsPermissions (wda, perms) {
if (!wda || !await wda.retrieveDerivedDataPath()) {
log.warn('No WebDriverAgent derived data available, so unable to set permissions on WDA attachments folder');
return;
}
const attachmentsFolder = path.join(await wda.retrieveDerivedDataPath(), 'Logs/Test/Attachments');
const permsStack = derivedDataPermissionsStacks.get(attachmentsFolder) || [];
if (permsStack.length) {
if (_.last(permsStack) === perms) {
permsStack.push(perms);
log.info(`Not changing permissions of '${attachmentsFolder}' to '${perms}', because they were already set by the other session`);
return;
}
if (permsStack.length > 1) {
permsStack.pop();
log.info(`Not changing permissions of '${attachmentsFolder}' to '${perms}', because the other session does not expect them to be changed`);
return;
}
}
derivedDataPermissionsStacks.set(attachmentsFolder, [perms]);
if (await fs.exists(attachmentsFolder)) {
log.info(`Setting '${perms}' permissions to '${attachmentsFolder}' folder`);
await fs.chmod(attachmentsFolder, perms);
return;
}
log.info(`There is no ${attachmentsFolder} folder, so not changing permissions`);
}
// 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 cleanupCmd = `find -E /private/var/folders ` +
`-regex '.*/Session-WebDriverAgentRunner.*\\.log$|.*/StandardOutputAndStandardError\\.txt$' ` +
`-type f -exec sh -c 'echo "" > "{}"' \\;`;
const cleanupTask = new SubProcess('bash', ['-c', cleanupCmd], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Do not wait for the task to be completed, since it might take a lot of time
// We keep it running after Appium process is killed
await cleanupTask.start(0, true);
log.debug(`Started background XCTest logs cleanup: ${cleanupCmd}`);
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;
}
/**
* Get the process id of the most recent running application
* having the particular command line pattern.
*
* @param {string} pgrepPattern - pgrep-compatible search pattern.
* @return {string} Either a process id or null if no matches were found.
*/
async function getPidUsingPattern (pgrepPattern) {
const args = ['-nif', pgrepPattern];
try {
const {stdout} = await exec('pgrep', args);
const pid = parseInt(stdout, 10);
if (isNaN(pid)) {
log.debug(`Cannot parse process id from 'pgrep ${args.join(' ')}' output: ${stdout}`);
return null;
}
return `${pid}`;
} catch (err) {
log.debug(`'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`);
return null;
}
}
/**
* Kill a process having the particular command line pattern.
* This method tries to send SIGINT, SIGTERM and SIGKILL to the
* matched processes in this order if the process is still running.
*
* @param {string} pgrepPattern - pgrep-compatible search pattern.
*/
async function killAppUsingPattern (pgrepPattern) {
for (const signal of [2, 15, 9]) {
if (!await getPidUsingPattern(pgrepPattern)) {
return;
}
const args = [`-${signal}`, '-if', pgrepPattern];
try {
await exec('pkill', args);
} catch (err) {
log.debug(`pkill ${args.join(' ')} -> ${err.message}`);
}
await B.delay(100);
}
}
/**
* Kills running XCTest processes for the particular device.
*
* @param {string} udid - The device UDID.
* @param {boolean} isSimulator - Equals to true if the current device is a Simulator
* @param {object} opts - Additional options mapping. Possible keys are:
* - {string|number} wdaLocalPort: The number of local port WDA is listening on.
*/
async function resetXCTestProcesses (udid, isSimulator, opts = {}) {
const processPatterns = [`xcodebuild.*${udid}`];
if (opts.wdaLocalPort) {
processPatterns.push(`iproxy ${opts.wdaLocalPort}`);
} else if (!isSimulator) {
processPatterns.push(`iproxy.*${udid}`);
}
if (isSimulator) {
processPatterns.push(`${udid}.*XCTRunner`);
}
log.debug(`Killing running processes '${processPatterns.join(', ')}' for the device ${udid}...`);
for (const pgrepPattern of processPatterns) {
await killAppUsingPattern(pgrepPattern);
}
}
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}`);
}
}
async function printLibimobiledeviceInfo () {
try {
let {stdout} = await exec('brew', ['info', 'libimobiledevice']);
let match = /libimobiledevice:(.+)/.exec(stdout);
if (match && match[1]) {
log.debug(`Current version of libimobiledevice: ${match[1].trim()}`);
}
} catch (err) {
log.debug(`Unable to get version of libimobiledevice: ${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} localFile - 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 (localFile, remotePath = null, uploadOptions = {}) {
if (!await fs.exists(localFile)) {
log.errorAndThrow(`The file at '${localFile}' does not exist or is not accessible`);
}
const {size} = await fs.stat(localFile);
log.debug(`The size of the file is ${util.toReadableSizeString(size)}`);
if (_.isEmpty(remotePath)) {
const maxMemoryLimit = v8.getHeapStatistics().total_available_size / 2;
if (size >= maxMemoryLimit) {
log.info(`The file might be too large to fit into the process memory ` +
`(${util.toReadableSizeString(size)} >= ${util.toReadableSizeString(maxMemoryLimit)}). ` +
`Provide a link to a remote writable location for video upload ` +
`(http(s) and ftp protocols are supported) if you experience Out Of Memory errors`);
}
const content = await fs.readFile(localFile);
return content.toString('base64');
}
const remoteUrl = url.parse(remotePath);
let options = {};
const {user, pass, method} = uploadOptions;
if (remoteUrl.protocol.startsWith('http')) {
options = {
url: remoteUrl.href,
method: method || 'PUT',
multipart: [{ body: _fs.createReadStream(localFile) }],
};
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(localFile, 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);
}
}
/**
* 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 {boolean} isSimulator - Should be set to `true` if the test will be executed on Simulator
* @returns {?boolean} The function returns `null` if the application does not exist or there is no
* `CFBundleSupportedPlatforms` key in its Info.plist manifest.
* `true` is returned if the bundle architecture matches the device architecture.
* @throws {Error} If bundle architecture does not match the device architecture.
*/
async function verifyApplicationPlatform (app, isSimulator) {
log.debug('Verifying application platform');
const infoPlist = path.resolve(app, 'Info.plist');
if (!await fs.exists(infoPlist)) {
log.debug(`'${infoPlist}' does not exist`);
return null;
}
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 null;
}
const isAppSupported = (isSimulator && CFBundleSupportedPlatforms.includes('iPhoneSimulator'))
|| (!isSimulator && CFBundleSupportedPlatforms.includes('iPhoneOS'));
if (isAppSupported) {
return true;
}
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.`);
}
export { detectUdid, getAndCheckXcodeVersion, getAndCheckIosSdkVersion,
adjustWDAAttachmentsPermissions, checkAppPresent, getDriverInfo,
clearSystemFiles, translateDeviceName, normalizeCommandTimeouts,
DEFAULT_TIMEOUT_KEY, resetXCTestProcesses, getPidUsingPattern,
markSystemFilesForCleanup, printUser, printLibimobiledeviceInfo,
getPIDsListeningOnPort, encodeBase64OrUpload, removeAllSessionWebSocketHandlers,
verifyApplicationPlatform };