require 'edn' require 'json' module Calabash module Cucumber # Low-level module for interacting directly with UIA # See also {https://developer.apple.com/library/ios/documentation/ToolsLanguages/Reference/UIAElementClassReference/UIAElement/UIAElement.html} # Typically used to interact with System or remote views. module UIA # Executes raw JavaScript in the UIAutomation environment (using `eval`). # @param {String} command the JavaScript snippet to execute # @return {Object} the result returned by the UIA process def uia(command, options={}) raise ArgumentError, "Please supply :command" unless command # UIA only makes sense if there is a run loop launcher = Calabash::Cucumber::Launcher.launcher_if_used run_loop = launcher && launcher.attached_to_automator? && launcher.run_loop # Automatically attach in the calabash console if !run_loop && defined?(IRB) RunLoop.log_debug("Attaching to current instruments process...") launcher = Calabash::Cucumber::Launcher.new Calabash::Cucumber::Launcher.attach run_loop = launcher.run_loop RunLoop.log_debug("Attached!") end strategy = run_loop[:uia_strategy] case strategy when :preferences, :shared_element path = strategy == :preferences ? 'uia' : 'uia-shared' res = http({:method => :post, :path => path}, {:command => command}.merge(options)) begin res = JSON.parse(res) rescue TypeError, JSON::ParserError => _ raise "Could not parse response '#{res}'; the app has probably crashed" end if res['outcome'] != 'SUCCESS' raise "uia action failed because: #{res['reason']}\n#{res['details']}" end res['results'].first when :host res = RunLoop.send_command(run_loop, command) status = res['status'] case status when 'success' res when 'error' value = res['value'] if value msg = "uia action failed because: #{res['value']}" else msg = 'uia action failed for an unknown reason' end raise msg else candidates = ['success', 'error'] raise RuntimeError, "expected '#{status}' to be one of #{candidates}" end else candidates = [:preferences, :shared_element, :host] raise ArgumentError, "expected '#{run_loop[:uia_strategy]}' to be one of #{candidates}" end end # Invoke a Calabash query inside the UIAutomation Calabash engine # Note that this traverses the UIA (accessibility) hierarchy. # @example uia query equivalent of "button marked:'Hello'" # uia_query :button, marked:'Hello' # @param {Array} queryparts array of segments in the query, e.g., `:button, {marked:'Hello'}` # @return {Array} UIAElements matching the query in serialized form. def uia_query(*queryparts) #TODO escape '\n etc in query uia_handle_command(:query, queryparts) end # @!visibility private def uia_query_el(*queryparts) #TODO escape '\n etc in query uia_handle_command(:queryEl, queryparts) end # Invoke a Calabash query inside the UIAutomation Calabash engine - includes all UIAWindows. # Note that this traverses the UIA (accessibility) hierarchy. # @example uia query equivalent of "button marked:'Hello'" # uia_query_windows :button # @param {Array} queryparts array of segments in the query, e.g., `:button, {marked:'Hello'}` # @return {Array} UIAElements matching the query in serialized form. def uia_query_windows(*queryparts) #TODO escape '\n etc in query uia_handle_command(:queryWindows, queryparts) end # Invoke a Calabash query inside the UIAutomation Calabash engine - includes all UIAWindows. # Note that this traverses the UIA (accessibility) hierarchy. # @example uia equivalent of `identifier "button"` # uia_names :button # # returns # [ # "Browse", # "UINavigationBarBackIndicatorDefault.png", # "16h", # "reader postaction comment blue", # "16h", # "52", # "17h", # "10", # "Reader", # "Notifications", # "Me", # "New Post" # ] # @param {Array} queryparts array of segments in the query, e.g., `:button, {marked:'Hello'}` # @return {Array} "names" (accessibilityIdentifier) of UIAElements matching the query. def uia_names(*queryparts) #TODO escape '\n etc in query uia_handle_command(:names, queryparts) end # Advanced method used to invoke UIAutomation JavaScript methods on objects found via Calabash queries # @see #uia_query # @example Calling UIAButton.isVisible() - {https://developer.apple.com/library/ios/documentation/ToolsLanguages/Reference/UIAElementClassReference/UIAElement/UIAElement.html} # uia_call [:button, {marked:'New Post'}], :isVisible # @example Advanced example that chains calls and uses arguments # uia_call [:view, marked:'New Post'], {withName:"New Post"}, :toString, {charAt:0} # # @param {Array} args_arr array describing the query, e.g., `[:button, {marked:'foo'}]` # @param {Array} opts optional arguments specifying a chained sequence of method calls (see example) def uia_call(args_arr, *opts) uia_call_method(:queryEl, [args_arr], *opts) end # Similar to `uia_call` but searches all windows # @see #uia_call def uia_call_windows(args_arr, *opts) uia_call_method(:queryElWindows, [args_arr], *opts) end # Advanced method used for fast keyboard entry by calling the setValue method # on the input with current keyboard focus. # This is an alternative to calling `keyboard_enter_text` # # @param {String} value the value to set def uia_set_responder_value(value) uia_call_method(:elementWithKeyboardFocus, [], setValue: value) end # @!visibility private def uia_tap(*queryparts) uia_handle_command(:tap, queryparts) end # @!visibility private def uia_tap_mark(mark) uia_handle_command(:tapMark, mark) end # @!visibility private def uia_tap_offset(offset) uia_handle_command(:tapOffset, offset) end # @!visibility private def uia_double_tap(*queryparts) uia_handle_command(:doubleTap, queryparts) end # @!visibility private def uia_double_tap_mark(mark) uia_double_tap(:view, {:marked => mark}) end # @!visibility private def uia_double_tap_offset(offset) uia_handle_command(:doubleTapOffset, offset) end # @!visibility private def uia_two_finger_tap(*queryparts) uia_handle_command(:twoFingerTap, queryparts) end # @!visibility private def uia_two_finger_tap_offset(offset) uia_handle_command(:twoFingerTapOffset, offset) end # @!visibility private def uia_flick_offset(from, to) uia_handle_command(:flickOffset, from, to) end # @!visibility private def uia_touch_hold(duration, *queryparts) uia_handle_command(:touchHold, duration, queryparts) end # @!visibility private def uia_touch_hold_offset(duration, offset) uia_handle_command(:touchHoldOffset, duration, offset) end # @!visibility private def uia_pan(from_q, to_q) uia_handle_command(:pan, from_q, to_q) end # @!visibility private def uia_pan_offset(from, to, options) uia_handle_command(:panOffset, from, to, options) end # @!visibility private def uia_swipe(*queryparts) uia_handle_command(:swipe, queryparts) end # @!visibility private def uia_swipe_offset(offset, options) uia_handle_command(:swipeOffset, offset, options) end # @!visibility private def uia_drag_inside(dir, queryparts, options={}) uia_call_method(:swipe, [dir, queryparts, options]) end # @!visibility private def uia_drag_inside_mark(dir, mark, options={}) uia_call_method(:swipeMark, [dir, "\"#{mark}\"", options]) end # @!visibility private def uia_pinch(*queryparts) uia_handle_command(:pinch, queryparts) end # @!visibility private def uia_pinch_offset(in_or_out, offset, duration) uia_handle_command(:pinchOffset, in_or_out, offset, duration) end # @!visibility private def uia_scroll_to(*queryparts) uia_handle_command(:scrollTo, queryparts) end # @!visibility private def uia_element_exists?(*queryparts) uia_handle_command(:elementExists, queryparts) end # @!visibility private def uia_element_does_not_exist?(*queryparts) uia_handle_command(:elementDoesNotExist, queryparts) end # @!visibility private def uia_screenshot(name) uia_handle_command(:screenshot, name) end # Simulates Rotation of the device # @param [String|Symbol] dir The position of the home button after the rotation. # Can be one of `{'clockwise' | 'counter-clockwise'| :left | :right}`. # def uia_rotate(dir) uia_handle_command(:rotate, dir) end # Gets the current orientation of the device # @return {String} the current orientation of the device # one of `{'portrait', 'portrait-upside-down', 'landscape-left', 'landscape-right', 'faceup', 'facedown' }` def uia_orientation o = uia_handle_command(:orientation).to_s o[1..o.length] end # Rotates the home button position to the position indicated by `dir`. # @note Refer to Apple's documentation for clarification about left vs. # right landscape orientations. # @param [Symbol|String] dir The position of the home button after the rotation. # Can be one of `{:down, :left, :right, :up }`. def uia_rotate_home_button_to(dir) dir = dir.to_sym if dir == :top dir = :up elsif dir == :bottom dir = :down end uia_orientation = case dir when :left then 'UIA_DEVICE_ORIENTATION_LANDSCAPERIGHT' when :right then 'UIA_DEVICE_ORIENTATION_LANDSCAPELEFT' when :up then 'UIA_DEVICE_ORIENTATION_PORTRAIT_UPSIDEDOWN' when :down then 'UIA_DEVICE_ORIENTATION_PORTRAIT' else raise "Unexpected direction #{dir}" end uia("target.setDeviceOrientation(#{uia_orientation})") end # Used for detecting keyboards that are not normally visible to calabash; # e.g. the keyboard on the `MFMailComposeViewController` # # @note # IMPORTANT this should only be used when the app does not respond to # `keyboard_visible?` and UIAutomation is being used. # # @see #keyboard_visible? # # @raise [RuntimeError] If the app was not launched with instruments def uia_keyboard_visible? res = uia_query_windows(:keyboard) res != ":nil" end # Waits for a keyboard that is not normally visible to calabash; # e.g. the keyboard on `MFMailComposeViewController`. # # @note # IMPORTANT this should only be used when the app does not respond to # `keyboard_visible?` and UIAutomation is being used. # # @see #keyboard_visible? # # @raise [RuntimeError] if the app was not launched with instruments def uia_wait_for_keyboard(options={}) default_opts = { :timeout => 10, :retry_frequency => 0.1, :post_timeout => 0.5, :timeout_message => "Keyboard did not appear" } options = default_opts.merge(options) wait_for(options) do uia_keyboard_visible? end true end # @!visibility private def uia_type_string(string, opt_text_before='', escape=true) result = uia_handle_command(:typeString, string, opt_text_before) # When 'status' == 'success', we get back result['value']. Sometimes, # the 'value' key is not present in the result - in which case we assume # success without error. return if result.nil? # Typing on UIWebViews returns result['value'] => ':nil'. There might # be other cases where result is _not_ a Hash. return unless result.is_a? Hash # If there is no 'status' key, then we assume success. Syntax errors # should be caught upstream. # https://github.com/calabash/calabash-ios/issues/374 return unless result.has_key? 'status' status = result['status'] # If status is not 'error' we punt. Should never happen. return if status != 'error' if result.has_key? 'value' raise "Could not type '#{string}' - UIAutomation returned an error: '#{result['value']}'" else raise "Could not type '#{string}' - UIAutomation returned '#{result}'" end end # @!visibility private def uia_type_string_raw(str) uia("uia.keyboard().typeString('#{str}')") end # @!visibility private def uia_enter uia_handle_command(:enter) end # @!visibility private def uia_set_location(options) validate_hash_is_location!(options) if options[:place] place = options[:place] search_results = Geocoder.search(place) raise "Got no results for #{place}" if search_results.empty? loc = search_results.first loc_data = {'latitude' => loc.latitude, 'longitude' => loc.longitude} elsif options.is_a?(Hash) loc_data = options end uia_handle_command(:setLocation, loc_data) end # @!visibility private def uia_send_app_to_background(secs) #uia_handle_command(:deactivate, secs) #Temporary workaround: https://github.com/calabash/calabash-ios/issues/556 js_deactivate = %Q[var x = target.deactivateAppForDuration(#{secs}); var MAX_RETRY=5, retry_count = 0; while (!x && retry_count < MAX_RETRY) { x = target.deactivateAppForDuration(#{secs}); retry_count += 1}; x] uia(js_deactivate) end # @!visibility private def uia_call_method(cmd, args_arr, *opts) if opts.empty? return uia_handle_command(cmd, *args_arr) end js_cmd = uia_serialize_command(cmd, *args_arr) js_args = [] opts.each do |invocation| js_args << case invocation when Symbol "#{invocation}()" when Hash m = invocation.keys.first args = invocation[m] if args.is_a?(Array) serialized_args = (args.map &:to_json).join(',') else serialized_args = args.to_json end "#{m}(#{serialized_args})" else raise "Invalid invocation spec #{invocation}" end end command = "#{js_cmd}.#{js_args.join('.')}" if RunLoop::Environment.debug? puts 'Sending UIA command' puts command end uia_result(uia(command)) end # @!visibility private def uia_handle_command(cmd, *query_args) command = uia_serialize_command(cmd, *query_args) if RunLoop::Environment.debug? puts 'Sending UIA command' puts command end s = uia(command) uia_result(s) end # @!visibility private def uia_serialize_command(cmd, *query_args) args = uia_serialize_arguments(query_args) %Q[uia.#{cmd}(#{args.join(', ')})] end # @!visibility private def uia_serialize_arguments(args) args.map do |part| uia_serialize_argument(part) end end # @!visibility private def uia_serialize_argument(part) if part.is_a?(String) "'#{escape_uia_string(part)}'" else "'#{escape_uia_string(part.to_edn)}'" end end # @!visibility private def escape_uia_string(string) escape_string string end # DEPRECATED: Use uia("...javascript..", options) instead. # deprecated because the method signature is poor # @!visibility private def send_uia_command(opts ={}) # TODO formally deprecate send_uia_command with _deprecated function #cmd = opts[:command] #new_opts = cmd.select{|x| x != :command} #RunLoop.deprecated("0.9.163", "Use 'uia(#{cmd}, #{new_opts})' instead") uia(opts[:command], opts) end private def validate_hash_is_location!(options) return if options[:latitude] and options[:longitude] if (options[:latitude] and not options[:longitude]) || (options[:longitude] and not options[:latitude]) raise 'Both latitude and longitude must be specified if either is.' elsif not options[:place] raise 'Either :place or :latitude and :longitude must be specified.' end end def uia_result(s) if RunLoop::Environment.debug? puts 'Result' p s end if s['status'] == 'success' s['value'] else s end end end end end module Calabash::Cucumber::UIA # @!visibility private def self.redefine_instance_methods_if_necessary(xcode, automator=nil) return if Calabash::Cucumber::Environment.xtc? if xcode.version_gte_8? reason = "UIAutomation is not available in Xcode >= 8.0." return self.redefine_instance_methods_to_raise(reason) end if automator && automator.name == :device_agent reason = "UIAutomation is not available when testing with DeviceAgent." return self.redefine_instance_methods_to_raise(reason) end end # @!visibility private def self.redefine_instance_methods_to_raise(reason) methods = Calabash::Cucumber::UIA.instance_methods methods.each do |method_name| Calabash::Cucumber::UIA.send(:remove_method, method_name) Calabash::Cucumber::UIA.send(:define_method, method_name) do |*args| case method_name when :uia raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. It is not possible to make raw UIAutomation JavaScript calls. If you are trying to make query, use the DeviceAgent query API. device_agent.query({type: "TextField", index:1}) device_agent.query({marked: "Cancel"}) If you are trying to perform a gesture or enter text, in most cases the normal Core method will work. If a normal Core method does work, try the DeviceAgent Gesture API. device_agent.touch({type: "TextField", index:1}) device_agent.touch({marked: "Button"}) If you cannot find an equivalent DeviceAgent workaround, please create an issue and include: 1. At a high level, what you are trying to do. 2. The JavaScript you are trying to invoke. Links: * http://calabashapi.xamarin.com/ios/Calabash/Cucumber/DeviceAgent.html * https://github.com/calabash/calabash-ios/issues ] when :uia_call, :uia_call_windows, :uia_call_method, :uia_names raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. There is no suggested workaround for this method. Please review the DeviceAgent API for a replacement. If you can find no replacement, please create an issue and include: If you are trying to make query, use the DeviceAgent query API. device_agent.query({type: "TextField", index:1}) device_agent.query({marked: "Cancel"}) If you are trying to perform a gesture or enter text, in most cases the normal Core method will work. If a normal Core method does work, try the DeviceAgent Gesture API. device_agent.touch({type: "TextField", index:1}) device_agent.touch({marked: "Button"}) If you cannot find an equivalent DeviceAgent workaround, please create an issue and include: 1. At a high level, what you are trying to do. 2. The method you are invoking with the arguments. Links: * http://calabashapi.xamarin.com/ios/Calabash/Cucumber/DeviceAgent.html * https://github.com/calabash/calabash-ios/issues ] when :uia_element_exists?, :uia_element_does_not_exist? raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. Use the DeviceAgent wait API. device_agent.wait_for_view({marked: "Cancel"}) device_agent.wait_for_no_view({marked: "Cancel"}) ] when :uia_query, :uia_query_el raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. Use the DeviceAgent query API. device_agent.query({marked: "Cancel"}) device_agent.query({type: "TextField", index:1}) ] when :uia_query_windows raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. Try to use the DeviceAgent query API. device_agent.query({marked: "Cancel"}) device_agent.query({type: "TextField", index:1}) If the DeviceAgent query API does not find the correct views, please create an issue and include: 1. At a high level, what you are trying to do. 2. The method you are invoking with the arguments. ] when :uia_screenshot raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. There is no replacement for this method. Please create an issue and include: 1. At a high level, what you are trying to do. 2. A screenshot of the view you are trying capture. ] when :uia_orientation, :uia_rotate_home_button_to, :uia_rotate raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. You should not be calling this method. Always call the orientation methods defined in the Core API. ] when :uia_type_string, :uia_type_string_raw, :uia_enter, :uia_set_responder_value raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. In general, you should use the the text input methods defined in Core. In some cases you will need to use the DeviceAgent query and keyboard API. device_agent.touch({type: "TextField", index: 1}) wait_for_keyboard keyboard_enter_text("Hello") It is important to note that the DeviceAgent implementations of: * keyboard_enter_text * keyboard_enter_char * enter_text_in * enter_text * fast_enter_text have exactly the same performance. You should prefer `enter_text` or `enter_text_in` because it matches the Calabash 2.0 API. ] when :uia_set_location raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. This method has been broken for various iOS versions and device combinations for years. At the moment, we do not have replacement for location spoofing with DeviceAgent. ] when :uia_send_app_to_background raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. You should not use this method. If the Core send_app_to_background is not working under UIAutomation, please create a GitHub issue. https://github.com/calabash/calabash-ios/issues ] when :uia_keyboard_visible?, :uia_wait_for_keyboard raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. We have not found a case (yet) where the the Core keyboard_visible? and wait_for_keyboard methods do not work when using DeviceAgent. If you find a case where the Core methods do not work, please create a GitHub issue. The current DeviceAgent keyboard API is scheduled for removal. It is crucial that you report workflows that require the DeviceAgent keyboard API. device_agent.keyboard_visible? wait_for { device_agent.keyboard_visible? } https://github.com/calabash/calabash-ios/issues ] when :uia_handle_command, :uia_serialize_command, :uia_serialize_arguments, :uia_serialize_argument, :escape_uia_string, :send_uia_command raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. There is no replacement. ] when :uia_tap, :uia_tap_mark, :uia_tap_offset, :uia_double_tap, :uia_double_tap_mark, :uia_double_tap_offset, :uia_two_finger_tap, :uia_two_finger_tap_offset, :uia_touch_hold, :uia_touch_hold_offset, :uia_pan, :uia_pan_offset, :uia_swipe, :uia_swipe_offset, :uia_flick_offset, :uia_drag_inside, :uia_drag_inside_mark, :uia_pinch, :uia_pinch_offset, :uia_scroll_to raise RuntimeError, %Q[ #{reason} #{method_name} has been removed from the Calabash API. DeviceAgent is our replacement for UIAutomation. In most cases, you will not need to use a special DeviceAgent gesture method like you did with UIAutomation. If a Core gesture method does not work, there is a DeviceAgent gesture API. device_agent.touch({type: "Button", marked: "Back"}) For UIA pan gestures (flick, swipe, pan) use pan_coordinates. from_point = device_agent.query_for_coordinate({marked: "From"}) to_point = device_agent.query_for_coordinate({marked: "To"}) pan_coordinates(from_point, to_point) ] else raise ArgumentError, "This #{method_name} has not been handled" end end end true end end