require 'fileutils' require 'tmpdir' require 'timeout' require 'json' require 'open3' require 'erb' require 'ap' module RunLoop class TimeoutError < RuntimeError end class WriteFailedError < RuntimeError end module Core START_DELIMITER = "OUTPUT_JSON:\n" END_DELIMITER="\nEND_OUTPUT" SCRIPTS_PATH = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'scripts')) SCRIPTS = { :dismiss => 'run_dismiss_location.js', :run_loop_fast_uia => 'run_loop_fast_uia.js', :run_loop_shared_element => 'run_loop_shared_element.js', :run_loop_host => 'run_loop_host.js', :run_loop_basic => 'run_loop_basic.js' } 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, xctools) return unless ENV['DEBUG'] == '1' # 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] = xctools.xcode_version.to_s options_to_log[:xcode_path] = xctools.xcode_developer_dir ap(options_to_log, {:sort_keys => true}) 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 def self.script_for_key(key) if SCRIPTS[key].nil? return nil end File.join(scripts_path, SCRIPTS[key]) end def self.detect_connected_device begin Timeout::timeout(1, TimeoutError) do return `#{File.join(scripts_path, 'udidetect')}`.chomp end rescue TimeoutError => _ `killall udidetect &> /dev/null` end nil end # Raise an error if the application binary is not compatible with the # target simulator. # # @note This method is implemented for CoreSimulator environments only; # for Xcode < 6.0 this method does nothing. # # @param [Hash] launch_options These options need to contain the app bundle # path and a udid that corresponds to a simulator name or simulator udid. # In practical terms: call this after merging the original launch # options with those options that are discovered. # # @param [RunLoop::SimControl] sim_control A simulator control object. # @raise [RuntimeError] Raises an error if the `launch_options[:udid]` # cannot be used to find a simulator. # @raise [RunLoop::IncompatibleArchitecture] Raises an error if the # application binary is not compatible with the target simulator. def self.expect_compatible_simulator_architecture(launch_options, sim_control) if sim_control.xcode_version_gte_6? sim_identifier = launch_options[:udid] simulator = sim_control.simulators.find do |simulator| [simulator.instruments_identifier(sim_control.xctools), simulator.udid].include?(sim_identifier) end if simulator.nil? raise "Could not find simulator with identifier '#{sim_identifier}'" end lipo = RunLoop::Lipo.new(launch_options[:bundle_dir_or_bundle_id]) lipo.expect_compatible_arch(simulator) if ENV['DEBUG'] == '1' puts "Simulator instruction set '#{simulator.instruction_set}' is compatible with #{lipo.info}" end true else if ENV['DEBUG'] == '1' puts "Xcode #{sim_control.xctools.xcode_version} detected; skipping simulator architecture check." end false end end def self.run_with_options(options) before = Time.now sim_control ||= options[:sim_control] || RunLoop::SimControl.new xctools ||= options[:xctools] || sim_control.xctools instruments = RunLoop::Instruments.new instruments.kill_instruments device_target = options[:udid] || options[:device_target] || detect_connected_device || 'simulator' if device_target && device_target.to_s.downcase == 'device' device_target = detect_connected_device end log_file = options[:log_path] timeout = options[:timeout] || 30 results_dir = options[:results_dir] || Dir.mktmpdir('run_loop') 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') code = File.read(options[:script]) code = code.gsub(/\$PATH/, results_dir) code = code.gsub(/\$READ_SCRIPT_PATH/, READ_SCRIPT_PATH) code = code.gsub(/\$TIMEOUT_SCRIPT_PATH/, TIMEOUT_SCRIPT_PATH) code = code.gsub(/\$MODE/, 'FLUSH') unless options[:no_flush] repl_path = File.join(results_dir, 'repl-cmd.pipe') FileUtils.rm_f(repl_path) uia_strategy = options[:uia_strategy] if uia_strategy == :host create_uia_pipe(repl_path) RunLoop::HostCache.default.clear end 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 code end # Compute udid and bundle_dir / bundle_id from options and target depending on Xcode version udid, bundle_dir_or_bundle_id = udid_and_bundle_for_launcher(device_target, options, xctools) args = options.fetch(:args, []) log_file ||= File.join(results_dir, 'run_loop.out') after = Time.now if ENV['DEBUG'] == '1' puts "Preparation took #{after-before} seconds" end discovered_options = { :udid => udid, :results_dir_trace => results_dir_trace, :bundle_dir_or_bundle_id => bundle_dir_or_bundle_id, :results_dir => results_dir, :script => script, :log_file => log_file, :args => args } merged_options = options.merge(discovered_options) if self.simulator_target?(merged_options, sim_control) # @todo only enable accessibility on the targeted simulator sim_control.enable_accessibility_on_sims({:verbose => false}) self.expect_compatible_simulator_architecture(merged_options, sim_control) end self.log_run_loop_options(merged_options, xctools) automation_template = automation_template(xctools) log_header("Starting on #{device_target} App: #{bundle_dir_or_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 => udid, :app => bundle_dir_or_bundle_id, :repl_path => repl_path, :log_file => log_file, :results_dir => results_dir} uia_timeout = options[:uia_timeout] || (ENV['UIA_TIMEOUT'] && ENV['UIA_TIMEOUT'].to_f) || 10 before = Time.now begin if options[:validate_channel] options[:validate_channel].call(run_loop, 0, uia_timeout) else cmd = "UIALogger.logMessage('Listening for run loop commands')" File.open(repl_path, 'w') { |file| file.puts "0:#{cmd}" } Timeout::timeout(timeout, TimeoutError) do read_response(run_loop, 0, uia_timeout) end end rescue TimeoutError => e if ENV['DEBUG'] == '1' puts "Failed to launch." puts "#{e}: #{e && e.message}" end raise TimeoutError, "Time out waiting for UIAutomation run-loop to Start. \n Logfile #{log_file} \n\n #{File.read(log_file)}\n" end if ENV['DEBUG']=='1' puts "Launching took #{Time.now-before} seconds" end dylib_path = self.dylib_path_from_options(merged_options) if dylib_path RunLoop::LLDB.kill_lldb_processes app = RunLoop::App.new(options[:app]) lldb = RunLoop::DylibInjector.new(app.executable_name, dylib_path) lldb.retriable_inject_dylib end 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 # @!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} def self.simulator_target?(run_options, sim_control = RunLoop::SimControl.new) value = run_options[:device_target] # match the behavior of udid_and_bundle_for_launcher return true if value.nil? or value == '' # support for 'simulator' and Xcode >= 5.1 device targets return true if value.downcase.include?('simulator') # if Xcode < 6.0, we are done return false if not sim_control.xcode_version_gte_6? # support for Xcode >= 6 simulator udids return true if sim_control.sim_udid? value # support for Xcode >= 6 'named simulators' sims = sim_control.simulators.each sims.find_index { |device| device.name == value } != nil 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::XCTools] xcode_tools Used to detect the current xcode # version. def self.default_simulator(xcode_tools=RunLoop::XCTools.new) if xcode_tools.xcode_version_gte_63? 'iPhone 5s (8.3 Simulator)' elsif xcode_tools.xcode_version_gte_62? 'iPhone 5s (8.2 Simulator)' elsif xcode_tools.xcode_version_gte_61? 'iPhone 5s (8.1 Simulator)' elsif xcode_tools.xcode_version_gte_6? 'iPhone 5s (8.0 Simulator)' else 'iPhone Retina (4-inch) - Simulator - iOS 7.1' end end def self.udid_and_bundle_for_launcher(device_target, options, xctools=RunLoop::XCTools.new) bundle_dir_or_bundle_id = options[:app] || ENV['BUNDLE_ID']|| ENV['APP_BUNDLE_PATH'] || ENV['APP'] 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 udid = nil if xctools.xcode_version_gte_51? if device_target.nil? || device_target.empty? || device_target == 'simulator' device_target = self.default_simulator(xctools) end udid = device_target unless /simulator/i.match(device_target) bundle_dir_or_bundle_id = options[:bundle_id] if options[:bundle_id] end else if device_target == 'simulator' unless File.exist?(bundle_dir_or_bundle_id) raise "Unable to find app in directory #{bundle_dir_or_bundle_id} when trying to launch simulator" end device = options[:device] || :iphone device = device && device.to_sym plistbuddy='/usr/libexec/PlistBuddy' plistfile="#{bundle_dir_or_bundle_id}/Info.plist" if device == :iphone uidevicefamily=1 else uidevicefamily=2 end system("#{plistbuddy} -c 'Delete :UIDeviceFamily' '#{plistfile}'") system("#{plistbuddy} -c 'Add :UIDeviceFamily array' '#{plistfile}'") system("#{plistbuddy} -c 'Add :UIDeviceFamily:0 integer #{uidevicefamily}' '#{plistfile}'") else udid = device_target bundle_dir_or_bundle_id = options[:bundle_id] if options[:bundle_id] end end return udid, bundle_dir_or_bundle_id end # @deprecated 1.0.0 replaced with Xctools#version def self.xcode_version(xctools=RunLoop::XCTools.new) xctools.xcode_version.to_s end def self.create_uia_pipe(repl_path) begin Timeout::timeout(5, 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 TimeoutError => _ raise 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)}" should_log = (ENV['DEBUG'] == '1') RunLoop.log_info(logger, cmd_str) if should_log write_succeeded = false 2.times do |i| RunLoop.log_info(logger, "Trying write of command #{cmd_str} at index #{index}") if should_log File.open(repl_path, 'w') { |f| f.puts(cmd_str) } write_succeeded = validate_index_written(run_loop, index, logger) break if write_succeeded end unless write_succeeded RunLoop.log_info(logger, 'Failing...Raising RunLoop::WriteFailedError') if should_log 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) index end def self.validate_index_written(run_loop, index, logger) begin Timeout::timeout(10, TimeoutError) do Core.read_response(run_loop, index, 10, 'last_index') end RunLoop.log_info(logger, "validate index written for index #{index} ok") return true rescue TimeoutError => _ RunLoop.log_info(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.read_response(run_loop, expected_index, empty_file_timeout=10, search_for_property='index') 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) $stderr.puts "\n\n****** Accessibility is not enabled on device/simulator, please enable it *** \n\n" $stderr.flush end raise TimeoutError.new('AXError: Could not auto-register for pid status change') end if /Automation Instrument ran into an exception/.match(output) raise TimeoutError.new('Exception while running script') end index_if_found = output.index(START_DELIMITER) if ENV['DEBUG_READ']=='1' 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 ENV['DEBUG_READ']=='1' puts "Index #{index_if_found}, Size: #{size} Offset #{offset}" puts ("parse #{json}") end offset = offset + json.size parsed_result = JSON.parse(json) if ENV['DEBUG_READ']=='1' 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) result end # @deprecated 1.0.5 def self.pids_for_run_loop(run_loop, &block) RunLoop::Instruments.new.instruments_pids(&block) end def self.automation_template(xctools, candidate = ENV['TRACE_TEMPLATE']) unless candidate && File.exist?(candidate) candidate = default_tracetemplate xctools end candidate end def self.default_tracetemplate(xctools=RunLoop::XCTools.new) templates = xctools.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. res = templates.select { |name| name == 'Automation' }.first return res if res res = templates.select do |path| path =~ /\/Automation.tracetemplate/ and path =~ /Xcode/ end.first.tr("\"", '').strip return res if res msgs = ['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 msgs.join("\n") end def self.log(message) if ENV['DEBUG']=='1' puts "#{Time.now } #{message}" $stdout.flush end end def self.log_header(message) if ENV['DEBUG']=='1' puts "\n\e[#{35}m### #{message} ###\e[0m" $stdout.flush end 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 end def self.default_script_for_uia_strategy(uia_strategy) case uia_strategy when :preferences Core.script_for_key(:run_loop_fast_uia) when :host Core.script_for_key(:run_loop_host) when :shared_element Core.script_for_key(:run_loop_shared_element) else Core.script_for_key(:run_loop_basic) end end def self.run(options={}) if RunLoop::Instruments.new.instruments_app_running? msg = [ "Please quit the Instruments.app.", "If Instruments.app is open, the instruments command line", "tool cannot take control of your application." ] raise msg.join("\n") end uia_strategy = options[:uia_strategy] if options[:script] script = validate_script(options[:script]) else if uia_strategy script = default_script_for_uia_strategy(uia_strategy) else if options[:calabash_lite] uia_strategy = :host script = Core.script_for_key(:run_loop_host) else uia_strategy = :preferences script = default_script_for_uia_strategy(uia_strategy) end end end # At this point, 'script' has been chosen, but uia_strategy might not unless uia_strategy desired_script = options[:script] if desired_script.is_a?(String) #custom path to script uia_strategy = :host elsif desired_script == :run_loop_host uia_strategy = :host elsif desired_script == :run_loop_fast_uia uia_strategy = :preferences elsif desired_script == :run_loop_shared_element uia_strategy = :shared_element else raise "Inconsistent state: desired script #{desired_script} has not uia_strategy" end end # At this point script and uia_strategy selected options[:script] = script options[:uia_strategy] = uia_strategy Core.run_with_options(options) end def self.send_command(run_loop, cmd, options={timeout: 60}, num_retries=0, last_error=nil) if num_retries > 3 if last_error raise last_error else raise "Max retries exceeded #{num_retries} > 3. No error recorded." end end if options.is_a?(Numeric) options = {timeout: options} end if not cmd.is_a?(String) raise "Illegal command #{cmd} (must be a string)" end if not options.is_a?(Hash) raise "Illegal options #{options} (must be a Hash (or number for compatibility))" end timeout = options[:timeout] || 60 logger = options[:logger] interrupt_retry_timeout = options[:interrupt_retry_timeout] || 25 expected_index = run_loop[:index] result = nil begin expected_index = Core.write_request(run_loop, cmd, logger) rescue RunLoop::WriteFailedError, Errno::EINTR => write_error # Attempt recover from interrupt by attempting to read result (assuming write went OK) # or retry if attempted read result fails run_loop[:index] = expected_index # restore expected index in case it changed log_info(logger, "Core.write_request failed: #{write_error}. Attempting recovery...") log_info(logger, "Attempting read in case the request was received... Please wait (#{interrupt_retry_timeout})...") begin Timeout::timeout(interrupt_retry_timeout, TimeoutError) do result = Core.read_response(run_loop, expected_index) end # Update run_loop expected index since we succeeded in reading the index run_loop[:index] = expected_index + 1 log_info(logger, "Did read response for interrupted request of index #{expected_index}... Proceeding.") return result rescue TimeoutError => _ log_info(logger, "Read did not result in a response for index #{expected_index}... Retrying send_command...") return send_command(run_loop, cmd, options, num_retries+1, write_error) end end begin Timeout::timeout(timeout, TimeoutError) do result = Core.read_response(run_loop, expected_index) end rescue TimeoutError => _ raise TimeoutError, "Time out waiting for UIAutomation run-loop for command #{cmd}. Waiting for index:#{expected_index}" end result end def self.stop(run_loop, out=Dir.pwd) return if run_loop.nil? results_dir = run_loop[:results_dir] dest = out RunLoop::Instruments.new.kill_instruments FileUtils.mkdir_p(dest) if results_dir pngs = Dir.glob(File.join(results_dir, 'Run 1', '*.png')) else pngs = [] end FileUtils.cp(pngs, dest) if pngs and pngs.length > 0 end def self.validate_script(script) if script.is_a?(String) unless File.exist?(script) raise "Unable to find file: #{script}" end elsif script.is_a?(Symbol) script = Core.script_for_key(script) unless script raise "Unknown script for symbol: #{script}. Options: #{Core::SCRIPTS.keys.join(', ')}" end else raise "Script must be a symbol or path: #{script}" end script end def self.log_info(logger, message) msg = "#{Time.now}: #{message}" if logger && logger.respond_to?(:info) logger.info(msg) else puts msg if ENV['DEBUG'] == '1' end end end