From 48c0ecbfcf2a57e51cab4d4cd805357e368d2095 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 19:18:02 +0100 Subject: [PATCH 1/2] feat: read simctl's JSON and stop blocking the event loop Devices come from simctl list devices --json instead of a regex over its text output. Stopping an app uses simctl terminate, falling back to killing the exact pid from launchctl, rather than kill -9 on anything ps | grep matched, which could kill other apps on the simulator. Booting waits on simctl bootstatus -b instead of a fixed sleep, and the fixed sleeps around starting and stopping apps, which blocked the event loop, are gone. Also fixes --wait-for-debugger, whose -w form simctl now rejects with app arguments, boot errors that crashed the process as unhandled rejections, the requested device being ignored when the Simulator app wasn't running, Xcode paths with spaces, the default device preferring whichever runtime sorted last as text, and getRunningSimulator never resolving. --- .gitignore | 59 +++---- lib/declarations.ts | 3 +- lib/ios-sim.ts | 80 ++------- lib/iphone-simulator-common.ts | 41 +++-- lib/iphone-simulator-xcode-simctl.ts | 232 +++++++++++---------------- lib/simctl.ts | 218 ++++++++++++++----------- lib/utils.ts | 6 +- lib/xcode.ts | 33 ++-- package.json | 2 +- test/simctl.test.js | 88 ++++++++++ test/simulator.test.js | 168 +++++++++++++++++++ 11 files changed, 563 insertions(+), 367 deletions(-) create mode 100644 test/simctl.test.js create mode 100644 test/simulator.test.js 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..1607dac 100644 --- a/lib/declarations.ts +++ b/lib/declarations.ts @@ -44,7 +44,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..0645a83 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,23 @@ 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); +export async function startSimulator( + xcodeVersion: number, + deviceId?: string +): Promise { + const xcodePath = await xcode.getPathFromXcodeSelect(); + if (xcodeVersion >= 27) { + await childProcess.spawn("open", [ + path.resolve(xcodePath, "..", "Applications", "DeviceHub.app"), + ]); + return; + } + + const args = [path.resolve(xcodePath, "Applications", "Simulator.app")]; + if (deviceId) { + args.push("--args", "-CurrentDeviceUDID", deviceId); } - childProcess.execSync(args.join(" ")); + await childProcess.spawn("open", args); } function parsePlist(fileNameOrBuffer: string | Buffer) { diff --git a/lib/iphone-simulator-xcode-simctl.ts b/lib/iphone-simulator-xcode-simctl.ts index 1221839..5e033cf 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,28 @@ 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); + if (!(await this.isSimulatorAppRunning())) { + await common.startSimulator(await this.getXcodeMajorVersion(), device.id); } - } - 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..89219db 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,29 @@ export class Simctl implements ISimctl { return result; } - public boot(deviceId: string): Promise { - return this.spawnAsync("boot", [deviceId]); + /** + * 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 +187,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 +213,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 +229,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..262ae85 --- /dev/null +++ b/test/simulator.test.js @@ -0,0 +1,168 @@ +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, + bootAndWait: async (id) => { + calls.push(`boot ${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"]); +}); + +test("a boot failure reaches the caller", async () => { + const { simulator } = setup([iOS("iPhone", "A", "26.2")], { + bootAndWait: 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"]); +}); + +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"]); +}); From 12fbe896ea475d44ab82259d3081a856cde9a8c3 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 19:30:45 +0100 Subject: [PATCH 2/2] fix: boot before opening the Simulator app Told which device to show, Simulator.app boots a shut-down device itself and shows an error dialog when simctl got there first. simctl now boots the device, and the app opens without a device argument and shows what is booted. --- lib/declarations.ts | 1 + lib/iphone-simulator-common.ts | 22 ++++++---------------- lib/iphone-simulator-xcode-simctl.ts | 6 +++++- lib/simctl.ts | 10 ++++++++++ test/simulator.test.js | 26 ++++++++++++++++++++++---- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/lib/declarations.ts b/lib/declarations.ts index 1607dac..274f966 100644 --- a/lib/declarations.ts +++ b/lib/declarations.ts @@ -44,6 +44,7 @@ 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; diff --git a/lib/iphone-simulator-common.ts b/lib/iphone-simulator-common.ts index 0645a83..87dcace 100644 --- a/lib/iphone-simulator-common.ts +++ b/lib/iphone-simulator-common.ts @@ -55,23 +55,13 @@ export function getInstalledApplications(deviceId: string): IApplication[] { return result; } -export async function startSimulator( - xcodeVersion: number, - deviceId?: string -): Promise { +export async function startSimulatorApp(xcodeVersion: number): Promise { const xcodePath = await xcode.getPathFromXcodeSelect(); - if (xcodeVersion >= 27) { - await childProcess.spawn("open", [ - path.resolve(xcodePath, "..", "Applications", "DeviceHub.app"), - ]); - return; - } - - const args = [path.resolve(xcodePath, "Applications", "Simulator.app")]; - if (deviceId) { - args.push("--args", "-CurrentDeviceUDID", deviceId); - } - await childProcess.spawn("open", args); + 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 5e033cf..47684ec 100755 --- a/lib/iphone-simulator-xcode-simctl.ts +++ b/lib/iphone-simulator-xcode-simctl.ts @@ -359,8 +359,12 @@ export class XCodeSimctlSimulator return; } + // 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.startSimulator(await this.getXcodeMajorVersion(), device.id); + await common.startSimulatorApp(await this.getXcodeMajorVersion()); } await this.simctl.bootAndWait(device.id); diff --git a/lib/simctl.ts b/lib/simctl.ts index 89219db..4f81152 100644 --- a/lib/simctl.ts +++ b/lib/simctl.ts @@ -128,6 +128,16 @@ export class Simctl implements ISimctl { return result; } + 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. */ diff --git a/test/simulator.test.js b/test/simulator.test.js index 262ae85..e6e78b6 100644 --- a/test/simulator.test.js +++ b/test/simulator.test.js @@ -25,9 +25,12 @@ function setup(devices = [], overrides = {}) { calls.push(`terminate ${id}`); }, getAppPid: async () => null, - bootAndWait: async (id) => { + boot: async (id) => { calls.push(`boot ${id}`); }, + bootAndWait: async (id) => { + calls.push(`wait ${id}`); + }, notifyPost: async (id, name) => { calls.push(`notify ${id} ${name}`); }, @@ -47,12 +50,27 @@ test("a booted device is left alone", async () => { 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"]); + 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")], { - bootAndWait: async () => { + boot: async () => { throw new Error("boot failed"); }, }); @@ -73,7 +91,7 @@ test("the default is the iPhone on the newest runtime", async () => { { ...iOS("Apple TV", "T", "99.0"), platform: "tvOS" }, ]); await simulator.startSimulator({}); - assert.deepStrictEqual(calls, ["boot N"]); + assert.deepStrictEqual(calls, ["boot N", "wait N"]); }); test("a booted device is preferred over the default", async () => {