diff --git a/index.js b/index.js deleted file mode 100644 index 60856a3..0000000 --- a/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import { Simctl } from './lib/simctl'; - -export { Simctl }; -export default Simctl; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..493d587 --- /dev/null +++ b/index.ts @@ -0,0 +1,13 @@ +import { Simctl } from './lib/simctl'; +export type { + SimctlOpts, + DeviceInfo, + SimCreationOpts, + BootMonitorOptions, + CertOptions, + XCRun, + AppInfo, +} from './lib/types'; + +export { Simctl }; +export default Simctl; diff --git a/lib/helpers.js b/lib/helpers.js deleted file mode 100644 index 0034fb4..0000000 --- a/lib/helpers.js +++ /dev/null @@ -1,37 +0,0 @@ -import * as semver from 'semver'; - -export const DEFAULT_EXEC_TIMEOUT = 10 * 60 * 1000; // ms -export const SIM_RUNTIME_NAME = 'com.apple.CoreSimulator.SimRuntime.'; - -/** - * "Normalize" the version, since iOS uses 'major.minor' but the runtimes can - * be 'major.minor.patch' - * - * @param {string} version - the string version - * @return {string} The version in 'major.minor' form - * @throws {Error} If the version not parseable by the `semver` package - */ -export function normalizeVersion (version) { - const semverVersion = semver.coerce(version); - if (!semverVersion) { - throw new Error(`Unable to parse version '${version}'`); - } - return `${semverVersion.major}.${semverVersion.minor}`; -} - -/** - * @returns {string} - */ -export function getXcrunBinary () { - return process.env.XCRUN_BINARY || 'xcrun'; -} - -/** - * Generate a UUID v4 - * - * @returns {Promise} - */ -export async function uuidV4 () { - const uuidLib = await import('uuid'); - return uuidLib.v4(); -} diff --git a/lib/helpers.ts b/lib/helpers.ts new file mode 100644 index 0000000..dfd7114 --- /dev/null +++ b/lib/helpers.ts @@ -0,0 +1,81 @@ +import * as semver from 'semver'; +import { spawn } from 'node:child_process'; +import { Readable } from 'node:stream'; + +export const DEFAULT_EXEC_TIMEOUT = 10 * 60 * 1000; // ms +export const SIM_RUNTIME_NAME = 'com.apple.CoreSimulator.SimRuntime.'; + +/** + * "Normalize" the version, since iOS uses 'major.minor' but the runtimes can + * be 'major.minor.patch' + * + * @param version - the string version + * @return The version in 'major.minor' form + * @throws {Error} If the version not parseable by the `semver` package + */ +export function normalizeVersion (version: string): string { + const semverVersion = semver.coerce(version); + if (!semverVersion) { + throw new Error(`Unable to parse version '${version}'`); + } + return `${semverVersion.major}.${semverVersion.minor}`; +} + +/** + * @returns The xcrun binary name + */ +export function getXcrunBinary (): string { + return process.env.XCRUN_BINARY || 'xcrun'; +} + +/** + * Generate a UUID v4 + * + * @returns Promise resolving to UUID string + */ +export async function uuidV4 (): Promise { + const uuidLib = await import('uuid'); + return uuidLib.v4(); +} + +/** + * Convert plist-style output to JSON using plutil + * + * @param plistInput - The plist-style string to convert + * @return Promise resolving to parsed JSON object + * @throws {Error} If plutil fails to convert the input + */ +export async function convertPlistToJson (plistInput: string): Promise { + const plutilProcess = spawn('plutil', ['-convert', 'json', '-o', '-', '-']); + let jsonOutput = ''; + plutilProcess.stdout.on('data', (chunk) => { + jsonOutput += chunk.toString(); + }); + const inputStream = Readable.from([plistInput]); + inputStream.pipe(plutilProcess.stdin); + try { + await new Promise((resolve, reject) => { + inputStream.once('error', reject); + plutilProcess.once('exit', (code, signal) => { + inputStream.unpipe(plutilProcess.stdin); + if (code === 0) { + resolve(); + } else { + reject(new Error(`plutil exited with code ${code}, signal ${signal}`)); + } + }); + plutilProcess.once('error', (e) => { + inputStream.unpipe(plutilProcess.stdin); + reject(e); + }); + }); + } catch (err) { + plutilProcess.kill(9); + throw new Error(`Failed to convert plist to JSON: ${err instanceof Error ? err.message : String(err)}`); + } finally { + plutilProcess.removeAllListeners(); + inputStream.removeAllListeners(); + } + return JSON.parse(jsonOutput); +} + diff --git a/lib/logger.js b/lib/logger.ts similarity index 71% rename from lib/logger.js rename to lib/logger.ts index 8273d70..3bd5f20 100644 --- a/lib/logger.js +++ b/lib/logger.ts @@ -1,6 +1,6 @@ import appiumLogger from '@appium/logger'; -const LOG_PREFIX = 'simctl'; +export const LOG_PREFIX = 'simctl'; function getLogger () { const logger = global._global_npmlog || appiumLogger; @@ -10,7 +10,4 @@ function getLogger () { return logger; } -const log = getLogger(); - -export { LOG_PREFIX }; -export default log; +export const log = getLogger(); diff --git a/lib/simctl.js b/lib/simctl.js deleted file mode 100644 index 66abd06..0000000 --- a/lib/simctl.js +++ /dev/null @@ -1,299 +0,0 @@ -import _ from 'lodash'; -import which from 'which'; -import log, { LOG_PREFIX } from './logger'; -import { - DEFAULT_EXEC_TIMEOUT, getXcrunBinary, -} from './helpers'; -import { exec as tpExec, SubProcess } from 'teen_process'; -import addmediaCommands from './subcommands/addmedia'; -import appinfoCommands from './subcommands/appinfo'; -import bootCommands from './subcommands/boot'; -import bootstatusCommands from './subcommands/bootstatus'; -import createCommands from './subcommands/create'; -import deleteCommands from './subcommands/delete'; -import eraseCommands from './subcommands/erase'; -import getappcontainerCommands from './subcommands/get_app_container'; -import installCommands from './subcommands/install'; -import ioCommands from './subcommands/io'; -import keychainCommands from './subcommands/keychain'; -import launchCommands from './subcommands/launch'; -import listCommands from './subcommands/list'; -import openurlCommands from './subcommands/openurl'; -import pbcopyCommands from './subcommands/pbcopy'; -import pbpasteCommands from './subcommands/pbpaste'; -import privacyCommands from './subcommands/privacy'; -import pushCommands from './subcommands/push'; -import envCommands from './subcommands/getenv'; -import shutdownCommands from './subcommands/shutdown'; -import spawnCommands from './subcommands/spawn'; -import terminateCommands from './subcommands/terminate'; -import uiCommands from './subcommands/ui'; -import uninstallCommands from './subcommands/uninstall'; -import locationCommands from './subcommands/location'; - -const SIMCTL_ENV_PREFIX = 'SIMCTL_CHILD_'; -const DEFAULT_OPTS = { - xcrun: { - path: null, - }, - execTimeout: DEFAULT_EXEC_TIMEOUT, - logErrors: true, -}; - -/** - * @typedef {Object} XCRun - * @property {string?} path Full path to the xcrun script - */ - -/** - * @typedef {{asynchronous: true}} TAsyncOpts - */ - -/** - * @typedef {Object} ExecOpts - * @property {string[]} [args=[]] - The list of additional subcommand arguments. - * It's empty by default. - * @property {Record} [env={}] - Environment variables mapping. All these variables - * will be passed Simulator and used in the executing function. - * @property {boolean} [logErrors=true] - Set it to _false_ to throw execution errors - * immediately without logging any additional information. - * @property {boolean} [asynchronous=false] - Whether to execute the given command - * 'synchronously' or 'asynchronously'. Affects the returned result of the function. - * @property {string} [encoding] - Explicitly sets streams encoding for the executed - * command input and outputs. - * @property {string|string[]} [architectures] - One or more architecture names to be enforced while - * executing xcrun. See https://github.com/appium/appium/issues/18966 for more details. - * @property {number} [timeout] - The maximum number of milliseconds - * to wait for single synchronous xcrun command. If not provided explicitly, then - * the value of execTimeout property is used by default. - */ - -/** - * @typedef {Object} SimctlOpts - * @property {XCRun} [xcrun] - The xcrun properties. Currently only one property - * is supported, which is `path` and it by default contains `null`, which enforces - * the instance to automatically detect the full path to `xcrun` tool and to throw - * an exception if it cannot be detected. If the path is set upon instance creation - * then it is going to be used by `exec` and no autodetection will happen. - * @property {number} [execTimeout=600000] - The default maximum number of milliseconds - * to wait for single synchronous xcrun command. - * @property {boolean} [logErrors=true] - Whether to wire xcrun error messages - * into debug log before throwing them. - * @property {string?} [udid] - The unique identifier of the current device, which is - * going to be implicitly passed to all methods, which require it. It can either be set - * upon instance creation if it is already known in advance or later when/if needed via the - * corresponding instance setter. - * @property {string?} [devicesSetPath] - Full path to the set of devices that you want to manage. - * By default this path usually equals to ~/Library/Developer/CoreSimulator/Devices - */ - - -class Simctl { - /** @type {XCRun} */ - xcrun; - - /** @type {number} */ - execTimeout; - - /** @type {boolean} */ - logErrors; - - /** - * @param {SimctlOpts} [opts={}] - */ - constructor (opts = {}) { - opts = _.cloneDeep(opts); - _.defaultsDeep(opts, DEFAULT_OPTS); - for (const key of _.keys(DEFAULT_OPTS)) { - this[key] = opts[key]; - } - /** @type {string?} */ - this._udid = _.isNil(opts.udid) ? null : opts.udid; - /** @type {string?} */ - this._devicesSetPath = _.isNil(opts.devicesSetPath) ? null : opts.devicesSetPath; - } - - set udid (value) { - this._udid = value; - } - - get udid () { - return this._udid; - } - - set devicesSetPath (value) { - this._devicesSetPath = value; - } - - get devicesSetPath () { - return this._devicesSetPath; - } - - /** - * @param {string?} [commandName=null] - * @returns {string} - */ - requireUdid (commandName = null) { - if (!this.udid) { - throw new Error(`udid is required to be set for ` + - (commandName ? `the '${commandName}' command` : 'this simctl command')); - } - return this.udid; - } - - /** - * @returns {Promise} - */ - async requireXcrun () { - const xcrunBinary = getXcrunBinary(); - - if (!this.xcrun.path) { - try { - this.xcrun.path = await which(xcrunBinary); - } catch { - throw new Error(`${xcrunBinary} tool has not been found in PATH. ` + - `Are Xcode developers tools installed?`); - } - } - return /** @type {string} */ (this.xcrun.path); - } - - /** - * Execute the particular simctl command. - * - * @template {ExecOpts} TExecOpts - * @param {string} subcommand - One of available simctl subcommands. - * Execute `xcrun simctl` in Terminal to see the full list of available subcommands. - * @param {TExecOpts} [opts] - * @return {Promise} - * Either the result of teen process's `exec` or - * `SubProcess` instance depending of `opts.asynchronous` value. - * @throws {Error} If the simctl subcommand command returns non-zero return code. - */ - async exec (subcommand, opts) { - let { - args = [], - env = {}, - asynchronous = false, - encoding, - logErrors = true, - architectures, - timeout, - } = opts ?? {}; - // run a particular simctl command - args = [ - 'simctl', - ...(this.devicesSetPath ? ['--set', this.devicesSetPath] : []), - subcommand, - ...args - ]; - // Prefix all passed in environment variables with 'SIMCTL_CHILD_', simctl - // will then pass these to the child (spawned) process. - env = _.defaults( - _.mapKeys(env, - (value, key) => _.startsWith(key, SIMCTL_ENV_PREFIX) ? key : `${SIMCTL_ENV_PREFIX}${key}`), - process.env - ); - - const execOpts = { - env, - encoding, - }; - if (!asynchronous) { - execOpts.timeout = timeout || this.execTimeout; - } - const xcrun = await this.requireXcrun(); - try { - let execArgs = [xcrun, args, execOpts]; - if (architectures?.length) { - const archArgs = _.flatMap( - (_.isArray(architectures) ? architectures : [architectures]).map((arch) => ['-arch', arch]) - ); - execArgs = ['arch', [...archArgs, xcrun, ...args], execOpts]; - } - // @ts-ignore We know what we are doing here - return asynchronous ? new SubProcess(...execArgs) : await tpExec(...execArgs); - } catch (e) { - if (!this.logErrors || !logErrors) { - // if we don't want to see the errors, just throw and allow the calling - // code do what it wants - } else if (e.stderr) { - const msg = `Error running '${subcommand}': ${e.stderr.trim()}`; - log.debug(LOG_PREFIX, msg); - e.message = msg; - } else { - log.debug(LOG_PREFIX, e.message); - } - throw e; - } - } - - addMedia = addmediaCommands.addMedia; - - appInfo = appinfoCommands.appInfo; - - bootDevice = bootCommands.bootDevice; - - startBootMonitor = bootstatusCommands.startBootMonitor; - - createDevice = createCommands.createDevice; - - deleteDevice = deleteCommands.deleteDevice; - - eraseDevice = eraseCommands.eraseDevice; - - getAppContainer = getappcontainerCommands.getAppContainer; - - getEnv = envCommands.getEnv; - - installApp = installCommands.installApp; - - getScreenshot = ioCommands.getScreenshot; - - addRootCertificate = keychainCommands.addRootCertificate; - addCertificate = keychainCommands.addCertificate; - resetKeychain = keychainCommands.resetKeychain; - - launchApp = launchCommands.launchApp; - - getDevicesByParsing = listCommands.getDevicesByParsing; - getDevices = listCommands.getDevices; - getRuntimeForPlatformVersionViaJson = listCommands.getRuntimeForPlatformVersionViaJson; - getRuntimeForPlatformVersion = listCommands.getRuntimeForPlatformVersion; - getDeviceTypes = listCommands.getDeviceTypes; - list = listCommands.list; - - setLocation = locationCommands.setLocation; - clearLocation = locationCommands.clearLocation; - - openUrl = openurlCommands.openUrl; - - setPasteboard = pbcopyCommands.setPasteboard; - - getPasteboard = pbpasteCommands.getPasteboard; - - grantPermission = privacyCommands.grantPermission; - revokePermission = privacyCommands.revokePermission; - resetPermission = privacyCommands.resetPermission; - - pushNotification = pushCommands.pushNotification; - - shutdownDevice = shutdownCommands.shutdownDevice; - - spawnProcess = spawnCommands.spawnProcess; - spawnSubProcess = spawnCommands.spawnSubProcess; - - terminateApp = terminateCommands.terminateApp; - - getAppearance = uiCommands.getAppearance; - setAppearance = uiCommands.setAppearance; - getIncreaseContrast = uiCommands.getIncreaseContrast; - setIncreaseContrast = uiCommands.setIncreaseContrast; - getContentSize = uiCommands.getContentSize; - setContentSize = uiCommands.setContentSize; - - removeApp = uninstallCommands.removeApp; -} - -export default Simctl; -export { Simctl }; diff --git a/lib/simctl.ts b/lib/simctl.ts new file mode 100644 index 0000000..3d32e22 --- /dev/null +++ b/lib/simctl.ts @@ -0,0 +1,221 @@ +import _ from 'lodash'; +import which from 'which'; +import { log, LOG_PREFIX } from './logger'; +import { + DEFAULT_EXEC_TIMEOUT, getXcrunBinary, +} from './helpers'; +import { exec as tpExec, SubProcess } from 'teen_process'; +import * as addmediaCommands from './subcommands/addmedia'; +import * as appinfoCommands from './subcommands/appinfo'; +import * as bootCommands from './subcommands/boot'; +import * as bootstatusCommands from './subcommands/bootstatus'; +import * as createCommands from './subcommands/create'; +import * as deleteCommands from './subcommands/delete'; +import * as eraseCommands from './subcommands/erase'; +import * as getappcontainerCommands from './subcommands/get_app_container'; +import * as installCommands from './subcommands/install'; +import * as ioCommands from './subcommands/io'; +import * as keychainCommands from './subcommands/keychain'; +import * as launchCommands from './subcommands/launch'; +import * as listCommands from './subcommands/list'; +import * as openurlCommands from './subcommands/openurl'; +import * as pbcopyCommands from './subcommands/pbcopy'; +import * as pbpasteCommands from './subcommands/pbpaste'; +import * as privacyCommands from './subcommands/privacy'; +import * as pushCommands from './subcommands/push'; +import * as envCommands from './subcommands/getenv'; +import * as shutdownCommands from './subcommands/shutdown'; +import * as spawnCommands from './subcommands/spawn'; +import * as terminateCommands from './subcommands/terminate'; +import * as uiCommands from './subcommands/ui'; +import * as uninstallCommands from './subcommands/uninstall'; +import * as locationCommands from './subcommands/location'; +import type { + XCRun, ExecOpts, SimctlOpts, ExecResult, +} from './types'; + +const SIMCTL_ENV_PREFIX = 'SIMCTL_CHILD_'; + +export class Simctl { + private xcrun: XCRun; + private execTimeout: number; + private logErrors: boolean; + private _udid: string | null; + private _devicesSetPath: string | null; + + constructor (opts: SimctlOpts = {}) { + this.xcrun = _.cloneDeep(opts.xcrun ?? { path: null }); + this.execTimeout = opts.execTimeout ?? DEFAULT_EXEC_TIMEOUT; + this.logErrors = opts.logErrors ?? true; + this._udid = opts.udid ?? null; + this._devicesSetPath = opts.devicesSetPath ?? null; + } + + set udid (value: string | null) { + this._udid = value; + } + + get udid (): string | null { + return this._udid; + } + + set devicesSetPath (value: string | null) { + this._devicesSetPath = value; + } + + get devicesSetPath (): string | null { + return this._devicesSetPath; + } + + /** + * @param commandName - Optional command name for error message + * @returns The UDID string + * @throws {Error} If UDID is not set + */ + requireUdid (commandName: string | null = null): string { + if (!this.udid) { + throw new Error(`udid is required to be set for ` + + (commandName ? `the '${commandName}' command` : 'this simctl command')); + } + return this.udid; + } + + /** + * @returns Promise resolving to the xcrun binary path + */ + async requireXcrun (): Promise { + const xcrunBinary = getXcrunBinary(); + + if (!this.xcrun.path) { + try { + this.xcrun.path = await which(xcrunBinary); + } catch { + throw new Error(`${xcrunBinary} tool has not been found in PATH. ` + + `Are Xcode developers tools installed?`); + } + } + if (!this.xcrun.path) { + throw new Error(`${xcrunBinary} tool path is not set`); + } + return this.xcrun.path; + } + + /** + * Execute the particular simctl command. + * + * @param subcommand - One of available simctl subcommands. + * Execute `xcrun simctl` in Terminal to see the full list of available subcommands. + * @param opts - Execution options + * @return Either the result of teen process's `exec` or + * `SubProcess` instance depending of `opts.asynchronous` value. + * @throws {Error} If the simctl subcommand command returns non-zero return code. + */ + async exec ( + subcommand: string, + opts?: T + ): Promise> { + const { + args: initialArgs = [], + env: initialEnv = {}, + asynchronous = false, + encoding, + logErrors = true, + architectures, + timeout, + } = opts ?? {} as T; + // run a particular simctl command + const args = [ + 'simctl', + ...(this.devicesSetPath ? ['--set', this.devicesSetPath] : []), + subcommand, + ...initialArgs + ]; + // Prefix all passed in environment variables with 'SIMCTL_CHILD_', simctl + // will then pass these to the child (spawned) process. + const env = _.defaults( + _.mapKeys(initialEnv, + (value, key) => _.startsWith(key, SIMCTL_ENV_PREFIX) ? key : `${SIMCTL_ENV_PREFIX}${key}`), + process.env + ); + + const execOpts: any = { + env, + encoding, + }; + if (!asynchronous) { + execOpts.timeout = timeout || this.execTimeout; + } + const xcrun = await this.requireXcrun(); + try { + let execArgs: [string, string[], any]; + if (architectures?.length) { + const archArgs = _.flatMap( + (_.isArray(architectures) ? architectures : [architectures]).map((arch) => ['-arch', arch]) + ); + execArgs = ['arch', [...archArgs, xcrun, ...args], execOpts]; + } else { + execArgs = [xcrun, args, execOpts]; + } + // We know what we are doing here - the type system can't handle the dynamic nature + return (asynchronous ? new SubProcess(...execArgs) : await tpExec(...execArgs)) as ExecResult; + } catch (e: any) { + if (!this.logErrors || !logErrors) { + // if we don't want to see the errors, just throw and allow the calling + // code do what it wants + } else if (e.stderr) { + const msg = `Error running '${subcommand}': ${e.stderr.trim()}`; + log.debug(LOG_PREFIX, msg); + e.message = msg; + } else { + log.debug(LOG_PREFIX, e.message); + } + throw e; + } + } + + // Extension methods + addMedia = addmediaCommands.addMedia; + appInfo = appinfoCommands.appInfo; + bootDevice = bootCommands.bootDevice; + startBootMonitor = bootstatusCommands.startBootMonitor; + createDevice = createCommands.createDevice; + deleteDevice = deleteCommands.deleteDevice; + eraseDevice = eraseCommands.eraseDevice; + getAppContainer = getappcontainerCommands.getAppContainer; + getEnv = envCommands.getEnv; + installApp = installCommands.installApp; + getScreenshot = ioCommands.getScreenshot; + addRootCertificate = keychainCommands.addRootCertificate; + addCertificate = keychainCommands.addCertificate; + resetKeychain = keychainCommands.resetKeychain; + launchApp = launchCommands.launchApp; + getDevicesByParsing = listCommands.getDevicesByParsing; + getDevices = listCommands.getDevices; + getRuntimeForPlatformVersionViaJson = listCommands.getRuntimeForPlatformVersionViaJson; + getRuntimeForPlatformVersion = listCommands.getRuntimeForPlatformVersion; + getDeviceTypes = listCommands.getDeviceTypes; + list = listCommands.list; + setLocation = locationCommands.setLocation; + clearLocation = locationCommands.clearLocation; + openUrl = openurlCommands.openUrl; + setPasteboard = pbcopyCommands.setPasteboard; + getPasteboard = pbpasteCommands.getPasteboard; + grantPermission = privacyCommands.grantPermission; + revokePermission = privacyCommands.revokePermission; + resetPermission = privacyCommands.resetPermission; + pushNotification = pushCommands.pushNotification; + shutdownDevice = shutdownCommands.shutdownDevice; + spawnProcess = spawnCommands.spawnProcess; + spawnSubProcess = spawnCommands.spawnSubProcess; + terminateApp = terminateCommands.terminateApp; + getAppearance = uiCommands.getAppearance; + setAppearance = uiCommands.setAppearance; + getIncreaseContrast = uiCommands.getIncreaseContrast; + setIncreaseContrast = uiCommands.setIncreaseContrast; + getContentSize = uiCommands.getContentSize; + setContentSize = uiCommands.setContentSize; + removeApp = uninstallCommands.removeApp; +} + +export default Simctl; + diff --git a/lib/subcommands/addmedia.js b/lib/subcommands/addmedia.ts similarity index 58% rename from lib/subcommands/addmedia.js rename to lib/subcommands/addmedia.ts index d5e2a7d..403150c 100644 --- a/lib/subcommands/addmedia.js +++ b/lib/subcommands/addmedia.ts @@ -1,21 +1,19 @@ -const commands = {}; +import type { Simctl } from '../simctl'; +import type { TeenProcessExecResult } from 'teen_process'; /** * Add the particular media file to Simulator's library. * It is required that Simulator is in _booted_ state. * - * @this {import('../simctl').Simctl} - * @param {string} filePath - Full path to a media file on the local + * @param filePath - Full path to a media file on the local * file system. - * @return {Promise} Command execution result. + * @return Command execution result. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.addMedia = async function addMedia (filePath) { +export async function addMedia (this: Simctl, filePath: string): Promise> { return await this.exec('addmedia', { args: [this.requireUdid('addmedia'), filePath], }); -}; - -export default commands; +} diff --git a/lib/subcommands/appinfo.js b/lib/subcommands/appinfo.js deleted file mode 100644 index e493159..0000000 --- a/lib/subcommands/appinfo.js +++ /dev/null @@ -1,51 +0,0 @@ -const commands = {}; - -/** - * Invoke hidden appinfo subcommand to get the information - * about applications installed on Simulator, including - * system applications ({@link getAppContainer} does not "see" such apps). - * Simulator server should be in 'booted' state for this call to work properly. - * The tool is only available since Xcode SDK 8.1 - * - * @this {import('../simctl').Simctl} - * @param {string} bundleId - The bundle identifier of the target application. - * @return {Promise} The information about installed application. - * - * Example output for non-existing application container: - *
- * {
- *   CFBundleIdentifier = "com.apple.MobileSafari";
- *   GroupContainers =     {
- *   };
- *   SBAppTags =     (
- *   );
- * }
- * 
- * - * Example output for an existing system application container: - *
- * {
- *   ApplicationType = Hidden;
- *   Bundle = "file:///Applications/Xcode-beta.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/CoreServices/SpringBoard.app";
- *   CFBundleDisplayName = SpringBoard;
- *   CFBundleExecutable = SpringBoard;
- *   CFBundleIdentifier = "com.apple.springboard";
- *   CFBundleName = SpringBoard;
- *   CFBundleVersion = 50;
- *   GroupContainers =     {
- *   };
- *   Path = "/Applications/Xcode-beta.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/CoreServices/SpringBoard.app";
- *   SBAppTags =     (
- *   );
- * }
- * 
- * @throws {Error} If the `udid` instance property is unset - */ -commands.appInfo = async function appInfo (bundleId) { - const {stdout} = await this.exec('appinfo', { - args: [this.requireUdid('appinfo'), bundleId], - }); - return (stdout || '').trim(); -}; - -export default commands; diff --git a/lib/subcommands/appinfo.ts b/lib/subcommands/appinfo.ts new file mode 100644 index 0000000..ca5e76e --- /dev/null +++ b/lib/subcommands/appinfo.ts @@ -0,0 +1,39 @@ +import type { Simctl } from '../simctl'; +import type { AppInfo } from '../types'; +import { convertPlistToJson } from '../helpers'; +import _ from 'lodash'; + +/** + * Get information about an app installed on the simulator + * + * @param bundleId - Bundle identifier of the application + * @return App info object + * @throws {Error} If the app is not found + * @throws {Error} If the corresponding simctl subcommand command + * returns non-zero return code. + * @throws {Error} If the `udid` instance property is unset + */ +export async function appInfo (this: Simctl, bundleId: string): Promise { + const {stdout} = await this.exec('appinfo', { + args: [this.requireUdid('appinfo'), bundleId], + }); + let result: any; + try { + result = JSON.parse(stdout); + } catch { + // If JSON parsing fails, use plutil to convert plist-style output to JSON + try { + result = await convertPlistToJson(stdout); + } catch (err) { + throw new Error( + `Cannot retrieve app info for ${bundleId}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + if (!_.isPlainObject(result) || !('ApplicationType' in result)) { + throw new Error(`App with bundle identifier "${bundleId}" not found. Is it installed?`); + } + + return result as AppInfo; +} diff --git a/lib/subcommands/boot.js b/lib/subcommands/boot.ts similarity index 72% rename from lib/subcommands/boot.js rename to lib/subcommands/boot.ts index a5e73e8..9a8cb42 100644 --- a/lib/subcommands/boot.js +++ b/lib/subcommands/boot.ts @@ -1,28 +1,23 @@ import _ from 'lodash'; -import log, { LOG_PREFIX } from '../logger'; - - -const commands = {}; +import { log, LOG_PREFIX } from '../logger'; +import type { Simctl } from '../simctl'; /** * Boot the particular Simulator if it is not running. * - * @this {import('../simctl').Simctl} * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.bootDevice = async function bootDevice () { +export async function bootDevice (this: Simctl): Promise { try { await this.exec('boot', { args: [this.requireUdid('boot')] }); - } catch (e) { + } catch (e: any) { if (_.includes(e.message, 'Unable to boot device in current state: Booted')) { throw e; } log.debug(LOG_PREFIX, `Simulator already in 'Booted' state. Continuing`); } -}; - -export default commands; +} diff --git a/lib/subcommands/bootstatus.js b/lib/subcommands/bootstatus.js deleted file mode 100644 index c0d4fa5..0000000 --- a/lib/subcommands/bootstatus.js +++ /dev/null @@ -1,119 +0,0 @@ -import log from '../logger'; -import { waitForCondition } from 'asyncbox'; -import _ from 'lodash'; - -const commands = {}; - -/** - * @typedef {Object} BootMonitorOptions - * @property {number} [timeout=240000] - Simulator booting timeout in ms. - * @property {Function} [onWaitingDataMigration] - This event is fired when data migration stage starts. - * @property {Function} [onWaitingSystemApp] - This event is fired when system app wait stage starts. - * @property {Function} [onFinished] - This event is fired when Simulator is fully booted. - * @property {Function} [onError] - This event is fired when there was an error while monitoring the booting process - * or when the timeout has expired. - * @property {boolean} [shouldPreboot=false] Whether to preboot the Simulator - * if this command is called and it is not already in booted or booting state. - */ - -/** - * Start monitoring for boot status of the particular Simulator. - * If onFinished property is not set then the method will block - * until Simulator booting is completed. - * The method is only available since Xcode8. - * - * @this {import('../simctl').Simctl} - * @param {BootMonitorOptions} [opts={}] - Monitoring options. - * @returns {Promise} The instance of the corresponding monitoring process. - * @throws {Error} If the Simulator fails to finish booting within the given timeout and onFinished - * property is not set. - * @throws {Error} If the `udid` instance property is unset - */ -commands.startBootMonitor = async function startBootMonitor (opts = {}) { - const { - timeout = 240000, - onWaitingDataMigration, - onWaitingSystemApp, - onFinished, - onError, - shouldPreboot, - } = opts; - const udid = this.requireUdid('bootstatus'); - - /** @type {string[]} */ - const status = []; - let isBootingFinished = false; - let error = null; - let timeoutHandler = null; - const args = [udid]; - if (shouldPreboot) { - args.push('-b'); - } - const bootMonitor = await this.exec('bootstatus', { - args, - asynchronous: true, - }); - const onStreamLine = (/** @type {string} */ line) => { - status.push(line); - if (onWaitingDataMigration && line.includes('Waiting on Data Migration')) { - onWaitingDataMigration(); - } else if (onWaitingSystemApp && line.includes('Waiting on System App')) { - onWaitingSystemApp(); - } - }; - for (const streamName of ['stdout', 'stderr']) { - bootMonitor.on(`line-${streamName}`, onStreamLine); - } - bootMonitor.once('exit', (code, signal) => { - if (timeoutHandler) { - clearTimeout(timeoutHandler); - } - if (code === 0) { - if (onFinished) { - onFinished(); - } - isBootingFinished = true; - } else { - const errMessage = _.isEmpty(status) - ? `The simulator booting process has exited with code ${code} by signal ${signal}` - : status.join('\n'); - error = new Error(errMessage); - if (onError) { - onError(error); - } - } - }); - await bootMonitor.start(0); - const stopMonitor = async () => { - if (bootMonitor.isRunning) { - try { - await bootMonitor.stop(); - } catch (e) { - log.warn(e.message); - } - } - }; - const start = process.hrtime(); - if (onFinished) { - timeoutHandler = setTimeout(stopMonitor, timeout); - } else { - try { - await waitForCondition(() => { - if (error) { - throw error; - } - return isBootingFinished; - }, {waitMs: timeout, intervalMs: 500}); - } catch { - await stopMonitor(); - const [seconds] = process.hrtime(start); - throw new Error( - `The simulator ${udid} has failed to finish booting after ${seconds}s. ` + - `Original status: ${status.join('\n')}` - ); - } - } - return bootMonitor; -}; - -export default commands; diff --git a/lib/subcommands/bootstatus.ts b/lib/subcommands/bootstatus.ts new file mode 100644 index 0000000..5d7ee50 --- /dev/null +++ b/lib/subcommands/bootstatus.ts @@ -0,0 +1,108 @@ +import { log } from '../logger'; +import { waitForCondition } from 'asyncbox'; +import _ from 'lodash'; +import type { Simctl } from '../simctl'; +import type { BootMonitorOptions } from '../types'; +import type { SubProcess } from 'teen_process'; + +/** + * Start monitoring for boot status of the particular Simulator. + * If onFinished property is not set then the method will block + * until Simulator booting is completed. + * The method is only available since Xcode8. + * + * @param opts - Monitoring options. + * @returns The instance of the corresponding monitoring process. + * @throws {Error} If the Simulator fails to finish booting within the given timeout and onFinished + * property is not set. + * @throws {Error} If the `udid` instance property is unset + */ +export async function startBootMonitor ( + this: Simctl, + opts: BootMonitorOptions = {} +): Promise { + const { + timeout = 240000, + onWaitingDataMigration, + onWaitingSystemApp, + onFinished, + onError, + shouldPreboot, + } = opts; + const udid = this.requireUdid('bootstatus'); + + const status: string[] = []; + let isBootingFinished = false; + let error: Error | null = null; + let timeoutHandler: NodeJS.Timeout | null = null; + const args = [udid]; + if (shouldPreboot) { + args.push('-b'); + } + const bootMonitor = await this.exec('bootstatus', { + args, + asynchronous: true, + }); + const onStreamLine = (line: string) => { + status.push(line); + if (onWaitingDataMigration && line.includes('Waiting on Data Migration')) { + onWaitingDataMigration(); + } else if (onWaitingSystemApp && line.includes('Waiting on System App')) { + onWaitingSystemApp(); + } + }; + for (const streamName of ['stdout', 'stderr']) { + bootMonitor.on(`line-${streamName}`, onStreamLine); + } + bootMonitor.once('exit', (code, signal) => { + if (timeoutHandler) { + clearTimeout(timeoutHandler); + } + if (code === 0) { + if (onFinished) { + onFinished(); + } + isBootingFinished = true; + } else { + const errMessage = _.isEmpty(status) + ? `The simulator booting process has exited with code ${code} by signal ${signal}` + : status.join('\n'); + error = new Error(errMessage); + if (onError) { + onError(error); + } + } + }); + await bootMonitor.start(0); + const stopMonitor = async () => { + if (bootMonitor.isRunning) { + try { + await bootMonitor.stop(); + } catch (e: any) { + log.warn(e.message); + } + } + }; + const start = process.hrtime(); + if (onFinished) { + timeoutHandler = setTimeout(stopMonitor, timeout); + } else { + try { + await waitForCondition(() => { + if (error) { + throw error; + } + return isBootingFinished; + }, {waitMs: timeout, intervalMs: 500}); + } catch { + await stopMonitor(); + const [seconds] = process.hrtime(start); + throw new Error( + `The simulator ${udid} has failed to finish booting after ${seconds}s. ` + + `Original status: ${status.join('\n')}` + ); + } + } + return bootMonitor; +} + diff --git a/lib/subcommands/create.js b/lib/subcommands/create.ts similarity index 64% rename from lib/subcommands/create.js rename to lib/subcommands/create.ts index ebc681f..d683aab 100644 --- a/lib/subcommands/create.js +++ b/lib/subcommands/create.ts @@ -1,41 +1,38 @@ import _ from 'lodash'; -import log, { LOG_PREFIX } from '../logger'; +import { log, LOG_PREFIX } from '../logger'; import { retryInterval } from 'asyncbox'; import { SIM_RUNTIME_NAME, normalizeVersion } from '../helpers'; - +import type { Simctl } from '../simctl'; +import type { SimCreationOpts } from '../types'; const SIM_RUNTIME_NAME_SUFFIX_IOS = 'iOS'; const DEFAULT_CREATE_SIMULATOR_TIMEOUT = 10000; -const commands = {}; - -/** - * @typedef {Object} SimCreationOpts - * @property {string} [platform='iOS'] - Platform name in order to specify runtime such as 'iOS', 'tvOS', 'watchOS' - * @property {number} [timeout=10000] - The maximum number of milliseconds to wait - * unit device creation is completed. - */ - /** * Create Simulator device with given name for the particular * platform type and version. * - * @this {import('../simctl').Simctl} - * @param {string} name - The device name to be created. - * @param {string} deviceTypeId - Device type, for example 'iPhone 6'. - * @param {string} platformVersion - Platform version, for example '10.3'. - * @param {SimCreationOpts} [opts={}] - Simulator options for creating devices. - * @return {Promise} The UDID of the newly created device. + * @param name - The device name to be created. + * @param deviceTypeId - Device type, for example 'iPhone 6'. + * @param platformVersion - Platform version, for example '10.3'. + * @param opts - Simulator options for creating devices. + * @return The UDID of the newly created device. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. */ -commands.createDevice = async function createDevice (name, deviceTypeId, platformVersion, opts = {}) { +export async function createDevice ( + this: Simctl, + name: string, + deviceTypeId: string, + platformVersion: string, + opts: SimCreationOpts = {} +): Promise { const { platform = SIM_RUNTIME_NAME_SUFFIX_IOS, timeout = DEFAULT_CREATE_SIMULATOR_TIMEOUT } = opts; - let runtimeIds = []; + const runtimeIds: string[] = []; // Try getting runtimeId using JSON flag try { @@ -45,7 +42,7 @@ commands.createDevice = async function createDevice (name, deviceTypeId, platfor if (_.isEmpty(runtimeIds)) { // at first make sure that the runtime id is the right one // in some versions of Xcode it will be a patch version - let runtimeId; + let runtimeId: string; try { runtimeId = await this.getRuntimeForPlatformVersion(platformVersion, platform); } catch { @@ -56,7 +53,7 @@ commands.createDevice = async function createDevice (name, deviceTypeId, platfor // get the possible runtimes, which will be iterated over // start with major-minor version - let potentialRuntimeIds = [normalizeVersion(runtimeId)]; + const potentialRuntimeIds = [normalizeVersion(runtimeId)]; if (runtimeId.split('.').length === 3) { // add patch version if it exists potentialRuntimeIds.push(runtimeId); @@ -71,7 +68,7 @@ commands.createDevice = async function createDevice (name, deviceTypeId, platfor } // go through the runtime ids and try to create a simulator with each - let udid; + let udid: string | undefined; for (const runtimeId of runtimeIds) { log.debug(LOG_PREFIX, `Creating simulator with name '${name}', device type id '${deviceTypeId}' and runtime id '${runtimeId}'`); @@ -95,17 +92,15 @@ commands.createDevice = async function createDevice (name, deviceTypeId, platfor // make sure that it gets out of the "Creating" state const retries = parseInt(`${timeout / 1000}`, 10); await retryInterval(retries, 1000, async () => { - const devices = _.values(await this.getDevices()); - for (const deviceArr of _.values(devices)) { - for (const device of deviceArr) { - if (device.udid === udid) { - if (device.state === 'Creating') { - // need to retry - throw new Error(`Device with udid '${udid}' still being created`); - } else { - // stop looking, we're done - return; - } + const devices = _.flatMap(_.values(await this.getDevices())); + for (const device of devices) { + if (device.udid === udid) { + if (device.state === 'Creating') { + // need to retry + throw new Error(`Device with udid '${udid}' still being created`); + } else { + // stop looking, we're done + return; } } } @@ -113,6 +108,5 @@ commands.createDevice = async function createDevice (name, deviceTypeId, platfor }); return udid; -}; +} -export default commands; diff --git a/lib/subcommands/delete.js b/lib/subcommands/delete.ts similarity index 69% rename from lib/subcommands/delete.js rename to lib/subcommands/delete.ts index a56f281..42c0a1f 100644 --- a/lib/subcommands/delete.js +++ b/lib/subcommands/delete.ts @@ -1,17 +1,15 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Delete the particular Simulator from available devices list. * - * @this {import('../simctl').Simctl} * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.deleteDevice = async function deleteDevice () { +export async function deleteDevice (this: Simctl): Promise { await this.exec('delete', { args: [this.requireUdid('delete')] }); -}; +} -export default commands; diff --git a/lib/subcommands/erase.js b/lib/subcommands/erase.ts similarity index 73% rename from lib/subcommands/erase.js rename to lib/subcommands/erase.ts index f1fb623..19a0129 100644 --- a/lib/subcommands/erase.js +++ b/lib/subcommands/erase.ts @@ -1,19 +1,17 @@ import { retryInterval } from 'asyncbox'; - -const commands = {}; +import type { Simctl } from '../simctl'; /** * Reset the content and settings of the particular Simulator. * It is required that Simulator is in _shutdown_ state. * - * @this {import('../simctl').Simctl} - * @param {number} [timeout=10000] - The maximum number of milliseconds to wait + * @param timeout - The maximum number of milliseconds to wait * unit device reset is completed. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.eraseDevice = async function eraseDevice (timeout = 1000) { +export async function eraseDevice (this: Simctl, timeout: number = 1000): Promise { // retry erase with a sleep in between because it's flakey const retries = parseInt(`${timeout / 200}`, 10); await retryInterval(retries, 200, @@ -21,6 +19,5 @@ commands.eraseDevice = async function eraseDevice (timeout = 1000) { args: [this.requireUdid('erase')] }) ); -}; +} -export default commands; diff --git a/lib/subcommands/get_app_container.js b/lib/subcommands/get_app_container.ts similarity index 67% rename from lib/subcommands/get_app_container.js rename to lib/subcommands/get_app_container.ts index 49988f7..6b9761b 100644 --- a/lib/subcommands/get_app_container.js +++ b/lib/subcommands/get_app_container.ts @@ -1,4 +1,4 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Get the full path to the particular application container @@ -7,24 +7,26 @@ const commands = {}; * like 'com.apple.springboard'. * It is required that Simulator is in _booted_ state. * -* @this {import('../simctl').Simctl} - * @param {string} bundleId - Bundle identifier of an application. - * @param {string?} [containerType=null] - Which container type to return. Possible values + * @param bundleId - Bundle identifier of an application. + * @param containerType - Which container type to return. Possible values * are 'app', 'data', 'groups', ''. * The default value is 'app'. - * @return {Promise} Full path to the given application container on the local + * @return Full path to the given application container on the local * file system. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.getAppContainer = async function getAppContainer (bundleId, containerType = null) { +export async function getAppContainer ( + this: Simctl, + bundleId: string, + containerType: string | null = null +): Promise { const args = [this.requireUdid('get_app_container'), bundleId]; if (containerType) { args.push(containerType); } const {stdout} = await this.exec('get_app_container', {args}); return (stdout || '').trim(); -}; +} -export default commands; diff --git a/lib/subcommands/getenv.js b/lib/subcommands/getenv.ts similarity index 57% rename from lib/subcommands/getenv.js rename to lib/subcommands/getenv.ts index 0d2d71b..60a3bb1 100644 --- a/lib/subcommands/getenv.js +++ b/lib/subcommands/getenv.ts @@ -1,21 +1,19 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Retrieves the value of a Simulator environment variable * - * @this {import('../simctl').Simctl} - * @param {string} varName - The name of the variable to be retrieved - * @returns {Promise} The value of the variable or null if the given variable + * @param varName - The name of the variable to be retrieved + * @returns The value of the variable or null if the given variable * is not present in the Simulator environment * @throws {Error} If there was an error while running the command * @throws {Error} If the `udid` instance property is unset */ -commands.getEnv = async function getEnv (varName) { +export async function getEnv (this: Simctl, varName: string): Promise { const {stdout, stderr} = await this.exec('getenv', { args: [this.requireUdid('getenv'), varName], logErrors: false, }); return stderr ? null : stdout; -}; +} -export default commands; diff --git a/lib/subcommands/install.js b/lib/subcommands/install.ts similarity index 67% rename from lib/subcommands/install.js rename to lib/subcommands/install.ts index 6167b76..a259339 100644 --- a/lib/subcommands/install.js +++ b/lib/subcommands/install.ts @@ -1,20 +1,18 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Install the particular application package on Simulator. * It is required that Simulator is in _booted_ state. * - * @this {import('../simctl').Simctl} - * @param {string} appPath - Full path to .app package, which is + * @param appPath - Full path to .app package, which is * going to be installed. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.installApp = async function installApp (appPath) { +export async function installApp (this: Simctl, appPath: string): Promise { await this.exec('install', { args: [this.requireUdid('install'), appPath], }); -}; +} -export default commands; diff --git a/lib/subcommands/io.js b/lib/subcommands/io.ts similarity index 79% rename from lib/subcommands/io.js rename to lib/subcommands/io.ts index 31ad34c..d58a480 100644 --- a/lib/subcommands/io.js +++ b/lib/subcommands/io.ts @@ -3,21 +3,19 @@ import path from 'path'; import os from 'os'; import fs from 'fs/promises'; import { uuidV4 } from '../helpers'; - -const commands = {}; +import type { Simctl } from '../simctl'; /** * Gets base64 screenshot for device * It is required that Simulator is in _booted_ state. * - * @this {import('../simctl').Simctl} * @since Xcode SDK 8.1 - * @return {Promise} Base64-encoded Simulator screenshot. + * @return Base64-encoded Simulator screenshot. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.getScreenshot = async function getScreenshot () { +export async function getScreenshot (this: Simctl): Promise { const udid = this.requireUdid('io screenshot'); const pathToScreenshotPng = path.resolve(os.tmpdir(), `${await uuidV4()}.png`); try { @@ -28,6 +26,5 @@ commands.getScreenshot = async function getScreenshot () { } finally { await rimraf(pathToScreenshotPng); } -}; +} -export default commands; diff --git a/lib/subcommands/keychain.js b/lib/subcommands/keychain.ts similarity index 63% rename from lib/subcommands/keychain.js rename to lib/subcommands/keychain.ts index f1644f6..7bdcec1 100644 --- a/lib/subcommands/keychain.js +++ b/lib/subcommands/keychain.ts @@ -4,15 +4,17 @@ import { uuidV4 } from '../helpers'; import path from 'path'; import _ from 'lodash'; import { rimraf } from 'rimraf'; - -const commands = {}; +import type { Simctl } from '../simctl'; +import type { CertOptions } from '../types'; /** - * - * @param {string|Buffer} payload - * @param {(filePath: string) => Promise} onPayloadStored + * @param payload - Certificate payload (string or Buffer) + * @param onPayloadStored - Callback function to execute with the file path */ -async function handleRawPayload (payload, onPayloadStored) { +async function handleRawPayload ( + payload: string | Buffer, + onPayloadStored: (filePath: string) => Promise +): Promise { const filePath = path.resolve(os.tmpdir(), `${await uuidV4()}.pem`); try { if (_.isBuffer(payload)) { @@ -26,81 +28,77 @@ async function handleRawPayload (payload, onPayloadStored) { } } - -/** - * @typedef {Object} CertOptions - * @property {boolean} [raw=false] - whether the `cert` argument - * is the path to the certificate on the local file system or - * a raw certificate content - */ - /** * Adds the given certificate to the Trusted Root Store on the simulator * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @param {string} cert the full path to a valid .cert file containing + * @param cert the full path to a valid .cert file containing * the certificate content or the certificate content itself, depending on * options - * @param {CertOptions} [opts={}] + * @param opts - Certificate options * @throws {Error} if the current SDK version does not support the command * or there was an error while adding the certificate * @throws {Error} If the `udid` instance property is unset */ -commands.addRootCertificate = async function addRootCertificate (cert, opts = {}) { +export async function addRootCertificate ( + this: Simctl, + cert: string | Buffer, + opts: CertOptions = {} +): Promise { const { raw = false, } = opts; - const execMethod = async (/** @type {string} */certPath) => await this.exec('keychain', { + const execMethod = async (certPath: string) => await this.exec('keychain', { args: [this.requireUdid('keychain add-root-cert'), 'add-root-cert', certPath], }); if (raw) { await handleRawPayload(cert, execMethod); } else { - await execMethod(cert); + await execMethod(cert as string); } -}; +} /** * Adds the given certificate to the Keychain Store on the simulator * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @param {string} cert the full path to a valid .cert file containing + * @param cert the full path to a valid .cert file containing * the certificate content or the certificate content itself, depending on * options - * @param {CertOptions} [opts={}] + * @param opts - Certificate options * @throws {Error} if the current SDK version does not support the command * or there was an error while adding the certificate * @throws {Error} If the `udid` instance property is unset */ -commands.addCertificate = async function addCertificate (cert, opts = {}) { +export async function addCertificate ( + this: Simctl, + cert: string | Buffer, + opts: CertOptions = {} +): Promise { const { raw = false, } = opts; - const execMethod = async (certPath) => await this.exec('keychain', { + const execMethod = async (certPath: string) => await this.exec('keychain', { args: [this.requireUdid('keychain add-cert'), 'add-cert', certPath], }); if (raw) { await handleRawPayload(cert, execMethod); } else { - await execMethod(cert); + await execMethod(cert as string); } -}; +} /** * Resets the simulator keychain * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} * @throws {Error} if the current SDK version does not support the command * or there was an error while resetting the keychain * @throws {Error} If the `udid` instance property is unset */ -commands.resetKeychain = async function resetKeychain () { +export async function resetKeychain (this: Simctl): Promise { await this.exec('keychain', { args: [this.requireUdid('keychain reset'), 'reset'], }); -}; +} -export default commands; diff --git a/lib/subcommands/launch.js b/lib/subcommands/launch.ts similarity index 58% rename from lib/subcommands/launch.js rename to lib/subcommands/launch.ts index 4258bb8..37df92c 100644 --- a/lib/subcommands/launch.js +++ b/lib/subcommands/launch.ts @@ -1,31 +1,28 @@ import _ from 'lodash'; import { retryInterval } from 'asyncbox'; - -const commands = {}; +import type { Simctl } from '../simctl'; /** * Execute the particular application package on Simulator. * It is required that Simulator is in _booted_ state and * the application with given bundle identifier is already installed. * - * @this {import('../simctl').Simctl} - * @param {string} bundleId - Bundle identifier of the application, + * @param bundleId - Bundle identifier of the application, * which is going to be removed. - * @param {number} [tries=5] - The maximum number of retries before + * @param tries - The maximum number of retries before * throwing an exception. - * @return {Promise} the actual command output + * @return the actual command output * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.launchApp = async function launchApp (bundleId, tries = 5) { - // @ts-ignore A string will always be returned - return await retryInterval(tries, 1000, async () => { +export async function launchApp (this: Simctl, bundleId: string, tries: number = 5): Promise { + const result = await retryInterval(tries, 1000, async () => { const {stdout} = await this.exec('launch', { args: [this.requireUdid('launch'), bundleId], }); - return _.trim(stdout); + return _.trim(stdout) || ''; }); -}; + return result || ''; +} -export default commands; diff --git a/lib/subcommands/list.js b/lib/subcommands/list.ts similarity index 75% rename from lib/subcommands/list.js rename to lib/subcommands/list.ts index 67c827a..413c68b 100644 --- a/lib/subcommands/list.js +++ b/lib/subcommands/list.ts @@ -1,31 +1,24 @@ import _ from 'lodash'; import { SIM_RUNTIME_NAME, normalizeVersion } from '../helpers'; -import log, { LOG_PREFIX } from '../logger'; - - -const commands = {}; - -/** - * @typedef {Object} DeviceInfo - * @property {string} name - The device name. - * @property {string} udid - The device UDID. - * @property {string} state - The current Simulator state, for example 'booted' or 'shutdown'. - * @property {string} sdk - The SDK version, for example '10.3'. - */ +import { log, LOG_PREFIX } from '../logger'; +import type { Simctl } from '../simctl'; +import type { DeviceInfo } from '../types'; /** * Parse the list of existing Simulator devices to represent * it as convenient mapping. * - * @this {import('../simctl').Simctl} - * @param {string?} [platform] - The platform name, for example 'watchOS'. - * @return {Promise>} The resulting mapping. Each key is platform version, + * @param platform - The platform name, for example 'watchOS'. + * @return The resulting mapping. Each key is platform version, * for example '10.3' and the corresponding value is an - * array of the matching {@link DeviceInfo} instances. + * array of the matching DeviceInfo instances. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. */ -commands.getDevicesByParsing = async function getDevicesByParsing (platform) { +export async function getDevicesByParsing ( + this: Simctl, + platform?: string | null +): Promise> { const {stdout} = await this.exec('list', { args: ['devices'], }); @@ -42,8 +35,8 @@ commands.getDevicesByParsing = async function getDevicesByParsing (platform) { const deviceSectionRe = _.isEmpty(platform) || !platform ? new RegExp(`\\-\\-\\s+(\\S+)\\s+(\\S+)\\s+\\-\\-(\\n\\s{4}.+)*`, 'mgi') : new RegExp(`\\-\\-\\s+${_.escapeRegExp(platform)}\\s+(\\S+)\\s+\\-\\-(\\n\\s{4}.+)*`, 'mgi'); - const matches = []; - let match; + const matches: RegExpExecArray[] = []; + let match: RegExpExecArray | null; // make an entry for each sdk version while ((match = deviceSectionRe.exec(stdout))) { matches.push(match); @@ -54,7 +47,7 @@ commands.getDevicesByParsing = async function getDevicesByParsing (platform) { const lineRe = /([^\s].+) \((\w+-.+\w+)\) \((\w+\s?\w+)\)/; // https://regex101.com/r/lG7mK6/3 // get all the devices for each sdk - const devices = {}; + const devices: Record = {}; for (match of matches) { const sdk = platform ? match[1] : match[2]; devices[sdk] = devices[sdk] || []; @@ -84,27 +77,40 @@ commands.getDevicesByParsing = async function getDevicesByParsing (platform) { } } return devices; -}; +} /** * Parse the list of existing Simulator devices to represent * it as convenient mapping for the particular platform version. * - * @this {import('../simctl').Simctl} - * @param {string?} [forSdk] - The sdk version, + * @param forSdk - The sdk version, * for which the devices list should be parsed, * for example '10.3'. - * @param {string?} [platform] - The platform name, for example 'watchOS'. - * @return {Promise} If _forSdk_ is set then the list + * @param platform - The platform name, for example 'watchOS'. + * @return If _forSdk_ is set then the list * of devices for the particular platform version. - * Otherwise the same result as for {@link getDevicesByParsing} + * Otherwise the same result as for getDevicesByParsing * function. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code or if no matching * platform version is found in the system. */ -commands.getDevices = async function getDevices (forSdk, platform) { - let devices = {}; +export async function getDevices ( + this: Simctl, + forSdk: string, + platform?: string | null +): Promise; +export async function getDevices ( + this: Simctl, + forSdk?: undefined | null, + platform?: string | null +): Promise>; +export async function getDevices ( + this: Simctl, + forSdk?: string | null, + platform?: string | null +): Promise | DeviceInfo[]> { + let devices: Record = {}; try { const {stdout} = await this.exec('list', { args: ['devices', '-j'], @@ -128,9 +134,9 @@ commands.getDevices = async function getDevices (forSdk, platform) { const versionMatchRe = _.isEmpty(platform) || !platform ? new RegExp(`^([^\\s-]+)[\\s-](\\S+)`, 'i') : new RegExp(`^${_.escapeRegExp(platform)}[\\s-](\\S+)`, 'i'); - for (let [sdkName, entries] of _.toPairs(JSON.parse(stdout).devices)) { + for (const [sdkNameRaw, entries] of _.toPairs(JSON.parse(stdout).devices)) { // there could be a longer name, so remove it - sdkName = sdkName.replace(SIM_RUNTIME_NAME, ''); + const sdkName = sdkNameRaw.replace(SIM_RUNTIME_NAME, ''); const versionMatch = versionMatchRe.exec(sdkName); if (!versionMatch) { continue; @@ -139,8 +145,8 @@ commands.getDevices = async function getDevices (forSdk, platform) { // the sdk can have dashes (`12-2`) or dots (`12.1`) const sdk = (platform ? versionMatch[1] : versionMatch[2]).replace('-', '.'); devices[sdk] = devices[sdk] || []; - devices[sdk].push(...entries.filter((el) => _.isUndefined(el.isAvailable) || el.isAvailable) - .map((el) => { + devices[sdk].push(...(entries as any[]).filter((el) => _.isUndefined(el.isAvailable) || el.isAvailable) + .map((el: any) => { delete el.availability; return { sdk, @@ -150,7 +156,7 @@ commands.getDevices = async function getDevices (forSdk, platform) { }) ); } - } catch (err) { + } catch (err: any) { log.debug(LOG_PREFIX, `Unable to get JSON device list: ${err.stack}`); log.debug(LOG_PREFIX, 'Falling back to manual parsing'); devices = await this.getDevicesByParsing(platform); @@ -170,20 +176,22 @@ commands.getDevices = async function getDevices (forSdk, platform) { ? ` Only the following Simulator SDK versions are available on your system: ${availableSDKs.join(', ')}` : ` No Simulator SDK versions are available on your system. Please install some via Xcode preferences.`; throw new Error(errMsg); -}; +} /** * Get the runtime for the particular platform version using --json flag * - * @this {import('../simctl').Simctl} - * @param {string} platformVersion - The platform version name, + * @param platformVersion - The platform version name, * for example '10.3'. - * @param {string} [platform='iOS'] - The platform name, for example 'watchOS'. - * @return {Promise} The corresponding runtime name for the given + * @param platform - The platform name, for example 'watchOS'. + * @return The corresponding runtime name for the given * platform version. */ -commands.getRuntimeForPlatformVersionViaJson = async function getRuntimeForPlatformVersionViaJson ( - platformVersion, platform = 'iOS') { +export async function getRuntimeForPlatformVersionViaJson ( + this: Simctl, + platformVersion: string, + platform: string = 'iOS' +): Promise { const {stdout} = await this.exec('list', { args: ['runtimes', '--json'], }); @@ -194,20 +202,22 @@ commands.getRuntimeForPlatformVersionViaJson = async function getRuntimeForPlatf } } throw new Error(`Could not use --json flag to parse platform version`); -}; +} /** * Get the runtime for the particular platform version. * - * @this {import('../simctl').Simctl} - * @param {string} platformVersion - The platform version name, + * @param platformVersion - The platform version name, * for example '10.3'. - * @param {string} [platform='iOS'] - The platform name, for example 'watchOS'. - * @return {Promise} The corresponding runtime name for the given + * @param platform - The platform name, for example 'watchOS'. + * @return The corresponding runtime name for the given * platform version. */ -commands.getRuntimeForPlatformVersion = async function getRuntimeForPlatformVersion ( - platformVersion, platform = 'iOS') { +export async function getRuntimeForPlatformVersion ( + this: Simctl, + platformVersion: string, + platform: string = 'iOS' +): Promise { // Try with parsing try { const {stdout} = await this.exec('list', { @@ -226,16 +236,15 @@ commands.getRuntimeForPlatformVersion = async function getRuntimeForPlatformVers // if nothing was found, pass platform version back return platformVersion; -}; +} /** * Get the list of device types available in the current Xcode installation * - * @this {import('../simctl').Simctl} - * @return {Promise} List of the types of devices available + * @return List of the types of devices available * @throws {Error} If the corresponding simctl command fails */ -commands.getDeviceTypes = async function getDeviceTypes () { +export async function getDeviceTypes (this: Simctl): Promise { const {stdout} = await this.exec('list', { args: ['devicetypes', '-j'], }); @@ -252,17 +261,16 @@ commands.getDeviceTypes = async function getDeviceTypes () { */ try { const deviceTypes = JSON.parse(stdout.trim()); - return deviceTypes.devicetypes.map((type) => type.name); - } catch (err) { + return deviceTypes.devicetypes.map((type: any) => type.name); + } catch (err: any) { throw new Error(`Unable to get list of device types: ${err.message}`); } -}; +} /** * Get the full list of runtimes, devicetypes, devices and pairs as Object * - * @this {import('../simctl').Simctl} - * @return {Promise} Object containing device types, runtimes devices and pairs. + * @return Object containing device types, runtimes devices and pairs. * The resulting JSON will be like: * { * "devicetypes" : [ @@ -293,15 +301,14 @@ commands.getDeviceTypes = async function getDeviceTypes () { * } * @throws {Error} If the corresponding simctl command fails */ -commands.list = async function list () { +export async function list (this: Simctl): Promise { const {stdout} = await this.exec('list', { args: ['-j'], }); try { return JSON.parse(stdout.trim()); - } catch (e) { + } catch (e: any) { throw new Error(`Unable to parse simctl list: ${e.message}`); } -}; +} -export default commands; diff --git a/lib/subcommands/location.js b/lib/subcommands/location.ts similarity index 62% rename from lib/subcommands/location.js rename to lib/subcommands/location.ts index ff21c81..fcae077 100644 --- a/lib/subcommands/location.js +++ b/lib/subcommands/location.ts @@ -1,13 +1,13 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Formats the given location argument for simctl usage * - * @param {string} name Argument name - * @param {string|number} value Location argument value - * @returns {string} Formatted value, for example -73.768254 + * @param name Argument name + * @param value Location argument value + * @returns Formatted value, for example -73.768254 */ -function formatArg (name, value) { +function formatArg (name: string, value: string | number): string { const flt = parseFloat(`${value}`); if (isNaN(flt)) { throw new TypeError(`${name} must be a valid number, got '${value}' instead`); @@ -19,31 +19,32 @@ function formatArg (name, value) { * Set the Simulator location to a specific latitude and longitude. * This functionality is only available since Xcode 14. * - * @this {import('../simctl').Simctl} - * @param {string|number} latitude Location latitude value - * @param {string|number} longitude Location longitude value + * @param latitude Location latitude value + * @param longitude Location longitude value * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {TypeError} If any of the arguments is not a valid value. */ -commands.setLocation = async function setLocation (latitude, longitude) { +export async function setLocation ( + this: Simctl, + latitude: string | number, + longitude: string | number +): Promise { const lat = formatArg('latitude', latitude); const lon = formatArg('longitude', longitude); await this.exec('location', { args: [this.requireUdid('location'), 'set', `${lat},${lon}`], }); -}; +} /** * Stop any running scenario and clear any simulated location. * * @since Xcode 14. - * @this {import('../simctl').Simctl} */ -commands.clearLocation = async function clearLocation () { +export async function clearLocation (this: Simctl): Promise { await this.exec('location', { args: [this.requireUdid('location'), 'clear'], }); -}; +} -export default commands; diff --git a/lib/subcommands/openurl.js b/lib/subcommands/openurl.ts similarity index 63% rename from lib/subcommands/openurl.js rename to lib/subcommands/openurl.ts index 6b5a50c..74f8043 100644 --- a/lib/subcommands/openurl.js +++ b/lib/subcommands/openurl.ts @@ -1,22 +1,21 @@ -const commands = {}; +import type { Simctl } from '../simctl'; +import type { TeenProcessExecResult } from 'teen_process'; /** * Open URL scheme on Simulator. iOS will automatically try * to find a matching application, which supports the given scheme. * It is required that Simulator is in _booted_ state. * - * @this {import('../simctl').Simctl} - * @param {string} url - The URL scheme to open, for example http://appiom.io + * @param url - The URL scheme to open, for example http://appiom.io * will be opened by the built-in mobile browser. - * @return {Promise} Command execution result. + * @return Command execution result. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.openUrl = async function openUrl (url) { +export async function openUrl (this: Simctl, url: string): Promise> { return await this.exec('openurl', { args: [this.requireUdid('openurl'), url], }); -}; +} -export default commands; diff --git a/lib/subcommands/pbcopy.js b/lib/subcommands/pbcopy.ts similarity index 66% rename from lib/subcommands/pbcopy.js rename to lib/subcommands/pbcopy.ts index a202309..ce914d9 100644 --- a/lib/subcommands/pbcopy.js +++ b/lib/subcommands/pbcopy.ts @@ -1,23 +1,27 @@ -const commands = {}; +import type { Simctl } from '../simctl'; +import type { SubProcess } from 'teen_process'; /** * Set the content of Simulator pasteboard. * It is required that Simulator is in _booted_ state. * * @since Xcode SDK 8.1 - * @this {import('../simctl').Simctl} - * @param {string} content - The actual string content to be set. - * @param {BufferEncoding} [encoding='utf8'] - The encoding of the given pasteboard content. + * @param content - The actual string content to be set. + * @param encoding - The encoding of the given pasteboard content. * utf8 by default. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.setPasteboard = async function setPasteboard (content, encoding = 'utf8') { +export async function setPasteboard ( + this: Simctl, + content: string, + encoding: BufferEncoding = 'utf8' +): Promise { const pbCopySubprocess = await this.exec('pbcopy', { args: [this.requireUdid('pbcopy')], asynchronous: true, - }); + }) as SubProcess; await pbCopySubprocess.start(0); const exitCodeVerifier = pbCopySubprocess.join(); const stdin = pbCopySubprocess.proc?.stdin; @@ -27,6 +31,5 @@ commands.setPasteboard = async function setPasteboard (content, encoding = 'utf8 stdin.end(); } await exitCodeVerifier; -}; +} -export default commands; diff --git a/lib/subcommands/pbpaste.js b/lib/subcommands/pbpaste.ts similarity index 59% rename from lib/subcommands/pbpaste.js rename to lib/subcommands/pbpaste.ts index 443b993..230a005 100644 --- a/lib/subcommands/pbpaste.js +++ b/lib/subcommands/pbpaste.ts @@ -1,24 +1,22 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Get the content of Simulator pasteboard. * It is required that Simulator is in _booted_ state. * * @since Xcode 8.1 SDK - * @this {import('../simctl').Simctl} - * @param {string} [encoding='utf8'] - The encoding of the returned pasteboard content. + * @param encoding - The encoding of the returned pasteboard content. * UTF-8 by default. - * @return {Promise} Current content of Simulator pasteboard or an empty string. + * @return Current content of Simulator pasteboard or an empty string. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.getPasteboard = async function getPasteboard (encoding = 'utf8') { +export async function getPasteboard (this: Simctl, encoding: BufferEncoding = 'utf8'): Promise { const {stdout} = await this.exec('pbpaste', { args: [this.requireUdid('pbpaste')], encoding, }); return stdout; -}; +} -export default commands; diff --git a/lib/subcommands/privacy.js b/lib/subcommands/privacy.ts similarity index 71% rename from lib/subcommands/privacy.js rename to lib/subcommands/privacy.ts index e835ab0..180c1fd 100644 --- a/lib/subcommands/privacy.js +++ b/lib/subcommands/privacy.ts @@ -1,13 +1,12 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Grants the given permission on the app with the given bundle identifier * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @param {string} bundleId the identifier of the application whose + * @param bundleId the identifier of the application whose * privacy settings are going to be changed - * @param {string} perm one of possible permission values: + * @param perm one of possible permission values: * - all: Apply the action to all services. * - calendar: Allow access to calendar. * - contacts-limited: Allow access to basic contact info. @@ -25,48 +24,45 @@ const commands = {}; * or there was an error while granting the permission * @throws {Error} If the `udid` instance property is unset */ -commands.grantPermission = async function grantPermission (bundleId, perm) { +export async function grantPermission (this: Simctl, bundleId: string, perm: string): Promise { await this.exec('privacy', { args: [this.requireUdid('privacy grant'), 'grant', perm, bundleId], }); -}; +} /** * Revokes the given permission on the app with the given bundle identifier * after it has been granted * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @param {string} bundleId the identifier of the application whose + * @param bundleId the identifier of the application whose * privacy settings are going to be changed - * @param {string} perm one of possible permission values (see `grantPermission`) + * @param perm one of possible permission values (see `grantPermission`) * @throws {Error} if the current SDK version does not support the command * or there was an error while revoking the permission * @throws {Error} If the `udid` instance property is unset */ -commands.revokePermission = async function revokePermission (bundleId, perm) { +export async function revokePermission (this: Simctl, bundleId: string, perm: string): Promise { await this.exec('privacy', { args: [this.requireUdid('privacy revoke'), 'revoke', perm, bundleId], }); -}; +} /** * Resets the given permission on the app with the given bundle identifier * to its default state * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @param {string} bundleId the identifier of the application whose + * @param bundleId the identifier of the application whose * privacy settings are going to be changed - * @param {string} perm one of possible permission values (see `grantPermission`) + * @param perm one of possible permission values (see `grantPermission`) * @throws {Error} if the current SDK version does not support the command * or there was an error while resetting the permission * @throws {Error} If the `udid` instance property is unset */ -commands.resetPermission = async function resetPermission (bundleId, perm) { +export async function resetPermission (this: Simctl, bundleId: string, perm: string): Promise { await this.exec('privacy', { args: [this.requireUdid('private reset'), 'reset', perm, bundleId], }); -}; +} -export default commands; diff --git a/lib/subcommands/push.js b/lib/subcommands/push.ts similarity index 73% rename from lib/subcommands/push.js rename to lib/subcommands/push.ts index eb65763..dd84eed 100644 --- a/lib/subcommands/push.js +++ b/lib/subcommands/push.ts @@ -3,17 +3,15 @@ import { uuidV4 } from '../helpers'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; - -const commands = {}; +import type { Simctl } from '../simctl'; /** * Send a simulated push notification * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @param {Object} payload - The object that describes Apple push notification content. + * @param 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. + * the target application's bundle identifier and "aps" key with valid Apple Push Notification values. * For example: * { * "Simulator Target Bundle": "com.apple.Preferences", @@ -27,7 +25,7 @@ const commands = {}; * or there was an error while pushing the notification * @throws {Error} If the `udid` instance property is unset */ -commands.pushNotification = async function pushNotification (payload) { +export async function pushNotification (this: Simctl, payload: Record): Promise { const dstPath = path.resolve(os.tmpdir(), `${await uuidV4()}.json`); try { await fs.writeFile(dstPath, JSON.stringify(payload), 'utf8'); @@ -37,6 +35,5 @@ commands.pushNotification = async function pushNotification (payload) { } finally { await rimraf(dstPath); } -}; +} -export default commands; diff --git a/lib/subcommands/shutdown.js b/lib/subcommands/shutdown.ts similarity index 71% rename from lib/subcommands/shutdown.js rename to lib/subcommands/shutdown.ts index 10077b3..4318381 100644 --- a/lib/subcommands/shutdown.js +++ b/lib/subcommands/shutdown.ts @@ -1,27 +1,24 @@ import _ from 'lodash'; -import log, { LOG_PREFIX } from '../logger'; - -const commands = {}; +import { log, LOG_PREFIX } from '../logger'; +import type { Simctl } from '../simctl'; /** * Shutdown the given Simulator if it is running. * - * @this {import('../simctl').Simctl} * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.shutdownDevice = async function shutdownDevice () { +export async function shutdownDevice (this: Simctl): Promise { try { await this.exec('shutdown', { args: [this.requireUdid('shutdown')], }); - } catch (e) { + } catch (e: any) { if (!_.includes(e.message, 'current state: Shutdown')) { throw e; } log.debug(LOG_PREFIX, `Simulator already in 'Shutdown' state. Continuing`); } -}; +} -export default commands; diff --git a/lib/subcommands/spawn.js b/lib/subcommands/spawn.ts similarity index 56% rename from lib/subcommands/spawn.js rename to lib/subcommands/spawn.ts index 218aca3..b38b7f1 100644 --- a/lib/subcommands/spawn.js +++ b/lib/subcommands/spawn.ts @@ -1,21 +1,23 @@ import _ from 'lodash'; - - -const commands = {}; +import type { Simctl } from '../simctl'; +import type { TeenProcessExecResult, SubProcess } from 'teen_process'; /** * Spawn the particular process on Simulator. * It is required that Simulator is in _booted_ state. * - * @this {import('../simctl').Simctl} - * @param {string|string[]} args - Spawn arguments - * @param {object} [env={}] - Additional environment variables mapping. - * @return {Promise} Command execution result. + * @param args - Spawn arguments + * @param env - Additional environment variables mapping. + * @return Command execution result. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.spawnProcess = async function spawnProcess (args, env = {}) { +export async function spawnProcess ( + this: Simctl, + args: string | string[], + env: Record = {} +): Promise> { if (_.isEmpty(args)) { throw new Error('Spawn arguments are required'); } @@ -24,19 +26,22 @@ commands.spawnProcess = async function spawnProcess (args, env = {}) { args: [this.requireUdid('spawn'), ...(_.isArray(args) ? args : [args])], env, }); -}; +} /** * Prepare SubProcess instance for a new process, which is going to be spawned * on Simulator. * - * @this {import('../simctl').Simctl} - * @param {string|string[]} args - Spawn arguments - * @param {object} [env={}] - Additional environment variables mapping. - * @return {Promise} The instance of the process to be spawned. + * @param args - Spawn arguments + * @param env - Additional environment variables mapping. + * @return The instance of the process to be spawned. * @throws {Error} If the `udid` instance property is unset */ -commands.spawnSubProcess = async function spawnSubProcess (args, env = {}) { +export async function spawnSubProcess ( + this: Simctl, + args: string | string[], + env: Record = {} +): Promise { if (_.isEmpty(args)) { throw new Error('Spawn arguments are required'); } @@ -45,7 +50,6 @@ commands.spawnSubProcess = async function spawnSubProcess (args, env = {}) { args: [this.requireUdid('spawn'), ...(_.isArray(args) ? args : [args])], env, asynchronous: true, - }); -}; + }) as SubProcess; +} -export default commands; diff --git a/lib/subcommands/terminate.js b/lib/subcommands/terminate.ts similarity index 67% rename from lib/subcommands/terminate.js rename to lib/subcommands/terminate.ts index 3a0927e..7a12683 100644 --- a/lib/subcommands/terminate.js +++ b/lib/subcommands/terminate.ts @@ -1,20 +1,18 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Terminate the given running application on Simulator. * It is required that Simulator is in _booted_ state. * - * @this {import('../simctl').Simctl} - * @param {string} bundleId - Bundle identifier of the application, + * @param bundleId - Bundle identifier of the application, * which is going to be terminated. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.terminateApp = async function terminateApp (bundleId) { +export async function terminateApp (this: Simctl, bundleId: string): Promise { await this.exec('terminate', { args: [this.requireUdid('terminate'), bundleId], }); -}; +} -export default commands; diff --git a/lib/subcommands/ui.js b/lib/subcommands/ui.ts similarity index 80% rename from lib/subcommands/ui.js rename to lib/subcommands/ui.ts index e9ee982..059a987 100644 --- a/lib/subcommands/ui.js +++ b/lib/subcommands/ui.ts @@ -1,40 +1,36 @@ import _ from 'lodash'; - - -const commands = {}; +import type { Simctl } from '../simctl'; /** * Retrieves the current UI appearance value from the given simulator * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @return {Promise} the appearance value, for example 'light' or 'dark' + * @return the appearance value, for example 'light' or 'dark' * @throws {Error} if the current SDK version does not support the command * or there was an error while getting the value * @throws {Error} If the `udid` instance property is unset */ -commands.getAppearance = async function getAppearance () { +export async function getAppearance (this: Simctl): Promise { const {stdout} = await this.exec('ui', { args: [this.requireUdid('ui'), 'appearance'], }); return _.trim(stdout); -}; +} /** * Sets the UI appearance to the given style * * @since Xcode 11.4 SDK - * @this {import('../simctl').Simctl} - * @param {string} appearance valid appearance value, for example 'light' or 'dark' + * @param appearance valid appearance value, for example 'light' or 'dark' * @throws {Error} if the current SDK version does not support the command * or there was an error while getting the value * @throws {Error} If the `udid` instance property is unset */ -commands.setAppearance = async function setAppearance (appearance) { +export async function setAppearance (this: Simctl, appearance: string): Promise { await this.exec('ui', { args: [this.requireUdid('ui'), 'appearance', appearance], }); -}; +} /** * Retrieves the current increase contrast configuration value from the given simulator. @@ -45,20 +41,19 @@ commands.setAppearance = async function setAppearance (appearance) { * - unknown: The current setting is unknown or there was an error detecting it. * * @since Xcode 15 (but lower xcode could have this command) - * @this {import('../simctl').Simctl} - * @return {Promise} the contrast configuration value. + * @return the contrast configuration value. * Possible return value is 'enabled', 'disabled', * 'unsupported' or 'unknown' with Xcode 16.2. * @throws {Error} if the current SDK version does not support the command * or there was an error while getting the value. * @throws {Error} If the `udid` instance property is unset */ -commands.getIncreaseContrast = async function getIncreaseContrast () { +export async function getIncreaseContrast (this: Simctl): Promise { const {stdout} = await this.exec('ui', { args: [this.requireUdid('ui'), 'increase_contrast'], }); return _.trim(stdout); -}; +} /** * Sets the increase constrast configuration for the given simulator. @@ -67,18 +62,17 @@ commands.getIncreaseContrast = async function getIncreaseContrast () { * in the caller side. * * @since Xcode 15 (but lower xcode could have this command) - * @this {import('../simctl').Simctl} - * @param {string} increaseContrast valid increase constrast configuration value. + * @param increaseContrast valid increase constrast configuration value. * Acceptable value is 'enabled' or 'disabled' with Xcode 16.2. * @throws {Error} if the current SDK version does not support the command * or the given value was invalid for the command. * @throws {Error} If the `udid` instance property is unset */ -commands.setIncreaseContrast = async function setIncreaseContrast (increaseContrast) { +export async function setIncreaseContrast (this: Simctl, increaseContrast: string): Promise { await this.exec('ui', { args: [this.requireUdid('ui'), 'increase_contrast', increaseContrast], }); -}; +} /** * Retrieves the current content size value from the given simulator. @@ -91,8 +85,7 @@ commands.setIncreaseContrast = async function setIncreaseContrast (increaseContr * Other values: unknown, unsupported. * * @since Xcode 15 (but lower xcode could have this command) - * @this {import('../simctl').Simctl} - * @return {Promise} the content size value. Possible return value is + * @return the content size value. Possible return value is * extra-small, small, medium, large, extra-large, extra-extra-large, * extra-extra-extra-large, accessibility-medium, accessibility-large, * accessibility-extra-large, accessibility-extra-extra-large, @@ -102,12 +95,12 @@ commands.setIncreaseContrast = async function setIncreaseContrast (increaseContr * or there was an error while getting the value. * @throws {Error} If the `udid` instance property is unset */ -commands.getContentSize = async function getContentSize () { +export async function getContentSize (this: Simctl): Promise { const {stdout} = await this.exec('ui', { args: [this.requireUdid('ui'), 'content_size'], }); return _.trim(stdout); -}; +} /** * Sets content size for the given simulator. @@ -122,8 +115,7 @@ commands.getContentSize = async function getContentSize () { * in the caller side. * * @since Xcode 15 (but lower xcode could have this command) - * @this {import('../simctl').Simctl} - * @param {string} contentSizeAction valid content size or action value. Acceptable value is + * @param contentSizeAction valid content size or action value. Acceptable value is * extra-small, small, medium, large, extra-large, extra-extra-large, * extra-extra-extra-large, accessibility-medium, accessibility-large, * accessibility-extra-large, accessibility-extra-extra-large, @@ -132,10 +124,9 @@ commands.getContentSize = async function getContentSize () { * or the given value was invalid for the command. * @throws {Error} If the `udid` instance property is unset */ -commands.setContentSize = async function setContentSize (contentSizeAction) { +export async function setContentSize (this: Simctl, contentSizeAction: string): Promise { await this.exec('ui', { args: [this.requireUdid('ui'), 'content_size', contentSizeAction], }); -}; +} -export default commands; diff --git a/lib/subcommands/uninstall.js b/lib/subcommands/uninstall.ts similarity index 71% rename from lib/subcommands/uninstall.js rename to lib/subcommands/uninstall.ts index 3e1d0b3..d998066 100644 --- a/lib/subcommands/uninstall.js +++ b/lib/subcommands/uninstall.ts @@ -1,21 +1,19 @@ -const commands = {}; +import type { Simctl } from '../simctl'; /** * Remove the particular application package from Simulator. * It is required that Simulator is in _booted_ state and * the application with given bundle identifier is already installed. * - * @this {import('../simctl').Simctl} - * @param {string} bundleId - Bundle identifier of the application, + * @param bundleId - Bundle identifier of the application, * which is going to be removed. * @throws {Error} If the corresponding simctl subcommand command * returns non-zero return code. * @throws {Error} If the `udid` instance property is unset */ -commands.removeApp = async function removeApp (bundleId) { +export async function removeApp (this: Simctl, bundleId: string): Promise { await this.exec('uninstall', { args: [this.requireUdid('uninstall'), bundleId], }); -}; +} -export default commands; diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..8630998 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,243 @@ +import type { SubProcess, TeenProcessExecResult } from 'teen_process'; + +/** + * XCRun configuration + */ +export interface XCRun { + /** + * Full path to the xcrun script + */ + path: string | null; +} + +/** + * Options for asynchronous execution + */ +export interface AsyncExecOpts { + asynchronous: true; +} + +/** + * Execution options for simctl commands + */ +export interface ExecOpts { + /** + * The list of additional subcommand arguments. + * It's empty by default. + */ + args?: string[]; + /** + * Environment variables mapping. All these variables + * will be passed Simulator and used in the executing function. + */ + env?: Record; + /** + * Set it to _false_ to throw execution errors + * immediately without logging any additional information. + */ + logErrors?: boolean; + /** + * Whether to execute the given command + * 'synchronously' or 'asynchronously'. Affects the returned result of the function. + */ + asynchronous?: boolean; + /** + * Explicitly sets streams encoding for the executed + * command input and outputs. + */ + encoding?: string; + /** + * One or more architecture names to be enforced while + * executing xcrun. See https://github.com/appium/appium/issues/18966 for more details. + */ + architectures?: string | string[]; + /** + * The maximum number of milliseconds + * to wait for single synchronous xcrun command. If not provided explicitly, then + * the value of execTimeout property is used by default. + */ + timeout?: number; +} + +/** + * Simctl instance options + */ +export interface SimctlOpts { + /** + * The xcrun properties. Currently only one property + * is supported, which is `path` and it by default contains `null`, which enforces + * the instance to automatically detect the full path to `xcrun` tool and to throw + * an exception if it cannot be detected. If the path is set upon instance creation + * then it is going to be used by `exec` and no autodetection will happen. + */ + xcrun?: XCRun; + /** + * The default maximum number of milliseconds + * to wait for single synchronous xcrun command. + */ + execTimeout?: number; + /** + * Whether to wire xcrun error messages + * into debug log before throwing them. + */ + logErrors?: boolean; + /** + * The unique identifier of the current device, which is + * going to be implicitly passed to all methods, which require it. It can either be set + * upon instance creation if it is already known in advance or later when/if needed via the + * corresponding instance setter. + */ + udid?: string | null; + /** + * Full path to the set of devices that you want to manage. + * By default this path usually equals to ~/Library/Developer/CoreSimulator/Devices + */ + devicesSetPath?: string | null; +} + +/** + * Device information + */ +export interface DeviceInfo { + /** + * The device name. + */ + name: string; + /** + * The device UDID. + */ + udid: string; + /** + * The current Simulator state, for example 'booted' or 'shutdown'. + */ + state: string; + /** + * The SDK version, for example '10.3'. + */ + sdk: string; + /** + * The platform name, for example 'iOS'. + */ + platform: string; +} + +/** + * Simulator creation options + */ +export interface SimCreationOpts { + /** + * Platform name in order to specify runtime such as 'iOS', 'tvOS', 'watchOS' + */ + platform?: string; + /** + * The maximum number of milliseconds to wait + * unit device creation is completed. + */ + timeout?: number; +} + +/** + * Result type for exec method - either SubProcess for async or TeenProcessExecResult for sync + */ +export type ExecResult = T extends AsyncExecOpts + ? SubProcess + : TeenProcessExecResult; + +/** + * Boot monitor options + */ +export interface BootMonitorOptions { + /** + * Simulator booting timeout in ms. + */ + timeout?: number; + /** + * This event is fired when data migration stage starts. + */ + onWaitingDataMigration?: () => void; + /** + * This event is fired when system app wait stage starts. + */ + onWaitingSystemApp?: () => void; + /** + * This event is fired when Simulator is fully booted. + */ + onFinished?: () => void; + /** + * This event is fired when there was an error while monitoring the booting process + * or when the timeout has expired. + */ + onError?: (error: Error) => void; + /** + * Whether to preboot the Simulator + * if this command is called and it is not already in booted or booting state. + */ + shouldPreboot?: boolean; +} + +/** + * Certificate options + */ +export interface CertOptions { + /** + * whether the `cert` argument + * is the path to the certificate on the local file system or + * a raw certificate content + */ + raw?: boolean; +} + +/** + * App information returned by simctl appinfo when the app is found + */ +export interface AppInfo { + /** + * Application type (e.g., "Hidden") + */ + ApplicationType: string; + /** + * Bundle URL (file:// URL) + */ + Bundle?: string; + /** + * Bundle container URL (file:// URL) + */ + BundleContainer?: string; + /** + * Display name of the application + */ + CFBundleDisplayName: string; + /** + * Executable name + */ + CFBundleExecutable: string; + /** + * Bundle identifier + */ + CFBundleIdentifier: string; + /** + * Bundle name + */ + CFBundleName: string; + /** + * Bundle version + */ + CFBundleVersion: string | number; + /** + * Data container URL (file:// URL) + */ + DataContainer?: string; + /** + * Group containers dictionary + */ + GroupContainers?: Record; + /** + * Path to the app bundle + */ + Path: string; + /** + * SpringBoard app tags + */ + SBAppTags?: string[]; +} + diff --git a/package.json b/package.json index 986b6db..beaab77 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lib": "./lib" }, "files": [ - "index.js", + "index.ts", "lib", "build/index.*", "build/lib", diff --git a/test/e2e/simctl-e2e-specs.ts b/test/e2e/simctl-e2e-specs.ts index a926d34..e7a500e 100644 --- a/test/e2e/simctl-e2e-specs.ts +++ b/test/e2e/simctl-e2e-specs.ts @@ -1,12 +1,12 @@ import _ from 'lodash'; -import { Simctl } from '../../lib/simctl.js'; +import { Simctl } from '../../lib/simctl'; import xcode from 'appium-xcode'; import { retryInterval } from 'asyncbox'; import { rimraf } from 'rimraf'; import { uuidV4 } from '../../lib/helpers'; -import path from 'path'; -import os from 'os'; -import fs from 'fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs/promises'; import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -55,8 +55,9 @@ describe('simctl', function () { }); it('should retrieve a device with compatible properties', async function () { - const devices = (await simctl.getDevices())[sdk]; - const firstDevice = devices[0]; + const devices = await simctl.getDevices(); + const sdkDevices = devices[sdk]; + const firstDevice = sdkDevices[0]; const expectedList = ['name', 'sdk', 'state', 'udid']; expect(firstDevice).to.have.any.keys(...expectedList); }); @@ -76,9 +77,11 @@ describe('simctl', function () { }); it('should create a device and be able to see it in devices list right away', async function () { - const numSimsBefore = (await simctl.getDevices())[sdk].length; + const devicesBefore = await simctl.getDevices(); + const numSimsBefore = devicesBefore[sdk].length; simctl.udid = await simctl.createDevice('node-simctl test', DEVICE_NAME, sdk); - const numSimsAfter = (await simctl.getDevices())[sdk].length; + const devicesAfter = await simctl.getDevices(); + const numSimsAfter = devicesAfter[sdk].length; expect(numSimsAfter).to.equal(numSimsBefore + 1); }); }); @@ -230,8 +233,14 @@ describe('simctl', function () { }); }); - it('should extract applications information', async function () { - expect(await simctl.appInfo('com.apple.springboard')).to.include('ApplicationType'); + describe('appInfo', function () { + it('should extract applications information', async function () { + const appInfo = await simctl.appInfo('com.apple.springboard'); + expect(appInfo.ApplicationType).to.equal('Hidden'); + }); + it('should throw an error if the app is not installed', async function () { + await expect(simctl.appInfo('com.apple.notinstalled')).to.be.eventually.rejected; + }); }); describe('getEnv', function () { diff --git a/test/unit/simctl-specs.ts b/test/unit/simctl-specs.ts index d5b35e3..9af888b 100644 --- a/test/unit/simctl-specs.ts +++ b/test/unit/simctl-specs.ts @@ -1,10 +1,10 @@ import sinon from 'sinon'; import _ from 'lodash'; -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import { Simctl } from '../../lib/simctl.js'; +import { Simctl } from '../../lib/simctl'; use(chaiAsPromised); diff --git a/tsconfig.json b/tsconfig.json index 9c85a36..1e06ded 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,11 +5,10 @@ "strict": false, "esModuleInterop": true, "outDir": "build", - "types": ["node", "mocha"], - "checkJs": true + "types": ["node", "mocha"] }, "include": [ - "index.js", + "index.ts", "lib", "test" ]