require 'fileutils' require 'tmpdir' require 'timeout' require 'json' require 'open3' require 'erb' require 'ap' module RunLoop module Core include RunLoop::Regex START_DELIMITER = "OUTPUT_JSON:\n" END_DELIMITER="\nEND_OUTPUT" SCRIPTS = { :dismiss => 'run_dismiss_location.js', :run_loop_host => 'run_loop_host.js', :run_loop_fast_uia => 'run_loop_fast_uia.js', :run_loop_shared_element => 'run_loop_shared_element.js', :run_loop_basic => 'run_loop_basic.js' } SCRIPTS_PATH = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'scripts')) READ_SCRIPT_PATH = File.join(SCRIPTS_PATH, 'read-cmd.sh') TIMEOUT_SCRIPT_PATH = File.join(SCRIPTS_PATH, 'timeout3') def self.scripts_path SCRIPTS_PATH end def self.log_run_loop_options(options, xcode) return unless RunLoop::Environment.debug? # Ignore :sim_control b/c it is a ruby object; printing is not useful. ignored_keys = [:sim_control] options_to_log = {} options.each_pair do |key, value| next if ignored_keys.include?(key) options_to_log[key] = value end # Objects that override '==' cannot be printed by awesome_print # https://github.com/michaeldv/awesome_print/issues/154 # RunLoop::Version overrides '==' options_to_log[:xcode] = xcode.version.to_s options_to_log[:xcode_path] = xcode.developer_dir message = options_to_log.ai({:sort_keys => true}) logger = options[:logger] RunLoop::Logging.log_debug(logger, "\n" + message) end def self.script_for_key(key) if SCRIPTS[key].nil? return nil end SCRIPTS[key] end def self.run_with_options(options) before = Time.now self.prepare(options) logger = options[:logger] sim_control = options[:sim_control] || options[:simctl] || RunLoop::SimControl.new xcode = options[:xcode] || RunLoop::Xcode.new instruments = options[:instruments] || RunLoop::Instruments.new # Find the Device under test, the App under test, UIA strategy, and reset options device = RunLoop::Device.detect_device(options, xcode, sim_control, instruments) app_details = RunLoop::DetectAUT.detect_app_under_test(options) uia_strategy = self.detect_uia_strategy(options, device, xcode) reset_options = self.detect_reset_options(options) instruments.kill_instruments(xcode) timeout = options[:timeout] || 30 results_dir = options[:results_dir] || RunLoop::DotDir.make_results_dir results_dir_trace = File.join(results_dir, 'trace') FileUtils.mkdir_p(results_dir_trace) dependencies = options[:dependencies] || [] dependencies << File.join(scripts_path, 'calabash_script_uia.js') dependencies.each do |dep| FileUtils.cp(dep, results_dir) end script = File.join(results_dir, '_run_loop.js') javascript = UIAScriptTemplate.new(SCRIPTS_PATH, options[:script]).result UIAScriptTemplate.sub_path_var!(javascript, results_dir) UIAScriptTemplate.sub_read_script_path_var!(javascript, READ_SCRIPT_PATH) UIAScriptTemplate.sub_timeout_script_path_var!(javascript, TIMEOUT_SCRIPT_PATH) # Using a :no_* option is confusing. # TODO Replace :no_flush with :flush_uia_logs; it should default to true if RunLoop::Environment.xtc? UIAScriptTemplate.sub_mode_var!(javascript, "FLUSH") unless options[:no_flush] else if self.detect_flush_uia_log_option(options) UIAScriptTemplate.sub_flush_uia_logs_var!(javascript, "FLUSH_LOGS") end end repl_path = File.join(results_dir, 'repl-cmd.pipe') FileUtils.rm_f(repl_path) if uia_strategy == :host create_uia_pipe(repl_path) else FileUtils.touch repl_path end RunLoop::HostCache.default.clear unless RunLoop::Environment.xtc? cal_script = File.join(SCRIPTS_PATH, 'calabash_script_uia.js') File.open(script, 'w') do |file| if include_calabash_script?(options) file.puts IO.read(cal_script) end file.puts javascript end args = options.fetch(:args, []) log_file = options[:log_path] || File.join(results_dir, 'run_loop.out') discovered_options = { :udid => device.udid, :device => device, :results_dir_trace => results_dir_trace, :bundle_id => app_details[:bundle_id], :app => app_details[:app] || app_details[:bundle_id], :results_dir => results_dir, :script => script, :log_file => log_file, :args => args } merged_options = options.merge(discovered_options) if device.simulator? self.prepare_simulator(app_details[:app], device, xcode, sim_control, reset_options) end self.log_run_loop_options(merged_options, xcode) automation_template = automation_template(instruments) RunLoop::Logging.log_header(logger, "Starting on #{device.name} App: #{app_details[:bundle_id]}") pid = instruments.spawn(automation_template, merged_options, log_file) File.open(File.join(results_dir, 'run_loop.pid'), 'w') do |f| f.write pid end run_loop = { :pid => pid, :index => 1, :uia_strategy => uia_strategy, :udid => device.udid, :app => app_details[:bundle_id], :repl_path => repl_path, :log_file => log_file, :results_dir => results_dir } uia_timeout = options[:uia_timeout] || RunLoop::Environment.uia_timeout || 10 RunLoop::Logging.log_debug(logger, "Preparation took #{Time.now-before} seconds") before_instruments_launch = Time.now fifo_retry_on = [ RunLoop::Fifo::NoReaderConfiguredError, RunLoop::Fifo::WriteTimedOut ] begin if options[:validate_channel] options[:validate_channel].call(run_loop, 0, uia_timeout) else cmd = "UIALogger.logMessage('Listening for run loop commands')" begin fifo_timeout = options[:fifo_timeout] || 30 RunLoop::Fifo.write(repl_path, "0:#{cmd}", timeout: fifo_timeout) rescue *fifo_retry_on => e message = "Error while writing to fifo. #{e}" RunLoop::Logging.log_debug(logger, message) raise RunLoop::TimeoutError.new(message) end Timeout::timeout(timeout, RunLoop::TimeoutError) do read_response(run_loop, 0, uia_timeout) end end rescue RunLoop::TimeoutError => e RunLoop::Logging.log_debug(logger, "Failed to launch. #{e}: #{e && e.message}") message = %Q( "Timed out waiting for UIAutomation run-loop #{e}. Logfile: #{log_file} #{File.read(log_file)} ) raise RunLoop::TimeoutError, message end RunLoop::Logging.log_debug(logger, "Launching took #{Time.now-before_instruments_launch} seconds") dylib_path = self.dylib_path_from_options(merged_options) if dylib_path if device.physical_device? raise RuntimeError, "Injecting a dylib is not supported when targeting a device" end app = app_details[:app] lldb = RunLoop::DylibInjector.new(app.executable_name, dylib_path) lldb.retriable_inject_dylib end RunLoop.log_debug("It took #{Time.now - before} seconds to launch the app") run_loop end # @!visibility private # Usually we include CalabashScript to ease uia automation. # However in certain scenarios we don't load it since # it slows down the UIAutomation initialization process # occasionally causing privacy/security dialogs not to be automated. # # @return {boolean} true if CalabashScript should be loaded def self.include_calabash_script?(options) if (options[:include_calabash_script] == false) || options[:dismiss_immediate_dialogs] return false end if Core.script_for_key(:run_loop_basic) == options[:script] return options[:include_calabash_script] end true end # Extracts the value of :inject_dylib from options Hash. # @param options [Hash] arguments passed to {RunLoop.run} # @return [String, nil] If the options contains :inject_dylibs and it is a # path to a dylib that exists, return the path. Otherwise return nil or # raise an error. # @raise [RuntimeError] If :inject_dylib points to a path that does not exist. # @raise [ArgumentError] If :inject_dylib is not a String. def self.dylib_path_from_options(options) inject_dylib = options.fetch(:inject_dylib, nil) return nil if inject_dylib.nil? unless inject_dylib.is_a? String raise ArgumentError, "Expected :inject_dylib to be a path to a dylib, but found '#{inject_dylib}'" end dylib_path = File.expand_path(inject_dylib) unless File.exist?(dylib_path) raise "Cannot load dylib. The file '#{dylib_path}' does not exist." end dylib_path end # Returns the a default simulator to target. This default needs to be one # that installed by default in the current Xcode version. # # For historical reasons, the most recent non-64b SDK should be used. # # @param [RunLoop::Xcode] xcode Used to detect the current xcode # version. def self.default_simulator(xcode=RunLoop::Xcode.new) if xcode.version_gte_73? "iPhone 6s (9.3)" elsif xcode.version_gte_72? "iPhone 6s (9.2)" elsif xcode.version_gte_71? "iPhone 6s (9.1)" elsif xcode.version_gte_7? "iPhone 5s (9.0)" elsif xcode.version_gte_64? "iPhone 5s (8.4 Simulator)" elsif xcode.version_gte_63? "iPhone 5s (8.3 Simulator)" elsif xcode.version_gte_62? "iPhone 5s (8.2 Simulator)" elsif xcode.version_gte_61? "iPhone 5s (8.1 Simulator)" else "iPhone 5s (8.0 Simulator)" end end def self.create_uia_pipe(repl_path) begin Timeout::timeout(5, RunLoop::TimeoutError) do loop do begin FileUtils.rm_f(repl_path) return repl_path if system(%Q[mkfifo "#{repl_path}"]) rescue Errno::EINTR => e #retry sleep(0.1) end end end rescue RunLoop::TimeoutError => _ raise RunLoop::TimeoutError, 'Unable to create pipe (mkfifo failed)' end end def self.jruby? RUBY_PLATFORM == 'java' end def self.write_request(run_loop, cmd, logger=nil) repl_path = run_loop[:repl_path] index = run_loop[:index] cmd_str = "#{index}:#{escape_host_command(cmd)}" RunLoop::Logging.log_debug(logger, cmd_str) write_succeeded = false 2.times do |i| RunLoop::Logging.log_debug(logger, "Trying write of command #{cmd_str} at index #{index}") begin RunLoop::Fifo.write(repl_path, cmd_str) write_succeeded = validate_index_written(run_loop, index, logger) rescue RunLoop::Fifo::NoReaderConfiguredError, RunLoop::Fifo::WriteTimedOut => e RunLoop::Logging.log_debug(logger, "Error while writing command (retry count #{i}). #{e}") end break if write_succeeded end unless write_succeeded RunLoop::Logging.log_debug(logger, 'Failing...Raising RunLoop::WriteFailedError') raise RunLoop::WriteFailedError.new("Trying write of command #{cmd_str} at index #{index}") end run_loop[:index] = index + 1 RunLoop::HostCache.default.write(run_loop) unless RunLoop::Environment.xtc? index end def self.validate_index_written(run_loop, index, logger) begin Timeout::timeout(10, RunLoop::TimeoutError) do Core.read_response(run_loop, index, 10, 'last_index') end RunLoop::Logging.log_debug(logger, "validate index written for index #{index} ok") return true rescue RunLoop::TimeoutError => _ RunLoop::Logging.log_debug(logger, "validate index written for index #{index} failed. Retrying.") return false end end def self.escape_host_command(cmd) backquote = "\\" cmd.gsub(backquote,backquote*4) end def self.log_instruments_error(msg) $stderr.puts "\033[31m\n\n*** #{msg} ***\n\n\033[0m" $stderr.flush end def self.read_response(run_loop, expected_index, empty_file_timeout=10, search_for_property='index') debug_read = RunLoop::Environment.debug_read? log_file = run_loop[:log_file] initial_offset = run_loop[:initial_offset] || 0 offset = initial_offset result = nil loop do unless File.exist?(log_file) && File.size?(log_file) sleep(0.2) next end size = File.size(log_file) output = File.read(log_file, size-offset, offset) if /AXError: Could not auto-register for pid status change/.match(output) if /kAXErrorServerNotFound/.match(output) self.log_instruments_error('Accessibility is not enabled on device/simulator, please enable it.') end raise RunLoop::TimeoutError.new('AXError: Could not auto-register for pid status change') end if /Automation Instrument ran into an exception/.match(output) raise RunLoop::TimeoutError.new('Exception while running script') end if /FBSOpenApplicationErrorDomain error/.match(output) msg = "Instruments failed to launch app: 'FBSOpenApplicationErrorDomain error 8" if RunLoop::Environment.debug? self.log_instruments_error(msg) end raise RunLoop::TimeoutError.new(msg) end if /Error: Script threw an uncaught JavaScript error: unknown JavaScript exception/.match(output) msg = "Instruments failed to launch: because of an unknown JavaScript exception" if RunLoop::Environment.debug? self.log_instruments_error(msg) end raise RunLoop::TimeoutError.new(msg) end index_if_found = output.index(START_DELIMITER) if debug_read puts output.gsub('*', '') puts "Size #{size}" puts "offset #{offset}" puts "index_of #{START_DELIMITER}: #{index_if_found}" end if index_if_found offset = offset + index_if_found rest = output[index_if_found+START_DELIMITER.size..output.length] index_of_json = rest.index("}#{END_DELIMITER}") if index_of_json.nil? #Wait for rest of json sleep(0.1) next end json = rest[0..index_of_json] if debug_read puts "Index #{index_if_found}, Size: #{size} Offset #{offset}" puts ("parse #{json}") end offset = offset + json.size parsed_result = JSON.parse(json) if debug_read p parsed_result end json_index_if_present = parsed_result[search_for_property] if json_index_if_present && json_index_if_present == expected_index result = parsed_result break end else sleep(0.1) end end run_loop[:initial_offset] = offset RunLoop::HostCache.default.write(run_loop) unless RunLoop::Environment.xtc? result end def self.automation_template(instruments, candidate=RunLoop::Environment.trace_template) unless candidate && File.exist?(candidate) candidate = default_tracetemplate(instruments) end candidate end def self.default_tracetemplate(instruments=RunLoop::Instruments.new) templates = instruments.templates # xcrun instruments -s templates # Xcode >= 6 will return known, Apple defined tracetemplates as names # e.g. Automation, Zombies, Allocations # # Xcode < 6 will return known, Apple defined tracetemplates as paths. # # Xcode 6 Beta versions also return paths, but revert to 'normal' # behavior when GM is released. # # Xcode 7 Beta versions appear to behavior like Xcode 6 Beta versions. template = templates.find { |name| name == 'Automation' } return template if template candidate = templates.find do |path| path =~ /\/Automation.tracetemplate/ and path =~ /Xcode/ end if !candidate.nil? return candidate.tr("\"", '').strip end message = ['Expected instruments to report an Automation tracetemplate.', 'Please report this as bug: https://github.com/calabash/run_loop/issues', "In the bug report, include the output of:\n", '$ xcrun xcodebuild -version', "$ xcrun instruments -s templates\n"] raise message.join("\n") end # @deprecated 2.1.0 # Replaced with Device.detect_physical_device_on_usb def self.detect_connected_device begin Timeout::timeout(1, RunLoop::TimeoutError) do return `#{File.join(scripts_path, 'udidetect')}`.chomp end rescue RunLoop::TimeoutError => _ `killall udidetect &> /dev/null` end nil end # @deprecated 2.1.0 # @!visibility private # Are we targeting a simulator? # # @note The behavior of this method is different than the corresponding # method in Calabash::Cucumber::Launcher method. If # `:device_target => {nil | ''}`, then the calabash-ios method returns # _false_. I am basing run-loop's behavior off the behavior in # `self.udid_and_bundle_for_launcher` # # @see {Core::RunLoop.udid_and_bundle_for_launcher} # # @todo sim_control argument is no longer necessary and can be removed. def self.simulator_target?(run_options, sim_control=nil) # TODO Enable deprecation warning # RunLoop.deprecated("2.1.0", "No replacement") value = run_options[:device_target] # Match the behavior of udid_and_bundle_for_launcher. return true if value.nil? or value == '' # 5.1 <= Xcode < 7.0 return true if value.downcase.include?('simulator') # Not a physical device. return false if value[DEVICE_UDID_REGEX, 0] != nil # Check for named simulators and Xcode >= 7.0 simulators. sim_control = run_options[:sim_control] || RunLoop::SimControl.new xcode = sim_control.xcode simulator = sim_control.simulators.find do |sim| [ sim.instruments_identifier(xcode) == value, sim.udid == value, sim.name == value ].any? end !simulator.nil? end # @!visibility private # @deprecated 2.1.0 # # Do not call this method. def self.udid_and_bundle_for_launcher(device_target, options, sim_control=RunLoop::SimControl.new) RunLoop.deprecated("2.1.0", "No replacement") xcode = sim_control.xcode bundle_dir_or_bundle_id = options[:app] || RunLoop::Environment.bundle_id || RunLoop::Environment.path_to_app_bundle unless bundle_dir_or_bundle_id raise 'key :app or environment variable APP_BUNDLE_PATH, BUNDLE_ID or APP must be specified as path to app bundle (simulator) or bundle id (device)' end if device_target.nil? || device_target.empty? || device_target == 'simulator' device_target = self.default_simulator(xcode) end udid = device_target unless self.simulator_target?(options) bundle_dir_or_bundle_id = options[:bundle_id] if options[:bundle_id] end return udid, bundle_dir_or_bundle_id end # @deprecated 1.0.5 def self.ensure_instruments_not_running! RunLoop::Instruments.new.kill_instruments end def self.instruments_running? RunLoop::Instruments.new.instruments_running? end # @deprecated 1.0.5 def self.instruments_pids RunLoop::Instruments.new.instruments_pids end # @deprecated 1.0.0 replaced with Xctools#version def self.xcode_version(xcode=RunLoop::Xcode.new) xcode.version end # @deprecated since 1.0.0 # still used extensively in calabash-ios launcher def self.above_or_eql_version?(target_version, xcode_version) if target_version.is_a?(RunLoop::Version) target = target_version else target = RunLoop::Version.new(target_version) end if xcode_version.is_a?(RunLoop::Version) xcode = xcode_version else xcode = RunLoop::Version.new(xcode_version) end target >= xcode end # @deprecated 1.0.5 def self.pids_for_run_loop(run_loop, &block) RunLoop::Instruments.new.instruments_pids(&block) end private # @!visibility private # # @param [Hash] options The launch options passed to .run_with_options def self.prepare(run_options) RunLoop::DotDir.rotate_result_directories RunLoop::Instruments.rotate_cache_directories true end # @!visibility private # # @param [RunLoop::Device] device The device under test. # @param [RunLoop::Xcode] xcode The active Xcode def self.default_uia_strategy(device, xcode) if xcode.version_gte_7? :host elsif device.physical_device? && device.version >= RunLoop::Version.new("8.0") :host else :preferences end end # @!visibility private # # @param [Hash] options The launch options passed to .run_with_options # @param [RunLoop::Device] device The device under test. # @param [RunLoop::Xcode] xcode The active Xcode. def self.detect_uia_strategy(options, device, xcode) strategy = options[:uia_strategy] || self.default_uia_strategy(device, xcode) if ![:host, :preferences, :shared_element].include?(strategy) raise ArgumentError, "Invalid strategy: expected '#{strategy}' to be :host, :preferences, or :shared_element" end strategy end # @!visibility private # # UIAutomation buffers log output in some very strange ways. RunLoop # attempts to work around this buffering by forcing characters onto the # UIALogger buffer. Once the buffer is full, UIAutomation will dump its # contents. It is essential that the communication between UIAutomation # and RunLoop be synchronized. # # Casual users should never set the :flush_uia_logs key; they should use the # defaults. # # :no_flush is supported (for now) as alternative key. # # @param [Hash] options The launch options passed to .run_with_options def self.detect_flush_uia_log_option(options) if options.has_key?(:no_flush) # Confusing. # :no_flush == false means, flush the logs. # :no_flush == true means, don't flush the logs. return !options[:no_flush] end return options.fetch(:flush_uia_logs, true) end # @!visibility private # # @param [Hash] options The launch options passed to .run_with_options def self.detect_reset_options(options) return options[:reset] if options.has_key?(:reset) return options[:reset_app_sandbox] if options.has_key?(:reset_app_sandbox) RunLoop::Environment.reset_between_scenarios? end # Prepares the simulator for running. # # 1. enabling accessibility and software keyboard # 2. installing / uninstalling apps # # TODO: move to CoreSimulator? def self.prepare_simulator(app, device, xcode, simctl, reset_options) # Validate the architecture. self.expect_simulator_compatible_arch(device, app) # Quits the simulator. core_sim = RunLoop::CoreSimulator.new(device, app, :xcode => xcode) # Calabash 0.x can only reset the app sandbox (true/false). # Calabash 2.x has advanced reset options. if reset_options core_sim.reset_app_sandbox end # Will quit the simulator if it is running. # @todo fix accessibility_enabled? so we don't have to quit the sim # SimControl#accessibility_enabled? is always false during Core#prepare_simulator # https://github.com/calabash/run_loop/issues/167 simctl.ensure_accessibility(device) # Will quit the simulator if it is running. # @todo fix software_keyboard_enabled? so we don't have to quit the sim # SimControl#software_keyboard_enabled? is always false during Core#prepare_simulator # https://github.com/calabash/run_loop/issues/168 simctl.ensure_software_keyboard(device) # Launches the simulator if the app is not installed. core_sim.install # If CoreSimulator has already launched the simulator, it will not launch it again. core_sim.launch_simulator end # @!visibility private # Raise an error if the application binary is not compatible with the # target simulator. # # @param [RunLoop::Device] device The device to install on. # @param [RunLoop::App] app The app to install. # # @raise [RunLoop::IncompatibleArchitecture] Raises an error if the # application binary is not compatible with the target simulator. def self.expect_simulator_compatible_arch(device, app) lipo = RunLoop::Lipo.new(app.path) lipo.expect_compatible_arch(device) RunLoop.log_debug("Simulator instruction set '#{device.instruction_set}' is compatible with '#{lipo.info}'") end end end