Source: src/device.js

'use strict';

const
	ps = require('ps-list'),
	output = require('./output.js'),
	childProcess = require('child_process');

/**
 * @class Device_Helper
 * @desc
 * Devices handling class, used to make shortcuts around the launching and
 * shutting down of emulators. Also has functions for finding iOS certificates
 * and provisioning profiles.
 */
class Device_Helper {
	/**
	 * Kill the iOS simulator using it's UDID, and then repeatedly check whether
	 * it has fully shut down
	 *
	 * @param {String} simName - The name of the iOS device to find
	 * @param {String} simVersion - The version of the iOS device to find
	 * @param {Object} opts - Optional arguments
	 * @param {Int} opts.initialWait - How long to wait for the first boot check
	 * @param {Int} opts.intervalWait - How long to wait between each check following the first
	 */
	static async killSim(simName, simVersion, { initialWait = 10000, intervalWait = 5000 } = {}) {
		if (!simName) { throw new Error('Empty simulator name argument passed'); }
		if (!simVersion) { throw new Error('Empty simulator version passed'); }

		output.debug(`Shutting Down the iOS Simulator: ${simName} (${simVersion})`);

		const udid = getUdid(simName, simVersion);

		output.debug(`Found UDID for Simulator: ${udid}`);

		childProcess.execSync(`xcrun simctl shutdown ${udid}`);

		await isShutdown(simName, simVersion, initialWait, intervalWait);

		// The simulator process hangs around even after the sim itself is shut down
		childProcess.spawn('killall', [ 'Simulator' ]);
	}

	/**
	 * Get the boot status of an iOS simulator via its UDID
	 *
	 * @param {String} simName - The name of the iOS device to find
	 * @param {String} simVersion - The version of the iOS device to find
	 */
	static getSimState(simName, simVersion) {
		if (!simName) { throw new Error('Empty simulator name argument passed'); }
		if (!simVersion) { throw new Error('Empty simulator version passed'); }

		return getState(simName, simVersion);
	}

	/**
	 * Kill all the Android emulators.
	 *
	 * @param {String} deviceName - The name of the Android device to kill
	 */
	static async killEmu(deviceName) {
		const devicePID = await getAndroidPID(deviceName);

		output.debug(`Found Android emulator PID: ${devicePID}`);

		if (process.platform === 'win32') {
			output.debug('Detected Windows, killing emulator with taskkill command');
			await childProcess.execSync(`taskkill /F /PID ${devicePID}`);
		} else {
			output.debug('Presuming UNIX, killing emulator with kill command');
			await childProcess.execSync(`kill -9 ${devicePID}`);
		}
	}

	/**
	 * Use ioslib to probe the machine for a particular iOS certificate, then
	 * return the certificate object.
	 *
	 * @param {String} type - The type of operation the cert is defined for
	 * @param {String} search - Part of the cert name, used to help locate it
	 */
	static async getCert(type, search) {
		if (process.platform !== 'darwin') {
			return undefined;
		}

		const ioslib = require('ioslib');

		const
			certs = await ioslib.certs.getCerts(),
			subCerts = certs[type],
			valid = [ 'developer', 'distribution' ];

		if (!valid.includes(type)) {
			throw Error(`Argument '${type}' is not a valid type of certificate`);
		}

		let foundCert;

		subCerts.forEach(cert => {
			if (cert.name.includes(search)) {
				foundCert = cert;
			}
		});

		if (!foundCert) {
			throw Error(`No certificate found with a name including '${search}'`);
		}

		return foundCert;
	}

	/**
	 * Use ioslib to probe the machine for a particular iOS provisioning profile,
	 * then return the provisioning profile object.
	 *
	 * @param {String} type - The type of operation the pp is defined for
	 * @param {String} search - Part of the pp name, used to help locate it
	 */
	static async getProfile(type, search) {
		if (process.platform !== 'darwin') {
			return undefined;
		}

		const ioslib = require('ioslib');

		const
			profiles = await ioslib.provisioning.getProvisioningProfiles(),
			subProfiles = profiles[type],
			valid = [ 'adhoc', 'development', 'distribution' ];

		if (!valid.includes(type)) {
			throw Error(`Argument '${type}' is not a valid type of provisioning profile`);
		}

		let foundProfile;

		subProfiles.forEach(profile => {
			if (profile.name === search) {
				foundProfile = profile;
			}
		});

		if (!foundProfile) {
			throw Error(`No provisioning profile found with a name matching '${search}'`);
		}

		return foundProfile;
	}
}

/**
 * Use the status of the simulator to determine whether the device is shutdown. The
 * initial wait value is used because a simulator will sometimes show as shutdown
 * before it really is and then change it's mind back to booted. So we use a long
 * wait to allow it to get past this phase
 * @private
 *
 * @param {String} simName - The name of the iOS device to find
 * @param {String} simVersion - The version of the iOS device to find
 * @param {Int} initialWait - How long to wait for the first boot check
 * @param {Int} intervalWait - How long to wait between each check following the first
 */
function isShutdown(simName, simVersion, initialWait, intervalWait) {
	return new Promise((resolve, reject) => {
		output.debug(`Starting Check for Simulator Shutdown, Waiting ${initialWait}ms for First Check Then Every ${intervalWait}ms`);
		setTimeout(() => {
			let count = 0;

			const interval = setInterval(() => {
				let state = getState(simName, simVersion);

				count++;

				output.debug(`${simName} (${simVersion}) is Currently ${state}`);

				if (state === 'Shutdown') {
					clearInterval(interval);
					return resolve();
				} else if (count >= 20) {
					clearInterval(interval);
					return reject('iOS simulator didn\'t shutdown in expected time, you may expierience instability');
				}
			}, intervalWait);
		}, initialWait);
	});
}

/**
 * Get the boot status of an iOS simulator via its UDID
 * @private
 *
 * @param {String} simName - The name of the iOS device to find
 * @param {String} simVersion - The version of the iOS device to find
 */
function getState(simName, simVersion) {
	const
		versionParts = simVersion.split('.'),
		major = versionParts[0],
		minor = versionParts[1];

	const udid = getUdid(simName, simVersion);

	const xcrunOut = childProcess.execSync('xcrun simctl list --json');

	const xcrunSims = JSON.parse(xcrunOut).devices[`com.apple.CoreSimulator.SimRuntime.iOS-${major}-${minor}`];

	try {
		for (const xcrunSim of xcrunSims) {
			if (xcrunSim.udid === udid) {
				return xcrunSim.state;
			}
		}
	} catch (e) {
		throw new Error(`An issue occured trying to get the boot status of iOS simulator "${simName} (${simVersion})", do you have this simulator configured?`);
	}

	throw new Error(`Cannot retrieve a status for simulator ${simName} (${simVersion})`);
}

/**
 * Get the UDID of an iOS simulator using the Titanium CLI
 * @private
 *
 * @param {String} simName - The name of the iOS device to find
 * @param {String} simVersion - The version of the iOS device to find
 */
function getUdid(simName, simVersion) {
	const tiOut = childProcess.execSync('ti info -t ios -o json');

	const tiSims = JSON.parse(tiOut).ios.simulators.ios[simVersion];

	try {
		for (const tiSim of tiSims) {
			if (tiSim.name === simName) {
				return tiSim.udid;
			}
		}
	} catch (e) {
		throw new Error(`An issue occured trying to find the UDID of iOS simulator "${simName} (${simVersion})", do you have this simulator configured?`);
	}

	throw new Error(`Cannot find a UDID for simulator ${simName} (${simVersion})`);
}

/**
 * Search the running processes on the system, and look for one with the
 * Android device name that we booted for testing.
 * @private
 *
 * @param {String} deviceName - The name of the Android device to find
 */
async function getAndroidPID(deviceName) {
	try {
		const list = await ps();

		let pid;

		if (process.platform === 'win32') {
			const proc = list.find(x => x.name.includes('qemu-system-x86_64'));
			pid = proc.pid;
		} else {
			const proc = list.find(x => x.cmd.includes(deviceName));
			pid = proc.pid;
		}

		return pid;
	} catch (err) {
		throw Error(`Cannot find an Android PID for ${deviceName}`);
	}
}

module.exports = Device_Helper;