UNPKG

appium-ios-simulator

Version:
1,032 lines (922 loc) 37.2 kB
import path from 'path'; import * as simctl from 'node-simctl'; import { default as xcode, getPath as getXcodePath } from 'appium-xcode'; import log from './logger'; import { fs } from 'appium-support'; import B from 'bluebird'; import _ from 'lodash'; import AsyncLock from 'async-lock'; import { killAllSimulators, safeRimRaf } from './utils.js'; import { asyncmap, retryInterval, waitForCondition, retry } from 'asyncbox'; import * as settings from './settings'; import { exec } from 'teen_process'; import { tailUntil } from './tail-until.js'; import extensions from './extensions/index'; import events from 'events'; import Calendar from './calendar'; const { EventEmitter } = events; const STARTUP_TIMEOUT = 60 * 1000; const EXTRA_STARTUP_TIME = 2000; const UI_CLIENT_ACCESS_GUARD = new AsyncLock(); /* * This event is emitted as soon as iOS Simulator * has finished booting and it is ready to accept xcrun commands. * The event handler is called after 'run' method is completed * for Xcode 7 and older and is only useful in Xcode 8+, * since one can start doing stuff (for example install/uninstall an app) in parallel * with Simulator UI startup, which shortens session startup time. */ const BOOT_COMPLETED_EVENT = 'bootCompleted'; class SimulatorXcode6 extends EventEmitter { /** * Constructs the object with the `udid` and version of Xcode. Use the exported `getSimulator(udid)` method instead. * * @param {string} udid - The Simulator ID. * @param {object} xcodeVersion - The target Xcode version in format {major, minor, build}. */ constructor (udid, xcodeVersion) { super(); 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 = {}; // list of files to check for when seeing if a simulator is "fresh" // (meaning it has never been booted). // If these files are present, we assume it's been successfully booted this.isFreshFiles = [ 'Library/ConfigurationProfiles', 'Library/Cookies', 'Library/Preferences/.GlobalPreferences.plist', 'Library/Preferences/com.apple.springboard.plist', 'var/run/syslog.pid' ]; // extra time to wait for simulator to be deemed booted this.extraStartupTime = EXTRA_STARTUP_TIME; this.calendar = new Calendar(this.getDir()); } /** * Check the state of Simulator UI client. * * @return {boolean} True of if UI client is running or false otherwise. */ async isUIClientRunning () { try { await exec('pgrep', ['-x', this.simulatorApp.split('.')[0]]); return true; } catch (err) { return false; } } /** * How long to wait before throwing an error about Simulator startup timeout happened. * * @return {number} The number of milliseconds. */ get startupTimeout () { return STARTUP_TIMEOUT; } /** * Get the platform version of the current Simulator. * * @return {string} SDK version, for example '8.3'. */ async getPlatformVersion () { if (!this._platformVersion) { let {sdk} = await this.stat(); this._platformVersion = sdk; } return this._platformVersion; } /** * Retrieve the full path to the directory where Simulator stuff is located. * * @return {string} The path string. */ getRootDir () { let home = process.env.HOME; return path.resolve(home, 'Library', 'Developer', 'CoreSimulator', 'Devices'); } /** * Retrieve the full path to the directory where Simulator applications data is located. * * @return {string} The path string. */ getDir () { return path.resolve(this.getRootDir(), this.udid, 'data'); } /** * Retrieve the full path to the directory where Simulator logs are stored. * * @return {string} The path string. */ getLogDir () { let home = process.env.HOME; return path.resolve(home, 'Library', 'Logs', 'CoreSimulator', this.udid); } /** * Install valid .app package on Simulator. * * @param {string} app - The path to the .app package. */ async installApp (app) { return await simctl.installApp(this.udid, app); } /** * Verify whether the particular application is installed on Simulator. * * @param {string} bundleId - The bundle id of the application to be checked. * @param {string} appFule - Application name minus ".app" (for iOS 7.1) * @return {boolean} True if the given application is installed */ async isAppInstalled (bundleId, appFile = null) { // `appFile` argument only necessary for iOS below version 8 let appDirs = await this.getAppDirs(appFile, bundleId); return appDirs.length !== 0; } /** * Retrieve the directory for a particular application's data. * * @param {string} id - Either a bundleId (e.g., com.apple.mobilesafari) or, for iOS 7.1, the app name without `.app` (e.g., MobileSafari) * @param {string} subdir - The sub-directory we expect to be within the application directory. Defaults to "Data". * @return {string} The root application folder. */ async getAppDir (id, subDir = 'Data') { this.appDataBundlePaths[subDir] = this.appDataBundlePaths[subDir] || {}; if (_.isEmpty(this.appDataBundlePaths[subDir]) && !await this.isFresh()) { 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. * * @param {string} subdir - The sub-directory we expect to be within the application directory. Defaults to "Data". * @return {object} The list of path-bundle pairs to an object where bundleIds are mapped to paths. */ async buildBundlePathMap (subDir = 'Data') { log.debug('Building bundle path map'); 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; }, {}); } /** * Get the state and specifics of this sim. * * @return {object} Simulator stats mapping, for example: * { 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 _.toPairs(devices)) { deviceArr = deviceArr.map((device) => { device.sdk = sdk; return device; }); normalizedDevices = normalizedDevices.concat(deviceArr); } return _.find(normalizedDevices, {udid: this.udid}); } /** * 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. * * @return {boolean} True if the current Simulator has never been started before */ async isFresh () { // if the following files don't exist, it hasn't been booted. // THIS IS NOT AN EXHAUSTIVE LIST log.debug('Checking whether simulator has been run before'); let files = this.isFreshFiles; 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 await fs.hasAccess(f); }); let fresh = _.compact(existences).length !== files.length; log.debug(`Simulator ${fresh ? 'has not' : 'has'} been run before`); return fresh; } /** * Retrieves the state of the current Simulator. One should distinguish the * states of Simulator UI and the Simulator itself. * * @return {boolean} True if the current Simulator is running. */ async isRunning () { let stat = await this.stat(); return stat.state === 'Booted'; } /** * Verify whether the Simulator booting is completed and/or wait for it * until the timeout expires. * * @param {number} startupTimeout - the number of milliseconds to wait until booting is completed. * @emits BOOT_COMPLETED_EVENT if the current Simulator is ready to accept simctl commands, like 'install'. */ async waitForBoot (startupTimeout) { // 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, startupTimeout); // 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 an extra ${this.extraStartupTime}ms for the simulator to really finish booting`); await B.delay(this.extraStartupTime); log.debug('Done waiting extra time for simulator'); this.emit(BOOT_COMPLETED_EVENT); } /** * Returns a magic string, which, if present in logs, reflects the fact that simulator booting has been completed. * * @return {string} The magic log string. */ 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': case '9.3': indicator = 'System app "com.apple.springboard" finished startup'; break; case '10.0': indicator = 'Switching to keyboard'; break; default: log.warn(`No boot indicator case for platform version '${platformVersion}'`); indicator = 'no boot indicator string available'; } return indicator; } /** * Start the Simulator UI client with the given arguments * * @param {object} opts - One or more of available Simulator UI client options: * - {string} scaleFactor: can be one of ['1.0', '0.75', '0.5', '0.33', '0.25']. * Defines the window scale value for the UI client window for the current Simulator. * Equals to null by default, which keeps the current scale unchanged. * - {boolean} connectHardwareKeyboard: whether to connect the hardware keyboard to the * Simulator UI client. Equals to false by default. * - {number} startupTimeout: number of milliseconds to wait until Simulator booting * process is completed. The default timeout will be used if not set explicitly. */ async startUIClient (opts = {}) { opts = Object.assign({ scaleFactor: null, connectHardwareKeyboard: false, startupTimeout: this.startupTimeout, }, opts); let simulatorApp = path.resolve(await getXcodePath(), 'Applications', this.simulatorApp); let args = ['-Fn', simulatorApp, '--args', '-CurrentDeviceUDID', this.udid]; if (opts.scaleFactor) { const supportedScales = ['1.0', '0.75', '0.5', '0.33', '0.25']; if (supportedScales.indexOf(opts.scaleFactor) < 0) { log.errorAndThrow(`Only "${supportedScales}" values are supported as scale factors. "${opts.scaleFactor}" is passed instead.`); } const stat = await this.stat(); const formattedDeviceName = stat.name.replace(/\s+/g, '-'); const argumentName = `-SimulatorWindowLastScale-com.apple.CoreSimulator.SimDeviceType.${formattedDeviceName}`; args.push(argumentName, opts.scaleFactor); } if (!opts.connectHardwareKeyboard) { args.push('-ConnectHardwareKeyboard', '0'); } log.info(`Starting Simulator UI with command: open ${args.join(' ')}`); await exec('open', args, {timeout: opts.startupTimeout}); } /** * Executes given Simulator with options. The Simulator will not be restarted if * it is already running. * * @param {object} opts - One or more of available Simulator options. * See {#startUIClient(opts)} documentation for more details on other supported keys. */ async run (opts = {}) { opts = Object.assign({ startupTimeout: this.startupTimeout, }, opts); const {state} = await this.stat(); const isServerRunning = state === 'Booted'; const isUIClientRunning = await this.isUIClientRunning(); if (isServerRunning && isUIClientRunning) { log.info(`Both Simulator with UDID ${this.udid} and the UI client are currently running`); return; } const startTime = process.hrtime(); try { await this.shutdown(); } catch (err) { log.warn(`Error on Simulator shutdown: ${err.message}`); } await this.startUIClient(opts); await this.waitForBoot(opts.startupTimeout); log.info(`Simulator with UDID ${this.udid} booted in ${process.hrtime(startTime)[0]} seconds`); } // TODO keep keychains /** * Reset the current Simulator to the clean state. */ async clean () { await this.endSimulatorDaemon(); log.info(`Cleaning simulator ${this.udid}`); await simctl.eraseDevice(this.udid, 10000); } /** * Scrub (delete the preferences and changed files) the particular application on Simulator. * * @param {string} appFile - Application name minus ".app". * @param {string} appBundleId - Bundle identifier of the application. * @return {array} Array of deletion promises. */ async scrubCustomApp (appFile, appBundleId) { return await this.cleanCustomApp (appFile, appBundleId, true); } /** * Clean/scrub the particular application on Simulator. * * @param {string} appFile - Application name minus ".app". * @param {string} appBundleId - Bundle identifier of the application. * @param {boolean} scrub - If `scrub` is false, we want to clean by deleting the app and all * files associated with it. If `scrub` is true, we just want to delete the preferences and * changed files. * @return {array} Array of deletion promises. */ async cleanCustomApp (appFile, appBundleId, scrub = false) { log.debug(`Cleaning app data files for '${appFile}', '${appBundleId}'`); if (!scrub) { log.debug(`Deleting app altogether`); } // get the directories to be deleted let appDirs = await this.getAppDirs(appFile, appBundleId, scrub); 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); } /** * Retrieve paths to dirs where application data is stored. iOS 8+ stores app data in two places, * and iOS 7.1 has only one directory * * @param {string} appFile - Application name minus ".app". * @param {string} appBundleId - Bundle identifier of the application. * @param {boolean} scrub - The `Bundle` directory has the actual app in it. If we are just scrubbing, * we want this to stay. If we are cleaning we delete. * @return {array} Array of application data paths. */ async getAppDirs (appFile, appBundleId, scrub = false) { let dirs = []; if (await this.getPlatformVersion() >= 8) { let data = await this.getAppDir(appBundleId); if (!data) return dirs; // eslint-disable-line curly let bundle = !scrub ? await this.getAppDir(appBundleId, 'Bundle') : undefined; for (let src of [data, bundle]) { if (src) { dirs.push(src); } } } else { let data = await this.getAppDir(appFile); if (data) { dirs.push(data); } } return dirs; } /** * Execute the Simulator in order to have the initial file structure created and shutdown it afterwards. * * @param {boolean} safari - Whether to execute mobile Safari after startup. * @param {number} startupTimeout - How long to wait until Simulator booting is completed (in milliseconds). */ async launchAndQuit (safari = false, startupTimeout = this.startupTimeout) { log.debug('Attempting to launch and quit the simulator, to create directory structure'); log.debug(`Will launch with Safari? ${safari}`); await this.run(startupTimeout); 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(); } /** * Looks for launchd daemons corresponding to the sim udid and tries to stop them cleanly * This prevents xcrun simctl erase from hanging. */ async endSimulatorDaemon () { 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!'); } } /** * Shutdown all the running Simulators and the UI client. */ async shutdown () { await killAllSimulators(); } /** * Delete the particular Simulator from devices list */ async delete () { await simctl.deleteDevice(this.udid); } /** * Update the particular preference file with the given key/value pairs. * * @param {string} plist - The preferences file to update. * @param {object} updates - The key/value pairs to update. */ async updateSettings (plist, updates) { return await settings.updateSettings(this, plist, updates); } /** * Authorize/de-authorize location settings for a particular application. * * @param {string} bundleId - The application ID to update. * @param {boolean} authorized - Whether or not to authorize. */ async updateLocationSettings (bundleId, authorized) { return await settings.updateLocationSettings(this, bundleId, authorized); } /** * Update settings for Safari. * * @param {object} updates - The hash of key/value pairs to update for Safari. */ async updateSafariSettings (updates) { await settings.updateSafariUserSettings(this, updates); await settings.updateSettings(this, 'mobileSafari', updates); } /** * Update the locale for the Simulator. * * @param {string} language - The language for the simulator. E.g., `"fr_US"`. * @param {string} locale - The locale to set for the simulator. E.g., `"en"`. * @param {string} calendarFormat - The format of the calendar. */ async updateLocale (language, locale, calendarFormat) { return await settings.updateLocale(this, language, locale, calendarFormat); } /** * Completely delete mobile Safari application from the current Simulator. */ 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 _.compact(dirs)) { log.debug(`Deleting directory: '${dir}'`); deletePromises.push(fs.rimraf(dir)); } await B.all(deletePromises); } /** * Clean up the directories for mobile Safari. * * @param {boolean} keepPrefs - Whether to keep Safari preferences from being deleted. */ async cleanSafari (keepPrefs = true) { log.debug('Cleaning mobile safari data files'); if (await 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 safariRoot = await this.getAppDir('com.apple.mobilesafari'); if (!safariRoot) { log.info('Could not find Safari support directories to clean out old ' + 'data. Probably there is nothing to clean out'); return; } let safariLibraryDir = path.resolve(safariRoot, 'Library'); let filesToDelete = [ 'Caches/Snapshots/com.apple.mobilesafari', 'Caches/com.apple.mobilesafari/*', 'Caches/com.apple.WebAppCache/*', 'Caches/com.apple.WebKit.Networking/*', 'Caches/com.apple.WebKit.WebContent/*', 'Image Cache/*', 'WebKit/com.apple.mobilesafari/*', 'WebKit/GeolocationSites.plist', 'WebKit/LocalStorage/*.*', 'Safari/*', 'Cookies/*.binarycookies', 'Caches/com.apple.UIStatusBar/*', 'Caches/com.apple.keyboards/images/*', 'Caches/com.apple.Safari.SafeBrowsing/*', '../tmp/com.apple.mobilesafari/*' ]; 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); } /** * Uninstall the given application from the current Simulator. * * @param {string} bundleId - The buindle ID of the application to be removed. */ async removeApp (bundleId) { await simctl.removeApp(this.udid, bundleId); } /** * Move a built-in application to a new place (actually, rename it). * * @param {string} appName - The name of the app to be moved. * @param {string} appPath - The current path to the application. * @param {string} newAppPath - The new path to the application. * If some application already exists by this path then it's going to be removed. */ 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]; } /** * Open the given URL in mobile Safari browser. * The browser will be started automatically if it is not running. * * @param {string} url - The URL to be opened. */ 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'); } } /** * Blocks until the given indicater string appears in Simulator logs. * * @param {string} bootedIndicator - The magic string, which appears in logs after Simulator booting is completed. * @param {number} timeoutMs - The maximumm number of milliseconds to wait for the string indicator presence. * @returns {Promise} 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, 200, async () => { let exists = await fs.exists(simLog); if (!exists) { throw new Error(`Could not find Simulator log: '${simLog}'`); } }); log.info(`Simulator log at '${simLog}'`); 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.'); } } /** * Enable Calendar access for the given application. * * @param {string} bundleID - Bundle ID of the application, for which the access should be granted. */ async enableCalendarAccess (bundleID) { await this.calendar.enableCalendarAccess(bundleID); } /** * Disable Calendar access for the given application. * * @param {string} bundleID - Bundle ID of the application, for which the access should be denied. */ async disableCalendarAccess (bundleID) { await this.calendar.disableCalendarAccess(bundleID); } /** * Check whether the given application has access to Calendar. * * @return {boolean} True if the given application has the access. */ async hasCalendarAccess (bundleID) { return await this.calendar.hasCalendarAccess(bundleID); } /** * Execute given Apple Script inside a critical section, so other * sessions cannot influence the UI client at the same time. * * @param {string} appleScript - The valid Apple Script snippet to be executed. * @return {string} The stdout output produced by the script. * @throws {Error} If osascript tool returns non-zero exit code. */ async executeUIClientScript (appleScript) { return await UI_CLIENT_ACCESS_GUARD.acquire(this.simulatorApp, async () => { try { const {stdout} = await exec('osascript', ['-e', appleScript]); return stdout; } catch (err) { log.errorAndThrow(`Make sure Simularor UI is running and the parent Appium application (e. g. Appium.app or Terminal.app) ` + `is present in System Preferences > Security & Privacy > Privacy > Accessibility list. ` + `Original error: ${err.message}`); } }); } /** * Get the current state of Touch ID Enrollment feature. * * @return {boolean} True if Touch ID Enrollment menu item is checked in Simulator menu */ async isTouchIDEnrolled () { const output = await this.executeUIClientScript(` tell application "System Events" tell process "Simulator" set frontmost to false set frontmost to true set dstMenuItem to menu item "Toggle Enrolled State" of menu 1 of menu item "Touch ID" of menu 1 of menu bar item "Hardware" of menu bar 1 set isChecked to (value of attribute "AXMenuItemMarkChar" of dstMenuItem) is "✓" end tell end tell `); log.debug(`Touch ID enrolled state: ${output}`); return _.isString(output) && output.trim() === 'true'; } /** * Execute a special Apple script, which changes Touch ID feature testing in Simulator UI client. * * @param {boolean} isEnabled - Set it to false in order to uncheck 'Toggle Enrolled State' flag */ async enrollTouchID (isEnabled = true) { await this.executeUIClientScript(` tell application "System Events" tell process "Simulator" set frontmost to false set frontmost to true set dstMenuItem to menu item "Toggle Enrolled State" of menu 1 of menu item "Touch ID" of menu 1 of menu bar item "Hardware" of menu bar 1 set isChecked to (value of attribute "AXMenuItemMarkChar" of dstMenuItem) is "✓" if ${isEnabled ? 'not ' : ''}isChecked then click dstMenuItem end if end tell end tell `); } /** * Execute a special Apple script, which clicks the particular button on Database alert. * * @param {boolean} increase - Click the button with 'Increase' title on the alert if this * parameter is true. The 'Cancel' button will be clicked otherwise. */ async dismissDatabaseAlert (increase = true) { let button = increase ? 'Increase' : 'Cancel'; log.debug(`Attempting to dismiss database alert with '${button}' button`); await this.executeUIClientScript(` activate application "Simulator" tell application "System Events" tell process "Simulator" click button "${button}" of window 1 end tell end tell `); } static async _getDeviceStringPlatformVersion (platformVersion) { let reqVersion = platformVersion; if (!reqVersion) { reqVersion = await xcode.getMaxIOSSDK(); log.warn(`No platform version set. Using max SDK version: ${reqVersion}`); // 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)' }; } /** * Takes a set of options and finds the correct device string in order for Instruments to * identify the correct simulator. * * @param {object} opts - The options available are: * - `deviceName` - a name for the device. If the given device name starts with `=`, the name, less the equals sign, is returned. * - `platformVersion` - the version of iOS to use. Defaults to the current Xcode's maximum SDK version. * - `forceIphone` - force the configuration of the device string to iPhone. Defaults to `false`. * - `forceIpad` - force the configuration of the device string to iPad. Defaults to `false`. * If both `forceIphone` and `forceIpad` are true, the device will be forced to iPhone. * * @return {string} The found device string. */ static async getDeviceString (opts) { opts = Object.assign({}, { deviceName: null, platformVersion: null, forceIphone: false, forceIpad: false }, opts); let logOpts = { deviceName: opts.deviceName, platformVersion: opts.platformVersion, forceIphone: opts.forceIphone, forceIpad: opts.forceIpad }; log.debug(`Getting device string from options: ${JSON.stringify(logOpts)}`); // 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'); // if someone passes in just "iPhone", make that "iPhone Simulator" to // conform to all the logic below if (/^(iPhone|iPad)$/.test(iosDeviceString)) { iosDeviceString += " Simulator"; } // we support deviceName: "iPhone Simulator", and also want to support // "iPhone XYZ Simulator", but these strings aren't in the device list. // So, if someone sent in "iPhone XYZ Simulator", strip off " Simulator" // in order to allow the default "iPhone XYZ" match if (/[^(iPhone|iPad)] Simulator/.test(iosDeviceString)) { iosDeviceString = iosDeviceString.replace(" 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 _.toPairs(extensions)) { SimulatorXcode6.prototype[cmd] = fn; } export default SimulatorXcode6; export { SimulatorXcode6, BOOT_COMPLETED_EVENT };