diff --git a/.gitignore b/.gitignore index d217157..cf1b49d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,31 @@ -.DS_Store - -*.js -*.d.ts -!/*.js -!bin/ios-sim-portable.js -!scripts/*.js -*.js.map - -lib-cov -*.seed -*.log -*.csv -*.dat -*.out -*.pid -*.gz -*.tgz -*.tmp -tscommand*.tmp.txt -.tscache/ -/lib/.d.ts - -.idea/ - -test-reports.xml - -npm-debug.log -node_modules +.DS_Store + +*.js +*.d.ts +!/*.js +!bin/ios-sim-portable.js +!scripts/*.js +!test/*.js +*.js.map + +lib-cov +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.gz +*.tgz +*.tmp +tscommand*.tmp.txt +.tscache/ +/lib/.d.ts + +.idea/ + +test-reports.xml + +npm-debug.log +node_modules package-lock.json \ No newline at end of file diff --git a/lib/declarations.ts b/lib/declarations.ts index 1ec54dc..274f966 100644 --- a/lib/declarations.ts +++ b/lib/declarations.ts @@ -45,6 +45,8 @@ interface ISkipErrorComposition { interface ISimctl { launch(deviceId: string, applicationIdentifier: string, options: IOptions): Promise; boot(deviceId: string): Promise; + bootAndWait(deviceId: string): Promise; + getAppPid(deviceId: string, appIdentifier: string): Promise; terminate(deviceId: string, appIdentifier: string): Promise; install(deviceId: string, applicationPath: string): Promise; uninstall(deviceId: string, applicationIdentifier: string, opts?: ISkipErrorComposition): Promise; diff --git a/lib/ios-sim.ts b/lib/ios-sim.ts index 996f9d6..6ec87d6 100644 --- a/lib/ios-sim.ts +++ b/lib/ios-sim.ts @@ -1,4 +1,5 @@ import * as _ from "lodash"; +import { delay } from "./utils"; function getSimulator(): ISimulator { let libraryPath = require("./iphone-simulator"); @@ -10,39 +11,19 @@ const publicApi = {}; Object.defineProperty(publicApi, "getRunningSimulator", { get: () => { - return (...args: any[]) => { - let isResolved = false; - - return new Promise((resolve, reject) => { - let libraryPath = require("./iphone-simulator-xcode-simctl"); - let simulator = new libraryPath.XCodeSimctlSimulator(); - - const tryGetBootedDevice = () => { - try { - return simulator.getBootedDevice.apply(simulator, args); - } catch (err) { - if (!isResolved) { - isResolved = true; - reject(err); - } - } + return async (...args: any[]) => { + const libraryPath = require("./iphone-simulator-xcode-simctl"); + const simulator = new libraryPath.XCodeSimctlSimulator(); + + for (let attempt = 0; attempt < 30; attempt++) { + const device = await simulator.getBootedDevice(...args); + if (device) { + return device; } + await delay(500); + } - let result = tryGetBootedDevice(); - - if (!isResolved && !result) { - let repeatCount = 30; - let timer = setInterval(() => { - result = tryGetBootedDevice(); - if ((result || !repeatCount) && !isResolved) { - isResolved = true; - clearInterval(timer); - resolve(result); - } - repeatCount--; - }, 500); - } - }); + return simulator.getBootedDevice(...args); } } }); @@ -50,40 +31,9 @@ Object.defineProperty(publicApi, "getRunningSimulator", { Object.defineProperty(publicApi, "getRunningSimulators", { get: () => { return (...args: any[]) => { - let isResolved = false; - - return new Promise(async (resolve, reject) => { - const libraryPath = require("./iphone-simulator-xcode-simctl"); - const simulator = new libraryPath.XCodeSimctlSimulator(); - - const tryGetBootedDevices = async () => { - try { - return await simulator.getBootedDevices.apply(simulator, args); - } catch (err) { - if (!isResolved) { - isResolved = true; - reject(err); - } - } - } - - let result = await tryGetBootedDevices(); - if (result && result.length) { - isResolved = true; - resolve(result); - return; - } - - if (!isResolved && (!result || !result.length)) { - const timer = setTimeout(async () => { - result = await tryGetBootedDevices(); - if (!isResolved) { - isResolved = true; - resolve(result); - } - }, 500); - } - }); + const libraryPath = require("./iphone-simulator-xcode-simctl"); + const simulator = new libraryPath.XCodeSimctlSimulator(); + return simulator.getBootedDevices(...args); } } }); diff --git a/lib/iphone-simulator-common.ts b/lib/iphone-simulator-common.ts index 2c91afe..87dcace 100644 --- a/lib/iphone-simulator-common.ts +++ b/lib/iphone-simulator-common.ts @@ -35,6 +35,10 @@ export function getInstalledApplications(deviceId: string): IApplication[] { applicationDirContents, (fileName) => path.extname(fileName) === ".app" ); + // Left behind by an install that didn't finish. + if (!applicationName) { + return; + } let plistFilePath = path.join( fullApplicationPath, applicationName, @@ -51,28 +55,13 @@ export function getInstalledApplications(deviceId: string): IApplication[] { return result; } -export function startSimulator(xcodeVersion: number, deviceId?: string - ): void { - let args = ["open"]; - if(xcodeVersion<27) { - let simulatorPath = path.resolve( - xcode.getPathFromXcodeSelect(), - "Applications", - "Simulator.app" - ); - args.push(simulatorPath); - if (deviceId) { - args.push("--args", "-CurrentDeviceUDID", deviceId); - } - } else { - let simulatorPath = path.resolve( - xcode.getPathFromXcodeSelect(),"..", - "Applications", - "DeviceHub.app" - ); - args.push(simulatorPath); - } - childProcess.execSync(args.join(" ")); +export async function startSimulatorApp(xcodeVersion: number): Promise { + const xcodePath = await xcode.getPathFromXcodeSelect(); + await childProcess.spawn("open", [ + xcodeVersion >= 27 + ? path.resolve(xcodePath, "..", "Applications", "DeviceHub.app") + : path.resolve(xcodePath, "Applications", "Simulator.app"), + ]); } function parsePlist(fileNameOrBuffer: string | Buffer) { diff --git a/lib/iphone-simulator-xcode-simctl.ts b/lib/iphone-simulator-xcode-simctl.ts index 1221839..47684ec 100755 --- a/lib/iphone-simulator-xcode-simctl.ts +++ b/lib/iphone-simulator-xcode-simctl.ts @@ -2,7 +2,7 @@ import * as child_process from "child_process"; import * as _ from "lodash"; import * as childProcess from "./child-process"; import * as errors from "./errors"; -import { Simctl } from "./simctl"; +import { compareVersions, Simctl } from "./simctl"; import * as path from "path"; import * as common from "./iphone-simulator-common"; @@ -20,19 +20,16 @@ export class XCodeSimctlSimulator private deviceLogChildProcess: any = null; private isDeviceLogOperationStarted = false; public defaultDeviceIdentifier = "iPhone 6"; - private _XCodeMajorVersion: number = 0; private simctl: ISimctl; constructor() { super(); this.simctl = new Simctl(); } - public get XCodeMajorVersion() { - if(!this._XCodeMajorVersion){ - this._XCodeMajorVersion = Number.parseInt(xcode.getXcodeVersionData().major); - } - return this._XCodeMajorVersion; + private async getXcodeMajorVersion(): Promise { + return Number.parseInt((await xcode.getXcodeVersionData()).major); } + public getDevices(): Promise { return this.simctl.getDevices(); } @@ -54,7 +51,7 @@ export class XCodeSimctlSimulator ): Promise { const device = await this.getDeviceToRun(options); - this.startSimulator(options, device); + await this.startSimulator(options, device); if (!options.skipInstall) { await this.installApplication(device.id, applicationPath); } @@ -66,12 +63,6 @@ export class XCodeSimctlSimulator notification: string, deviceId: string ): Promise { - let device = this.getDeviceFromIdentifier(deviceId); - - if (!device) { - errors.fail("Could not find device."); - } - return this.simctl.notifyPost(deviceId, notification); } @@ -104,99 +95,91 @@ export class XCodeSimctlSimulator return this.simctl.uninstall(deviceId, appIdentifier, { skipError: true }); } - private static startingApps: IDictionary> = {}; + // The app gets this long to start, in order to avoid a frozen simulator when + // it is killed on the splash screen. Debug sessions also ask to start an app + // that was launched just before, which must not launch it again. + private static APP_START_MILLISECONDS = 3000; + private static startingApps: IDictionary<{ + launch: Promise; + startedAt: number; + }> = {}; private static stoppingApps: IDictionary> = {}; - public async startApplication( + public startApplication( deviceId: string, appIdentifier: string, options: IOptions ): Promise { - const startingAppKey: string = deviceId + appIdentifier; - if (XCodeSimctlSimulator.startingApps[startingAppKey]) { - return XCodeSimctlSimulator.startingApps[startingAppKey]; + const appKey = deviceId + appIdentifier; + const starting = XCodeSimctlSimulator.startingApps[appKey]; + if ( + starting && + Date.now() - starting.startedAt < XCodeSimctlSimulator.APP_START_MILLISECONDS + ) { + return starting.launch; } - const deferPromise = utils.deferPromise(); - XCodeSimctlSimulator.startingApps[startingAppKey] = deferPromise.promise; - // let the app start for 3 seconds in order to avoid a frozen simulator - // when the app is killed on splash screen - setTimeout(() => { - delete XCodeSimctlSimulator.startingApps[startingAppKey]; - deferPromise.resolve(); - }, 3000); - - // simctl launch command does not launch the process immediately and we have to wait a little bit, - // just to ensure all related processes and services are alive. - const launchResult = await this.simctl.launch( - deviceId, - appIdentifier, - options - ); - utils.sleep(0.5); - return launchResult; + const launch = this.simctl.launch(deviceId, appIdentifier, options); + const entry = { launch, startedAt: Date.now() }; + XCodeSimctlSimulator.startingApps[appKey] = entry; + launch.catch(() => { + if (XCodeSimctlSimulator.startingApps[appKey] === entry) { + delete XCodeSimctlSimulator.startingApps[appKey]; + } + }); + + return launch; } - public async stopApplication( + public stopApplication( deviceId: string, appIdentifier: string, - bundleExecutable: string + bundleExecutable?: string ): Promise { - const appKey: string = deviceId + appIdentifier; - if (XCodeSimctlSimulator.stoppingApps[appKey]) { - return XCodeSimctlSimulator.stoppingApps[appKey]; + const appKey = deviceId + appIdentifier; + if (!XCodeSimctlSimulator.stoppingApps[appKey]) { + XCodeSimctlSimulator.stoppingApps[appKey] = this.stopApp( + deviceId, + appIdentifier + ).finally(() => { + delete XCodeSimctlSimulator.stoppingApps[appKey]; + }); } - const deferPromise = utils.deferPromise(); - XCodeSimctlSimulator.stoppingApps[appKey] = deferPromise.promise; - - if (XCodeSimctlSimulator.startingApps[appKey]) { - await XCodeSimctlSimulator.startingApps[appKey]; - } + return XCodeSimctlSimulator.stoppingApps[appKey]; + } - try { - let pid = this.getPid(deviceId, bundleExecutable); - while (pid) { - childProcess.execSync(`kill -9 ${pid}`, null, { skipError: true }); - pid = this.getPid(deviceId, bundleExecutable); - if (pid) { - utils.sleep(0.1); - } + private async stopApp(deviceId: string, appIdentifier: string): Promise { + const appKey = deviceId + appIdentifier; + const starting = XCodeSimctlSimulator.startingApps[appKey]; + if (starting) { + const remaining = + XCodeSimctlSimulator.APP_START_MILLISECONDS - + (Date.now() - starting.startedAt); + if (remaining > 0) { + await utils.delay(remaining); } - } catch (e) { - // ingore + delete XCodeSimctlSimulator.startingApps[appKey]; } try { await this.simctl.terminate(deviceId, appIdentifier); - } catch (e) { - // sometimes simctl can fail and return a non-zero exit code - // in most cases we should be fine to ignore it and continue - // todo: find cases where this may break things down the line + } catch (err) { + if (err.message.indexOf("found nothing to terminate") === -1) { + await this.killApp(deviceId, appIdentifier); + } } - utils.sleep(0.5); - - delete XCodeSimctlSimulator.stoppingApps[appKey]; - deferPromise.resolve(); - - return deferPromise.promise; } - private getPid(deviceId: string, bundleExecutable: string): string { - // Sample output of the command ps -ef | grep app3grep | grep /86DCBE59-7ED0-447D-832B-A397322BD712/ - // 1203284766 59345 51524 0 7:11pm ?? 0:01.32 /Users/username/Library/Developer/CoreSimulator/Devices/86DCBE59-7ED0-447D-832B-A397322BD712/data/Containers/Bundle/Application/4E79A7E8-9AAB-40B9-89EF-C8D7B91B819E/app3grep.app/app3grep - // 1203284766 59486 59396 0 7:15pm ?? 0:00.01 /bin/sh -c ps -ef | grep app3grep | grep /86DCBE59-7ED0-447D-832B-A397322BD712/ - // The process, that is execed by Node.js is also returned, so we need to exclude it from the result. - // To achieve this, remove the command we've executed from the ps result. - const grepAppProcessCommand = `ps -ef | grep ${bundleExecutable} | grep \/${deviceId}\/`; - return childProcess - .execSync( - `${grepAppProcessCommand} | grep -v "${grepAppProcessCommand}" | awk '{print $2}'`, - null, - { skipError: true } - ) - .toString() - .trim(); + private async killApp(deviceId: string, appIdentifier: string): Promise { + try { + const pid = await this.simctl.getAppPid(deviceId, appIdentifier); + if (pid) { + process.kill(pid, "SIGKILL"); + } + } catch (err) { + // The simulator may be gone, in which case the app is too. + } } public async getDeviceLogProcess( @@ -239,7 +222,7 @@ export class XCodeSimctlSimulator const deviceVersion = device ? device.runtimeVersion : ""; const majorVersion = deviceVersion.split(".")[0]; - const isVisionOS = device.platform === "visionOS"; + const isVisionOS = device && device.platform === "visionOS"; const isSupportediOSVersion = majorVersion && parseInt(majorVersion) >= 11; @@ -285,10 +268,7 @@ export class XCodeSimctlSimulator options: IOptions, device?: any ): Promise { - let devices = _.sortBy( - await this.simctl.getDevices(), - (device) => device.runtimeVersion - ), + let devices = await this.simctl.getDevices(), sdkVersion = options.sdkVersion || options.sdk, deviceIdOrName = options.device; @@ -321,18 +301,23 @@ export class XCodeSimctlSimulator } }); - if (!result) { - result = _.find( - devices, - (device: IDevice) => device.name === this.defaultDeviceIdentifier - ); - } + return result || this.getDefaultDevice(devices); + } - if (!result) { - result = _.last(devices); - } + private getDefaultDevice(devices: IDevice[]): IDevice { + const newest = (candidates: IDevice[]) => + _.last( + [...candidates].sort((a, b) => + compareVersions(a.runtimeVersion, b.runtimeVersion) + ) + ); + const iOS = devices.filter((device) => device.platform === "iOS"); - return result; + return ( + newest(iOS.filter((device) => device.name.startsWith("iPhone"))) || + newest(iOS) || + newest(devices) + ); } private isDeviceBooted(device: IDevice): boolean { @@ -366,53 +351,32 @@ export class XCodeSimctlSimulator await this.verifyDevice(device); } - if (device && (!device.runtimeVersion || !device.fullId)) { - device = null; + if (!device || !device.runtimeVersion || !device.fullId) { + device = await this.getDeviceToRun(options); } - if (!device || !this.isDeviceBooted(device)) { - const isSimulatorAppRunning = this.isSimulatorAppRunning(); - const haveBootedDevices = await this.haveBootedDevices(); - - if (isSimulatorAppRunning) { - // In case user closes simulator window but simulator app is still alive - if (!haveBootedDevices || !device) { - device = await this.getDeviceToRun(options); - } - this.simctl.boot(device.id); - } else { - - common.startSimulator(this.XCodeMajorVersion, device && device.id); + if (this.isDeviceBooted(device)) { + return; + } - if(this.XCodeMajorVersion >=27 ) { - if (!device || !device?.id) { - device = await this.getDeviceToRun(options); - } - // device hub does not start a device by default - await this.simctl.boot(device.id); - } - } - // startSimulaltor doesn't always finish immediately, and the subsequent - // install fails since the simulator is not running. - // Give it some time to start before we attempt installing. - utils.sleep(1); + // Booted before the app opens, without telling it which device to show: + // Simulator.app would boot that device too, and show an error dialog if + // simctl got there first. It shows the devices that are booted on its own. + await this.simctl.boot(device.id); + if (!(await this.isSimulatorAppRunning())) { + await common.startSimulatorApp(await this.getXcodeMajorVersion()); } - } - private async haveBootedDevices(): Promise { - const bootedDevices = await this.getBootedDevices(); - return bootedDevices && bootedDevices.length > 0; + await this.simctl.bootAndWait(device.id); } - private isSimulatorAppRunning(): boolean { - const simulatorAppName = this.XCodeMajorVersion >= 27? ["DeviceHub"] : ["Simulator"]; + private async isSimulatorAppRunning(): Promise { + const appName = (await this.getXcodeMajorVersion()) >= 27 ? "DeviceHub" : "Simulator"; try { - const output = childProcess.execSync( - `ps cax | grep -w ${simulatorAppName}`, {encoding:"utf-8"} - ); - return output.indexOf(simulatorAppName) !== -1; - } catch (e) { + await childProcess.spawn("pgrep", ["-x", appName]); + return true; + } catch (err) { return false; } } diff --git a/lib/simctl.ts b/lib/simctl.ts index ebe2f60..4f81152 100644 --- a/lib/simctl.ts +++ b/lib/simctl.ts @@ -2,7 +2,103 @@ import * as child_process from "child_process"; import * as fs from "fs"; import * as _ from "lodash"; import * as childProcess from "./child-process"; -import * as errors from "./errors"; + +const SUPPORTED_PLATFORMS = ["iOS", "tvOS", "visionOS"]; + +let xcodeToolsCheck: Promise; + +function getXcodeToolsError(): Promise { + if (!xcodeToolsCheck) { + xcodeToolsCheck = childProcess + .spawn("xcode-select", ["-p"]) + .then((path) => + path + ? null + : new Error( + "Unable to work with iOS Simulator as Xcode Command Line Tools cannot be found." + ) + ) + .catch((err) => err); + } + + return xcodeToolsCheck; +} + +// Runtime keys look like com.apple.CoreSimulator.SimRuntime.iOS-26-2. visionOS +// runtimes are named xrOS there. +function parseRuntime( + runtime: string +): { platform: string; version: string } | null { + const match = /(?:^|\.)([A-Za-z]+)[- ]([\d.-]+)$/.exec(runtime); + if (!match) { + return null; + } + + const platform = match[1] === "xrOS" ? "visionOS" : match[1]; + return SUPPORTED_PLATFORMS.includes(platform) + ? { platform, version: match[2].replace(/-/g, ".") } + : null; +} + +export function compareVersions(a: string, b: string): number { + const left = a.split(".").map(Number); + const right = b.split(".").map(Number); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] || 0) - (right[i] || 0); + if (difference) { + return difference; + } + } + + return 0; +} + +export function parseDevices(simctlJson: string): IDevice[] { + const runtimes: { [runtime: string]: any[] } = + JSON.parse(simctlJson).devices || {}; + + return Object.keys(runtimes) + .map((key) => ({ runtime: parseRuntime(key), devices: runtimes[key] })) + .filter(({ runtime }) => runtime) + .sort( + (a, b) => + SUPPORTED_PLATFORMS.indexOf(a.runtime.platform) - + SUPPORTED_PLATFORMS.indexOf(b.runtime.platform) || + compareVersions(a.runtime.version, b.runtime.version) + ) + .reduce( + (result, { runtime, devices }) => + result.concat( + devices + .filter((device) => device.isAvailable !== false) + .map((device) => ({ + name: device.name, + id: device.udid, + fullId: "com.apple.CoreSimulator.SimDeviceType." + device.name, + runtimeVersion: runtime.version, + state: device.state, + platform: runtime.platform, + })) + ), + [] + ); +} + +// launchctl list prints "\t\tUIKitApplication:[...]". +export function parseAppPid( + launchctlList: string, + appIdentifier: string +): number | null { + const label = `UIKitApplication:${appIdentifier}[`; + for (const line of (launchctlList || "").split("\n")) { + const [pid, , service] = line.split("\t"); + if (service && service.startsWith(label) && /^\d+$/.test(pid)) { + return Number(pid); + } + } + + return null; +} export class Simctl implements ISimctl { public async launch( @@ -13,7 +109,7 @@ export class Simctl implements ISimctl { options = options || {}; let args: string[] = []; if (options.waitForDebugger) { - args.push("-w"); + args.push("--wait-for-debugger"); } args = args.concat([deviceId, appIdentifier]); @@ -32,14 +128,39 @@ export class Simctl implements ISimctl { return result; } - public boot(deviceId: string): Promise { - return this.spawnAsync("boot", [deviceId]); + public async boot(deviceId: string): Promise { + try { + await this.spawnAsync("boot", [deviceId]); + } catch (err) { + if (err.message.indexOf("current state: Booted") === -1) { + throw err; + } + } + } + + /** + * Boots the device if needed, and resolves once it can install and launch apps. + */ + public bootAndWait(deviceId: string): Promise { + return this.spawnAsync("bootstatus", [deviceId, "-b"]); } public terminate(deviceId: string, appIdentifier: string): Promise { return this.spawnAsync("terminate", [deviceId, appIdentifier]); } + public async getAppPid( + deviceId: string, + appIdentifier: string + ): Promise { + const output: string = await this.spawnAsync("spawn", [ + deviceId, + "launchctl", + "list", + ]); + return parseAppPid(output, appIdentifier); + } + public install(deviceId: string, applicationPath: string): Promise { return this.spawnAsync("install", [deviceId, applicationPath]); } @@ -76,71 +197,8 @@ export class Simctl implements ISimctl { } public async getDevices(): Promise { - let rawDevices = await this.spawnAsync("list", ["devices"]); - - // expect to get a listing like - // -- iOS 8.1 -- - // iPhone 4s (3CA6E7DD-220E-45E5-B716-1E992B3A429C) (Shutdown) - // ... - // -- iOS 8.2 -- - // iPhone 4s (A99FFFC3-8E19-4DCF-B585-7D9D46B4C16E) (Shutdown) - // ... - // so, get the `-- iOS X.X --` line to find the sdk (X.X) - // and the rest of the listing in order to later find the devices - - let deviceSectionRegex = /-- (iOS|visionOS|tvOS) (.+) --(\n .+)*/gm; - let match = deviceSectionRegex.exec(rawDevices); - - let matches: any[] = []; - - // make an entry for each sdk version - while (match !== null) { - matches.push(match); - match = deviceSectionRegex.exec(rawDevices); - } - - if (matches.length < 1) { - errors.fail("Could not find device section. " + match); - } - - // get all the devices for each sdk - let devices: IDevice[] = []; - for (match of matches) { - let sdk: string = match[2]; - - // split the full match into lines and remove the first - for (let line of match[0].split("\n").slice(1)) { - // a line is something like - // iPhone 4s (A99FFFC3-8E19-4DCF-B585-7D9D46B4C16E) (Shutdown) - // iPad Air 2 (9696A8ED-3020-49FC-90D6-DAFD29A0EA8D) (Shutdown) - // iPad Pro (9.7 inch) (7FF984D4-0755-432D-BE0E-6EB44F0489CB) (Shutdown) - // iPad Pro (12.9 inch) (F02012C8-6D4D-46FF-90D7-5DF90EF579E8) (Booted) - // retrieve: - // iPhone 4s - // A99FFFC3-8E19-4DCF-B585-7D9D46B4C16E - // Shutdown - let lineRegex = - /^\s+(.*?)\s+\(([0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12})\)\s+\((.*?)\)(\s+\((?:.*?)\))?/; - let lineMatch = lineRegex.exec(line); - if (lineMatch === null) { - errors.fail("Could not match line. " + line); - } - - let available = lineMatch[4]; - if (available === null || available === undefined) { - devices.push({ - name: lineMatch[1], - id: lineMatch[2], - fullId: "com.apple.CoreSimulator.SimDeviceType." + lineMatch[1], - runtimeVersion: sdk, - state: lineMatch[3], - platform: match[1], - }); - } - } - } - - return devices; + const output = await this.spawnAsync("list", ["devices", "--json"]); + return parseDevices(output); } public getLog( @@ -165,14 +223,12 @@ export class Simctl implements ISimctl { spawnOpts?: child_process.SpawnOptions, opts?: ISkipErrorComposition ): Promise { - const { canExecuteXcrun, xcodeToolsError } = - this.verifyXcodeCommandLineToolsAreInstalled(); - if (!canExecuteXcrun) { + const xcodeToolsError = await getXcodeToolsError(); + if (xcodeToolsError) { if (opts && opts.skipError) { return null; - } else { - throw xcodeToolsError; } + throw xcodeToolsError; } return childProcess.spawn( @@ -183,30 +239,6 @@ export class Simctl implements ISimctl { ); } - private verifyXcodeCommandLineToolsAreInstalled(): { - canExecuteXcrun: boolean; - xcodeToolsError: Error; - } { - let canExecuteXcrun = false; - let xcodeToolsError: Error = null; - - try { - const result = childProcess.execSync("xcode-select -p", { - stdio: "pipe", - }); - canExecuteXcrun = !!(result && result.toString().trim()); - if (!canExecuteXcrun) { - xcodeToolsError = new Error( - "Unable to work with iOS Simulator as Xcode Command Line Tools cannot be found." - ); - } - } catch (err) { - xcodeToolsError = err; - } - - return { canExecuteXcrun, xcodeToolsError }; - } - private simctlSpawn( command: string, args: string[], diff --git a/lib/utils.ts b/lib/utils.ts index ecb5ac5..0c1a617 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,3 @@ -import * as childProcess from "./child-process"; - export function stringify(arr: string[], delimiter?: string): string { delimiter = delimiter || ", "; return arr.join(delimiter); @@ -10,8 +8,8 @@ export function getCurrentEpochTime(): number { return dateTime.getTime(); } -export function sleep(seconds: number): void { - childProcess.execSync(`sleep ${seconds}`); +export function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); } export function deferPromise(): IDeferPromise { diff --git a/lib/xcode.ts b/lib/xcode.ts index cecb1b6..3c38c20 100644 --- a/lib/xcode.ts +++ b/lib/xcode.ts @@ -1,16 +1,25 @@ -import * as childProcess from "child_process"; +import * as childProcess from "./child-process"; -export function getPathFromXcodeSelect(): string { - return childProcess.execSync("xcode-select -print-path").toString().trim(); +export function getPathFromXcodeSelect(): Promise { + return childProcess.spawn("xcode-select", ["-print-path"]); } -export function getXcodeVersionData(): IXcodeVersionData { - let rawData = childProcess.execSync("xcodebuild -version"); - let lines = rawData.toString().split("\n"); - let parts = lines[0].split(" ")[1].split("."); - return { - major: parts[0], - minor: parts[1], - build: lines[1].split("Build version ")[1], - }; +let versionData: Promise; + +export function getXcodeVersionData(): Promise { + if (!versionData) { + versionData = childProcess + .spawn("xcodebuild", ["-version"]) + .then((output) => { + const lines = output.split("\n"); + const parts = lines[0].split(" ")[1].split("."); + return { + major: parts[0], + minor: parts[1], + build: lines[1].split("Build version ")[1], + }; + }); + } + + return versionData; } diff --git a/package.json b/package.json index 98cc9bc..7937f43 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "prepack": "tsc", "dev": "tsc --watch", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "tsc && node --test test/" }, "bin": { "ios-sim-portable": "./bin/ios-sim-portable.js", diff --git a/test/simctl.test.js b/test/simctl.test.js new file mode 100644 index 0000000..0c916aa --- /dev/null +++ b/test/simctl.test.js @@ -0,0 +1,88 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { compareVersions, parseAppPid, parseDevices } = require("../lib/simctl"); + +const device = (name, udid, state = "Shutdown", isAvailable = true) => ({ + name, + udid, + state, + isAvailable, +}); +const list = (devices) => JSON.stringify({ devices }); +const key = (runtime) => `com.apple.CoreSimulator.SimRuntime.${runtime}`; + +test("parseDevices maps simctl's JSON to devices", () => { + assert.deepStrictEqual( + parseDevices(list({ [key("iOS-26-2")]: [device("iPad Pro (9.7 inch)", "A", "Booted")] })), + [ + { + name: "iPad Pro (9.7 inch)", + id: "A", + fullId: "com.apple.CoreSimulator.SimDeviceType.iPad Pro (9.7 inch)", + runtimeVersion: "26.2", + state: "Booted", + platform: "iOS", + }, + ] + ); +}); + +test("parseDevices names visionOS by its marketing name and keeps patch versions", () => { + const devices = parseDevices( + list({ + [key("xrOS-2-0")]: [device("Apple Vision Pro", "V")], + [key("iOS-18-3-1")]: [device("iPhone 16", "I")], + }) + ); + assert.deepStrictEqual( + devices.map((d) => [d.platform, d.runtimeVersion]), + [ + ["iOS", "18.3.1"], + ["visionOS", "2.0"], + ] + ); +}); + +test("parseDevices leaves out unavailable devices and unsupported platforms", () => { + const devices = parseDevices( + list({ + [key("iOS-26-2")]: [device("iPhone", "A"), device("Broken", "B", "Shutdown", false)], + [key("watchOS-11-0")]: [device("Apple Watch", "C")], + }) + ); + assert.deepStrictEqual(devices.map((d) => d.id), ["A"]); +}); + +test("parseDevices orders runtimes by version rather than as text", () => { + const devices = parseDevices( + list({ + [key("iOS-9-3")]: [device("Old", "O")], + [key("iOS-26-2")]: [device("New", "N")], + [key("iOS-18-6")]: [device("Mid", "M")], + }) + ); + assert.deepStrictEqual(devices.map((d) => d.id), ["O", "M", "N"]); +}); + +test("parseDevices copes with no simulators", () => { + assert.deepStrictEqual(parseDevices(list({})), []); +}); + +test("compareVersions compares numerically", () => { + assert.ok(compareVersions("9.3", "26.2") < 0); + assert.ok(compareVersions("18.3.1", "18.3") > 0); + assert.strictEqual(compareVersions("18.0", "18"), 0); +}); + +test("parseAppPid only matches the exact bundle id of a running app", () => { + const output = [ + "PID\tStatus\tLabel", + "123\t0\tUIKitApplication:org.example.app2[a1b2][rb-legacy]", + "456\t0\tUIKitApplication:org.example.app[c3d4][rb-legacy]", + "-\t0\tUIKitApplication:org.example.stopped[e5f6][rb-legacy]", + ].join("\n"); + + assert.strictEqual(parseAppPid(output, "org.example.app"), 456); + assert.strictEqual(parseAppPid(output, "org.example.stopped"), null); + assert.strictEqual(parseAppPid(output, "org.example"), null); +}); diff --git a/test/simulator.test.js b/test/simulator.test.js new file mode 100644 index 0000000..e6e78b6 --- /dev/null +++ b/test/simulator.test.js @@ -0,0 +1,186 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { XCodeSimctlSimulator } = require("../lib/iphone-simulator-xcode-simctl"); + +const iOS = (name, id, runtimeVersion, state = "Shutdown") => ({ + name, + id, + fullId: `type.${name}`, + runtimeVersion, + state, + platform: "iOS", +}); + +let nextApp = 0; +function setup(devices = [], overrides = {}) { + const calls = []; + const simulator = new XCodeSimctlSimulator(); + simulator.simctl = { + getDevices: async () => devices, + launch: async (id) => { + calls.push(`launch ${id}`); + return `app: ${calls.length}`; + }, + terminate: async (id) => { + calls.push(`terminate ${id}`); + }, + getAppPid: async () => null, + boot: async (id) => { + calls.push(`boot ${id}`); + }, + bootAndWait: async (id) => { + calls.push(`wait ${id}`); + }, + notifyPost: async (id, name) => { + calls.push(`notify ${id} ${name}`); + }, + ...overrides, + }; + simulator.isSimulatorAppRunning = async () => true; + return { simulator, calls, app: `org.example.app${nextApp++}` }; +} + +test("a booted device is left alone", async () => { + const booted = iOS("iPhone", "A", "26.2", "Booted"); + const { simulator, calls } = setup([booted]); + await simulator.startSimulator({ device: "A" }, booted); + assert.deepStrictEqual(calls, []); +}); + +test("the device named in the options is the one that boots", async () => { + const { simulator, calls } = setup([iOS("iPhone", "A", "26.2"), iOS("iPad", "B", "26.2")]); + await simulator.startSimulator({ device: "iPad" }); + assert.deepStrictEqual(calls, ["boot B", "wait B"]); +}); + +test("the Simulator app opens after the boot has started", async () => { + const { simulator, calls } = setup([iOS("iPhone", "A", "26.2")]); + simulator.isSimulatorAppRunning = async () => { + calls.push("check app"); + return false; + }; + simulator.getXcodeMajorVersion = async () => { + calls.push("open app"); + return 0; + }; + require("../lib/iphone-simulator-common").startSimulatorApp = async () => {}; + await simulator.startSimulator({ device: "A" }); + assert.deepStrictEqual(calls, ["boot A", "check app", "open app", "wait A"]); +}); + +test("a boot failure reaches the caller", async () => { + const { simulator } = setup([iOS("iPhone", "A", "26.2")], { + boot: async () => { + throw new Error("boot failed"); + }, + }); + await assert.rejects(simulator.startSimulator({ device: "A" }), /boot failed/); +}); + +test("an unknown device is refused", async () => { + const { simulator } = setup([iOS("iPhone", "A", "26.2")]); + await assert.rejects(simulator.startSimulator({ device: "nope" })); +}); + +test("the default is the iPhone on the newest runtime", async () => { + const { simulator, calls } = setup([ + iOS("iPhone Old", "O", "9.3"), + iOS("iPhone New", "N", "26.2"), + iOS("iPhone Mid", "M", "18.6"), + iOS("iPad Newest", "P", "27.0"), + { ...iOS("Apple TV", "T", "99.0"), platform: "tvOS" }, + ]); + await simulator.startSimulator({}); + assert.deepStrictEqual(calls, ["boot N", "wait N"]); +}); + +test("a booted device is preferred over the default", async () => { + const { simulator, calls } = setup([iOS("iPhone", "N", "26.2"), iOS("iPad", "B", "18.6", "Booted")]); + await simulator.startSimulator({}); + assert.deepStrictEqual(calls, []); +}); + +test("starting an app that was just started returns its launch instead of launching again", async () => { + const { simulator, calls, app } = setup(); + const first = await simulator.startApplication("A", app, {}); + const second = await simulator.startApplication("A", app, {}); + assert.strictEqual(second, first); + assert.deepStrictEqual(calls, ["launch A"]); +}); + +test("a failed launch can be retried straight away", async () => { + let attempts = 0; + const { simulator, app } = setup([], { + launch: async () => { + if (!attempts++) { + throw new Error("launch failed"); + } + return "app: 1"; + }, + }); + await assert.rejects(simulator.startApplication("A", app, {}), /launch failed/); + assert.strictEqual(await simulator.startApplication("A", app, {}), "app: 1"); +}); + +test("an app that is stopped starts again", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + const { simulator, calls, app } = setup(); + await simulator.startApplication("A", app, {}); + const stopping = simulator.stopApplication("A", app); + t.mock.timers.tick(3000); + await stopping; + await simulator.startApplication("A", app, {}); + assert.deepStrictEqual(calls, ["launch A", "terminate A", "launch A"]); +}); + +test("stopping waits until the app has had time to start", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + const { simulator, calls, app } = setup(); + await simulator.startApplication("A", app, {}); + const stopping = simulator.stopApplication("A", app); + await Promise.resolve(); + t.mock.timers.tick(2900); + await Promise.resolve(); + assert.ok(!calls.includes("terminate A")); + t.mock.timers.tick(100); + await stopping; + assert.ok(calls.includes("terminate A")); +}); + +test("concurrent stops terminate once", async () => { + const { simulator, calls, app } = setup(); + await Promise.all([simulator.stopApplication("A", app), simulator.stopApplication("A", app)]); + assert.deepStrictEqual(calls, ["terminate A"]); +}); + +test("an app that isn't running needs no kill", async () => { + let looked = false; + const { simulator, app } = setup([], { + terminate: async () => { + throw new Error("failed. found nothing to terminate"); + }, + getAppPid: async () => { + looked = true; + }, + }); + await simulator.stopApplication("A", app); + assert.strictEqual(looked, false); +}); + +test("an app simctl can't terminate is killed by its own pid", async (t) => { + const kill = t.mock.method(process, "kill", () => true); + const { simulator, app } = setup([], { + terminate: async () => { + throw new Error("simctl failed"); + }, + getAppPid: async () => 4242, + }); + await simulator.stopApplication("A", app); + assert.deepStrictEqual(kill.mock.calls[0].arguments, [4242, "SIGKILL"]); +}); + +test("a notification goes straight to simctl", async () => { + const { simulator, calls } = setup([]); + await simulator.sendNotification("hello", "A"); + assert.deepStrictEqual(calls, ["notify A hello"]); +});