diff --git a/lib/defaults-utils.js b/lib/defaults-utils.js index f6f299c..1b4950c 100644 --- a/lib/defaults-utils.js +++ b/lib/defaults-utils.js @@ -16,7 +16,7 @@ import log from './logger'; * the given value * @throws {TypeError} If it is not known how to serialize the given value */ -function toXmlArg (value, serialize = true) { +export function toXmlArg (value, serialize = true) { let xmlDoc = null; if (_.isPlainObject(value)) { @@ -72,7 +72,7 @@ function toXmlArg (value, serialize = true) { * @returns {string[][]} Each item in the array * is the `defaults write ` command suffix */ -function generateDefaultsCommandArgs (valuesMap, replace = false) { +export function generateDefaultsCommandArgs (valuesMap, replace = false) { /** @type {string[][]} */ const resultArgs = []; for (const [key, value] of _.toPairs(valuesMap)) { @@ -106,8 +106,7 @@ function generateDefaultsCommandArgs (valuesMap, replace = false) { return resultArgs; } - -class NSUserDefaults { +export class NSUserDefaults { constructor (plist) { this.plist = plist; } @@ -155,9 +154,3 @@ class NSUserDefaults { } } } - - -export { - NSUserDefaults, - toXmlArg, generateDefaultsCommandArgs, -}; diff --git a/lib/device-utils.js b/lib/device-utils.js index 8dbbf8a..ca378f9 100644 --- a/lib/device-utils.js +++ b/lib/device-utils.js @@ -1,7 +1,7 @@ import Simctl from 'node-simctl'; /** - * @param {Record} [simctlOpts] + * @param {import('@appium/types').StringRecord} [simctlOpts] * @returns {Promise} */ export async function getDevices(simctlOpts) { diff --git a/lib/extensions/applications.js b/lib/extensions/applications.js index e18080b..6b08a22 100644 --- a/lib/extensions/applications.js +++ b/lib/extensions/applications.js @@ -1,28 +1,27 @@ import _ from 'lodash'; import path from 'path'; import { fs, plist, util } from '@appium/support'; -import log from '../logger'; import B from 'bluebird'; import { waitForCondition } from 'asyncbox'; -const extensions = {}; - /** * Install valid .app package on Simulator. * + * @this {CoreSimulatorWithApps} * @param {string} app - The path to the .app package. */ -extensions.installApp = async function installApp (app) { +export async function installApp (app) { return await this.simctl.installApp(app); -}; +} /** * Returns user installed bundle ids which has 'bundleName' in their Info.Plist as 'CFBundleName' * + * @this {CoreSimulatorWithApps} * @param {string} bundleName - The bundle name of the application to be checked. * @return {Promise} - The list of bundle ids which have 'bundleName' */ -extensions.getUserInstalledBundleIdsByBundleName = async function getUserInstalledBundleIdsByBundleName (bundleName) { +export async function getUserInstalledBundleIdsByBundleName (bundleName) { const appsRoot = path.resolve(this.getDir(), 'Containers', 'Bundle', 'Application'); // glob all Info.plist from simdir/data/Containers/Bundle/Application const infoPlists = await fs.glob('*/*.app/Info.plist', { @@ -49,23 +48,27 @@ extensions.getUserInstalledBundleIdsByBundleName = async function getUserInstall return []; } - log.debug( + this.log.debug( `The simulator has ${util.pluralize('bundle', bundleIds.length, true)} which ` + `have '${bundleName}' as their 'CFBundleName': ${JSON.stringify(bundleIds)}` ); return bundleIds; -}; +} /** * Verify whether the particular application is installed on Simulator. * + * @this {CoreSimulatorWithApps} * @param {string} bundleId - The bundle id of the application to be checked. * @return {Promise} True if the given application is installed. */ -extensions.isAppInstalled = async function isAppInstalled (bundleId) { +export async function isAppInstalled (bundleId) { try { const appContainer = await this.simctl.getAppContainer(bundleId); - return appContainer.endsWith('.app'); + if (!appContainer.endsWith('.app')) { + return false; + } + return await fs.exists(appContainer); } catch (err) { // get_app_container subcommand fails for system applications, // so we try the hidden appinfo subcommand, which prints correct info for @@ -73,36 +76,29 @@ extensions.isAppInstalled = async function isAppInstalled (bundleId) { try { const info = await this.simctl.appInfo(bundleId); return info.includes('ApplicationType'); - } catch (e) { - return false; - } + } catch (ign) {} } -}; + return false; +} /** * Uninstall the given application from the current Simulator. * + * @this {CoreSimulatorWithApps} * @param {string} bundleId - The buindle ID of the application to be removed. */ -extensions.removeApp = async function removeApp (bundleId) { +export async function removeApp (bundleId) { await this.simctl.removeApp(bundleId); -}; - -/** - * @typedef {Object} LaunchAppOpts - * @property {boolean} wait [false] Whether to wait until the app has fully started and - * is present in processes list - * @property {number} timeoutMs [10000] The number of milliseconds to wait until - * the app is fully started. Only applicatble if `wait` is true. - */ +} /** * Starts the given application on Simulator * + * @this {CoreSimulatorWithApps} * @param {string} bundleId - The buindle ID of the application to be launched - * @param {Partial} opts + * @param {import('../types').LaunchAppOptions} [opts={}] */ -extensions.launchApp = async function launchApp (bundleId, opts = {}) { +export async function launchApp (bundleId, opts = {}) { await this.simctl.launchApp(bundleId); const { wait = false, @@ -120,41 +116,44 @@ extensions.launchApp = async function launchApp (bundleId, opts = {}) { } catch (e) { throw new Error(`App '${bundleId}' is not runnning after ${timeoutMs}ms timeout.`); } -}; +} /** * Stops the given application on Simulator. * + * @this {CoreSimulatorWithApps} * @param {string} bundleId - The buindle ID of the application to be stopped */ -extensions.terminateApp = async function terminateApp (bundleId) { +export async function terminateApp (bundleId) { await this.simctl.terminateApp(bundleId); -}; +} /** * Check if app with the given identifier is running. * + * @this {CoreSimulatorWithApps} * @param {string} bundleId - The buindle ID of the application to be checked. */ -extensions.isAppRunning = async function isAppRunning (bundleId) { +export async function isAppRunning (bundleId) { return (await this.ps()).some(({name}) => name === bundleId); -}; +} /** * Scrub (delete the preferences and changed files) the particular application on Simulator. * The app will be terminated automatically if it is running. * + * @this {CoreSimulatorWithApps} * @param {string} bundleId - Bundle identifier of the application. * @throws {Error} if the given app is not installed. */ -extensions.scrubApp = async function scrubApp (bundleId) { +export async function scrubApp (bundleId) { const appDataRoot = await this.simctl.getAppContainer(bundleId, 'data'); const appFiles = await fs.glob('**/*', { cwd: appDataRoot, nodir: true, absolute: true, }); - log.info(`Found ${appFiles.length} ${bundleId} app ${util.pluralize('file', appFiles.length, false)} to scrub`); + this.log.info(`Found ${appFiles.length} ${bundleId} app ${util.pluralize('file', appFiles.length, false)} to scrub`); if (_.isEmpty(appFiles)) { return; } @@ -163,6 +162,8 @@ extensions.scrubApp = async function scrubApp (bundleId) { await this.terminateApp(bundleId); } catch (ign) {} await B.all(appFiles.map((p) => fs.rimraf(p))); -}; +} -export default extensions; +/** + * @typedef {import('../types').CoreSimulator & import('../types').InteractsWithApps} CoreSimulatorWithApps + */ diff --git a/lib/extensions/biometric.js b/lib/extensions/biometric.js index 2b1556a..6e1ae95 100644 --- a/lib/extensions/biometric.js +++ b/lib/extensions/biometric.js @@ -1,62 +1,80 @@ import _ from 'lodash'; -import log from '../logger'; -const extensions = {}; +const ENROLLMENT_NOTIFICATION_RECEIVER = 'com.apple.BiometricKit.enrollmentChanged'; +const BIOMETRICS = { + touchId: 'fingerTouch', + faceId: 'pearl', +}; /** - * Get the current state of Biometric Enrollment feature. - * - * @returns {Promise} Either true or false - * @throws {Error} If Enrollment state cannot be determined + * @this {CoreSimulatorWithBiometric} + * @returns {Promise} */ -extensions.isBiometricEnrolled = async function isBiometricEnrolled () { - const output = await this.executeUIClientScript(` - tell application "System Events" - tell process "Simulator" - 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'; -}; +export async function isBiometricEnrolled () { + const {stdout} = await this.simctl.spawnProcess([ + 'notifyutil', + '-g', ENROLLMENT_NOTIFICATION_RECEIVER + ]); + const match = (new RegExp(`${_.escapeRegExp(ENROLLMENT_NOTIFICATION_RECEIVER)}\\s+([01])`)) + .exec(stdout); + if (!match) { + throw new Error(`Cannot parse biometric enrollment state from '${stdout}'`); + } + this.log.info(`Current biometric enrolled state for ${this.udid} Simulator: ${match[1]}`); + return match[1] === '1'; +} /** - * Enrolls biometric (TouchId, FaceId) feature testing in Simulator UI client. - * - * @param {boolean} isEnabled - Defines whether biometric state is enabled/disabled - * @throws {Error} If the enrolled state cannot be changed + * @this {CoreSimulatorWithBiometric} + * @param {boolean} isEnabled */ -extensions.enrollBiometric = async function enrollBiometric (isEnabled = true) { - await this.executeUIClientScript(` - tell application "System Events" - tell process "Simulator" - 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 - `); -}; +export async function enrollBiometric (isEnabled = true) { + this.log.debug(`Setting biometric enrolled state for ${this.udid} Simulator to '${isEnabled ? 'enabled' : 'disabled'}'`); + await this.simctl.spawnProcess([ + 'notifyutil', + '-s', ENROLLMENT_NOTIFICATION_RECEIVER, isEnabled ? '1' : '0' + ]); + await this.simctl.spawnProcess([ + 'notifyutil', + '-p', ENROLLMENT_NOTIFICATION_RECEIVER + ]); + if (await this.isBiometricEnrolled() !== isEnabled) { + throw new Error(`Cannot set biometric enrolled state for ${this.udid} Simulator to '${isEnabled ? 'enabled' : 'disabled'}'`); + } +} /** - * Sends a notification to match/not match the touch id. + * Sends a notification to match/not match the particular biometric. * - * @param {?boolean} shouldMatch [true] - Set it to true or false in order to emulate + * @this {CoreSimulatorWithBiometric} + * @param {boolean} shouldMatch [true] - Set it to true or false in order to emulate * matching/not matching the corresponding biometric + * @param {string} biometricName [touchId] - Either touchId or faceId (faceId is only available since iOS 11) */ -extensions.sendBiometricMatch = async function sendBiometricMatch (shouldMatch = true) { - await this.executeUIClientScript(` - tell application "System Events" - tell process "Simulator" - set dstMenuItem to menu item "${shouldMatch ? 'Matching Touch' : 'Non-matching Touch'}" of menu 1 of menu item "Touch ID" of menu 1 of menu bar item "Hardware" of menu bar 1 - click dstMenuItem - end tell - end tell - `); -}; +export async function sendBiometricMatch (shouldMatch = true, biometricName = 'touchId') { + const domainComponent = toBiometricDomainComponent(biometricName); + const domain = `com.apple.BiometricKit_Sim.${domainComponent}.${shouldMatch ? '' : 'no'}match`; + await this.simctl.spawnProcess([ + 'notifyutil', + '-p', domain + ]); + this.log.info( + `Sent notification ${domain} to ${shouldMatch ? 'match' : 'not match'} ${biometricName} biometric ` + + `for ${this.udid} Simulator` + ); +} -export default extensions; +/** + * @param {string} name + * @returns {string} + */ +export function toBiometricDomainComponent (name) { + if (!BIOMETRICS[name]) { + throw new Error(`'${name}' is not a valid biometric. Use one of: ${JSON.stringify(_.keys(BIOMETRICS))}`); + } + return BIOMETRICS[name]; +} + +/** + * @typedef {import('../types').CoreSimulator & import('../types').SupportsBiometric} CoreSimulatorWithBiometric + */ diff --git a/lib/extensions/geolocation.js b/lib/extensions/geolocation.js index 810ffac..32c5bfc 100644 --- a/lib/extensions/geolocation.js +++ b/lib/extensions/geolocation.js @@ -1,18 +1,37 @@ -import _ from 'lodash'; import { fs} from '@appium/support'; -import log from '../logger'; import { exec } from 'teen_process'; const LYFT_SET_LOCATION = 'set-simulator-location'; -const DECIMAL_SEPARATOR_SCRIPT = ` -use framework "Foundation" -use framework "AppKit" -use scripting additions -set theFormatter to current application's NSNumberFormatter's new() -set result to theFormatter's decimalSeparator() -log result as string -`; +/** + * Set custom geolocation parameters for the given Simulator using AppleScript. + * + * @this {CoreSimulatorWithGeolocation} + * @param {string|number} latitude - The latitude value, which is going to be entered + * into the corresponding edit field, for example '39,0006'. + * @param {string|number} longitude - The longitude value, which is going to be entered + * into the corresponding edit field, for example '19,0068'. + * @returns {Promise} True if the given parameters have correct format and were successfully accepted. + * @throws {Error} If there was an error while setting the location + */ +export async function setGeolocation (latitude, longitude) { + const locationSetters = [ + async () => await setLocationWithLyft(this.udid, latitude, longitude), + async () => await setLocationWithIdb(this.idb, latitude, longitude), + ]; + + let lastError; + for (const setter of locationSetters) { + try { + await setter(); + return true; + } catch (e) { + this.log.info(e.message); + lastError = e; + } + } + throw lastError; +} /** * Set custom geolocation parameters for the given Simulator using LYFT_SET_LOCATION. @@ -68,76 +87,5 @@ async function setLocationWithIdb (idb, latitude, longitude) { } /** - * Set custom geolocation parameters for the given Simulator using AppleScript - * - * @param {Object} sim - The SimulatorXcode object - * @param {string|number} latitude - The latitude value, which is going to be entered - * into the corresponding edit field, for example '39,0006'. - * @param {string|number} longitude - The longitude value, which is going to be entered - * into the corresponding edit field, for example '19,0068'. - * @param {string} [menu=Debug] - The menu field in which the 'Location' feature is found - * @throws {Error} If it failed to set the location + * @typedef {import('../types').CoreSimulator & import('../types').SupportsGeolocation} CoreSimulatorWithGeolocation */ -async function setLocationWithAppleScript (sim, latitude, longitude, menu = 'Debug') { - // Make sure system-wide decimal separator is used - const {stdout, stderr} = await exec('osascript', ['-e', DECIMAL_SEPARATOR_SCRIPT]); - const decimalSeparator = _.trim(stdout || stderr); - const [latitudeStr, longitudeStr] = [latitude, longitude] - .map((coord) => `${coord}`.replace(/[.,]/, decimalSeparator)); - - const output = await sim.executeUIClientScript(` - tell application "System Events" - tell process "Simulator" - set featureName to "Custom Location" - set dstMenuItem to menu item (featureName & "…") of menu 1 of menu item "Location" of menu 1 of menu bar item "${menu}" of menu bar 1 - click dstMenuItem - delay 1 - set value of text field 1 of window featureName to "${latitudeStr}" - delay 0.5 - set value of text field 2 of window featureName to "${longitudeStr}" - delay 0.5 - click button "OK" of window featureName - delay 0.5 - set isInvisible to (not (exists (window featureName))) - end tell - end tell - `); - log.debug(`Geolocation parameters dialog accepted: ${output}`); - if (_.trim(output) !== 'true') { - throw new Error(`Failed to set geolocation with AppleScript. Original error: ${output}`); - } -} - -const extensions = {}; - -/** - * Set custom geolocation parameters for the given Simulator using AppleScript. - * - * @param {string|number} latitude - The latitude value, which is going to be entered - * into the corresponding edit field, for example '39,0006'. - * @param {string|number} longitude - The longitude value, which is going to be entered - * into the corresponding edit field, for example '19,0068'. - * @returns {Promise} True if the given parameters have correct format and were successfully accepted. - * @throws {Error} If there was an error while setting the location - */ -extensions.setGeolocation = async function setGeolocation (latitude, longitude) { - const locationSetters = [ - async () => await setLocationWithLyft(this.udid, latitude, longitude), - async () => await setLocationWithIdb(this.idb, latitude, longitude), - async () => await setLocationWithAppleScript(this, latitude, longitude, this._locationMenu), - ]; - - let lastError; - for (const setter of locationSetters) { - try { - await setter(); - return true; - } catch (e) { - log.info(e.message); - lastError = e; - } - } - throw lastError; -}; - -export default extensions; diff --git a/lib/extensions/index.js b/lib/extensions/index.js deleted file mode 100644 index 4f66602..0000000 --- a/lib/extensions/index.js +++ /dev/null @@ -1,27 +0,0 @@ -import appExtensions from './applications'; -import safariExtensions from './safari'; -import keychainExtensions from './keychain'; -import gelolocationExtensions from './geolocation'; -import settingsExtensions from './settings'; -import biometricExtensions from './biometric'; -import permissionsExtensions from './permissions'; -import miscExtensions from './misc'; - -const extensions = {}; - -const allExtensions = [ - appExtensions, - safariExtensions, - keychainExtensions, - gelolocationExtensions, - settingsExtensions, - biometricExtensions, - permissionsExtensions, - miscExtensions, - // add new extensions here -]; -for (const ext of allExtensions) { - Object.assign(extensions, ext); -} - -export default extensions; diff --git a/lib/extensions/keychain.js b/lib/extensions/keychain.js index 705394b..2df1960 100644 --- a/lib/extensions/keychain.js +++ b/lib/extensions/keychain.js @@ -1,24 +1,7 @@ import _ from 'lodash'; import path from 'path'; import { fs, mkdirp, tempDir } from '@appium/support'; -import log from '../logger'; import { exec } from 'teen_process'; -import { getDeveloperRoot } from '../utils'; - -const extensions = {}; - -/** - * Resolve full path to Simlator's LaunchDaemons root folder - * - * @returns {Promise} Full path to Simlator's LaunchDaemons root folder - */ -extensions.getLaunchDaemonsRoot = async function getLaunchDaemonsRoot () { - const devRoot = await getDeveloperRoot(); - return path.resolve( - devRoot, - 'Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/LaunchDaemons' - ); -}; /** * Create the backup of keychains folder. @@ -26,10 +9,10 @@ extensions.getLaunchDaemonsRoot = async function getLaunchDaemonsRoot () { * deleted if this method was called twice in a row without * `restoreKeychains` being invoked. * - * @this {import('../simulator-xcode-14').default} + * @this {CoreSimulatorWithKeychain} * @returns {Promise} True if the backup operation was successfull. */ -extensions.backupKeychains = async function backupKeychains () { +export async function backupKeychains () { if (!await fs.exists(this.keychainPath)) { return false; } @@ -42,32 +25,29 @@ extensions.backupKeychains = async function backupKeychains () { '-r', backupPath, `${this.keychainPath}${path.sep}` ]; - log.debug(`Creating keychains backup with 'zip ${zipArgs.join(' ')}' command`); + this.log.debug(`Creating keychains backup with 'zip ${zipArgs.join(' ')}' command`); await exec('zip', zipArgs); - // @ts-ignore if (_.isString(this._keychainsBackupPath) && await fs.exists(this._keychainsBackupPath)) { - // @ts-ignore await fs.unlink(this._keychainsBackupPath); } - // @ts-ignore this._keychainsBackupPath = backupPath; return true; -}; +} /** * Restore the previsouly created keychains backup. * + * @this {CoreSimulatorWithKeychain} * @param {string[]} excludePatterns - The list * of file name patterns to be excluded from restore. The format * of each item should be the same as '-x' option format for * 'unzip' utility. This can also be a comma-separated string, * which is going be transformed into a list automatically, * for example: '*.db*,blabla.sqlite' - * @this {import('../simulator-xcode-14').default} * @returns {Promise} If the restore opration was successful. * @throws {Error} If there is no keychains backup available for restore. */ -extensions.restoreKeychains = async function restoreKeychains (excludePatterns = []) { +export async function restoreKeychains (excludePatterns = []) { if (!_.isString(this._keychainsBackupPath) || !await fs.exists(this._keychainsBackupPath)) { throw new Error(`The keychains backup archive does not exist. ` + `Are you sure it was created before?`); @@ -93,7 +73,7 @@ extensions.restoreKeychains = async function restoreKeychains (excludePatterns = ...(_.flatMap(excludePatterns.map((x) => ['-x', x]))), '-d', '/' ]; - log.debug(`Restoring keychains with 'unzip ${unzipArgs.join(' ')}' command`); + this.log.debug(`Restoring keychains with 'unzip ${unzipArgs.join(' ')}' command`); await exec('unzip', unzipArgs); await fs.unlink(this._keychainsBackupPath); this._keychainsBackupPath = null; @@ -103,15 +83,16 @@ extensions.restoreKeychains = async function restoreKeychains (excludePatterns = } } return true; -}; +} /** * Clears Keychains for the particular simulator in runtime (there is no need to stop it). * - * @this {import('../simulator-xcode-14').default} + * @this {CoreSimulatorWithKeychain} + * @returns {Promise} * @throws {Error} If keychain cleanup has failed. */ -extensions.clearKeychains = async function clearKeychains () { +export async function clearKeychains () { const plistPath = path.resolve(await this.getLaunchDaemonsRoot(), 'com.apple.securityd.plist'); if (!await fs.exists(plistPath)) { throw new Error(`Cannot clear keychains because '${plistPath}' does not exist`); @@ -125,6 +106,8 @@ extensions.clearKeychains = async function clearKeychains () { } finally { await this.simctl.spawnProcess(['launchctl', 'load', plistPath]); } -}; +} -export default extensions; +/** + * @typedef {import('../types').CoreSimulator & import('../types').InteractsWithKeychain} CoreSimulatorWithKeychain + */ diff --git a/lib/extensions/misc.js b/lib/extensions/misc.js index f84e888..8406082 100644 --- a/lib/extensions/misc.js +++ b/lib/extensions/misc.js @@ -1,39 +1,45 @@ -import log from '../logger'; - -const extensions = {}; +/* eslint-disable @typescript-eslint/no-unused-vars */ /** + * @this {CoreSimulatorWithMiscFeatures} * Perform Shake gesture on Simulator window. */ -extensions.shake = async function shake () { - log.info(`Performing shake gesture on ${this.udid} Simulator`); +export async function shake () { + this.log.info(`Performing shake gesture on ${this.udid} Simulator`); await this.simctl.spawnProcess([ 'notifyutil', '-p', 'com.apple.UIKit.SimulatorShake' ]); -}; +} /** * Adds the given certificate into the Trusted Root Store on the simulator. * The simulator must be shut down in order for this method to work properly. * + * @this {CoreSimulatorWithMiscFeatures} * @param {string} payload the content of the PEM certificate + * @param {import('../types').CertificateOptions} [opts={}] * @returns {Promise} `true` if the certificate has been successfully installed * or `false` if it has already been there */ // eslint-disable-next-line require-await -extensions.addCertificate = async function addCertificate (payload) { +export async function addCertificate (payload, opts = {}) { throw new Error(`Xcode SDK '${this.xcodeVersion}' is too old add certificates`); -}; +} /** * Simulates push notification delivery * + * @this {CoreSimulatorWithMiscFeatures} + * @param {import('@appium/types').StringRecord} payload + * @returns {Promise} * @since Xcode SDK 11.4 */ // eslint-disable-next-line require-await -extensions.pushNotification = async function pushNotification (/* payload */) { +export async function pushNotification (payload) { throw new Error(`Xcode SDK '${this.xcodeVersion}' is too old to push notifications`); -}; +} -export default extensions; +/** + * @typedef {import('../types').CoreSimulator & import('../types').HasMiscFeatures} CoreSimulatorWithMiscFeatures + */ diff --git a/lib/extensions/permissions.js b/lib/extensions/permissions.js index a761fc3..3e76314 100644 --- a/lib/extensions/permissions.js +++ b/lib/extensions/permissions.js @@ -1,4 +1,3 @@ -import log from '../logger'; import _ from 'lodash'; import { fs, util } from '@appium/support'; import { exec } from 'teen_process'; @@ -11,9 +10,7 @@ const STATUS = Object.freeze({ YES: 'yes', LIMITED: 'limited', }); - const WIX_SIM_UTILS = 'applesimutils'; - // `location` permission does not work with WIX/applesimutils. // Note that except for 'contacts', the Apple's privacy command sets // permissions properly but it kills the app process while WIX/applesimutils does not. @@ -23,7 +20,6 @@ const PERMISSIONS_APPLIED_VIA_SIMCTL = [ 'location', 'location-always' ]; - const SERVICES = Object.freeze({ calendar: 'kTCCServiceCalendar', camera: 'kTCCServiceCamera', @@ -39,6 +35,51 @@ const SERVICES = Object.freeze({ speech: 'kTCCServiceSpeechRecognition', }); +/** + * Sets the particular permission to the application bundle. See https://github.com/wix/AppleSimulatorUtils + * or `xcrun simctl privacy` for more details on the available service names and statuses. + * + * @this {CoreSimulatorWithAppPermissions} + * @param {string} bundleId - Application bundle identifier. + * @param {string} permission - Service name to be set. + * @param {string} value - The desired status for the service. + * @throws {Error} If there was an error while changing permission. + */ +export async function setPermission (bundleId, permission, value) { + await this.setPermissions(bundleId, {[permission]: value}); +} + +/** + * Sets the permissions for the particular application bundle. + * + * @this {CoreSimulatorWithAppPermissions} + * @param {string} bundleId - Application bundle identifier. + * @param {Object} permissionsMapping - A mapping where kays + * are service names and values are their corresponding status values. + * See https://github.com/wix/AppleSimulatorUtils or `xcrun simctl privacy` + * for more details on available service names and statuses. + * @throws {Error} If there was an error while changing permissions. + */ +export async function setPermissions (bundleId, permissionsMapping) { + this.log.debug(`Setting access for '${bundleId}': ${JSON.stringify(permissionsMapping, null, 2)}`); + await setAccess.bind(this)(bundleId, permissionsMapping); +} + +/** + * Retrieves current permission status for the given application bundle. + * + * @this {CoreSimulatorWithAppPermissions} + * @param {string} bundleId - Application bundle identifier. + * @param {string} serviceName - One of available service names. + * @returns {Promise} + * @throws {Error} If there was an error while retrieving permissions. + */ +export async function getPermission (bundleId, serviceName) { + const result = await getAccess.bind(this)(bundleId, serviceName); + this.log.debug(`Got ${serviceName} access status for '${bundleId}': ${result}`); + return result; +} + function toInternalServiceName (serviceName) { if (_.has(SERVICES, _.toLower(serviceName))) { return SERVICES[_.toLower(serviceName)]; @@ -55,12 +96,13 @@ function formatStatus (status) { /** * Runs a command line sqlite3 query * + * @this {CoreSimulatorWithAppPermissions} * @param {string} db - Full path to sqlite database * @param {string} query - The actual query string * @returns {Promise} sqlite command stdout */ async function execSQLiteQuery (db, query) { - log.debug(`Executing SQL query "${query}" on '${db}'`); + this.log.debug(`Executing SQL query "${query}" on '${db}'`); try { return (await exec('sqlite3', ['-line', db, query])).stdout; } catch (err) { @@ -70,6 +112,11 @@ async function execSQLiteQuery (db, query) { } } +/** + * @this {CoreSimulatorWithAppPermissions} + * @param {string[]} args + * @returns {Promise} + */ async function execWix (args) { try { await fs.which(WIX_SIM_UTILS); @@ -81,10 +128,10 @@ async function execWix (args) { ); } - log.debug(`Executing: ${WIX_SIM_UTILS} ${util.quote(args)}`); + this.log.debug(`Executing: ${WIX_SIM_UTILS} ${util.quote(args)}`); try { const {stdout} = await exec(WIX_SIM_UTILS, args); - log.debug(`Command output: ${stdout}`); + this.log.debug(`Command output: ${stdout}`); return stdout; } catch (e) { throw new Error(`Cannot execute "${WIX_SIM_UTILS} ${util.quote(args)}". Original error: ${e.stderr || e.message}`); @@ -94,8 +141,7 @@ async function execWix (args) { /** * Sets permissions for the given application * - * @param {import('node-simctl').Simctl} simctl - node-simctl object. - * @param {string} udid - udid of the target simulator device. + * @this {CoreSimulatorWithAppPermissions} * @param {string} bundleId - bundle identifier of the target application. * @param {Object} permissionsMapping - An object, where keys are service names * and values are corresponding state values. Services listed in PERMISSIONS_APPLIED_VIA_SIMCTL @@ -105,7 +151,7 @@ async function execWix (args) { * Note that the `xcrun simctl privacy` command kill the app process. * @throws {Error} If there was an error while changing permissions. */ -async function setAccess (simctl, udid, bundleId, permissionsMapping) { +async function setAccess (bundleId, permissionsMapping) { const /** @type {Record} */ wixPermissions = {}; const /** @type {string[]} */ grantPermissions = []; @@ -129,31 +175,34 @@ async function setAccess (simctl, udid, bundleId, permissionsMapping) { resetPermissions.push(serviceName); break; default: - log.errorAndThrow(`${serviceName} does not support ${permissionsMapping[serviceName]}. Please specify 'yes', 'no' or 'unset'.`); - }; + this.log.errorAndThrow( + `${serviceName} does not support ${permissionsMapping[serviceName]}. Please specify 'yes', 'no' or 'unset'.` + ); + } } } - const /** @type {string[]} */ permissionPromises = []; + /** @type {Promise[]} */ + const permissionPromises = []; if (!_.isEmpty(grantPermissions)) { - log.debug(`Granting ${util.pluralize('permission', grantPermissions.length, false)} for ${bundleId}: ${grantPermissions}`); + this.log.debug(`Granting ${util.pluralize('permission', grantPermissions.length, false)} for ${bundleId}: ${grantPermissions}`); for (const action of grantPermissions) { - permissionPromises.push(simctl.grantPermission(bundleId, action)); + permissionPromises.push(this.simctl.grantPermission(bundleId, action)); } } if (!_.isEmpty(revokePermissions)) { - log.debug(`Revoking ${util.pluralize('permission', revokePermissions.length, false)} for ${bundleId}: ${revokePermissions}`); + this.log.debug(`Revoking ${util.pluralize('permission', revokePermissions.length, false)} for ${bundleId}: ${revokePermissions}`); for (const action of revokePermissions) { - permissionPromises.push(simctl.revokePermission(bundleId, action)); + permissionPromises.push(this.simctl.revokePermission(bundleId, action)); } } if (!_.isEmpty(resetPermissions)) { - log.debug(`Resetting ${util.pluralize('permission', resetPermissions.length, false)} for ${bundleId}: ${resetPermissions}`); + this.log.debug(`Resetting ${util.pluralize('permission', resetPermissions.length, false)} for ${bundleId}: ${resetPermissions}`); for (const action of resetPermissions) { - permissionPromises.push(simctl.resetPermission(bundleId, action)); + permissionPromises.push(this.simctl.resetPermission(bundleId, action)); } } @@ -162,12 +211,12 @@ async function setAccess (simctl, udid, bundleId, permissionsMapping) { } if (!_.isEmpty(wixPermissions)) { - log.debug(`Setting permissions for ${bundleId} wit ${WIX_SIM_UTILS} as ${JSON.stringify(wixPermissions)}`); + this.log.debug(`Setting permissions for ${bundleId} wit ${WIX_SIM_UTILS} as ${JSON.stringify(wixPermissions)}`); const permissionsArg = _.toPairs(wixPermissions) .map((x) => `${x[0]}=${formatStatus(x[1])}`) .join(','); - await execWix([ - '--byId', udid, + await execWix.bind(this)([ + '--byId', this.udid, '--bundle', bundleId, '--setPermissions', permissionsArg, ]); @@ -179,21 +228,21 @@ async function setAccess (simctl, udid, bundleId, permissionsMapping) { /** * Retrieves the current permission status for the given service and application. * + * @this {CoreSimulatorWithAppPermissions} * @param {string} bundleId - bundle identifier of the target application. * @param {string} serviceName - the name of the service. Should be one of * `SERVICES` keys. - * @param {string} simDataRoot - the path to Simulator `data` root * @returns {Promise} - The current status: yes/no/unset/limited * @throws {Error} If there was an error while retrieving permissions. */ -async function getAccess (bundleId, serviceName, simDataRoot) { +async function getAccess (bundleId, serviceName) { const internalServiceName = toInternalServiceName(serviceName); - const dbPath = path.resolve(simDataRoot, 'Library', 'TCC', 'TCC.db'); + const dbPath = path.resolve(this.getDir(), 'Library', 'TCC', 'TCC.db'); const getAccessStatus = async (statusPairs, statusKey) => { for (const [statusValue, status] of statusPairs) { const sql = `SELECT count(*) FROM 'access' ` + `WHERE client='${bundleId}' AND ${statusKey}=${statusValue} AND service='${internalServiceName}'`; - const count = await execSQLiteQuery(dbPath, sql); + const count = await execSQLiteQuery.bind(this)(dbPath, sql); if (parseInt(count.split('=')[1], 10) > 0) { return status; } @@ -217,47 +266,6 @@ async function getAccess (bundleId, serviceName, simDataRoot) { } } -const extensions = {}; - /** - * Sets the particular permission to the application bundle. See https://github.com/wix/AppleSimulatorUtils - * or `xcrun simctl privacy` for more details on the available service names and statuses. - * - * @param {string} bundleId - Application bundle identifier. - * @param {string} permission - Service name to be set. - * @param {string} value - The desired status for the service. - * @throws {Error} If there was an error while changing permission. + * @typedef {import('../types').CoreSimulator & import('../types').SupportsAppPermissions} CoreSimulatorWithAppPermissions */ -extensions.setPermission = async function setPermission (bundleId, permission, value) { - await this.setPermissions(bundleId, {[permission]: value}); -}; - -/** - * Sets the permissions for the particular application bundle. - * - * @param {string} bundleId - Application bundle identifier. - * @param {Object} permissionsMapping - A mapping where kays - * are service names and values are their corresponding status values. - * See https://github.com/wix/AppleSimulatorUtils or `xcrun simctl privacy` - * for more details on available service names and statuses. - * @throws {Error} If there was an error while changing permissions. - */ -extensions.setPermissions = async function setPermissions (bundleId, permissionsMapping) { - log.debug(`Setting access for '${bundleId}': ${JSON.stringify(permissionsMapping, null, 2)}`); - await setAccess(this.simctl, this.udid, bundleId, permissionsMapping); -}; - -/** - * Retrieves current permission status for the given application bundle. - * - * @param {string} bundleId - Application bundle identifier. - * @param {string} serviceName - One of available service names. - * @throws {Error} If there was an error while retrieving permissions. - */ -extensions.getPermission = async function getPermission (bundleId, serviceName) { - const result = await getAccess(bundleId, serviceName, this.getDir()); - log.debug(`Got ${serviceName} access status for '${bundleId}': ${result}`); - return result; -}; - -export default extensions; diff --git a/lib/extensions/safari.js b/lib/extensions/safari.js index 09b29ec..7005d6c 100644 --- a/lib/extensions/safari.js +++ b/lib/extensions/safari.js @@ -1,9 +1,10 @@ import _ from 'lodash'; import path from 'path'; import { fs, timing } from '@appium/support'; -import log from '../logger'; import B from 'bluebird'; import { MOBILE_SAFARI_BUNDLE_ID, SAFARI_STARTUP_TIMEOUT_MS } from '../utils'; +import { waitForCondition } from 'asyncbox'; +import { exec } from 'teen_process'; // The root of all these files is located under Safari data container root // in 'Library' subfolder @@ -18,44 +19,63 @@ const DATA_FILES = [ ['..', 'tmp', MOBILE_SAFARI_BUNDLE_ID, '*'], ]; -const extensions = {}; - /** * 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. + * @this {CoreSimulatorWithSafariBrowser} + * @param {string} url */ -extensions.openUrl = async function openUrl (url) { +export async function openUrl (url) { if (!await this.isRunning()) { throw new Error(`Tried to open '${url}', but Simulator is not in Booted state`); } const timer = new timing.Timer().start(); + await this.simctl.openUrl(url); + /** @type {Error|undefined|null} */ + let psError; try { - await this.launchApp(MOBILE_SAFARI_BUNDLE_ID, { - wait: true, - timeoutMs: SAFARI_STARTUP_TIMEOUT_MS, + await waitForCondition(async () => { + let procList = []; + try { + procList = await this.ps(); + psError = null; + } catch (e) { + this.log.debug(e.message); + psError = e; + } + return procList.some(({name}) => name === MOBILE_SAFARI_BUNDLE_ID); + }, { + waitMs: SAFARI_STARTUP_TIMEOUT_MS, + intervalMs: 500, }); - await this.simctl.openUrl(url); } catch (err) { - throw new Error(`Safari could not open '${url}' after ${timer.getDuration().asSeconds.toFixed(3)}s. ` + - `Original error: ${err.stderr || err.message}`); + const secondsElapsed = timer.getDuration().asSeconds; + if (psError) { + this.log.warn(`Mobile Safari process existence cannot be verified after ${secondsElapsed.toFixed(3)}s. ` + + `Original error: ${psError.message}`); + this.log.warn('Continuing anyway'); + } else { + throw new Error(`Mobile Safari cannot open '${url}' after ${secondsElapsed.toFixed(3)}s. ` + + `Its process ${MOBILE_SAFARI_BUNDLE_ID} does not exist in the list of Simulator processes`); + } } - log.debug(`Safari successfully opened '${url}' in ${timer.getDuration().asSeconds.toFixed(3)}s`); -}; + this.log.debug(`Safari successfully opened '${url}' in ${timer.getDuration().asSeconds.toFixed(3)}s`); +} /** * Clean up the directories for mobile Safari. * Safari will be terminated if it is running. * + * @this {CoreSimulatorWithSafariBrowser} * @param {boolean} keepPrefs - Whether to keep Safari preferences from being deleted. */ -extensions.scrubSafari = async function scrubSafari (keepPrefs = true) { +export async function scrubSafari (keepPrefs = true) { try { await this.terminateApp(MOBILE_SAFARI_BUNDLE_ID); } catch (ign) {} - log.debug('Scrubbing Safari data files'); + this.log.debug('Scrubbing Safari data files'); const safariData = await this.simctl.getAppContainer(MOBILE_SAFARI_BUNDLE_ID, 'data'); const libraryDir = path.resolve(safariData, 'Library'); const deletePromises = DATA_FILES.map((p) => fs.rimraf(path.join(libraryDir, ...p))); @@ -63,13 +83,14 @@ extensions.scrubSafari = async function scrubSafari (keepPrefs = true) { deletePromises.push(fs.rimraf(path.join(libraryDir, 'Preferences', '*.plist'))); } await B.all(deletePromises); -}; +} /** * Updates variious Safari settings. Simulator must be booted in order to for it * to success. * - * @param {object} updates An object containing Safari settings to be updated. + * @this {CoreSimulatorWithSafariBrowser} + * @param {import('@appium/types').StringRecord} updates An object containing Safari settings to be updated. * The list of available setting names and their values could be retrived by * changing the corresponding Safari settings in the UI and then inspecting * 'Library/Preferences/com.apple.mobilesafari.plist' file inside of @@ -79,8 +100,9 @@ extensions.scrubSafari = async function scrubSafari (keepPrefs = true) { * command output. * Use the `xcrun simctl spawn defaults read ` command * to print the plist content to the Terminal. + * @returns {Promise} */ -extensions.updateSafariSettings = async function updateSafariSettings (updates) { +export async function updateSafariSettings(updates) { if (_.isEmpty(updates)) { return false; } @@ -88,6 +110,44 @@ extensions.updateSafariSettings = async function updateSafariSettings (updates) const containerRoot = await this.simctl.getAppContainer(MOBILE_SAFARI_BUNDLE_ID, 'data'); const plistPath = path.join(containerRoot, 'Library', 'Preferences', 'com.apple.mobilesafari.plist'); return await this.updateSettings(plistPath, updates); -}; +} + +/** + * @this {CoreSimulatorWithSafariBrowser} + * @returns {Promise} + */ +export async function getWebInspectorSocket() { + if (this._webInspectorSocket) { + return this._webInspectorSocket; + } -export default extensions; + // lsof -aUc launchd_sim gives a set of records like + // https://github.com/appium/appium-ios-simulator/commit/c00901a9ddea178c5581a7a57d96d8cee3f17c59#diff-2be09dd2ea01cfd6bbbd73e10bc468da782a297365eec706999fc3709c01478dR102 + // these _appear_ to always be grouped together by PID for each simulator. + // Therefore, by obtaining simulator PID with an expected simulator UDID, + // we can get the correct `com.apple.webinspectord_sim.socket` + // without depending on the order of `lsof -aUc launchd_sim` result. + const {stdout} = await exec('lsof', ['-aUc', 'launchd_sim']); + const udidPattern = `([0-9]{1,5}).+${this.udid}`; + const udidMatch = stdout.match(new RegExp(udidPattern)); + if (!udidMatch) { + this.log.debug(`Failed to get Web Inspector socket. lsof result: ${stdout}`); + return null; + } + + const pidPattern = `${udidMatch[1]}.+\\s+(\\S+com\\.apple\\.webinspectord_sim\\.socket)`; + const pidMatch = stdout.match(new RegExp(pidPattern)); + if (!pidMatch) { + this.log.debug(`Failed to get Web Inspector socket. lsof result: ${stdout}`); + return null; + } + this._webInspectorSocket = pidMatch[1]; + return this._webInspectorSocket; +} + +/** + * @typedef {import('../types').CoreSimulator + * & import('../types').InteractsWithSafariBrowser + * & import('../types').InteractsWithApps + * & import('../types').HasSettings} CoreSimulatorWithSafariBrowser + */ diff --git a/lib/extensions/settings.js b/lib/extensions/settings.js index e4eceee..8ee7ecb 100644 --- a/lib/extensions/settings.js +++ b/lib/extensions/settings.js @@ -1,41 +1,71 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import _ from 'lodash'; -import { generateDefaultsCommandArgs } from '../defaults-utils'; +import { NSUserDefaults, generateDefaultsCommandArgs } from '../defaults-utils'; import B from 'bluebird'; +import path from 'path'; +import { exec } from 'teen_process'; +import AsyncLock from 'async-lock'; +import { fs } from '@appium/support'; -const extensions = {}; +// com.apple.SpringBoard: translates com.apple.SpringBoard and system prompts for push notification +// com.apple.locationd: translates system prompts for location +// com.apple.tccd: translates system prompts for camera, microphone, contact, photos and app tracking transparency +const SERVICES_FOR_TRANSLATION = ['com.apple.SpringBoard', 'com.apple.locationd', 'com.apple.tccd']; +const GLOBAL_PREFS_PLIST = '.GlobalPreferences.plist'; +const PREFERENCES_PLIST_GUARD = new AsyncLock(); +const DOMAIN = /** @type {const} */ Object.freeze({ + KEYBOARD: 'com.apple.keyboard.preferences', + ACCESSIBILITY: 'com.apple.keyboard.preferences', +}); /** * Updates Reduce Motion setting state. * + * @this {CoreSimulatorWithSettings} * @param {boolean} reduceMotion Whether to enable or disable the setting. */ -extensions.setReduceMotion = async function setReduceMotion (reduceMotion) { - return await this.updateSettings('com.apple.Accessibility', { +export async function setReduceMotion (reduceMotion) { + return await this.updateSettings(DOMAIN.ACCESSIBILITY, { ReduceMotionEnabled: Number(reduceMotion) }); -}; +} /** * Updates Reduce Transparency setting state. * + * @this {CoreSimulatorWithSettings} * @param {boolean} reduceTransparency Whether to enable or disable the setting. */ -extensions.setReduceTransparency = async function setReduceTransparency (reduceTransparency) { - return await this.updateSettings('com.apple.Accessibility', { +export async function setReduceTransparency (reduceTransparency) { + return await this.updateSettings(DOMAIN.ACCESSIBILITY, { EnhancedBackgroundContrastEnabled: Number(reduceTransparency) }); -}; +} + +/** + * Disable keyboard tutorial as 'com.apple.keyboard.preferences' domain via 'defaults' command. + * @this {CoreSimulatorWithSettings} + * @returns {Promise} + */ +export async function disableKeyboardIntroduction () { + return await this.updateSettings(DOMAIN.KEYBOARD, { + // To disable 'DidShowContinuousPathIntroduction' for iOS 15+ simulators since changing the preference via WDA + // does not work on them. Lower than the versions also can have this preference, but nothing happen. + DidShowContinuousPathIntroduction: 1 + }); +} /** * Allows to update Simulator preferences in runtime. * + * @this {CoreSimulatorWithSettings} * @param {string} domain The name of preferences domain to be updated, * for example, 'com.apple.Preferences' or 'com.apple.Accessibility' or * full path to a plist file on the local file system. - * @param {object} updates Mapping of keys/values to be updated + * @param {import('@appium/types').StringRecord} updates Mapping of keys/values to be updated * @returns {Promise} True if settings were actually changed */ -extensions.updateSettings = async function updateSettings (domain, updates) { +export async function updateSettings (domain, updates) { if (_.isEmpty(updates)) { return false; } @@ -45,42 +75,317 @@ extensions.updateSettings = async function updateSettings (domain, updates) { 'defaults', 'write', domain, ...args ]))); return true; -}; +} /** * Sets UI appearance style. * This function can only be called on a booted simulator. * + * @this {CoreSimulatorWithSettings} + * @param {string} value * @since Xcode SDK 11.4 + * @returns {Promise} */ -extensions.setAppearance = async function setAppearance (/* value */) { // eslint-disable-line require-await +export async function setAppearance (value) { // eslint-disable-line require-await throw new Error(`Xcode SDK '${this.xcodeVersion}' is too old to set UI appearance`); -}; +} /** * Gets the current UI appearance style * This function can only be called on a booted simulator. * + * @this {CoreSimulatorWithSettings} + * @returns {Promise} * @since Xcode SDK 11.4 */ -extensions.getAppearance = async function getAppearance () { // eslint-disable-line require-await +export async function getAppearance () { // eslint-disable-line require-await throw new Error(`Xcode SDK '${this.xcodeVersion}' is too old to get UI appearance`); -}; +} -// eslint-disable-next-line require-await -extensions.configureLocalization = async function configureLocalization () { - throw new Error(`Xcode SDK '${this.xcodeVersion}' is too old to configure the Simulator locale`); -}; +/** + * Change localization settings on the currently booted simulator + * + * @this {CoreSimulatorWithSettings} + * @param {import('../types').LocalizationOptions} [opts={}] + * @throws {Error} If there was a failure while setting the preferences + * @returns {Promise} `true` if any of settings has been successfully changed + */ +export async function configureLocalization (opts = {}) { + if (_.isEmpty(opts)) { + return false; + } + + const { language, locale, keyboard } = opts; + const globalPrefs = {}; + let keyboardId = null; + if (_.isPlainObject(keyboard)) { + // @ts-ignore The above check ensures keyboard is what it should be + const { name, layout, hardware } = keyboard; + if (!name) { + throw new Error(`The 'keyboard' field must have a valid name set`); + } + if (!layout) { + throw new Error(`The 'keyboard' field must have a valid layout set`); + } + keyboardId = `${name}@sw=${layout}`; + if (hardware) { + keyboardId += `;@hw=${hardware}`; + } + globalPrefs.AppleKeyboards = [keyboardId]; + } + if (_.isPlainObject(language)) { + // @ts-ignore The above check ensures language is what it should be + const { name } = language; + if (!name) { + throw new Error(`The 'language' field must have a valid name set`); + } + globalPrefs.AppleLanguages = [name]; + } + if (_.isPlainObject(locale)) { + // @ts-ignore The above check ensures locale is what it should be + const { name, calendar } = locale; + if (!name) { + throw new Error(`The 'locale' field must have a valid name set`); + } + let localeId = name; + if (calendar) { + localeId += `@calendar=${calendar}`; + } + globalPrefs.AppleLocale = localeId; + } + if (_.isEmpty(globalPrefs)) { + return false; + } + + let previousAppleLanguages = null; + if (globalPrefs.AppleLanguages) { + const absolutePrefsPath = path.join(this.getDir(), 'Library', 'Preferences', GLOBAL_PREFS_PLIST); + try { + const {stdout} = await exec('plutil', ['-convert', 'json', absolutePrefsPath, '-o', '-']); + previousAppleLanguages = JSON.parse(stdout).AppleLanguages; + } catch (e) { + this.log.debug(`Cannot retrieve the current value of the 'AppleLanguages' preference: ${e.message}`); + } + } + + const argChunks = generateDefaultsCommandArgs(globalPrefs, true); + await B.all(argChunks.map((args) => this.simctl.spawnProcess([ + 'defaults', 'write', GLOBAL_PREFS_PLIST, ...args + ]))); + + if (keyboard && keyboardId) { + const argChunks = generateDefaultsCommandArgs({ + KeyboardsCurrentAndNext: [keyboardId], + KeyboardLastUsed: keyboardId, + KeyboardLastUsedForLanguage: { [keyboard.name]: keyboardId } + }, true); + await B.all(argChunks.map((args) => this.simctl.spawnProcess([ + 'defaults', 'write', 'com.apple.Preferences', ...args + ]))); + } + + if (globalPrefs.AppleLanguages) { + if (_.isEqual(previousAppleLanguages, globalPrefs.AppleLanguages)) { + this.log.info( + `The 'AppleLanguages' preference is already set to '${globalPrefs.AppleLanguages}'. ` + + `Skipping services reset` + ); + } else if (language?.skipSyncUiDialogTranslation) { + this.log.info('Skipping services reset as requested. This might leave some system UI alerts untranslated'); + } else { + this.log.info( + `Will restart the following services in order to sync UI dialogs translation: ` + + `${SERVICES_FOR_TRANSLATION}. This might have unexpected side effects, ` + + `see https://github.com/appium/appium/issues/19440 for more details` + ); + await B.all(SERVICES_FOR_TRANSLATION.map((arg) => this.simctl.spawnProcess([ + 'launchctl', 'stop', arg + ]))); + } + } + + return true; +} /** * Updates Auto Fill Passwords setting state. * + * @this {CoreSimulatorWithSettings} * @param {boolean} isEnabled Whether to enable or disable the setting. + * @returns {Promise} */ -extensions.setAutoFillPasswords = async function setAutoFillPasswords (isEnabled) { +export async function setAutoFillPasswords (isEnabled) { return await this.updateSettings('com.apple.WebUI', { AutoFillPasswords: Number(isEnabled) }); -}; +} + +/** + * Update the common iOS Simulator preferences file with new values. + * It is necessary to restart the corresponding Simulator before + * these changes are applied. + * + * @private + * @this {CoreSimulatorWithSettings} + * @param {import('../types').DevicePreferences} [devicePrefs={}] - The mapping, which represents new device preference values + * for the given Simulator. + * @param {import('../types').CommonPreferences} [commonPrefs={}] - The mapping, which represents new common preference values + * for all Simulators. + * @return {Promise} True if the preferences were successfully updated. + */ +export async function updatePreferences (devicePrefs = {}, commonPrefs = {}) { + if (!_.isEmpty(devicePrefs)) { + this.log.debug(`Setting preferences of ${this.udid} Simulator to ${JSON.stringify(devicePrefs)}`); + } + if (!_.isEmpty(commonPrefs)) { + this.log.debug(`Setting common Simulator preferences to ${JSON.stringify(commonPrefs)}`); + } + const homeFolderPath = process.env.HOME; + if (!homeFolderPath) { + this.log.warn(`Cannot get the path to HOME folder from the process environment. ` + + `Ignoring Simulator preferences update.`); + return false; + } + verifyDevicePreferences.bind(this)(devicePrefs); + const plistPath = path.resolve(homeFolderPath, 'Library', 'Preferences', 'com.apple.iphonesimulator.plist'); + return await PREFERENCES_PLIST_GUARD.acquire(this.constructor.name, async () => { + const defaults = new NSUserDefaults(plistPath); + const prefsToUpdate = _.clone(commonPrefs); + try { + if (!_.isEmpty(devicePrefs)) { + let existingDevicePrefs; + const udidKey = this.udid.toUpperCase(); + if (await fs.exists(plistPath)) { + const currentPlistContent = await defaults.asJson(); + if (_.isPlainObject(currentPlistContent.DevicePreferences) + && _.isPlainObject(currentPlistContent.DevicePreferences[udidKey])) { + existingDevicePrefs = currentPlistContent.DevicePreferences[udidKey]; + } + } + Object.assign(prefsToUpdate, { + DevicePreferences: { + [udidKey]: Object.assign({}, existingDevicePrefs || {}, devicePrefs) + } + }); + } + await defaults.update(prefsToUpdate); + this.log.debug(`Updated ${this.udid} Simulator preferences at '${plistPath}' with ` + + JSON.stringify(prefsToUpdate)); + return true; + } catch (e) { + this.log.warn(`Cannot update ${this.udid} Simulator preferences at '${plistPath}'. ` + + `Try to delete the file manually in order to reset it. Original error: ${e.message}`); + return false; + } + }); +} -export default extensions; +/** + * Creates device and common Simulator preferences, which could + * be later applied using `defaults` CLI utility. + * + * @this {CoreSimulatorWithSettings} + * @private + * @param {import('../types').RunOptions} [opts={}] + * @returns {any[]} The first array item is the resulting device preferences + * object and the second one is common preferences object + */ +export function compileSimulatorPreferences (opts = {}) { + const { + connectHardwareKeyboard, + tracePointer, + pasteboardAutomaticSync, + scaleFactor, + } = opts; + const commonPreferences = { + // This option is necessary to make the Simulator window follow + // the actual XCUIDevice orientation + RotateWindowWhenSignaledByGuest: true, + // https://github.com/appium/appium/issues/16418 + StartLastDeviceOnLaunch: false, + DetachOnWindowClose: false, + AttachBootedOnStart: true, + }; + const devicePreferences = opts.devicePreferences ? _.cloneDeep(opts.devicePreferences) : {}; + if (scaleFactor) { + devicePreferences.SimulatorWindowLastScale = parseFloat(scaleFactor); + } + if (_.isBoolean(connectHardwareKeyboard) || _.isNil(connectHardwareKeyboard)) { + devicePreferences.ConnectHardwareKeyboard = connectHardwareKeyboard ?? false; + commonPreferences.ConnectHardwareKeyboard = connectHardwareKeyboard ?? false; + } + if (_.isBoolean(tracePointer)) { + commonPreferences.ShowSingleTouches = tracePointer; + commonPreferences.ShowPinches = tracePointer; + commonPreferences.ShowPinchPivotPoint = tracePointer; + commonPreferences.HighlightEdgeGestures = tracePointer; + } + switch (_.lowerCase(pasteboardAutomaticSync)) { + case 'on': + commonPreferences.PasteboardAutomaticSync = true; + break; + case 'off': + // Improve launching simulator performance + // https://github.com/WebKit/webkit/blob/master/Tools/Scripts/webkitpy/xcode/simulated_device.py#L413 + commonPreferences.PasteboardAutomaticSync = false; + break; + case 'system': + // Do not add -PasteboardAutomaticSync + break; + default: + this.log.info(`['on', 'off' or 'system'] are available as the pasteboard automatic sync option. Defaulting to 'off'`); + commonPreferences.PasteboardAutomaticSync = false; + } + return [devicePreferences, commonPreferences]; +} + +/** + * Perform verification of device preferences correctness. + * + * @private + * @this {CoreSimulatorWithSettings} + * @param {import('../types').DevicePreferences} [prefs={}] - The preferences to be verified + * @returns {void} + * @throws {Error} If any of the given preference values does not match the expected + * format. + */ +export function verifyDevicePreferences (prefs = {}) { + if (_.isEmpty(prefs)) { + return; + } + + if (!_.isUndefined(prefs.SimulatorWindowLastScale)) { + if (!_.isNumber(prefs.SimulatorWindowLastScale) || prefs.SimulatorWindowLastScale <= 0) { + this.log.errorAndThrow(`SimulatorWindowLastScale is expected to be a positive float value. ` + + `'${prefs.SimulatorWindowLastScale}' is assigned instead.`); + } + } + + if (!_.isUndefined(prefs.SimulatorWindowCenter)) { + // https://regex101.com/r/2ZXOij/2 + const verificationPattern = /{-?\d+(\.\d+)?,-?\d+(\.\d+)?}/; + if (!_.isString(prefs.SimulatorWindowCenter) || !verificationPattern.test(prefs.SimulatorWindowCenter)) { + this.log.errorAndThrow(`SimulatorWindowCenter is expected to match "{floatXPosition,floatYPosition}" format (without spaces). ` + + `'${prefs.SimulatorWindowCenter}' is assigned instead.`); + } + } + + if (!_.isUndefined(prefs.SimulatorWindowOrientation)) { + const acceptableValues = ['Portrait', 'LandscapeLeft', 'PortraitUpsideDown', 'LandscapeRight']; + if (!prefs.SimulatorWindowOrientation || !acceptableValues.includes(prefs.SimulatorWindowOrientation)) { + this.log.errorAndThrow(`SimulatorWindowOrientation is expected to be one of ${acceptableValues}. ` + + `'${prefs.SimulatorWindowOrientation}' is assigned instead.`); + } + } + + if (!_.isUndefined(prefs.SimulatorWindowRotationAngle)) { + if (!_.isNumber(prefs.SimulatorWindowRotationAngle)) { + this.log.errorAndThrow(`SimulatorWindowRotationAngle is expected to be a valid number. ` + + `'${prefs.SimulatorWindowRotationAngle}' is assigned instead.`); + } + } +} + +/** + * @typedef {import('../types').CoreSimulator & import('../types').HasSettings} CoreSimulatorWithSettings + */ diff --git a/lib/logger.js b/lib/logger.js index f8a1e06..92a7739 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -1,18 +1,5 @@ import { logger } from '@appium/support'; -import _ from 'lodash'; +export const log = logger.getLogger('Simulator'); -let prefix = 'iOSSim'; -function setLoggingPlatform (platform) { - if (!_.isEmpty(platform)) { - prefix = `${platform}Sim`; - } -} - -const log = logger.getLogger(function getPrefix () { - return prefix; -}); - - -export { log, setLoggingPlatform }; export default log; diff --git a/lib/simulator-xcode-10.js b/lib/simulator-xcode-10.js index 9dbd284..76338af 100644 --- a/lib/simulator-xcode-10.js +++ b/lib/simulator-xcode-10.js @@ -1,79 +1,627 @@ -import SimulatorXcode93 from './simulator-xcode-9.3'; -import { fs, timing } from '@appium/support'; -import { waitForCondition } from 'asyncbox'; -import { MOBILE_SAFARI_BUNDLE_ID, SAFARI_STARTUP_TIMEOUT_MS } from './utils'; -import log from './logger'; +import { fs, timing, util } from '@appium/support'; +import { waitForCondition, retryInterval } from 'asyncbox'; +import { getDeveloperRoot, SIMULATOR_APP_NAME} from './utils'; +import { exec } from 'teen_process'; +import defaultLog from './logger'; +import EventEmitter from 'events'; +import AsyncLock from 'async-lock'; +import _ from 'lodash'; +import path from 'path'; +import B from 'bluebird'; +import { getPath as getXcodePath } from 'appium-xcode'; +import Simctl from 'node-simctl'; +import * as appExtensions from './extensions/applications'; +import * as biometricExtensions from './extensions/biometric'; +import * as safariExtensions from './extensions/safari'; +import * as keychainExtensions from './extensions/keychain'; +import * as geolocationExtensions from './extensions/geolocation'; +import * as settingsExtensions from './extensions/settings'; +import * as permissionsExtensions from './extensions/permissions'; +import * as miscExtensions from './extensions/misc'; -class SimulatorXcode10 extends SimulatorXcode93 { - constructor (udid, xcodeVersion) { - super(udid, xcodeVersion); +const SIMULATOR_SHUTDOWN_TIMEOUT = 15 * 1000; +const STARTUP_LOCK = new AsyncLock(); +const UI_CLIENT_BUNDLE_ID = 'com.apple.iphonesimulator'; +const STARTUP_TIMEOUT_MS = 120 * 1000; + +/** + * @typedef {import('./types').CoreSimulator} CoreSimulator + * @typedef {import('./types').HasSettings} HasSettings + * @typedef {import('./types').InteractsWithApps} InteractsWithApps + * @typedef {import('./types').InteractsWithKeychain} InteractsWithKeychain + * @typedef {import('./types').SupportsGeolocation} SupportsGeolocation + * @typedef {import('./types').HasMiscFeatures} HasMiscFeatures + * @typedef {import('./types').InteractsWithSafariBrowser} InteractsWithSafariBrowser + * @typedef {import('./types').SupportsBiometric} SupportsBiometric + */ + +/** + * @implements {CoreSimulator} + * @implements {HasSettings} + * @implements {InteractsWithApps} + * @implements {InteractsWithKeychain} + * @implements {SupportsGeolocation} + * @implements {HasMiscFeatures} + * @implements {InteractsWithSafariBrowser} + * @implements {SupportsBiometric} + */ +export class SimulatorXcode10 extends EventEmitter { + /** @type {string|undefined|null} */ + _keychainsBackupPath; + + /** @type {string|undefined|null} */ + _platformVersion; + + /** @type {string|undefined|null} */ + _webInspectorSocket; + + /** + * Constructs the object with the `udid` and version of Xcode. Use the exported `getSimulator(udid)` method instead. + * + * @param {string} udid - The Simulator ID. + * @param {import('appium-xcode').XcodeVersion} xcodeVersion - The target Xcode version in format {major, minor, build}. + * @param {import('@appium/types').AppiumLogger?} log + */ + constructor (udid, xcodeVersion, log = null) { + super(); + + this._udid = String(udid); + this._simctl = new Simctl({ + udid: this._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._idb = null; + this._webInspectorSocket = null; + this._log = log ?? defaultLog; + } + + /** + * @returns {string} + */ + get udid() { + return this._udid; } /** - * Verify whether the particular application is installed on Simulator. + * @returns {Simctl} + */ + get simctl() { + return this._simctl; + } + + /** + * @returns {import('appium-xcode').XcodeVersion} + */ + get xcodeVersion() { + return this._xcodeVersion; + } + + /** + * @returns {string} + */ + get keychainPath() { + return path.resolve(this.getDir(), 'Library', 'Keychains'); + } + + /** + * @return {import('@appium/types').AppiumLogger} + */ + get log() { + return this._log; + } + + /** + * @return {string} Bundle identifier of Simulator UI client. + */ + get uiClientBundleId () { + return UI_CLIENT_BUNDLE_ID; + } + + /** + * @return {number} The max number of milliseconds to wait until Simulator booting is completed. + */ + get startupTimeout () { + return STARTUP_TIMEOUT_MS; + } + + /** + * @return {?string} The full path to the devices set where the current simulator is located. + * `null` value means that the default path is used, which is usually `~/Library/Developer/CoreSimulator/Devices` + */ + get devicesSetPath () { + return this.simctl.devicesSetPath; + } + + /** + * Set the full path to the devices set. It is recommended to set this value + * once right after Simulator instance is created and to not change it during + * the instance lifecycle * - * @param {string} bundleId - The bundle id of the application to be checked. - * @return {Promise} True if the given application is installed. + * @param {?string} value The full path to the devices set root on the + * local file system */ - async isAppInstalled (bundleId) { - try { - const appContainer = await this.simctl.getAppContainer(bundleId); - if (!appContainer.endsWith('.app')) { - return false; + set devicesSetPath (value) { + this.simctl.devicesSetPath = value; + } + + /** + * IDB instance setter + * + * @param {any} value + */ + set idb (value) { + this._idb = value; + } + + /** + * @return {Promise} idb instance + */ + get idb () { + return this._idb; + } + + /** + * Retrieve the full path to the directory where Simulator stuff is located. + * + * @return {string} The path string. + */ + getRootDir () { + return path.resolve(process.env.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 () { + return path.resolve(process.env.HOME ?? '', 'Library', 'Logs', 'CoreSimulator', this.udid); + } + + /** + * Get the state and specifics of this sim. + * + * @return {Promise>} Simulator stats mapping, for example: + * { name: 'iPhone 4s', + * udid: 'C09B34E5-7DCB-442E-B79C-AB6BC0357417', + * state: 'Shutdown', + * sdk: '8.3' + * } + */ + async stat () { + for (const [sdk, deviceArr] of _.toPairs(await this.simctl.getDevices())) { + for (let device of deviceArr) { + if (device.udid === this.udid) { + device.sdk = sdk; + return device; + } } - return await fs.exists(appContainer); - } catch (err) { - // get_app_container subcommand fails for system applications, - // so we try the hidden appinfo subcommand, which prints correct info for - // system/hidden apps + } + + return {}; + } + + /** + * Check if the Simulator has been booted at least once + * and has not been erased before + * + * @return {Promise} True if the current Simulator has never been started before + */ + async isFresh () { + const cachesRoot = path.resolve(this.getDir(), 'Library', 'Caches'); + return (await fs.exists(cachesRoot)) + ? (await fs.glob('*', {cwd: cachesRoot})).length === 0 + : true; + } + + /** + * Retrieves the state of the current Simulator. One should distinguish the + * states of Simulator UI and the Simulator itself. + * + * @return {Promise} True if the current Simulator is running. + */ + async isRunning () { + try { + await this.simctl.getEnv('dummy'); + return true; + } catch (e) { + return false; + } + } + + /** + * Checks if the simulator is in shutdown state. + * This method is necessary, because Simulator might also be + * in the transitional Shutting Down state right after the `shutdown` + * command has been issued. + * + * @return {Promise} True if the current Simulator is shut down. + */ + async isShutdown () { + try { + await this.simctl.getEnv('dummy'); + return false; + } catch (e) { + return _.includes(e.stderr, 'Current state: Shutdown'); + } + } + + /** + * Retrieves the current process id of the UI client + * + * @return {Promise} The process ID or null if the UI client is not running + */ + async getUIClientPid () { + let stdout; + try { + ({stdout} = await exec('pgrep', ['-fn', `${SIMULATOR_APP_NAME}/Contents/MacOS/`])); + } catch (e) { + return null; + } + if (isNaN(parseInt(stdout, 10))) { + return null; + } + stdout = stdout.trim(); + this.log.debug(`Got Simulator UI client PID: ${stdout}`); + return stdout; + } + + /** + * Check the state of Simulator UI client. + * + * @return {Promise} True of if UI client is running or false otherwise. + */ + async isUIClientRunning () { + return !_.isNull(await this.getUIClientPid()); + } + + /** + * Get the platform version of the current Simulator. + * + * @return {Promise} SDK version, for example '8.3'. + */ + async getPlatformVersion () { + if (!this._platformVersion) { + const {sdk} = await this.stat(); + this._platformVersion = sdk; + } + return /** @type {string} */ (this._platformVersion); + } + + /** + * Boots Simulator if not already booted. + * Does nothing if it is already running. + * This API does NOT wait until Simulator is fully booted. + * + * @throws {Error} If there was a failure while booting the Simulator. + */ + async boot () { + const bootEventsEmitter = new EventEmitter(); + await this.simctl.startBootMonitor({ + onError: (err) => bootEventsEmitter.emit('failure', err), + onFinished: () => bootEventsEmitter.emit('finish'), + shouldPreboot: true, + }); + try { + await new B((resolve, reject) => { + // Historically this call was always asynchronous, + // e.g. it was not waiting until Simulator is fully booted. + // So we preserve that behavior, and if no errors are received for a while + // then we assume the Simulator booting is still in progress. + setTimeout(resolve, 3000); + bootEventsEmitter.once('failure', (err) => { + if (_.includes(err?.message, 'state: Booted')) { + resolve(); + } else { + reject(err); + } + }); + bootEventsEmitter.once('finish', resolve); + }); + } finally { + bootEventsEmitter.removeAllListeners(); + } + } + + /** + * 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. + */ + async waitForBoot (startupTimeout) { + await this.simctl.startBootMonitor({timeout: startupTimeout}); + } + + /** + * Reset the current Simulator to the clean state. + * It is expected the simulator is in shutdown state when this API is called. + */ + async clean () { + this.log.info(`Cleaning simulator ${this.udid}`); + await this.simctl.eraseDevice(10000); + } + + /** + * Delete the particular Simulator from devices list + */ + async delete () { + await this.simctl.deleteDevice(); + } + + /** + * Shut down the current Simulator. + * + * @param {import('./types').ShutdownOptions} [opts={}] + * @throws {Error} If Simulator fails to transition into Shutdown state after + * the given timeout + */ + async shutdown (opts = {}) { + if (await this.isShutdown()) { + return; + } + + await retryInterval(5, 500, this.simctl.shutdownDevice.bind(this.simctl)); + const waitMs = parseInt(`${opts.timeout ?? 0}`, 10); + if (waitMs > 0) { try { - const info = await this.simctl.appInfo(bundleId); - return info.includes('ApplicationType'); - } catch (ign) {} + await waitForCondition(async () => await this.isShutdown(), { + waitMs, + intervalMs: 100, + }); + } catch (err) { + throw new Error(`Simulator is not in 'Shutdown' state after ${waitMs}ms`); + } } - return false; } /** - * @param {string} url + * Boots simulator and opens simulators UI Client if not already opened. + * + * @param {boolean} isUiClientRunning - process id of simulator UI client. + * @param {import('./types').RunOptions} [opts={}] - arguments to start simulator UI client with. */ - async openUrl (url) { - if (!await this.isRunning()) { - throw new Error(`Tried to open '${url}', but Simulator is not in Booted state`); + async launchWindow (isUiClientRunning, opts = {}) { + await this.boot(); + if (!isUiClientRunning) { + await this.startUIClient(opts); } - const timer = new timing.Timer().start(); - await this.simctl.openUrl(url); - /** @type {Error|undefined|null} */ - let psError; + } + + /** + * Start the Simulator UI client with the given arguments + * + * @param {import('./types').StartUiClientOptions} [opts={}] - Simulator startup options + */ + async startUIClient (opts = {}) { + opts = _.cloneDeep(opts); + _.defaultsDeep(opts, { + startupTimeout: this.startupTimeout, + }); + + const simulatorApp = path.resolve(await getXcodePath(), 'Applications', SIMULATOR_APP_NAME); + const args = ['-Fn', simulatorApp]; + this.log.info(`Starting Simulator UI: ${util.quote(['open', ...args])}`); try { - await waitForCondition(async () => { - let procList = []; + await exec('open', args, {timeout: opts.startupTimeout}); + } catch (err) { + throw new Error(`Got an unexpected error while opening Simulator UI: ` + + err.stderr || err.stdout || err.message); + } + } + + /** + * Executes given Simulator with options. The Simulator will not be restarted if + * it is already running and the current UI state matches to `isHeadless` option. + * + * @param {import('./types').RunOptions} [opts={}] - One or more of available Simulator options + */ + async run (opts = {}) { + opts = _.cloneDeep(opts); + _.defaultsDeep(opts, { + isHeadless: false, + startupTimeout: this.startupTimeout, + }); + + const [devicePreferences, commonPreferences] = settingsExtensions.compileSimulatorPreferences.bind(this)(opts); + await settingsExtensions.updatePreferences.bind(this)(devicePreferences, commonPreferences); + + const timer = new timing.Timer().start(); + const shouldWaitForBoot = await STARTUP_LOCK.acquire(this.uiClientBundleId, async () => { + const isServerRunning = await this.isRunning(); + const uiClientPid = await this.getUIClientPid(); + if (opts.isHeadless) { + if (isServerRunning && !uiClientPid) { + this.log.info(`Simulator with UDID '${this.udid}' is already booted in headless mode.`); + return false; + } + if (await this.killUIClient({pid: uiClientPid})) { + this.log.info(`Detected the Simulator UI client was running and killed it. Verifying the current Simulator state`); + } try { - procList = await this.ps(); - psError = null; + // Stopping the UI client kills all running servers for some early XCode versions. This is a known bug + await waitForCondition(async () => await this.isShutdown(), { + waitMs: 5000, + intervalMs: 100, + }); } catch (e) { - log.debug(e.message); - psError = e; + if (!await this.isRunning()) { + throw new Error(`Simulator with UDID '${this.udid}' cannot be transitioned to headless mode`); + } + return false; } - return procList.some(({name}) => name === MOBILE_SAFARI_BUNDLE_ID); - }, { - waitMs: SAFARI_STARTUP_TIMEOUT_MS, - intervalMs: 500, - }); - } catch (err) { - const secondsElapsed = timer.getDuration().asSeconds; - if (psError) { - log.warn(`Mobile Safari process existence cannot be verified after ${secondsElapsed.toFixed(3)}s. ` + - `Original error: ${psError.message}`); - log.warn('Continuing anyway'); + this.log.info(`Booting Simulator with UDID '${this.udid}' in headless mode. ` + + `All UI-related capabilities are going to be ignored`); + await this.boot(); } else { - throw new Error(`Mobile Safari cannot open '${url}' after ${secondsElapsed.toFixed(3)}s. ` + - `Its process ${MOBILE_SAFARI_BUNDLE_ID} does not exist in the list of Simulator processes`); + if (isServerRunning && uiClientPid) { + this.log.info(`Both Simulator with UDID '${this.udid}' and the UI client are currently running`); + return false; + } + if (isServerRunning) { + this.log.info(`Simulator '${this.udid}' is booted while its UI is not visible. ` + + `Trying to restart it with the Simulator window visible`); + await this.shutdown({timeout: SIMULATOR_SHUTDOWN_TIMEOUT}); + } + await this.launchWindow(Boolean(uiClientPid), opts); } + return true; + }); + + if (shouldWaitForBoot && opts.startupTimeout) { + await this.waitForBoot(opts.startupTimeout); + this.log.info(`Simulator with UDID ${this.udid} booted in ${timer.getDuration().asSeconds.toFixed(3)}s`); } - log.debug(`Safari successfully opened '${url}' in ${timer.getDuration().asSeconds.toFixed(3)}s`); + + (async () => { + try { + await this.disableKeyboardIntroduction(); + } catch (e) { + this.log.info(`Cannot disable Simulator keyboard introduction. Original error: ${e.message}`); + } + })(); } -} -export default SimulatorXcode10; + /** + * Kill the UI client if it is running. + * + * @param {import('./types').KillUiClientOptions} [opts={}] + * @return {Promise} True if the UI client was successfully killed or false + * if it is not running. + * @throws {Error} If sending the signal to the client process fails + */ + async killUIClient (opts = {}) { + let { + pid, + signal = 2, + } = opts; + const clientPid = pid || await this.getUIClientPid(); + if (!clientPid) { + return false; + } + + this.log.debug(`Sending ${signal} kill signal to Simulator UI client with PID ${clientPid}`); + try { + await exec('kill', [`-${signal}`, `${clientPid}`]); + return true; + } catch (e) { + if (e.code === 1) { + return false; + } + throw new Error(`Cannot kill the Simulator UI client. Original error: ${e.message}`); + } + } + + /** + * Lists processes that are currently running on the given Simulator. + * The simulator must be in running state in order for this + * method to work properly. + * + * @return {Promise} The list of retrieved process + * information + * @throws {Error} if no process information could be retrieved. + */ + async ps () { + const {stdout} = await this.simctl.spawnProcess([ + 'launchctl', + 'print', + 'system', + ]); + + const servicesMatch = /^\s*services\s*=\s*{([^}]+)/m.exec(stdout); + if (!servicesMatch) { + this.log.debug(stdout); + throw new Error(`The list of active processes cannot be retrieved`); + } + /* + Example match: + 0 78 com.apple.resourcegrabberd + 82158 - com.apple.assistant_service + 82120 - com.apple.nanoregistryd + 82087 - com.apple.notifyd + 82264 - UIKitApplication:com.apple.Preferences[704b][rb-legacy] + */ + /** @type {import('./types').ProcessInfo[]} */ + const result = []; + const pattern = /^\s*(\d+)\s+[\d-]+\s+([\w\-.]+:)?([\w\-.]+)/gm; + let match; + while ((match = pattern.exec(servicesMatch[1]))) { + result.push({ + pid: parseInt(match[1], 10), + group: _.trimEnd(match[2], ':') || null, + name: match[3], + }); + } + return result; + } + + /** + * @returns {Promise} + */ + async getLaunchDaemonsRoot () { + const devRoot = await getDeveloperRoot(); + return path.resolve(devRoot, + 'Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/LaunchDaemons'); + } + + installApp = appExtensions.installApp; + getUserInstalledBundleIdsByBundleName = appExtensions.getUserInstalledBundleIdsByBundleName; + isAppInstalled = appExtensions.isAppInstalled; + removeApp = appExtensions.removeApp; + launchApp = appExtensions.launchApp; + terminateApp = appExtensions.terminateApp; + isAppRunning = appExtensions.isAppRunning; + scrubApp = appExtensions.scrubApp; + + openUrl = safariExtensions.openUrl; + scrubSafari = safariExtensions.scrubSafari; + updateSafariSettings = safariExtensions.updateSafariSettings; + getWebInspectorSocket = /** @type {() => Promise} */ ( + /** @type {unknown} */ (safariExtensions.getWebInspectorSocket) + ); + + isBiometricEnrolled = biometricExtensions.isBiometricEnrolled; + enrollBiometric = biometricExtensions.enrollBiometric; + sendBiometricMatch = biometricExtensions.sendBiometricMatch; + + setGeolocation = geolocationExtensions.setGeolocation; + + backupKeychains = /** @type {() => Promise} */ ( + /** @type {unknown} */ (keychainExtensions.backupKeychains) + ); + restoreKeychains = /** @type {() => Promise} */ ( + /** @type {unknown} */ (keychainExtensions.restoreKeychains) + ); + clearKeychains = keychainExtensions.clearKeychains; + + shake = miscExtensions.shake; + addCertificate = miscExtensions.addCertificate; + pushNotification = miscExtensions.pushNotification; + + setPermission = permissionsExtensions.setPermission; + setPermissions = permissionsExtensions.setPermissions; + getPermission = permissionsExtensions.getPermission; + + updateSettings = settingsExtensions.updateSettings; + setAppearance = settingsExtensions.setAppearance; + getAppearance = settingsExtensions.getAppearance; + configureLocalization = settingsExtensions.configureLocalization; + setAutoFillPasswords = settingsExtensions.setAutoFillPasswords; + setReduceMotion = settingsExtensions.setReduceMotion; + setReduceTransparency = settingsExtensions.setReduceTransparency; + disableKeyboardIntroduction = settingsExtensions.disableKeyboardIntroduction; +} diff --git a/lib/simulator-xcode-11.4.js b/lib/simulator-xcode-11.4.js index 1cb595d..ebd69d8 100644 --- a/lib/simulator-xcode-11.4.js +++ b/lib/simulator-xcode-11.4.js @@ -1,71 +1,61 @@ import _ from 'lodash'; -import SimulatorXcode11 from './simulator-xcode-11'; - -class SimulatorXcode11_4 extends SimulatorXcode11 { - constructor (udid, xcodeVersion) { - super(udid, xcodeVersion); - - // for setting the location using AppleScript, the top-level menu through which - // the 'Location' option is found - this._locationMenu = 'Features'; - } +import { SimulatorXcode11 } from './simulator-xcode-11'; +export class SimulatorXcode11_4 extends SimulatorXcode11 { /** * Sets UI appearance style. * This function can only be called on a booted simulator. * + * @override * @since Xcode SDK 11.4 * @param {string} value one of possible appearance values: * - dark: to switch to the Dark mode * - light: to switch to the Light mode + * @returns {Promise} */ - async setAppearance (value) { + setAppearance = async (value) => { await this.simctl.setAppearance(_.toLower(value)); - } + }; /** * Gets the current UI appearance style * This function can only be called on a booted simulator. * + * @override * @since Xcode SDK 11.4 * @returns {Promise} the current UI appearance style. * Possible values are: * - dark: to switch to the Dark mode * - light: to switch to the Light mode */ - async getAppearance () { - return await this.simctl.getAppearance(); - } - - /** - * @typedef {Object} CertificateOptions - * @property {boolean} isRoot [true] - Whether to install the given - * certificate into the Trusted Root store (`true`) or to the keychain - * (`false`) - */ + getAppearance = async () => await this.simctl.getAppearance(); /** * Adds the given certificate to the booted simulator. * The simulator could be in both running and shutdown states * in order for this method to run as expected. * + * @override + * @since Xcode 11.4 * @param {string} payload the content of the PEM certificate - * @param {Partial} opts + * @param {import('./types').CertificateOptions} [opts={}] + * @returns {Promise} */ - async addCertificate (payload, opts = {}) { + addCertificate = async (payload, opts = {}) => { const { isRoot = true, } = opts; const methodName = isRoot ? 'addRootCertificate' : 'addCertificate'; await this.simctl[methodName](payload, {raw: true}); return true; - } + }; /** * Simulates push notification delivery to the booted simulator * + * @override * @since Xcode SDK 11.4 - * @param {Object} payload - The object that describes Apple push notification content. + * @param {import('@appium/types').StringRecord} payload - The object that describes Apple push notification content. * It must contain a top-level "Simulator Target Bundle" key with a string value matching * the target application‘s bundle identifier and "aps" key with valid Apple Push Notification values. * For example: @@ -77,21 +67,29 @@ class SimulatorXcode11_4 extends SimulatorXcode11 { * "sound": "default" * } * } + * @returns {Promise} */ - async pushNotification (payload) { + pushNotification = async (payload) => { await this.simctl.pushNotification(payload); - } + }; /** + * @override + * @inheritdoc + * + * @returns {Promise} */ - async clearKeychains () { + clearKeychains = async () => { await this.simctl.resetKeychain(); - } + }; /** * @inheritdoc * @override - * */ + * + * @param {boolean} isUiClientRunning - process id of simulator UI client. + * @param {import('./types').RunOptions} [opts={}] - arguments to start simulator UI client with. + */ async launchWindow (isUiClientRunning, opts) { // In xcode 11.4, UI Client must be first launched, otherwise // sim window stays minimized @@ -104,6 +102,8 @@ class SimulatorXcode11_4 extends SimulatorXcode11 { /** * @inheritdoc * @override + * + * @return {Promise} */ async ps () { const {stdout} = await this.simctl.spawnProcess([ @@ -155,7 +155,4 @@ class SimulatorXcode11_4 extends SimulatorXcode11 { } return result; } - } - -export default SimulatorXcode11_4; diff --git a/lib/simulator-xcode-11.js b/lib/simulator-xcode-11.js index 2188b35..98a419a 100644 --- a/lib/simulator-xcode-11.js +++ b/lib/simulator-xcode-11.js @@ -1,15 +1,11 @@ -import SimulatorXcode10 from './simulator-xcode-10'; +import { SimulatorXcode10 } from './simulator-xcode-10'; import path from 'path'; import { getDeveloperRoot } from './utils.js'; - -class SimulatorXcode11 extends SimulatorXcode10 { - constructor (udid, xcodeVersion) { - super(udid, xcodeVersion); - } - +export class SimulatorXcode11 extends SimulatorXcode10 { /** * @override + * @return {Promise} */ async getLaunchDaemonsRoot () { const devRoot = await getDeveloperRoot(); @@ -17,5 +13,3 @@ class SimulatorXcode11 extends SimulatorXcode10 { 'Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/LaunchDaemons'); } } - -export default SimulatorXcode11; diff --git a/lib/simulator-xcode-14.js b/lib/simulator-xcode-14.js index 4b5e719..2ec6990 100644 --- a/lib/simulator-xcode-14.js +++ b/lib/simulator-xcode-14.js @@ -1,13 +1,19 @@ -import SimulatorXcode11_4 from './simulator-xcode-11.4'; +import { SimulatorXcode11_4 } from './simulator-xcode-11.4'; -class SimulatorXcode14 extends SimulatorXcode11_4 { +/** + * @typedef {import('./types').SupportsGeolocation} SupportsGeolocation + */ + +export class SimulatorXcode14 extends SimulatorXcode11_4 { /** + * @override + * @inheritdoc * @param {string|number} latitude * @param {string|number} longitude + * @returns {Promise} */ - async setGeolocation (latitude, longitude) { + setGeolocation = async (latitude, longitude) => { await this.simctl.setLocation(latitude, longitude); - } + return true; + }; } - -export default SimulatorXcode14; diff --git a/lib/simulator-xcode-15.js b/lib/simulator-xcode-15.js index e8c3f41..3becdd3 100644 --- a/lib/simulator-xcode-15.js +++ b/lib/simulator-xcode-15.js @@ -3,12 +3,25 @@ import { exec } from 'teen_process'; import path from 'path'; import _ from 'lodash'; import B from 'bluebird'; -import SimulatorXcode14 from './simulator-xcode-14'; +import { SimulatorXcode14 } from './simulator-xcode-14'; -class SimulatorXcode15 extends SimulatorXcode14 { +export class SimulatorXcode15 extends SimulatorXcode14 { /** @type {Set} */ _systemAppBundleIds; + /** + * Retrives the full path to where the simulator system R/O volume is mounted + * + * @returns {Promise} + */ + async _getSystemRoot() { + const simRoot = await this.simctl.getEnv('IPHONE_SIMULATOR_ROOT'); + if (!simRoot) { + throw new Error('The IPHONE_SIMULATOR_ROOT environment variable value cannot be retrieved'); + } + return _.trim(simRoot); + } + /** * Collects and caches bundle indetifier of system Simulator apps * @@ -19,10 +32,7 @@ class SimulatorXcode15 extends SimulatorXcode14 { return this._systemAppBundleIds; } - const appsRoot = path.resolve( - _.trim(await this.simctl.getEnv('IPHONE_SIMULATOR_ROOT')), - 'Applications' - ); + const appsRoot = path.resolve(await this._getSystemRoot(), 'Applications'); const fetchBundleId = async (appRoot) => { const infoPlistPath = path.resolve(appRoot, 'Info.plist'); try { @@ -45,13 +55,13 @@ class SimulatorXcode15 extends SimulatorXcode14 { } /** - * Verify whether the particular application is installed on Simulator. * @override + * @inheritdoc * * @param {string} bundleId - The bundle id of the application to be checked. * @return {Promise} True if the given application is installed. */ - async isAppInstalled (bundleId) { + isAppInstalled = async (bundleId) => { try { const appContainer = await this.simctl.getAppContainer(bundleId); return appContainer.endsWith('.app') && await fs.exists(appContainer); @@ -60,18 +70,20 @@ class SimulatorXcode15 extends SimulatorXcode14 { // as well as the hidden appinfo command return (await this._fetchSystemAppBundleIds()).has(bundleId); } - } + }; /** * @override * @inheritdoc + * + * @returns {Promise} */ async getLaunchDaemonsRoot () { - return path.resolve( - _.trim(await this.simctl.getEnv('IPHONE_SIMULATOR_ROOT')), - 'System/Library/LaunchDaemons' - ); + const simRoot = await this.simctl.getEnv('IPHONE_SIMULATOR_ROOT'); + if (!simRoot) { + throw new Error('The IPHONE_SIMULATOR_ROOT environment variable value cannot be retrieved'); + } + + return path.resolve(await this._getSystemRoot(), 'System/Library/LaunchDaemons'); } } - -export default SimulatorXcode15; diff --git a/lib/simulator-xcode-8.js b/lib/simulator-xcode-8.js deleted file mode 100644 index 86ffc32..0000000 --- a/lib/simulator-xcode-8.js +++ /dev/null @@ -1,540 +0,0 @@ -import _ from 'lodash'; -import log from './logger'; -import { exec } from 'teen_process'; -import { activateApp, SIMULATOR_APP_NAME } from './utils'; -import path from 'path'; -import { getPath as getXcodePath } from 'appium-xcode'; -import { fs, timing } from '@appium/support'; -import AsyncLock from 'async-lock'; -import { retryInterval, waitForCondition } from 'asyncbox'; -import { EventEmitter } from 'events'; -import Simctl from 'node-simctl'; -import extensions from './extensions/index'; - -/* - * 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'; - -const STARTUP_TIMEOUT_MS = 120 * 1000; -const UI_CLIENT_ACCESS_GUARD = new AsyncLock(); -const UI_CLIENT_BUNDLE_ID = 'com.apple.iphonesimulator'; - - -class SimulatorXcode8 extends EventEmitter { - /** @type {string|undefined|null} */ - _keychainsBackupPath; - - /** - * Constructs the object with the `udid` and version of Xcode. Use the exported `getSimulator(udid)` method instead. - * - * @param {string} udid - The Simulator ID. - * @param {import('appium-xcode').XcodeVersion} xcodeVersion - The target Xcode version in format {major, minor, build}. - */ - constructor (udid, xcodeVersion) { - super(); - - this.udid = String(udid); - this.simctl = new Simctl({ - udid: this.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._idb = null; - - // for setting the location using AppleScript, the top-level menu through which - // the 'Location' option is found - this._locationMenu = 'Debug'; - } - - /** - * @return {string} Bundle identifier of Simulator UI client. - */ - get uiClientBundleId () { - return UI_CLIENT_BUNDLE_ID; - } - - /** - * @return {?string} The full path to the devices set where the current simulator is located. - * `null` value means that the default path is used, which is usually `~/Library/Developer/CoreSimulator/Devices` - */ - get devicesSetPath () { - return this.simctl.devicesSetPath; - } - - /** - * Set the full path to the devices set. It is recommended to set this value - * once right after Simulator instance is created and to not change it during - * the instance lifecycle - * - * @param {?string} value The full path to the devices set root on the - * local file system - */ - set devicesSetPath (value) { - this.simctl.devicesSetPath = value; - } - - /** - * Retrieves the current process id of the UI client - * - * @return {Promise} The process ID or null if the UI client is not running - */ - async getUIClientPid () { - let stdout; - try { - ({stdout} = await exec('pgrep', ['-fn', `${SIMULATOR_APP_NAME}/Contents/MacOS/`])); - } catch (e) { - return null; - } - if (isNaN(parseInt(stdout, 10))) { - return null; - } - stdout = stdout.trim(); - log.debug(`Got Simulator UI client PID: ${stdout}`); - return stdout; - } - - /** - * Check the state of Simulator UI client. - * - * @return {Promise} True of if UI client is running or false otherwise. - */ - async isUIClientRunning () { - return !_.isNull(await this.getUIClientPid()); - } - - /** - * Get the platform version of the current Simulator. - * - * @return {Promise} SDK version, for example '8.3'. - */ - async getPlatformVersion () { - if (!this._platformVersion) { - const {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 () { - return path.resolve(process.env.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 () { - return path.resolve(process.env.HOME ?? '', 'Library', 'Logs', 'CoreSimulator', this.udid); - } - - /** - * Get the state and specifics of this sim. - * - * @return {Promise>} Simulator stats mapping, for example: - * { name: 'iPhone 4s', - * udid: 'C09B34E5-7DCB-442E-B79C-AB6BC0357417', - * state: 'Shutdown', - * sdk: '8.3' - * } - */ - async stat () { - for (const [sdk, deviceArr] of _.toPairs(await this.simctl.getDevices())) { - for (let device of deviceArr) { - if (device.udid === this.udid) { - device.sdk = sdk; - return device; - } - } - } - - return {}; - } - - /** - * Check if the Simulator has been booted at least once - * and has not been erased before - * - * @return {Promise} True if the current Simulator has never been started before - */ - async isFresh () { - const cachesRoot = path.resolve(this.getDir(), 'Library', 'Caches'); - return (await fs.exists(cachesRoot)) - ? (await fs.glob('*', {cwd: cachesRoot})).length === 0 - : true; - } - - /** - * Retrieves the state of the current Simulator. One should distinguish the - * states of Simulator UI and the Simulator itself. - * - * @return {Promise} True if the current Simulator is running. - */ - async isRunning () { - try { - await this.simctl.getEnv('dummy'); - return true; - } catch (e) { - return false; - } - } - - /** - * Checks if the simulator is in shutdown state. - * This method is necessary, because Simulator might also be - * in the transitional Shutting Down state right after the `shutdown` - * command has been issued. - * - * @return {Promise} True if the current Simulator is shut down. - */ - async isShutdown () { - try { - await this.simctl.getEnv('dummy'); - return false; - } catch (e) { - return _.includes(e.stderr, 'Current state: Shutdown'); - } - } - - /** - * @typedef {Object} SimulatorOptions - * @property {?string} scaleFactor [null] - 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. - * It should be one of ['1.0', '0.75', '0.5', '0.33', '0.25']. - * @property {number} startupTimeout [60000] - Number of milliseconds to wait until Simulator booting - * process is completed. The default timeout will be used if not set explicitly. - */ - - /** - * Start the Simulator UI client with the given arguments - * @param {Partial} opts - Simulator startup options - */ - async startUIClient (opts = {}) { - opts = _.cloneDeep(opts); - _.defaultsDeep(opts, { - scaleFactor: null, - startupTimeout: this.startupTimeout, - }); - - const simulatorApp = path.resolve(await getXcodePath(), 'Applications', SIMULATOR_APP_NAME); - const args = [ - '-Fn', simulatorApp, - '--args', '-CurrentDeviceUDID', this.udid, - ]; - - if (opts.scaleFactor) { - const {name} = await this.stat(); - const formattedDeviceName = name.replace(/\s+/g, '-'); - const argumentName = `-SimulatorWindowLastScale-com.apple.CoreSimulator.SimDeviceType.${formattedDeviceName}`; - args.push(argumentName, opts.scaleFactor); - } - - log.info(`Starting Simulator UI with command: open ${args.join(' ')}`); - try { - await exec('open', args, {timeout: opts.startupTimeout}); - } catch (err) { - if (!(err.stdout || '').includes('-10825') && !(err.stderr || '').includes('-10825')) { - throw err; - } - log.warn(`Error while opening UI: ${err.stdout || err.stderr}. Continuing`); - } - } - - /** - * 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 isServerRunning = await this.isRunning(); - 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 timer = new timing.Timer().start(); - 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 ${timer.getDuration().asSeconds.toFixed(3)}s`); - } - - /** - * Reset the current Simulator to the clean state. - * It is expected the simulator is in shutdown state when this API is called. - */ - async clean () { - log.info(`Cleaning simulator ${this.udid}`); - await this.simctl.eraseDevice(10000); - } - - /** - * @typedef {Object} ShutdownOptions - * @property {?number|string} timeout The number of milliseconds to wait until - * Simulator is shut down completely. No wait happens if the timeout value is not set - */ - - /** - * Shut down the current Simulator. - * - * @param {Partial} opts - * @throws {Error} If Simulator fails to transition into Shutdown state after - * the given timeout - */ - async shutdown (opts = {}) { - if (await this.isShutdown()) { - return; - } - - await retryInterval(5, 500, this.simctl.shutdownDevice.bind(this.simctl)); - const waitMs = parseInt(`${opts.timeout ?? 0}`, 10); - if (waitMs > 0) { - try { - await waitForCondition(async () => await this.isShutdown(), { - waitMs, - intervalMs: 100, - }); - } catch (err) { - throw new Error(`Simulator is not in 'Shutdown' state after ${waitMs}ms`); - } - } - } - - /** - * Delete the particular Simulator from devices list - */ - async delete () { - await this.simctl.deleteDevice(); - } - - /** - * Activates Simulator window. - * - * @protected - * @returns {Promise} If the method returns a string then it should be a valid Apple Script which - * is appended before each UI client command is executed. Otherwise the method should activate the window - * itself and return nothing. - */ - async _activateWindow () { - const pid = await this.getUIClientPid(); - if (pid) { - try { - await activateApp(pid); - return null; - } catch (e) { - log.debug(e.stderr || e.message); - } - } - return ` - tell application "System Events" - tell process "Simulator" - set frontmost to false - set frontmost to true - end tell - end tell - `; - } - - /** - * 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 {Promise} The stdout output produced by the script. - * @throws {Error} If osascript tool returns non-zero exit code. - */ - async executeUIClientScript (appleScript) { - const windowActivationScript = await this._activateWindow(); - const resultScript = `${windowActivationScript ? windowActivationScript + '\n' : ''}${appleScript}`; - log.debug(`Executing UI Apple Script on Simulator with UDID ${this.udid}: ${resultScript}`); - return await UI_CLIENT_ACCESS_GUARD.acquire(SIMULATOR_APP_NAME, async () => { - try { - const {stdout} = await exec('osascript', ['-e', resultScript]); - return stdout; - } catch (err) { - log.errorAndThrow( - `Could not complete operation. Make sure Simulator 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. If the operation is still unsuccessful then ` + - `it is not supported by this Simulator. Original error: ${err.message}` - ); - } - }); - } - - /** - * @typedef {Object} ProcessInfo - * @property {number} pid The actual process identifier. - * Could be zero if the process is the system one. - * @property {?string} group The process group identifier. - * This could be `null` if the process is not a part of the - * particular group. For `normal` application processes the group - * name usually equals to `UIKitApplication`. - * @property {string} name The process name, for example - * `com.apple.Preferences` - */ - - /** - * Lists processes that are currently running on the given Simulator. - * The simulator must be in running state in order for this - * method to work properly. - * - * @return {Promise} The list of retrieved process - * information - * @throws {Error} if no process information could be retrieved. - */ - async ps () { - const {stdout} = await this.simctl.spawnProcess([ - 'launchctl', - 'print', - 'system', - ]); - - const servicesMatch = /^\s*services\s*=\s*{([^}]+)/m.exec(stdout); - if (!servicesMatch) { - log.debug(stdout); - throw new Error(`The list of active processes cannot be retrieved`); - } - /* - Example match: - 0 78 com.apple.resourcegrabberd - 82158 - com.apple.assistant_service - 82120 - com.apple.nanoregistryd - 82087 - com.apple.notifyd - 82264 - UIKitApplication:com.apple.Preferences[704b][rb-legacy] - */ - const result = []; - const pattern = /^\s*(\d+)\s+[\d-]+\s+([\w\-.]+:)?([\w\-.]+)/gm; - let match; - while ((match = pattern.exec(servicesMatch[1]))) { - result.push({ - pid: parseInt(match[1], 10), - group: _.trimEnd(match[2], ':') || null, - name: match[3], - }); - } - return result; - } - - /** - * @return {Promise} The full path to the simulator's WebInspector Unix Domain Socket - * or `null` if there is no socket. - */ - async getWebInspectorSocket () { // eslint-disable-line require-await - // there is no WebInspector socket for this version of Xcode - return null; - } - - /** - * IDB instance setter - * - * @param {any} value - */ - set idb (value) { - this._idb = value; - } - - /** - * @return {Promise} idb instance - */ - get idb () { - return this._idb; - } - - /** - * @typedef {Object} KillOpts - * @property {(number|string)?} pid - Process id of the UI Simulator window - * @property {number|string} signal [2] - The signal number to send to the - * `kill` command - */ - - /** - * Kill the UI client if it is running. - * - * @param {Partial} opts - * @return {Promise} True if the UI client was successfully killed or false - * if it is not running. - * @throws {Error} If sending the signal to the client process fails - */ - async killUIClient (opts = {}) { - let { - pid, - signal = 2, - } = opts; - pid = pid || await this.getUIClientPid(); - if (!pid) { - return false; - } - - log.debug(`Sending ${signal} kill signal to Simulator UI client with PID ${pid}`); - try { - await exec('kill', [`-${signal}`, `${pid}`]); - return true; - } catch (e) { - if (e.code === 1) { - return false; - } - throw new Error(`Cannot kill the Simulator UI client. Original error: ${e.message}`); - } - } - - /** - * @return {number} The max number of milliseconds to wait until Simulator booting is completed. - */ - get startupTimeout () { - return STARTUP_TIMEOUT_MS; - } - - /** - * 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) { - await this.simctl.startBootMonitor({timeout: startupTimeout}); - this.emit(BOOT_COMPLETED_EVENT); - } -} - -for (const [cmd, fn] of _.toPairs(extensions)) { - SimulatorXcode8.prototype[cmd] = fn; -} - -export default SimulatorXcode8; diff --git a/lib/simulator-xcode-9.3.js b/lib/simulator-xcode-9.3.js deleted file mode 100644 index a35ccc2..0000000 --- a/lib/simulator-xcode-9.3.js +++ /dev/null @@ -1,46 +0,0 @@ -import SimulatorXcode9 from './simulator-xcode-9'; -import { exec } from 'teen_process'; -import log from './logger'; - - -class SimulatorXcode93 extends SimulatorXcode9 { - constructor (udid, xcodeVersion) { - super(udid, xcodeVersion); - - this.webInspectorSocket = null; - } - - /* - * @override - */ - async getWebInspectorSocket () { - if (this.webInspectorSocket) { - return this.webInspectorSocket; - } - - // lsof -aUc launchd_sim gives a set of records like - // https://github.com/appium/appium-ios-simulator/commit/c00901a9ddea178c5581a7a57d96d8cee3f17c59#diff-2be09dd2ea01cfd6bbbd73e10bc468da782a297365eec706999fc3709c01478dR102 - // these _appear_ to always be grouped together by PID for each simulator. - // Therefore, by obtaining simulator PID with an expected simulator UDID, - // we can get the correct `com.apple.webinspectord_sim.socket` - // without depending on the order of `lsof -aUc launchd_sim` result. - const {stdout} = await exec('lsof', ['-aUc', 'launchd_sim']); - const udidPattern = `([0-9]{1,5}).+${this.udid}`; - const udidMatch = stdout.match(new RegExp(udidPattern)); - if (!udidMatch) { - log.debug(`Failed to get Web Inspector socket. lsof result: ${stdout}`); - return null; - } - - const pidPattern = `${udidMatch[1]}.+\\s+(\\S+com\\.apple\\.webinspectord_sim\\.socket)`; - const pidMatch = stdout.match(new RegExp(pidPattern)); - if (!pidMatch) { - log.debug(`Failed to get Web Inspector socket. lsof result: ${stdout}`); - return null; - } - this.webInspectorSocket = pidMatch[1]; - return this.webInspectorSocket; - } -} - -export default SimulatorXcode93; diff --git a/lib/simulator-xcode-9.js b/lib/simulator-xcode-9.js deleted file mode 100644 index 042014c..0000000 --- a/lib/simulator-xcode-9.js +++ /dev/null @@ -1,623 +0,0 @@ -import SimulatorXcode8 from './simulator-xcode-8'; -import _ from 'lodash'; -import path from 'path'; -import { fs, timing, util } from '@appium/support'; -import AsyncLock from 'async-lock'; -import log from './logger'; -import { waitForCondition } from 'asyncbox'; -import { - toBiometricDomainComponent, getDeveloperRoot, SIMULATOR_APP_NAME -} from './utils.js'; -import { NSUserDefaults, generateDefaultsCommandArgs } from './defaults-utils'; -import B from 'bluebird'; -import { EventEmitter } from 'events'; -import { getPath as getXcodePath } from 'appium-xcode'; -import { exec } from 'teen_process'; - -const SIMULATOR_SHUTDOWN_TIMEOUT = 15 * 1000; -const startupLock = new AsyncLock(); -const preferencesPlistGuard = new AsyncLock(); -const ENROLLMENT_NOTIFICATION_RECEIVER = 'com.apple.BiometricKit.enrollmentChanged'; -const DOMAIN_KEYBOARD_PREFERENCES = 'com.apple.keyboard.preferences'; -// com.apple.SpringBoard: translates com.apple.SpringBoard and system prompts for push notification -// com.apple.locationd: translates system prompts for location -// com.apple.tccd: translates system prompts for camera, microphone, contact, photos and app tracking transparency -const SERVICES_FOR_TRANSLATION = ['com.apple.SpringBoard', 'com.apple.locationd', 'com.apple.tccd']; -const GLOBAL_PREFS_PLIST = '.GlobalPreferences.plist'; - -/** - * Creates device and common Simulator preferences, which could - * be later applied using `defaults` CLI utility. - * - * @param {Partial} opts - * @returns {any[]} The first array item is the resulting device preferences - * object and the second one is common preferences object - */ -function compileSimulatorPreferences (opts = {}) { - const { - connectHardwareKeyboard, - tracePointer, - pasteboardAutomaticSync, - scaleFactor, - } = opts; - const commonPreferences = { - // This option is necessary to make the Simulator window follow - // the actual XCUIDevice orientation - RotateWindowWhenSignaledByGuest: true, - // https://github.com/appium/appium/issues/16418 - StartLastDeviceOnLaunch: false, - DetachOnWindowClose: false, - AttachBootedOnStart: true, - }; - const devicePreferences = opts.devicePreferences ? _.cloneDeep(opts.devicePreferences) : {}; - if (scaleFactor) { - devicePreferences.SimulatorWindowLastScale = parseFloat(scaleFactor); - } - if (_.isBoolean(connectHardwareKeyboard) || _.isNil(connectHardwareKeyboard)) { - devicePreferences.ConnectHardwareKeyboard = connectHardwareKeyboard ?? false; - commonPreferences.ConnectHardwareKeyboard = connectHardwareKeyboard ?? false; - } - if (_.isBoolean(tracePointer)) { - commonPreferences.ShowSingleTouches = tracePointer; - commonPreferences.ShowPinches = tracePointer; - commonPreferences.ShowPinchPivotPoint = tracePointer; - commonPreferences.HighlightEdgeGestures = tracePointer; - } - switch (_.lowerCase(pasteboardAutomaticSync)) { - case 'on': - commonPreferences.PasteboardAutomaticSync = true; - break; - case 'off': - // Improve launching simulator performance - // https://github.com/WebKit/webkit/blob/master/Tools/Scripts/webkitpy/xcode/simulated_device.py#L413 - commonPreferences.PasteboardAutomaticSync = false; - break; - case 'system': - // Do not add -PasteboardAutomaticSync - break; - default: - log.info(`['on', 'off' or 'system'] are available as the pasteboard automatic sync option. Defaulting to 'off'`); - commonPreferences.PasteboardAutomaticSync = false; - } - return [devicePreferences, commonPreferences]; -} - -class SimulatorXcode9 extends SimulatorXcode8 { - constructor (udid, xcodeVersion) { - super(udid, xcodeVersion); - } - - /** - * @typedef {Object} DevicePreferences - * @property {?number} SimulatorExternalDisplay - TBD. Example value: 2.114 - * @property {?string} ChromeTint - TBD. Example value: '' - * @property {?number} SimulatorWindowLastScale - Scale value for the particular Simulator window. - * 1.0 means 100% scale. - * @property {?string} SimulatorWindowOrientation - Simulator window orientation. Possible values are: - * 'Portrait', 'LandscapeLeft', 'PortraitUpsideDown' and 'LandscapeRight'. - * @property {?number} SimulatorWindowRotationAngle - Window rotation angle. This value is expected to be in sync - * with _SimulatorWindowOrientation_. The corresponding values are: - * 0, 90, 180 and 270. - * @property {?string} SimulatorWindowCenter - The coordinates of Simulator's window center in pixels, - * for example '{-1294.5, 775.5}'. - * @property {?boolean} ConnectHardwareKeyboard - Equals to 1 if hardware keyboard should be connected. - * Otherwise 0. - */ - - /** - * @typedef {Object} CommonPreferences - * @property {boolean} ConnectHardwareKeyboard - Whether to connect hardware keyboard - */ - - /** - * @typedef {Object} RunOptions - * @property {string} scaleFactor: Any positive float value. 1.0 means 1:1 scale. - * 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. - * @property {boolean} connectHardwareKeyboard: whether to connect the hardware keyboard to the - * Simulator UI client. Equals to `false` by default. - * @property {number} startupTimeout: number of milliseconds to wait until Simulator booting - * process is completed. The default timeout will be used if not set explicitly. - * @property {boolean} isHeadless: whether to start the Simulator in headless mode (with UI - * client invisible). `false` by default. - * @property {?boolean} tracePointer [false] - Whether to highlight touches on Simulator - * screen. This is helpful while debugging automated tests or while observing the automation - * recordings. - * @property {string} pasteboardAutomaticSync ['off'] - Whether to disable pasteboard sync with the - * Simulator UI client or respect the system wide preference. 'on', 'off', or 'system' is available. - * The sync increases launching simulator process time, but it allows system to sync pasteboard - * with simulators. Follows system-wide preference if the value is 'system'. - * Defaults to 'off'. - * @property {DevicePreferences} devicePreferences: preferences of the newly created Simulator - * device - */ - - /** - * Executes given Simulator with options. The Simulator will not be restarted if - * it is already running and the current UI state matches to `isHeadless` option. - * @override - * - * @param {Partial} opts - One or more of available Simulator options - */ - async run (opts = {}) { - opts = _.cloneDeep(opts); - _.defaultsDeep(opts, { - isHeadless: false, - startupTimeout: this.startupTimeout, - }); - - const [devicePreferences, commonPreferences] = compileSimulatorPreferences(opts); - await this.updatePreferences(devicePreferences, commonPreferences); - - const timer = new timing.Timer().start(); - const shouldWaitForBoot = await startupLock.acquire(this.uiClientBundleId, async () => { - const isServerRunning = await this.isRunning(); - const uiClientPid = await this.getUIClientPid(); - if (opts.isHeadless) { - if (isServerRunning && !uiClientPid) { - log.info(`Simulator with UDID '${this.udid}' is already booted in headless mode.`); - return false; - } - if (await this.killUIClient({pid: uiClientPid})) { - log.info(`Detected the Simulator UI client was running and killed it. Verifying the current Simulator state`); - } - try { - // Stopping the UI client kills all running servers for some early XCode versions. This is a known bug - await waitForCondition(async () => await this.isShutdown(), { - waitMs: 5000, - intervalMs: 100, - }); - } catch (e) { - if (!await this.isRunning()) { - throw new Error(`Simulator with UDID '${this.udid}' cannot be transitioned to headless mode`); - } - return false; - } - log.info(`Booting Simulator with UDID '${this.udid}' in headless mode. ` + - `All UI-related capabilities are going to be ignored`); - await this.boot(); - } else { - if (isServerRunning && uiClientPid) { - log.info(`Both Simulator with UDID '${this.udid}' and the UI client are currently running`); - return false; - } - if (isServerRunning) { - log.info(`Simulator '${this.udid}' is booted while its UI is not visible. ` + - `Trying to restart it with the Simulator window visible`); - await this.shutdown({timeout: SIMULATOR_SHUTDOWN_TIMEOUT}); - } - await this.launchWindow(Boolean(uiClientPid), opts); - } - return true; - }); - - if (shouldWaitForBoot && opts.startupTimeout) { - await this.waitForBoot(opts.startupTimeout); - log.info(`Simulator with UDID ${this.udid} booted in ${timer.getDuration().asSeconds.toFixed(3)}s`); - } - - (async () => { - try { - await this.disableKeyboardIntroduction(); - } catch (e) { - log.info(`Cannot disable Simulator keyboard introduction. Original error: ${e.message}`); - } - })(); - } - - /** - * @override - */ - async startUIClient (opts = {}) { - opts = _.cloneDeep(opts); - _.defaultsDeep(opts, { - startupTimeout: this.startupTimeout, - }); - - const simulatorApp = path.resolve(await getXcodePath(), 'Applications', SIMULATOR_APP_NAME); - const args = ['-Fn', simulatorApp]; - log.info(`Starting Simulator UI: ${util.quote(['open', ...args])}`); - try { - await exec('open', args, {timeout: opts.startupTimeout}); - } catch (err) { - throw new Error(`Got an unexpected error while opening Simulator UI: ` + - err.stderr || err.stdout || err.message); - } - } - - /** - * Disable keyboard tutorial as 'com.apple.keyboard.preferences' domain via 'defaults' command. - */ - async disableKeyboardIntroduction () { - const argChunks = generateDefaultsCommandArgs({ - // To disable 'DidShowContinuousPathIntroduction' for iOS 15+ simulators since changing the preference via WDA - // does not work on them. Lower than the versions also can have this preference, but nothing happen. - DidShowContinuousPathIntroduction: 1 - }, true); - await B.all(argChunks.map((args) => this.simctl.spawnProcess([ - 'defaults', 'write', DOMAIN_KEYBOARD_PREFERENCES, ...args - ]))); - } - - /*** - * Boots simulator and opens simulators UI Client if not already opened. - * - * @param {boolean} isUiClientRunning - process id of simulator UI client. - * @param {Partial} opts - arguments to start simulator UI client with. - */ - async launchWindow (isUiClientRunning, opts = {}) { - await this.boot(); - if (!isUiClientRunning) { - await this.startUIClient(opts); - } - } - - /** - * Boots Simulator if not already booted. - * Does nothing if it is already running. - * This API does NOT wait until Simulator is fully booted. - * - * @throws {Error} If there was a failure while booting the Simulator. - */ - async boot () { - const bootEventsEmitter = new EventEmitter(); - await this.simctl.startBootMonitor({ - onError: (err) => bootEventsEmitter.emit('failure', err), - onFinished: () => bootEventsEmitter.emit('finish'), - shouldPreboot: true, - }); - try { - await new B((resolve, reject) => { - // Historically this call was always asynchronous, - // e.g. it was not waiting until Simulator is fully booted. - // So we preserve that behavior, and if no errors are received for a while - // then we assume the Simulator booting is still in progress. - setTimeout(resolve, 3000); - bootEventsEmitter.once('failure', (err) => { - if (_.includes(err?.message, 'state: Booted')) { - resolve(); - } else { - reject(err); - } - }); - bootEventsEmitter.once('finish', resolve); - }); - } finally { - bootEventsEmitter.removeAllListeners(); - } - } - - /** - * Perform verification of device preferences correctness. - * - * @param {Partial} prefs [{}] - The preferences to be verified - * @throws {Error} If any of the given preference values does not match the expected - * format. - */ - verifyDevicePreferences (prefs = {}) { - if (_.isEmpty(prefs)) { - return; - } - - if (!_.isUndefined(prefs.SimulatorWindowLastScale)) { - if (!_.isNumber(prefs.SimulatorWindowLastScale) || prefs.SimulatorWindowLastScale <= 0) { - log.errorAndThrow(`SimulatorWindowLastScale is expected to be a positive float value. ` + - `'${prefs.SimulatorWindowLastScale}' is assigned instead.`); - } - } - - if (!_.isUndefined(prefs.SimulatorWindowCenter)) { - // https://regex101.com/r/2ZXOij/2 - const verificationPattern = /{-?\d+(\.\d+)?,-?\d+(\.\d+)?}/; - if (!_.isString(prefs.SimulatorWindowCenter) || !verificationPattern.test(prefs.SimulatorWindowCenter)) { - log.errorAndThrow(`SimulatorWindowCenter is expected to match "{floatXPosition,floatYPosition}" format (without spaces). ` + - `'${prefs.SimulatorWindowCenter}' is assigned instead.`); - } - } - - if (!_.isUndefined(prefs.SimulatorWindowOrientation)) { - const acceptableValues = ['Portrait', 'LandscapeLeft', 'PortraitUpsideDown', 'LandscapeRight']; - if (!prefs.SimulatorWindowOrientation || !acceptableValues.includes(prefs.SimulatorWindowOrientation)) { - log.errorAndThrow(`SimulatorWindowOrientation is expected to be one of ${acceptableValues}. ` + - `'${prefs.SimulatorWindowOrientation}' is assigned instead.`); - } - } - - if (!_.isUndefined(prefs.SimulatorWindowRotationAngle)) { - if (!_.isNumber(prefs.SimulatorWindowRotationAngle)) { - log.errorAndThrow(`SimulatorWindowRotationAngle is expected to be a valid number. ` + - `'${prefs.SimulatorWindowRotationAngle}' is assigned instead.`); - } - } - } - - /** - * Update the common iOS Simulator preferences file with new values. - * It is necessary to restart the corresponding Simulator before - * these changes are applied. - * - * @param {Partial} devicePrefs [{}] - The mapping, which represents new device preference values - * for the given Simulator. - * @param {Partial} commonPrefs [{}] - The mapping, which represents new common preference values - * for all Simulators. - * @return {Promise} True if the preferences were successfully updated. - */ - async updatePreferences (devicePrefs = {}, commonPrefs = {}) { - if (!_.isEmpty(devicePrefs)) { - log.debug(`Setting preferences of ${this.udid} Simulator to ${JSON.stringify(devicePrefs)}`); - } - if (!_.isEmpty(commonPrefs)) { - log.debug(`Setting common Simulator preferences to ${JSON.stringify(commonPrefs)}`); - } - const homeFolderPath = process.env.HOME; - if (!homeFolderPath) { - log.warn(`Cannot get the path to HOME folder from the process environment. ` + - `Ignoring Simulator preferences update.`); - return false; - } - this.verifyDevicePreferences(devicePrefs); - const plistPath = path.resolve(homeFolderPath, 'Library', 'Preferences', 'com.apple.iphonesimulator.plist'); - return await preferencesPlistGuard.acquire(SimulatorXcode9.name, async () => { - const defaults = new NSUserDefaults(plistPath); - const prefsToUpdate = _.clone(commonPrefs); - try { - if (!_.isEmpty(devicePrefs)) { - let existingDevicePrefs; - const udidKey = this.udid.toUpperCase(); - if (await fs.exists(plistPath)) { - const currentPlistContent = await defaults.asJson(); - if (_.isPlainObject(currentPlistContent.DevicePreferences) - && _.isPlainObject(currentPlistContent.DevicePreferences[udidKey])) { - existingDevicePrefs = currentPlistContent.DevicePreferences[udidKey]; - } - } - Object.assign(prefsToUpdate, { - DevicePreferences: { - [udidKey]: Object.assign({}, existingDevicePrefs || {}, devicePrefs) - } - }); - } - await defaults.update(prefsToUpdate); - log.debug(`Updated ${this.udid} Simulator preferences at '${plistPath}' with ` + - JSON.stringify(prefsToUpdate)); - return true; - } catch (e) { - log.warn(`Cannot update ${this.udid} Simulator preferences at '${plistPath}'. ` + - `Try to delete the file manually in order to reset it. Original error: ${e.message}`); - return false; - } - }); - } - - /** - * @inheritdoc - * @override - * @protected - */ - async _activateWindow () { - let selfName; - let selfSdk; - let bootedDevicesCount = 0; - for (const [sdk, deviceArr] of _.toPairs(await this.simctl.getDevices())) { - for (const {state, udid, name} of deviceArr) { - if (state === 'Booted') { - bootedDevicesCount++; - } - if (!selfName && udid === this.udid) { - selfSdk = sdk; - selfName = name; - } - } - } - if (bootedDevicesCount < 2) { - return await super._activateWindow(); - } - - // There are potentially more that one Simulator window - return ` - tell application "System Events" - tell process "Simulator" - set frontmost to false - set frontmost to true - click (menu item 1 where (its name contains "${selfName} " and its name contains "${selfSdk}")) of menu 1 of menu bar item "Window" of menu bar 1 - end tell - end tell - `; - } - - /** - * @returns {Promise} - */ - async isBiometricEnrolled () { - const {stdout} = await this.simctl.spawnProcess([ - 'notifyutil', - '-g', ENROLLMENT_NOTIFICATION_RECEIVER - ]); - const match = (new RegExp(`${_.escapeRegExp(ENROLLMENT_NOTIFICATION_RECEIVER)}\\s+([01])`)) - .exec(stdout); - if (!match) { - throw new Error(`Cannot parse biometric enrollment state from '${stdout}'`); - } - log.info(`Current biometric enrolled state for ${this.udid} Simulator: ${match[1]}`); - return match[1] === '1'; - } - - /** - * - * @param {boolean} isEnabled - */ - async enrollBiometric (isEnabled = true) { - log.debug(`Setting biometric enrolled state for ${this.udid} Simulator to '${isEnabled ? 'enabled' : 'disabled'}'`); - await this.simctl.spawnProcess([ - 'notifyutil', - '-s', ENROLLMENT_NOTIFICATION_RECEIVER, isEnabled ? '1' : '0' - ]); - await this.simctl.spawnProcess([ - 'notifyutil', - '-p', ENROLLMENT_NOTIFICATION_RECEIVER - ]); - if (await this.isBiometricEnrolled() !== isEnabled) { - throw new Error(`Cannot set biometric enrolled state for ${this.udid} Simulator to '${isEnabled ? 'enabled' : 'disabled'}'`); - } - } - - /** - * Sends a notification to match/not match the particular biometric. - * - * @param {boolean} shouldMatch [true] - Set it to true or false in order to emulate - * matching/not matching the corresponding biometric - * @param {string} biometricName [touchId] - Either touchId or faceId (faceId is only available since iOS 11) - */ - async sendBiometricMatch (shouldMatch = true, biometricName = 'touchId') { - const domainComponent = toBiometricDomainComponent(biometricName); - const domain = `com.apple.BiometricKit_Sim.${domainComponent}.${shouldMatch ? '' : 'no'}match`; - await this.simctl.spawnProcess([ - 'notifyutil', - '-p', domain - ]); - log.info(`Sent notification ${domain} to ${shouldMatch ? 'match' : 'not match'} ${biometricName} biometric ` + - `for ${this.udid} Simulator`); - } - - /** - * @returns {Promise} - */ - async getLaunchDaemonsRoot () { - const devRoot = await getDeveloperRoot(); - return path.resolve(devRoot, - 'Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/LaunchDaemons'); - } - - /** - * @typedef {Object} KeyboardOptions - * @property {string} name The name of the keyboard locale, for example `en_US` or `de_CH` - * @property {string} layout The keyboard layout, for example `QUERTY` or `Ukrainian` - * @property {string?} hardware Could either be `Automatic` or `null` - */ - - /** - * @typedef {Object} LanguageOptions - * @property {string} name The name of the language, for example `de` or `zh-Hant-CN` - * @property {boolean} [skipSyncUiDialogTranslation] no Simulator services will be reset if this option is set to true. - * See https://github.com/appium/appium/issues/19440 for more details - */ - - /** - * @typedef {Object} LocaleOptions - * @property {string} name The name of the system locale, for example `de_CH` or `zh_CN` - * @property {string} calendar Optional calendar format, for example `gregorian` or `persian` - */ - - /** - * @typedef {Object} LocalizationOptions - * @property {KeyboardOptions} keyboard - * @property {LanguageOptions} language - * @property {LocaleOptions} locale - */ - - /** - * Change localization settings on the currently booted simulator - * - * @param {Partial} opts - * @throws {Error} If there was a failure while setting the preferences - * @returns {Promise} `true` if any of settings has been successfully changed - */ - async configureLocalization (opts = {}) { - if (_.isEmpty(opts)) { - return false; - } - - const { language, locale, keyboard } = opts; - const globalPrefs = {}; - let keyboardId = null; - if (_.isPlainObject(keyboard)) { - // @ts-ignore The above check ensures keyboard is what it should be - const { name, layout, hardware } = keyboard; - if (!name) { - throw new Error(`The 'keyboard' field must have a valid name set`); - } - if (!layout) { - throw new Error(`The 'keyboard' field must have a valid layout set`); - } - keyboardId = `${name}@sw=${layout}`; - if (hardware) { - keyboardId += `;@hw=${hardware}`; - } - globalPrefs.AppleKeyboards = [keyboardId]; - } - if (_.isPlainObject(language)) { - // @ts-ignore The above check ensures language is what it should be - const { name } = language; - if (!name) { - throw new Error(`The 'language' field must have a valid name set`); - } - globalPrefs.AppleLanguages = [name]; - } - if (_.isPlainObject(locale)) { - // @ts-ignore The above check ensures locale is what it should be - const { name, calendar } = locale; - if (!name) { - throw new Error(`The 'locale' field must have a valid name set`); - } - let localeId = name; - if (calendar) { - localeId += `@calendar=${calendar}`; - } - globalPrefs.AppleLocale = localeId; - } - if (_.isEmpty(globalPrefs)) { - return false; - } - - let previousAppleLanguages = null; - if (globalPrefs.AppleLanguages) { - const absolutePrefsPath = path.join(this.getDir(), 'Library', 'Preferences', GLOBAL_PREFS_PLIST); - try { - const {stdout} = await exec('plutil', ['-convert', 'json', absolutePrefsPath, '-o', '-']); - previousAppleLanguages = JSON.parse(stdout).AppleLanguages; - } catch (e) { - log.debug(`Cannot retrieve the current value of the 'AppleLanguages' preference: ${e.message}`); - } - } - - const argChunks = generateDefaultsCommandArgs(globalPrefs, true); - await B.all(argChunks.map((args) => this.simctl.spawnProcess([ - 'defaults', 'write', GLOBAL_PREFS_PLIST, ...args - ]))); - - if (keyboard && keyboardId) { - const argChunks = generateDefaultsCommandArgs({ - KeyboardsCurrentAndNext: [keyboardId], - KeyboardLastUsed: keyboardId, - KeyboardLastUsedForLanguage: { [keyboard.name]: keyboardId } - }, true); - await B.all(argChunks.map((args) => this.simctl.spawnProcess([ - 'defaults', 'write', 'com.apple.Preferences', ...args - ]))); - } - - if (globalPrefs.AppleLanguages) { - if (_.isEqual(previousAppleLanguages, globalPrefs.AppleLanguages)) { - log.info( - `The 'AppleLanguages' preference is already set to '${globalPrefs.AppleLanguages}'. ` + - `Skipping services reset` - ); - } else if (language?.skipSyncUiDialogTranslation) { - log.info('Skipping services reset as requested. This might leave some system UI alerts untranslated'); - } else { - log.info( - `Will restart the following services in order to sync UI dialogs translation: ` + - `${SERVICES_FOR_TRANSLATION}. This might have unexpected side effects, ` + - `see https://github.com/appium/appium/issues/19440 for more details` - ); - await B.all(SERVICES_FOR_TRANSLATION.map((arg) => this.simctl.spawnProcess([ - 'launchctl', 'stop', arg - ]))); - } - } - - return true; - } -} - -export default SimulatorXcode9; diff --git a/lib/simulator.js b/lib/simulator.js index d77b29c..83d7cb4 100644 --- a/lib/simulator.js +++ b/lib/simulator.js @@ -1,17 +1,19 @@ -import SimulatorXcode8 from './simulator-xcode-8'; -import SimulatorXcode9 from './simulator-xcode-9'; -import SimulatorXcode93 from './simulator-xcode-9.3'; -import SimulatorXcode10 from './simulator-xcode-10'; -import SimulatorXcode11 from './simulator-xcode-11'; -import SimulatorXcode11_4 from './simulator-xcode-11.4'; -import SimulatorXcode14 from './simulator-xcode-14'; -import SimulatorXcode15 from './simulator-xcode-15'; +import { SimulatorXcode10 } from './simulator-xcode-10'; +import { SimulatorXcode11 } from './simulator-xcode-11'; +import { SimulatorXcode11_4 } from './simulator-xcode-11.4'; +import { SimulatorXcode14 } from './simulator-xcode-14'; +import { SimulatorXcode15 } from './simulator-xcode-15'; import { getSimulatorInfo } from './utils'; import xcode from 'appium-xcode'; -import { log, setLoggingPlatform } from './logger'; +import { log } from './logger'; -const MIN_SUPPORTED_XCODE_VERSION = 8; +const MIN_SUPPORTED_XCODE_VERSION = 10; +/** + * @template {import('appium-xcode').XcodeVersion} V + * @param {V} xcodeVersion + * @returns {V} + */ function handleUnsupportedXcode (xcodeVersion) { if (xcodeVersion.major < MIN_SUPPORTED_XCODE_VERSION) { throw new Error( @@ -22,34 +24,27 @@ function handleUnsupportedXcode (xcodeVersion) { return xcodeVersion; } -/** - * @typedef {Object} SimulatorLookupOptions - * @property {?string} platform [iOS] - The name of the simulator platform - * @property {?boolean} checkExistence [true] - Set it to `false` in order to - * skip simulator existence verification - * @property {?string} devicesSetPath - The full path to the devices set where - * the current simulator is located. `null` value means that the default path is - * used, which is usually `~/Library/Developer/CoreSimulator/Devices` - */ - /** * Finds and returns the corresponding Simulator instance for the given ID. * * @param {string} udid - The ID of an existing Simulator. - * @param {Partial} opts + * @param {import('./types').SimulatorLookupOptions} [opts={}] * @throws {Error} If the Simulator with given udid does not exist in devices list. * If you want to create a new simulator, you can use the `createDevice()` method of * [node-simctl](github.com/appium/node-simctl). - * @return {Promise} Simulator object associated with the udid passed in. + * @return {Promise} Simulator object associated with the udid passed in. */ -async function getSimulator (udid, opts = {}) { +export async function getSimulator (udid, opts = {}) { let { platform = 'iOS', checkExistence = true, devicesSetPath, + logger, } = opts; - const xcodeVersion = handleUnsupportedXcode(await xcode.getVersion(true)); + const xcodeVersion = handleUnsupportedXcode( + /** @type {import('appium-xcode').XcodeVersion} */ (await xcode.getVersion(true)) + ); if (checkExistence) { const simulatorInfo = await getSimulatorInfo(udid, { devicesSetPath @@ -62,21 +57,12 @@ async function getSimulator (udid, opts = {}) { platform = simulatorInfo.platform; } - // make sure we have the right logging prefix - setLoggingPlatform(platform); - - log.info( + (logger ?? log).info( `Constructing ${platform} simulator for Xcode version ${xcodeVersion.versionString} with udid '${udid}'` ); let SimClass; switch (xcodeVersion.major) { - case 8: - SimClass = SimulatorXcode8; - break; - case 9: - SimClass = xcodeVersion.minor < 3 ? SimulatorXcode9 : SimulatorXcode93; - break; - case 10: + case MIN_SUPPORTED_XCODE_VERSION: SimClass = SimulatorXcode10; break; case 11: @@ -95,11 +81,9 @@ async function getSimulator (udid, opts = {}) { break; } - const result = new SimClass(udid, xcodeVersion); + const result = new SimClass(udid, xcodeVersion, logger); if (devicesSetPath) { result.devicesSetPath = devicesSetPath; } return result; } - -export { getSimulator }; diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..426e102 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,299 @@ +import type { EventEmitter } from 'node:events'; +import type { Simctl } from 'node-simctl'; +import type { XcodeVersion } from 'appium-xcode'; +import type { AppiumLogger, StringRecord } from '@appium/types'; + +export interface ProcessInfo { + /** + * The actual process identifier. + * Could be zero if the process is the system one. + */ + pid: number; + /** + * The process group identifier. + * This could be `null` if the process is not a part of the + * particular group. For `normal` application processes the group + * name usually equals to `UIKitApplication`. + */ + group: string|null; + /** + * The process name, for example `com.apple.Preferences` + */ + name: string; +} + +export interface DevicePreferences { + /** TBD. Example value: 2.114 */ + SimulatorExternalDisplay?: number; + /** TBD. Example value: '' */ + ChromeTint?: string; + /** Scale value for the particular Simulator window. 1.0 means 100% scale. */ + SimulatorWindowLastScale?: number; + /** Simulator window orientation. Possible values are: 'Portrait', 'LandscapeLeft', 'PortraitUpsideDown' and 'LandscapeRight'. */ + SimulatorWindowOrientation?: string; + /** + * Window rotation angle. This value is expected to be in sync + * with _SimulatorWindowOrientation_. The corresponding values are: + * 0, 90, 180 and 270. + */ + SimulatorWindowRotationAngle?: number; + /** + * The coordinates of Simulator's window center in pixels, for example '{-1294.5, 775.5}'. + */ + SimulatorWindowCenter?: string; + /** Equals to 1 if hardware keyboard should be connected. Otherwise 0. */ + ConnectHardwareKeyboard?: boolean; +} + +export interface CommonPreferences { + /** Whether to connect hardware keyboard */ + ConnectHardwareKeyboard?: boolean; +} + +export interface StartUiClientOptions { + /** + * 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. + * It should be one of ['1.0', '0.75', '0.5', '0.33', '0.25']. + */ + scaleFactor?: string; + /** + * Number of milliseconds to wait until Simulator booting + * process is completed. The default timeout of 60000 ms will be used if not set explicitly. + */ + startupTimeout?: number; +} + +export interface RunOptions extends StartUiClientOptions { + /** + * Whether to connect the hardware keyboard to the + * Simulator UI client. Equals to `false` by default. + */ + connectHardwareKeyboard?: boolean; + /** + * Whether to start the Simulator in headless mode (with UI + * client invisible). `false` by default. + */ + isHeadless?: boolean; + /** + * Whether to highlight touches on Simulator + * screen. This is helpful while debugging automated tests or while observing the automation + * recordings. `false` by default. + */ + tracePointer?: boolean; + /** + * Whether to disable pasteboard sync with the + * Simulator UI client or respect the system wide preference. 'on', 'off', or 'system' is available. + * The sync increases launching simulator process time, but it allows system to sync pasteboard + * with simulators. Follows system-wide preference if the value is 'system'. + * Defaults to 'off'. + */ + pasteboardAutomaticSync?: string; + /** + * Preferences of the newly created Simulator device + */ + devicePreferences?: DevicePreferences; +} + +export interface ShutdownOptions { + /** + * The number of milliseconds to wait until + * Simulator is shut down completely. No wait happens if the timeout value is not set + */ + timeout?: number|string; +} + +export interface KillUiClientOptions { + /** Process id of the UI Simulator window */ + pid?: number | string | null; + /** The signal number to send to the. 2 (SIGINT) by default */ + signal?: number | string; +} + +export interface DeviceStat { + /** Simulator name, for example 'iPhone 10' */ + name: string; + /** Device UDID, for example 'C09B34E5-7DCB-442E-B79C-AB6BC0357417' */ + udid: string; + /** For example 'Booted' or 'Shutdown' */ + state: string; + /** For example '12.4' */ + sdk: string; +} + +export interface CoreSimulator extends EventEmitter { + _keychainsBackupPath: string|null|undefined; + _webInspectorSocket: string|null|undefined; + + get keychainPath(): string; + get udid(): string; + get simctl(): Simctl; + get xcodeVersion(): XcodeVersion; + + set devicesSetPath(value: string|null); + get devicesSetPath(): string|null; + + get idb(): any; + set idb(value: any); + + get startupTimeout(): number; + get uiClientBundleId(): string; + + get log(): AppiumLogger; + + getUIClientPid(): Promise; + isUIClientRunning(): Promise; + getPlatformVersion(): Promise; + getRootDir(): string; + getDir(): string; + getLogDir(): string; + stat(): Promise>; + isFresh(): Promise; + isRunning(): Promise; + isShutdown(): Promise; + startUIClient(opts?: StartUiClientOptions): Promise; + run(opts?: RunOptions): Promise; + clean(): Promise; + shutdown(opts?: ShutdownOptions): Promise; + delete(): Promise; + ps(): Promise; + killUIClient(opts?: KillUiClientOptions): Promise; + waitForBoot(startupTimeout: number): Promise; + getLaunchDaemonsRoot(): Promise; +} + +export interface LaunchAppOptions { + /** + * Whether to wait until the app has fully started and + * is present in processes list. `false` by default. + */ + wait?: boolean; + /** + * The number of milliseconds to wait until + * the app is fully started. Only applicatble if `wait` is true. 10000 ms by default. + */ + timeoutMs?: number; +} + +export interface InteractsWithApps { + installApp(app: string): Promise; + getUserInstalledBundleIdsByBundleName(bundleName: string): Promise; + isAppInstalled(bundleId: string): Promise; + removeApp(bundleId: string): Promise; + launchApp(bundleId: string, opts?: LaunchAppOptions): Promise; + terminateApp(bundleId: string): Promise; + isAppRunning(bundleId: string): Promise; + scrubApp(bundleId: string): Promise; +} + +export interface SupportsBiometric { + isBiometricEnrolled(): Promise; + enrollBiometric(isEnabled: boolean): Promise; + sendBiometricMatch(shouldMatch: boolean, biometricName: string): Promise; +} + +export interface SupportsGeolocation { + setGeolocation(latitude: string|number, longitude: string|number): Promise; +} + +export interface InteractsWithKeychain { + backupKeychains(): Promise; + restoreKeychains(excludePatterns: string[]): Promise; + clearKeychains(): Promise; +} + +export interface SupportsAppPermissions { + setPermission(bundleId: string, permission: string, value: string): Promise; + setPermissions(bundleId: string, permissionsMapping: StringRecord): Promise; + getPermission(bundleId: string, serviceName: string): Promise; +} + +export interface InteractsWithSafariBrowser { + openUrl(url: string): Promise; + scrubSafari(keepPrefs: boolean): Promise; + updateSafariSettings(updates: StringRecord): Promise; + getWebInspectorSocket(): Promise; +} + +interface KeyboardOptions { + /** The name of the keyboard locale, for example `en_US` or `de_CH` */ + name: string; + /** The keyboard layout, for example `QUERTY` or `Ukrainian` */ + layout: string; + /** hardware Could either be `Automatic` or `null` */ + hardware?: string|null; +} + +export interface LanguageOptions { + /** The name of the language, for example `de` or `zh-Hant-CN` */ + name: string; + /** + * No Simulator services will be reset if this option is set to true. + * See https://github.com/appium/appium/issues/19440 for more details + */ + skipSyncUiDialogTranslation?: boolean; +} + +export interface LocaleOptions { + /** The name of the system locale, for example `de_CH` or `zh_CN` */ + name: string; + /** Optional calendar format, for example `gregorian` or `persian` */ + calendar?: string; +} + +export interface LocalizationOptions { + keyboard?: KeyboardOptions; + language?: LanguageOptions; + locale?: LocaleOptions; +} + +export interface HasSettings { + setReduceMotion(reduceMotion: boolean): Promise; + setReduceTransparency(reduceTransparency: boolean): Promise; + updateSettings(domain: string, updates: StringRecord): Promise; + setAppearance(value: string): Promise; + getAppearance(): Promise; + disableKeyboardIntroduction(): Promise; + configureLocalization(opts?: LocalizationOptions): Promise; + setAutoFillPasswords(isEnabled: boolean): Promise; +} + +export interface CertificateOptions { + /** + * Whether to install the given + * certificate into the Trusted Root store (`true`, the default value) or to the keychain (`false`) + */ + isRoot?: boolean; +} + +export interface HasMiscFeatures { + shake(): Promise; + addCertificate(payload: string, opts?: CertificateOptions): Promise; + pushNotification(payload: StringRecord): Promise; +} + +export interface SimulatorLookupOptions { + /** The name of the simulator platform, iOS by default */ + platform?: string; + /** Set it to `false` in order to skip simulator existence verification. `true` by default */ + checkExistence?: boolean; + /** + * The full path to the devices set where + * the current simulator is located. `null` value means that the default path is + * used, which is usually `~/Library/Developer/CoreSimulator/Devices` + */ + devicesSetPath?: string|null; + /** The logger to use for the simulator class. A default logger will be created if not provided */ + logger?: AppiumLogger; +} + +export type Simulator = CoreSimulator + & InteractsWithSafariBrowser + & InteractsWithApps + & HasSettings + & InteractsWithApps + & SupportsBiometric + & SupportsGeolocation + & InteractsWithKeychain + & SupportsAppPermissions + & HasMiscFeatures; diff --git a/lib/utils.js b/lib/utils.js index dedc1de..e36f46a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -7,42 +7,9 @@ import path from 'path'; import { getDevices } from './device-utils'; const DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS = 30000; -const SAFARI_STARTUP_TIMEOUT_MS = 25 * 1000; -const MOBILE_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; -const SIMULATOR_APP_NAME = 'Simulator.app'; -const APP_ACTIVATION_SCRIPT = (pid) => ` -use framework "Foundation" -use framework "AppKit" -use scripting additions - -set theApp to current application's NSRunningApplication's runningApplicationWithProcessIdentifier:${pid} -if theApp = null then - log "Cannot find Simulator window under PID ${pid}. Is it running?" - error number 1 -end if -set result to theApp's activateWithOptions:3 -if not result then - log "Cannot activate Simulator window under PID ${pid}. Is it running?" - error number 1 -end if -`; - - -const BIOMETRICS = { - touchId: 'fingerTouch', - faceId: 'pearl', -}; - -/** - * @param {string} name - * @returns {string} - */ -function toBiometricDomainComponent (name) { - if (!BIOMETRICS[name]) { - throw new Error(`'${name}' is not a valid biometric. Use one of: ${JSON.stringify(_.keys(BIOMETRICS))}`); - } - return BIOMETRICS[name]; -} +export const SAFARI_STARTUP_TIMEOUT_MS = 25 * 1000; +export const MOBILE_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; +export const SIMULATOR_APP_NAME = 'Simulator.app'; /** * @param {string} appName @@ -73,7 +40,7 @@ async function pkill (appName, forceKill = false) { * @param {number} [timeout=DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS] * @returns {Promise} */ -async function killAllSimulators (timeout = DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS) { +export async function killAllSimulators (timeout = DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS) { log.debug('Killing all iOS Simulators'); const xcodeVersion = await getVersion(true); if (_.isString(xcodeVersion)) { @@ -149,10 +116,10 @@ async function killAllSimulators (timeout = DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS) { /** * @param {string} udid - * @param {Record} [opts={}] + * @param {{devicesSetPath?: string|null}} [opts={}] * @returns {Promise} */ -async function getSimulatorInfo (udid, opts = {}) { +export async function getSimulatorInfo (udid, opts = {}) { const { devicesSetPath } = opts; @@ -167,43 +134,14 @@ async function getSimulatorInfo (udid, opts = {}) { * @param {string} udid * @returns {Promise} */ -async function simExists (udid) { +export async function simExists (udid) { return !!(await getSimulatorInfo(udid)); } /** * @returns {Promise} */ -async function getDeveloperRoot () { +export async function getDeveloperRoot () { const {stdout} = await exec('xcode-select', ['-p']); return stdout.trim(); } - -/** - * Activates the app having the given process identifier. - * See https://developer.apple.com/documentation/appkit/nsrunningapplication/1528725-activatewithoptions?language=objc - * for more details. - * - * @param {number|string} pid App process identifier - * @throws {Error} If the given PID is not running or there was a failure - * while activating the app - */ -async function activateApp (pid) { - try { - await exec('osascript', ['-e', APP_ACTIVATION_SCRIPT(pid)]); - } catch (e) { - throw new Error(`Simulator window cannot be activated. Original error: ${e.stderr || e.message}`); - } -} - -export { - killAllSimulators, - simExists, - getSimulatorInfo, - toBiometricDomainComponent, - getDeveloperRoot, - activateApp, - SAFARI_STARTUP_TIMEOUT_MS, - MOBILE_SAFARI_BUNDLE_ID, - SIMULATOR_APP_NAME, -}; diff --git a/package.json b/package.json index cca4cb6..d1586ce 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "files": [ "index.js", "lib", - "build/index.js", + "build/index.*", "build/lib", "CHANGELOG.md" ], @@ -41,7 +41,7 @@ "asyncbox": "^3.0.0", "bluebird": "^3.5.1", "lodash": "^4.2.1", - "node-simctl": "^7.1.0", + "node-simctl": "^7.4.1", "semver": "^7.0.0", "source-map-support": "^0.x", "teen_process": "^2.0.0" @@ -110,5 +110,6 @@ "sinon": "^17.0.0", "ts-node": "^10.9.1", "typescript": "^5.4.2" - } + }, + "types": "./build/index.d.ts" } diff --git a/test/assets/deviceList.js b/test/assets/deviceList.js index 652ec3f..b3db6eb 100644 --- a/test/assets/deviceList.js +++ b/test/assets/deviceList.js @@ -1,7 +1,7 @@ // for testing, this is sample output of node-simclt.listDevices(); let devices = { - '8.1': [ + '10.0': [ { name: 'iPhone 4s', udid: '0829568F-7479-4ADE-9E51-B208DC99C107', state: 'Shutdown' }, @@ -32,7 +32,7 @@ let devices = { { name: 'Resizable iPad', udid: '6DAB91C9-CCD1-4C17-9124-D765E2F0567A', state: 'Shutdown' }], - '8.3': [ + '11.4': [ { name: 'iPhone 4s', udid: '3D1A8D2A-615A-4C1E-A73C-91E92D6637FF', state: 'Shutdown' }, diff --git a/test/unit/simulator-specs.js b/test/unit/simulator-specs.js index 9740fed..d48a71b 100644 --- a/test/unit/simulator-specs.js +++ b/test/unit/simulator-specs.js @@ -1,5 +1,3 @@ -// transpile:mocha - import { getSimulator } from '../../lib/simulator'; import * as teenProcess from 'teen_process'; import * as deviceUtils from '../../lib/device-utils'; @@ -9,18 +7,15 @@ import sinon from 'sinon'; import { devices } from '../assets/deviceList'; import B from 'bluebird'; import xcode from 'appium-xcode'; -import SimulatorXcode8 from '../../lib/simulator-xcode-8'; -import SimulatorXcode9 from '../../lib/simulator-xcode-9'; -import SimulatorXcode93 from '../../lib/simulator-xcode-9.3'; -import SimulatorXcode10 from '../../lib/simulator-xcode-10'; -import SimulatorXcode11 from '../../lib/simulator-xcode-11'; -import SimulatorXcode11_4 from '../../lib/simulator-xcode-11.4'; +import { SimulatorXcode10 } from '../../lib/simulator-xcode-10'; +import { SimulatorXcode11 } from '../../lib/simulator-xcode-11'; +import { SimulatorXcode11_4 } from '../../lib/simulator-xcode-11.4'; chai.should(); chai.use(chaiAsPromised); -const UDID = devices['8.1'][0].udid; +const UDID = devices['10.0'][0].udid; describe('simulator', function () { let xcodeMock; @@ -38,18 +33,15 @@ describe('simulator', function () { describe('getSimulator', function () { it('should create a simulator with default xcode version', async function () { - let xcodeVersion = {major: 8, versionString: '8.0.0'}; + let xcodeVersion = {major: 10, versionString: '10.0.0'}; xcodeMock.expects('getVersion').returns(B.resolve(xcodeVersion)); let sim = await getSimulator(UDID); sim.xcodeVersion.should.equal(xcodeVersion); - sim.constructor.name.should.be.eql(SimulatorXcode8.name); + sim.constructor.name.should.be.eql(SimulatorXcode10.name); }); const xcodeVersions = [ - [8, 0, '8.0.0', SimulatorXcode8], - [9, 0, '9.0.0', SimulatorXcode9], - [9, 3, '9.3.0', SimulatorXcode93], [10, 0, '10.0.0', SimulatorXcode10], [11, 0, '11.0.0', SimulatorXcode11], [11, 4, '11.4.0', SimulatorXcode11_4], @@ -76,7 +68,7 @@ describe('simulator', function () { }); it('should list stats for sim', async function () { - let xcodeVersion = {major: 8, versionString: '8.0.0'}; + let xcodeVersion = {major: 10, versionString: '10.0.0'}; xcodeMock.expects('getVersion').atLeast(1).returns(B.resolve(xcodeVersion)); const sims = (await B.all([ @@ -118,7 +110,7 @@ launchd_s 35621 mwakizaka 16u unix 0x7b7dbedd6d62e84f 0t0 /private/ beforeEach(function () { sinon.stub(teenProcess, 'exec').callsFake(() => ({ stdout })); - const xcodeVersion = {major: 9, versionString: '9.3.0'}; + const xcodeVersion = {major: 10, versionString: '10.0.0'}; xcodeMock.expects('getVersion').atLeast(1).returns(B.resolve(xcodeVersion)); }); afterEach(function () { @@ -150,7 +142,7 @@ launchd_s 35621 mwakizaka 16u unix 0x7b7dbedd6d62e84f 0t0 /private/ let sim; let spawnProcessSpy; beforeEach(async function () { - const xcodeVersion = {major: 9, versionString: '9.0.0'}; + const xcodeVersion = {major: 10, versionString: '10.0.0'}; xcodeMock.expects('getVersion').atLeast(1).returns(B.resolve(xcodeVersion)); sim = await getSimulator(UDID); spawnProcessSpy = sinon.stub(sim.simctl, 'spawnProcess'); diff --git a/test/unit/utils-specs.js b/test/unit/utils-specs.js index 25a9aa2..20223da 100644 --- a/test/unit/utils-specs.js +++ b/test/unit/utils-specs.js @@ -6,20 +6,21 @@ import sinon from 'sinon'; import B from 'bluebird'; import * as TeenProcess from 'teen_process'; import xcode from 'appium-xcode'; -import { - toBiometricDomainComponent, killAllSimulators, simExists, -} from '../../lib/utils'; +import {killAllSimulators, simExists} from '../../lib/utils'; +import { toBiometricDomainComponent } from '../../lib/extensions/biometric'; +import { verifyDevicePreferences } from '../../lib/extensions/settings'; + import * as deviceUtils from '../../lib/device-utils'; import { devices } from '../assets/deviceList'; -import SimulatorXcode9 from '../../lib/simulator-xcode-9'; +import { SimulatorXcode10 } from '../../lib/simulator-xcode-10'; chai.should(); chai.use(chaiAsPromised); -const XCODE_VERSION_9 = { - versionString: '9.0', - versionFloat: 9.0, - major: 9, +const XCODE_VERSION_10 = { + versionString: '10.0', + versionFloat: 10.0, + major: 10, minor: 0, patch: undefined }; @@ -58,7 +59,7 @@ describe('util', function () { describe('killAllSimulators', function () { it('should call exec if pgrep does not find any running Simulator with Xcode9', async function () { - xcodeMock.expects('getVersion').once().withArgs(true).returns(B.resolve(XCODE_VERSION_9)); + xcodeMock.expects('getVersion').once().withArgs(true).returns(B.resolve(XCODE_VERSION_10)); execStub.withArgs('xcrun').returns(); execStub.withArgs('pgrep').throws({code: 1}); @@ -117,14 +118,14 @@ describe('util', function () { }); describe('Device preferences verification', function () { - const sim = new SimulatorXcode9('1234', XCODE_VERSION_9); + const sim = new SimulatorXcode10('1234', XCODE_VERSION_10); describe('for SimulatorWindowLastScale option', function () { it('should pass if correct', function () { const validValues = [0.5, 1, 1.5]; for (const validValue of validValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowLastScale: validValue })).should.not.throw(); } @@ -133,7 +134,7 @@ describe('Device preferences verification', function () { it('should throw if incorrect', function () { const invalidValues = [-1, 0.0, '', 'abc', null]; for (const invalidValue of invalidValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowLastScale: invalidValue })).should.throw(Error, /is expected to be a positive float value/); } @@ -147,7 +148,7 @@ describe('Device preferences verification', function () { const validValues = ['{0,0}', '{0.0,0}', '{0,0.0}', '{-10,0}', '{0,-10}', '{-32.58,0}', '{0,-32.58}', '{-32.58,-32.58}']; for (const validValue of validValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowCenter: validValue })).should.not.throw(); } @@ -157,7 +158,7 @@ describe('Device preferences verification', function () { const invalidValues = ['', '{}', '{,}', '{0,}', '{,0}', '{abc}', null, '{-10,-10', '{0. 0, 0}', '{ 0,0}', '{0, 0}']; for (const invalidValue of invalidValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowCenter: invalidValue })).should.throw(Error, /is expected to match/); } @@ -170,7 +171,7 @@ describe('Device preferences verification', function () { it('should pass if correct', function () { const validValues = ['Portrait', 'LandscapeLeft', 'PortraitUpsideDown', 'LandscapeRight']; for (const validValue of validValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowOrientation: validValue })).should.not.throw(); } @@ -179,7 +180,7 @@ describe('Device preferences verification', function () { it('should throw if incorrect', function () { const invalidValues = ['', null, 'portrait', 'bla', -1]; for (const invalidValue of invalidValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowOrientation: invalidValue })).should.throw(Error, /is expected to be one of/); } @@ -192,7 +193,7 @@ describe('Device preferences verification', function () { it('should pass if correct', function () { const validValues = [0, -100, 100, 1.0]; for (const validValue of validValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowRotationAngle: validValue })).should.not.throw(); } @@ -201,7 +202,7 @@ describe('Device preferences verification', function () { it('should throw if incorrect', function () { const invalidValues = ['', null, 'bla', '0']; for (const invalidValue of invalidValues) { - (() => sim.verifyDevicePreferences({ + (() => verifyDevicePreferences.bind(sim)({ SimulatorWindowRotationAngle: invalidValue })).should.throw(Error, /is expected to be a valid number/); }