class RunLoop::Simctl
@!visibility private An interface to the ‘simctl` command line tool for CoreSimulator
.
Replacement for SimControl.
Constants
- DEFAULTS
@!visibility private
- SIMCTL_PLIST_DIR
@!visibility private
- SIM_STATES
@!visibility private
Attributes
@!visibility private
@!visibility private
@!visibility private
@!visibility private
@!visibility private
Public Class Methods
@!visibility private
# File lib/run_loop/simctl.rb, line 42 def self.ensure_valid_core_simulator_service max_tries = 4 valid = false 4.times do |try| valid = self.valid_core_simulator_service? break if valid RunLoop.log_debug("Invalid CoreSimulator service for active Xcode: try #{try + 1} of #{max_tries}") end valid end
@!visibility private
# File lib/run_loop/simctl.rb, line 74 def initialize @ios_devices = [] @tvos_devices = [] @watchos_devices = [] Simctl.ensure_valid_core_simulator_service end
@!visibility private
# File lib/run_loop/simctl.rb, line 32 def self.uia_automation_plist File.join(SIMCTL_PLIST_DIR, 'com.apple.UIAutomation.plist') end
@!visibility private
# File lib/run_loop/simctl.rb, line 37 def self.uia_automation_plugin_plist File.join(SIMCTL_PLIST_DIR, 'com.apple.UIAutomationPlugIn.plist') end
@!visibility private
# File lib/run_loop/simctl.rb, line 54 def self.valid_core_simulator_service? require "run_loop/shell" args = ["xcrun", "simctl", "help"] options = {timeout: 5 } begin hash = Shell.run_shell_command(args, options) return false if hash[:exit_status] != 0 return false if hash[:out][/Failed to locate a valid instance of CoreSimulatorService/] return false if hash[:out][/CoreSimulatorService connection became invalid/] true rescue RunLoop::Shell::TimeoutError, RunLoop::Shell::Error => _ false end end
Public Instance Methods
@!visibility private
This method is not supported on Xcode
< 7 - returns nil.
Simulator must be booted in El Cap, which makes this method useless for us because we have to do a bunch of pre-launch checks for sandbox resetting.
Testing has shown that moving the device in and out of the booted state takes a long time (seconds) and is unpredictable.
TODO ensure a booted state.
@param [String] bundle_id The CFBundleIdentifier of the app. @param [RunLoop::Device] device The device under test. @return [String] The path to the .app bundle if it exists; nil otherwise.
# File lib/run_loop/simctl.rb, line 115 def app_container(device, bundle_id) cmd = ["simctl", "get_app_container", device.udid, bundle_id] hash = shell_out_with_xcrun(cmd, DEFAULTS) exit_status = hash[:exit_status] if exit_status != 0 nil else hash[:out].strip end end
# File lib/run_loop/simctl.rb, line 210 def boot(device) if simulator_state_as_int(device) == SIM_STATES["Booted"] RunLoop.log_debug("Simulator is already Booted") true else cmd = ["simctl", "boot", device.udid] hash = shell_out_with_xcrun(cmd, DEFAULTS) exit_status = hash[:exit_status] if exit_status != 0 if simulator_state_as_int(device) == SIM_STATES["Booted"] RunLoop.log_debug("simctl boot called when state is 'Booted'; ignoring error") else raise RuntimeError, %Q[Could not boot the simulator: command: xcrun #{cmd.join(" ")} simulator: #{device} state: #{device.state} #{hash[:out]} This usually means your CoreSimulator processes need to be restarted. You can restart the CoreSimulator processes with this command: $ bundle exec run-loop simctl manage-processes ] end end true end end
@!visibility private Erases the simulator.
@param [RunLoop::Device] device The simulator to erase. @param [Numeric] wait_timeout How long to wait for the simulator to have
state "Shutdown"; passed to #wait_for_shutdown.
@param [Numeric] wait_delay How long to wait between calls to
#simulator_state_as_int while waiting for the simulator have to state "Shutdown"; passed to #wait_for_shutdown
# File lib/run_loop/simctl.rb, line 262 def erase(device, wait_timeout, wait_delay) require "run_loop/core_simulator" CoreSimulator.quit_simulator shutdown(device) wait_for_shutdown(device, wait_timeout, wait_delay) # TODO: when we encounter a "data couldn't be removed because you don't # have permission to access it" error" it is possible we can ignore the # error. cmd = ["simctl", "erase", device.udid] hash = shell_out_with_xcrun(cmd, DEFAULTS) exit_status = hash[:exit_status] if exit_status != 0 raise RuntimeError, %Q[Could not erase the simulator: command: xcrun #{cmd.join(" ")} simulator: #{device} #{hash[:out]} This usually means your CoreSimulator processes need to be restarted. You can restart the CoreSimulator processes with this command: $ bundle exec run-loop simctl manage-processes ] end true end
@!visibility private
# File lib/run_loop/simctl.rb, line 87 def inspect to_s end
@!visibility private
Launches the app on on the device.
Caller is responsible for the following:
-
Launching the simulator.
No checks are made.
@param [RunLoop::Device] device The simulator to launch on. @param [RunLoop::App] app The app to launch. @param [Numeric] timeout How long to wait for simctl to complete.
# File lib/run_loop/simctl.rb, line 414 def install(device, app, timeout) cmd = ["simctl", "install", device.udid, app.path] options = DEFAULTS.dup options[:timeout] = timeout success = false tries = 5 hash = nil tries.times do |try| hash = shell_out_with_xcrun(cmd, options) exit_status = hash[:exit_status] if exit_status == 0 RunLoop.log_debug("Successful install on #{try + 1} of #{tries} attempts") success = true break else out = hash[:out] if out[/This app could not be installed at this time/] RunLoop.log_debug("Install failed on attempt #{try + 1} of #{tries}") sleep(1.0) else # Any other error, fail. success = false break end end end return true if success raise RuntimeError, %Q[Could not install app on simulator: command: xcrun #{cmd.join(" ")} simulator: #{device} app: #{app} #{hash[:out]} This usually means your CoreSimulator processes need to be restarted. You can restart the CoreSimulator processes with this command: $ bundle exec run-loop simctl manage-processes ] end
@!visibility private
Launches the app on on the device.
Caller is responsible for the following:
-
Launching the simulator.
-
Installing the application.
No checks are made.
@param [RunLoop::Device] device The simulator to launch on. @param [RunLoop::App] app The app to launch. @param [Numeric] timeout How long to wait for simctl to complete.
# File lib/run_loop/simctl.rb, line 311 def launch(device, app, timeout) cmd = ["simctl", "launch", device.udid, app.bundle_identifier] options = DEFAULTS.dup options[:timeout] = timeout hash = shell_out_with_xcrun(cmd, options) exit_status = hash[:exit_status] if exit_status != 0 raise RuntimeError, %Q[Could not launch app on simulator: command: xcrun #{cmd.join(" ")} simulator: #{device} app: #{app} #{hash[:out]} This usually means your CoreSimulator processes need to be restarted. You can restart the CoreSimulator processes with this command: $ bundle exec run-loop simctl manage-processes ] end true end
@!visibility private
# File lib/run_loop/simctl.rb, line 246 def reboot(device) shutdown(device) wait_for_shutdown(device, DEFAULTS[:timeout], 1.0) boot(device) device.simulator_wait_for_stable_state end
@!visibility private
# File lib/run_loop/simctl.rb, line 144 def shutdown(device) if simulator_state_as_int(device) == SIM_STATES["Shutdown"] RunLoop.log_debug("Simulator is already shutdown") true else cmd = ["simctl", "shutdown", device.udid] hash = shell_out_with_xcrun(cmd, DEFAULTS) exit_status = hash[:exit_status] if exit_status != 0 if simulator_state_as_int(device) == SIM_STATES["Shutdown"] || hash[:out][/Unable to shutdown device in current state: Shutdown/] RunLoop.log_debug("simctl shutdown called when state is 'Shutdown'; ignoring error") else raise RuntimeError, %Q[Could not shutdown the simulator: command: xcrun #{cmd.join(" ")} simulator: #{device} #{hash[:out]} This usually means your CoreSimulator processes need to be restarted. You can restart the CoreSimulator processes with this command: $ bundle exec run-loop simctl manage-processes ] end end true end end
@!visibility private
# File lib/run_loop/simctl.rb, line 128 def simulator_state_as_int(device) plist = device.simulator_device_plist if pbuddy.plist_key_exists?("state", plist) pbuddy.plist_read("state", plist).to_i else SIM_STATES["Plist Missing Key"] end end
@!visibility private
# File lib/run_loop/simctl.rb, line 139 def simulator_state_as_string(device) string_for_sim_state(simulator_state_as_int(device)) end
@!visibility private
# File lib/run_loop/simctl.rb, line 92 def simulators simulators = ios_devices if simulators.empty? simulators = fetch_devices![:ios] end simulators end
@!visibility private
# File lib/run_loop/simctl.rb, line 82 def to_s "#<Simctl: #{xcode.version}>" end
@!visibility private
Removes the application from the device.
Caller is responsible for the following:
-
Launching the simulator.
-
Verifying that the application is installed; simctl uninstall will fail if app is installed.
No checks are made.
@param [RunLoop::Device] device The simulator to launch on. @param [RunLoop::App] app The app to launch. @param [Numeric] timeout How long to wait for simctl to complete.
# File lib/run_loop/simctl.rb, line 355 def uninstall(device, app, timeout) cmd = ["simctl", "uninstall", device.udid, app.bundle_identifier] options = DEFAULTS.dup options[:timeout] = timeout hash = shell_out_with_xcrun(cmd, options) exit_status = hash[:exit_status] if exit_status != 0 raise RuntimeError, %Q[Could not uninstall app from simulator: command: xcrun #{cmd.join(" ")} simulator: #{device} app: #{app} #{hash[:out]} This usually means your CoreSimulator processes need to be restarted. You can restart the CoreSimulator processes with this command: $ bundle exec run-loop simctl manage-processes ] end app_container = app_container(device, app.bundle_identifier) if app_container RunLoop.log_debug("After uninstall, simctl thinks app container exists") if File.exist?(app_container) RunLoop.log_debug("App container _does_ exist on disk; deleting it") FileUtils.rm_rf(app_container) else RunLoop.log_debug("App container does _not_ exist on disk") end RunLoop.log_debug("Rebooting the device") reboot(device) if app_container(device, app.bundle_identifier) raise "simctl uninstall succeeded, but simctl says app is still installed" end end true end
@!visibility private
Waiting for anything but ‘Shutdown’ is not advised. The simulator reports that it is “Booted” long before it is ready to receive commands.
Waiting for ‘Shutdown’ is required for erasing the simulator and launching launching the simulator with iOSDeviceManager.
# File lib/run_loop/simctl.rb, line 186 def wait_for_shutdown(device, timeout, delay) now = Time.now poll_until = now + timeout in_state = false state = nil while Time.now < poll_until state = simulator_state_as_int(device) in_state = state == SIM_STATES["Shutdown"] break if in_state sleep delay if delay != 0 end elapsed = Time.now - now RunLoop.log_debug("Waited for #{elapsed} seconds for device to have state: 'Shutdown'.") unless in_state string = string_for_sim_state(state) raise "Expected 'Shutdown' state but found '#{string}' after waiting for #{elapsed} seconds." end in_state end
Private Instance Methods
@!visibility private
# File lib/run_loop/simctl.rb, line 620 def bucket_for_key(key) if device_key_is_ios?(key) bin = @ios_devices elsif device_key_is_tvos?(key) bin = @tvos_devices elsif device_key_is_watchos?(key) bin = @watchos_devices else raise RuntimeError, "Unexpected key while processing simctl output: key = #{key} is not an iOS, tvOS, or watchOS device" end bin end
@!visibility private
# File lib/run_loop/simctl.rb, line 607 def device_available?(record) record["isAvailable"] == true || record["isAvailable"] == "YES" || record["availability"] == "(available)" end
@!visibility private
# File lib/run_loop/simctl.rb, line 612 def device_from_record(record, version) RunLoop::Device.new(record["name"], version, record["udid"], record["state"]) end
@!visibility private
# File lib/run_loop/simctl.rb, line 577 def device_key_is_ios?(key) key[/iOS/, 0] end
@!visibility private
# File lib/run_loop/simctl.rb, line 582 def device_key_is_tvos?(key) key[/tvOS/, 0] end
@!visibility private
# File lib/run_loop/simctl.rb, line 587 def device_key_is_watchos?(key) key[/watchOS/, 0] end
@!visibility private
# File lib/run_loop/simctl.rb, line 592 def device_key_to_version(key) str = if key.include?(" ") key.split(" ").last else matches = RunLoop::Regex::XCODE_102_SIMULATOR_REGEX.match(key) if matches.nil? raise RuntimeError, "Cannot fetch the Simulator version from #{key}" end matches[:version].tr("-", ".") end RunLoop::Version.new(str) end
@!visibility private
Starting in Xcode
7, simctl allows a –json option for listing devices.
On Xcode
6, we will fall back to SimControl which does a line-by-line processing of ‘simctl list devices`. tvOS and watchOS devices are not available on Xcode
< 7.
This is a destructive operation on ‘@ios_devices`, `@tvos_devices`, and `@watchos_devices`. Callers should check for existing devices to avoid the overhead of calling `simctl list devices –json`.
# File lib/run_loop/simctl.rb, line 505 def fetch_devices! @ios_devices = [] @tvos_devices = [] @watchos_devices = [] cmd = ["simctl", "list", "devices", "--json"] hash = shell_out_with_xcrun(cmd, DEFAULTS) out = hash[:out] exit_status = hash[:exit_status] if exit_status != 0 raise RuntimeError, %Q[simctl exited #{exit_status}: #{out} while trying to list devices. ] end devices = json_to_hash(out)["devices"] devices.each do |key, device_list| version = device_key_to_version(key) bucket = bucket_for_key(key) device_list.each do |record| if device_available?(record) bucket << device_from_record(record, version) end end end { :ios => ios_devices, :tvos => tvos_devices, :watchos => watchos_devices } end
@!visibility private
command_runner_ng combines stderr and stdout and starting in Xcode
7.3, simctl has started generating stderr output. This must be filtered out so that we can parse the JSON response.
# File lib/run_loop/simctl.rb, line 548 def filter_stderr(out) out.split($-0).map do |line| if stderr_line?(line) nil else line end end.compact.join($-0) end
@!visibility private
# File lib/run_loop/simctl.rb, line 564 def json_to_hash(json) filtered = filter_stderr(json) begin JSON.parse(filtered) rescue TypeError, JSON::ParserError => e raise RuntimeError, %Q[Could not parse simctl JSON response: #{e} ] end end
@!visibility private
# File lib/run_loop/simctl.rb, line 489 def shell_out_with_xcrun(array, options) merged = DEFAULTS.merge(options) xcrun.run_command_in_context(array, merged) end
@!visibility private
# File lib/run_loop/simctl.rb, line 559 def stderr_line?(line) line[/CoreSimulatorService/, 0] || line[/simctl\[.+\]/, 0] end
@!visibility private
# File lib/run_loop/simctl.rb, line 478 def string_for_sim_state(integer) SIM_STATES.each do |key, value| if value == integer return key end end raise ArgumentError, "Could not find state for #{integer}" end
@!visibility private
# File lib/run_loop/simctl.rb, line 468 def xcode @xcode ||= RunLoop::Xcode.new end
@!visibility private
# File lib/run_loop/simctl.rb, line 638 def xcrun @xcrun ||= RunLoop::Xcrun.new end