UNPKG

appium-ios-simulator

Version:
572 lines (488 loc) 19.3 kB
import path from 'path'; import * as simctl from 'node-simctl'; import { getPath as getXcodePath } from 'appium-xcode'; import log from './logger'; import { fs } from 'appium-support'; import B from 'bluebird'; import _ from 'lodash'; import { killAllSimulators, safeRimRaf } from './utils.js'; import { asyncmap, waitForCondition, retryInterval, retry } from 'asyncbox'; import * as settings from './settings'; import { exec } from 'teen_process'; import xcode from 'appium-xcode'; import { tailUntil } from './tail-until.js'; import extensions from './extensions/index'; class SimulatorXcode6 { constructor (udid, xcodeVersion) { this.udid = String(udid); this.xcodeVersion = xcodeVersion; // platformVersion cannot be found initially, since getting it has side effects for // our logic for figuring out if a sim has been run // it will be set when it is needed this._platformVersion = null; this.keychainPath = path.resolve(this.getDir(), 'Library', 'Keychains'); this.simulatorApp = 'iOS Simulator.app'; this.appDataBundlePaths = {}; } async getPlatformVersion () { if (!this._platformVersion) { let {sdk} = await this.stat(); this._platformVersion = sdk; } return this._platformVersion; } getRootDir () { let home = process.env.HOME; return path.resolve(home, 'Library', 'Developer', 'CoreSimulator', 'Devices'); } getDir () { return path.resolve(this.getRootDir(), this.udid, 'data'); } getLogDir () { let home = process.env.HOME; return path.resolve(home, 'Library', 'Logs', 'CoreSimulator', this.udid); } /* * takes an `id`, which is either a bundleId (e.g., com.apple.mobilesafari) * or, for iOS 7.1, the app name without `.app` (e.g., MobileSafari) */ async getAppDir (id, subDir = 'Data') { if (await this.isFresh()) { log.info('Attempted to get an app path from a fresh simulator ' + 'quickly launching the sim to populate its directories'); await this.launchAndQuit(); } if (!this.appDataBundlePaths[subDir]) { this.appDataBundlePaths[subDir] = await this.buildBundlePathMap(subDir); } return this.appDataBundlePaths[subDir][id]; } /* * the xcode 6 simulators are really annoying, and bury the main app * directories inside directories just named with Hashes. * This function finds the proper directory by traversing all of them * and reading a metadata plist (Mobile Container Manager) to get the * bundle id. */ async buildBundlePathMap (subDir = 'Data') { let applicationList; let pathBundlePair; if (await this.getPlatformVersion() === '7.1') { // apps available // Web.app, // WebViewService.app, // MobileSafari.app, // WebContentAnalysisUI.app, // DDActionsService.app, // StoreKitUIService.app applicationList = path.resolve(this.getDir(), 'Applications'); pathBundlePair = async (dir) => { dir = path.resolve(applicationList, dir); let appFiles = await fs.glob(`${dir}/*.app`); let bundleId = appFiles[0].match(/.*\/(.*)\.app/)[1]; return {path: dir, bundleId}; }; } else { applicationList = path.resolve(this.getDir(), 'Containers', subDir, 'Application'); // given a directory, find the plist file and pull the bundle id from it let readBundleId = async (dir) => { let plist = path.resolve(dir, '.com.apple.mobile_container_manager.metadata.plist'); let metadata = await settings.read(plist); return metadata.MCMMetadataIdentifier; }; // given a directory, return the path and bundle id associated with it pathBundlePair = async (dir) => { dir = path.resolve(applicationList, dir); let bundleId = await readBundleId(dir); return {path: dir, bundleId}; }; } let bundlePathDirs = await fs.readdir(applicationList); let bundlePathPairs = await asyncmap(bundlePathDirs, async (dir) => { return await pathBundlePair(dir); }, false); // reduce the list of path-bundle pairs to an object where bundleIds are mapped to paths return bundlePathPairs.reduce((bundleMap, bundlePath) => { bundleMap[bundlePath.bundleId] = bundlePath.path; return bundleMap; }, {}); } // returns state and specifics of this sim. // { name: 'iPhone 4s', // udid: 'C09B34E5-7DCB-442E-B79C-AB6BC0357417', // state: 'Shutdown', // sdk: '8.3' // } async stat () { let devices = await simctl.getDevices(); let normalizedDevices = []; // add sdk attribute to all entries, add to normalizedDevices for (let [sdk, deviceArr] of _.pairs(devices)) { deviceArr = deviceArr.map((device) => { device.sdk = sdk; return device; }); normalizedDevices = normalizedDevices.concat(deviceArr); } return _.findWhere(normalizedDevices, {udid: this.udid}); } async isFresh () { // this is a best-bet heuristic for whether or not a sim has been booted // before. We usually want to start a simulator to "warm" it up, have // Xcode populate it with plists for us to manipulate before a real // test run. // if the following files don't exist, it hasn't been booted. // THIS IS NOT AN EXHAUSTIVE LIST let files = [ 'Library/ConfigurationProfiles', 'Library/Cookies', 'Library/Logs', 'Library/Preferences/.GlobalPreferences.plist', 'Library/Preferences/com.apple.springboard.plist', 'var/run/syslog.pid' ]; let pv = await this.getPlatformVersion(); if (pv !== '7.1') { files.push('Library/Preferences/com.apple.Preferences.plist'); } else { files.push('Applications'); } files = files.map((s) => { return path.resolve(this.getDir(), s); }); let existences = await asyncmap(files, async (f) => { return fs.hasAccess(f); }); for (let i = 0; i < files.length; i++) { if (!existences[i]) { log.debug(`File ${files[i]} not yet found`); } } return _.compact(existences).length !== files.length; } async isRunning () { let stat = await this.stat(); return stat.state === 'Booted'; } async getBootedIndicatorString () { let indicator; let platformVersion = await this.getPlatformVersion(); switch (platformVersion) { case '7.1': case '8.1': case '8.2': case '8.3': case '8.4': indicator = 'profiled: Service starting...'; break; case '9.0': case '9.1': case '9.2': indicator = 'System app "com.apple.springboard" finished startup'; break; default: log.warn(`No boot indicator case for platform version '${platformVersion}'`); indicator = 'no boot indicator string available'; } return indicator; } async run () { const OPEN_TIMEOUT = 3000; const STARTUP_TIMEOUT = 60 * 1000; const EXTRA_STARTUP_TIME = 2000; // start simulator let simulatorApp = path.resolve(await getXcodePath(), 'Applications', this.simulatorApp); let args = [simulatorApp, '--args', '-CurrentDeviceUDID', this.udid]; log.info(`Starting simulator with command: open ${args.join(' ')}`); let startTime = Date.now(); await exec('open', args, {timeout: OPEN_TIMEOUT}); // wait for the simulator to boot // waiting for the simulator status to be 'booted' isn't good enough // it claims to be booted way before finishing loading // let's tail the simulator system log until we see a magic line (this.bootedIndicator) let bootedIndicator = await this.getBootedIndicatorString(); await this.tailLogsUntil(bootedIndicator, STARTUP_TIMEOUT); // so sorry, but we should wait another two seconds, just to make sure we've really started // we can't look for another magic log line, because they seem to be app-dependent (not system dependent) log.debug(`Waiting and extra ${EXTRA_STARTUP_TIME}ms for the simulator to really finish booting`); await B.delay(EXTRA_STARTUP_TIME); log.debug('Done waiting extra time for simulator'); log.info(`Simulator booted in ${Date.now() - startTime}ms`); } // TODO keep keychains async clean () { await this.endSimulatorDaemon(); log.info(`Cleaning simulator ${this.udid}`); await simctl.eraseDevice(this.udid); } async cleanCustomApp (appFile, appBundleId) { log.debug(`Cleaning app data files for '${appFile}', '${appBundleId}'`); let appDirs = await this.getAppDirs(appFile, appBundleId); if (appDirs.length === 0) { log.debug("Could not find app directories to delete. It is probably not installed"); return; } let deletePromises = []; for (let dir of appDirs) { log.debug(`Deleting directory: '${dir}'`); deletePromises.push(fs.rimraf(dir)); } if (await this.getPlatformVersion() >= 8) { let relRmPath = `Library/Preferences/${appBundleId}.plist`; let rmPath = path.resolve(this.getRootDir(), relRmPath); log.debug(`Deleting file: '${rmPath}'`); deletePromises.push(fs.rimraf(rmPath)); } await B.all(deletePromises); } async getAppDirs (appFile, appBundleId) { let dirs = []; // iOS 8+ stores app data in two places, // iOS 7.1 has only one directory if (await this.getPlatformVersion() >= 8) { let data = await this.getAppDir(appBundleId); let bundle = await this.getAppDir(appBundleId, 'Bundle'); if (data) { dirs.push(data); } if (bundle) { dirs.push(bundle); } } else { let data = await this.getAppDir(appFile); if (data) { dirs.push(data); } } return dirs; } async launchAndQuit (safari = false) { log.debug('Attempting to launch and quit the simulator, to create directory structure'); log.debug(`Will launch with Safari? ${safari}`); await this.run(); if (safari) { await this.openUrl('http://www.appium.io'); } // wait for the system to create the files we will manipulate // need quite a high retry number, in order to accommodate iOS 7.1 // locally, 7.1 averages 8.5 retries (from 6 - 12) // 8 averages 0.6 retries (from 0 - 2) // 9 averages 14 retries await retryInterval(20, 250, async () => { if (await this.isFresh()) { let msg = 'Simulator files not fully created. Waiting a bit'; log.debug(msg); throw new Error(msg); } }); // and quit await this.shutdown(); } async endSimulatorDaemon () { // Looks for launchd daemons corresponding to the sim udid and tries to stop them cleanly // This prevents xcrun simctl erase hangs. log.debug(`Killing any simulator daemons for ${this.udid}`); let launchctlCmd = `launchctl list | grep ${this.udid} | cut -f 3 | xargs -n 1 launchctl`; try { let stopCmd = `${launchctlCmd} stop`; await exec('bash', ['-c', stopCmd]); } catch (err) { log.warn(`Could not stop simulator daemons: ${err.message}`); log.debug('Carrying on anyway!'); } try { let removeCmd = `${launchctlCmd} remove`; await exec('bash', ['-c', removeCmd]); } catch (err) { log.warn(`Could not remove simulator daemons: ${err.message}`); log.debug('Carrying on anyway!'); } try { // Waits 10 sec for the simulator launchd services to stop. await waitForCondition(async () => { let {stdout} = await exec('bash', ['-c', `ps -e | grep ${this.udid} | grep launchd_sim | grep -v bash | grep -v grep | awk {'print$1'}`]); return stdout.trim().length === 0; }, {waitMs: 10000, intervalMs: 500}); } catch (err) { log.warn(`Could not end simulator daemon for ${this.udid}: ${err.message}`); log.debug('Carrying on anyway!'); } } async shutdown () { await killAllSimulators(); } async delete () { await simctl.deleteDevice(this.udid); } async updateSettings (plist, updates) { return await settings.updateSettings(this, plist, updates); } async updateLocationSettings (bundleId, authorized) { return await settings.updateLocationSettings(this, bundleId, authorized); } async updateSafariSettings (updates) { await settings.updateSafariUserSettings(this, updates); await settings.updateSettings(this, 'mobileSafari', updates); } async updateLocale (language, locale, calendarFormat) { return await settings.updateLocale(this, language, locale, calendarFormat); } async deleteSafari () { log.debug('Deleting Safari apps from simulator'); let dirs = []; // get the data directory dirs.push(await this.getAppDir('com.apple.mobilesafari')); let pv = await this.getPlatformVersion(); if (pv >= 8) { // get the bundle directory dirs.push(await this.getAppDir('com.apple.mobilesafari', 'Bundle')); } let deletePromises = []; for (let dir of dirs) { log.debug(`Deleting directory: '${dir}'`); deletePromises.push(fs.rimraf(dir)); } await B.all(deletePromises); } async cleanSafari (keepPrefs = true) { log.debug('Cleaning mobile safari data files'); if (this.isFresh()) { log.info('Could not find Safari support directories to clean out old ' + 'data. Probably there is nothing to clean out'); return; } let libraryDir = path.resolve(this.getDir(), 'Library'); let safariLibraryDir = path.resolve(await this.getAppDir('com.apple.mobilesafari'), 'Library'); let filesToDelete = [ 'Caches/Snapshots/com.apple.mobilesafari', 'Caches/com.apple.mobilesafari/Cache.db*', 'Caches/com.apple.WebAppCache/*.db', 'Safari', 'WebKit/LocalStorage/*.*', 'WebKit/GeolocationSites.plist', 'Cookies/*.binarycookies' ]; let deletePromises = []; for (let file of filesToDelete) { deletePromises.push(fs.rimraf(path.resolve(libraryDir, file))); deletePromises.push(fs.rimraf(path.resolve(safariLibraryDir, file))); } if (!keepPrefs) { deletePromises.push(fs.rimraf(path.resolve(safariLibraryDir, 'Preferences/*.plist'))); } await B.all(deletePromises); } async moveBuiltInApp (appName, appPath, newAppPath) { await safeRimRaf(newAppPath); await fs.copyFile(appPath, newAppPath); log.debug(`Copied '${appName}' to '${newAppPath}'`); await fs.rimraf(appPath); log.debug(`Temporarily deleted original app at '${appPath}'`); return [newAppPath, appPath]; } async openUrl (url) { const SAFARI_BOOTED_INDICATOR = 'MobileSafari['; const SAFARI_STARTUP_TIMEOUT = 15 * 1000; const EXTRA_STARTUP_TIME = 3 * 1000; if (await this.isRunning()) { await retry(5000, simctl.openUrl, this.udid, url); await this.tailLogsUntil(SAFARI_BOOTED_INDICATOR, SAFARI_STARTUP_TIMEOUT); // So sorry, but the logs have nothing else for Safari starting.. just delay a little bit log.debug(`Safari started, waiting ${EXTRA_STARTUP_TIME}ms for it to fully start`); await B.delay(EXTRA_STARTUP_TIME); log.debug('Done waiting for Safari'); return; } else { throw new Error('Tried to open a url, but the Simulator is not Booted'); } } // returns a promise that resolves when the ios simulator logs output a line matching `bootedIndicator` // times out after timeoutMs async tailLogsUntil (bootedIndicator, timeoutMs) { let simLog = path.resolve(this.getLogDir(), 'system.log'); // we need to make sure log file exists before we can tail it await retryInterval(200, 2000, async () => { if (!await fs.exists(simLog)) { throw new Error('Could not find Simulator log'); } }); log.info(`Tailing simulator logs until we encounter the string "${bootedIndicator}"`); log.info(`We will time out after ${timeoutMs}ms`); try { await tailUntil(simLog, bootedIndicator, timeoutMs); } catch (err) { log.debug('Simulator startup timed out. Continuing anyway.'); } } static async _getDeviceStringPlatformVersion (platformVersion) { let reqVersion = platformVersion; if (!reqVersion) { reqVersion = await xcode.getMaxIOSSDK(); // this will be a number, and possibly an integer (e.g., if max iOS SDK is 9) // so turn it into a string and add a .0 if necessary if (!_.isString(reqVersion)) { reqVersion = (reqVersion % 1) ? String(reqVersion) : `${reqVersion}.0`; } } return reqVersion; } // change the format in subclasses, as necessary static async _getDeviceStringVersionString (platformVersion) { let reqVersion = await this._getDeviceStringPlatformVersion(platformVersion); return `(${reqVersion} Simulator)`; } // change the format in subclasses, as necessary static _getDeviceStringConfigFix () { // some devices need to be updated return { 'iPad Simulator (7.1 Simulator)': 'iPad 2 (7.1 Simulator)', 'iPad Simulator (8.0 Simulator)': 'iPad 2 (8.0 Simulator)', 'iPad Simulator (8.1 Simulator)': 'iPad 2 (8.1 Simulator)', 'iPad Simulator (8.2 Simulator)': 'iPad 2 (8.2 Simulator)', 'iPad Simulator (8.3 Simulator)': 'iPad 2 (8.3 Simulator)', 'iPad Simulator (8.4 Simulator)': 'iPad 2 (8.4 Simulator)', 'iPhone Simulator (7.1 Simulator)': 'iPhone 5s (7.1 Simulator)', 'iPhone Simulator (8.4 Simulator)': 'iPhone 6 (8.4 Simulator)', 'iPhone Simulator (8.3 Simulator)': 'iPhone 6 (8.3 Simulator)', 'iPhone Simulator (8.2 Simulator)': 'iPhone 6 (8.2 Simulator)', 'iPhone Simulator (8.1 Simulator)': 'iPhone 6 (8.1 Simulator)', 'iPhone Simulator (8.0 Simulator)': 'iPhone 6 (8.0 Simulator)' }; } static async getDeviceString (opts) { opts = Object.assign({}, { deviceName: null, platformVersion: null, forceIphone: false, forceIpad: false }, opts); log.debug(`Getting device string: ${JSON.stringify(opts)}`); // short circuit if we already have a device name if ((opts.deviceName || '')[0] === '=') { return opts.deviceName.substring(1); } let isiPhone = !!opts.forceIphone || !opts.forceIpad; if (opts.deviceName) { let device = opts.deviceName.toLowerCase(); if (device.indexOf('iphone') !== -1) { isiPhone = true; } else if (device.indexOf('ipad') !== -1) { isiPhone = false; } } let iosDeviceString = opts.deviceName || (isiPhone ? 'iPhone Simulator' : 'iPad Simulator'); iosDeviceString += ` ${await this._getDeviceStringVersionString(opts.platformVersion)}`; let CONFIG_FIX = this._getDeviceStringConfigFix(); let configFix = CONFIG_FIX; if (configFix[iosDeviceString]) { iosDeviceString = configFix[iosDeviceString]; log.debug(`Fixing device. Changed from '${opts.deviceName}' `+ `to '${iosDeviceString}'`); } log.debug(`Final device string is '${iosDeviceString}'`); return iosDeviceString; } } for (let [cmd, fn] of _.pairs(extensions)) { SimulatorXcode6.prototype[cmd] = fn; } export default SimulatorXcode6;