require 'json' require 'frank-cucumber/gateway' require 'frank-cucumber/host_scripting' require 'frank-cucumber/wait_helper' require 'frank-cucumber/keyboard_helper' require 'frank-cucumber/bonjour' module Frank module Cucumber # FrankHelper provides a core set of helper functions for use when interacting with Frank. # # == Most helpful methods # * {#touch} # * {#wait_for_element_to_exist} # * {#wait_for_element_to_exist_and_then_touch_it} # * {#wait_for_nothing_to_be_animating} # * {#app_exec} # # == Configuring the Frank driver # There are some class-level facilities which configure how all Frank interactions work. For example you can specify which selector engine to use # with {FrankHelper.selector_engine}. You can specify the base url which the native app's Frank server is listening on with {FrankHelper.server_base_url}. # # Two common use cases are covered more conveniently with {FrankHelper.use_shelley_from_now_on} and {FrankHelper.test_on_physical_device_via_bonjour}. module FrankHelper include WaitHelper include KeyboardHelper include HostScripting # @!attribute [rw] selector_engine class << self # @return [String] the selector engine we tell Frank to use when interpreting view selectors. attr_accessor :selector_engine # @return [String] the base url which the Frank server is running on. All Frank commands will be sent to that server. attr_accessor :server_base_url # After calling this method all subsequent commands will ask Frank to use the Shelley selector engine to interpret view selectors. def use_shelley_from_now_on @selector_engine = 'shelley_compat' end # Use Bonjour to search for a running Frank server. The server found will be the recipient for all subsequent Frank commands. # @raise a generic exception if no Frank server could be found via Bonjour def test_on_physical_device_via_bonjour @server_base_url = Bonjour.new.lookup_frank_base_uri raise 'could not detect running Frank server' unless @server_base_url end end #@api private #@return [:String] convient shorthand for {Frank::Cucumber::FrankHelper.selector_engine}, defaulting to 'uiquery' def selector_engine Frank::Cucumber::FrankHelper.selector_engine || 'uiquery' # default to UIQuery for backwards compatibility end #@api private #@return [:String] convient shorthand for {Frank::Cucumber::FrankHelper.server_base_url} def base_server_url Frank::Cucumber::FrankHelper.server_base_url end # Ask Frank to touch all views matching the specified selector. There may be views in the view heirarchy which match the selector but # which Frank cannot or will not touch - for example views which are outside the current viewport. You can discover which of the matching # views were actually touched by inspecting the Array which is returned. # # @param [String] selector a view selector. # @return [Array] an array indicating for each view which matched the selector whether it was touched or not. # @raise an expection if no views matched the selector # @raise an expection if no views which matched the selector could be touched def touch( selector ) touch_successes = frankly_map( selector, 'touch' ) raise "could not find anything matching [#{selector}] to touch" if touch_successes.empty? raise "some views could not be touched (probably because they are not within the current viewport)" if touch_successes.include?(false) end # Indicate whether there are any views in the current view heirarchy which match the specified selector. # @param [String] selector a view selector. # @return [Boolean] # @see #check_element_exists def element_exists( selector ) matches = frankly_map( selector, 'accessibilityLabel' ) # TODO: raise warning if matches.count > 1 !matches.empty? end # Assert whether there are any views in the current view heirarchy which match the specified selector. # @param [String] selector a view selector. # @raise an rspec exception if the assertion fails # @see #element_exists, #check_element_does_not_exist def check_element_exists( selector ) element_exists( selector ).should be_true end # Assert whether there are no views in the current view heirarchy which match the specified selector. # @param [String] selector a view selector. # @raise an rspec exception if the assertion fails # @see #element_exists, #check_element_exists def check_element_does_not_exist( selector ) element_exists( selector ).should be_false end # Indicate whether there are any views in the current view heirarchy which contain the specified accessibility label. # @param [String] expected_mark the expected accessibility label # @return [Boolean] # @see #check_view_with_mark_exists def view_with_mark_exists(expected_mark) element_exists( "view marked:'#{expected_mark}'" ) end # Assert whether there are any views in the current view heirarchy which contain the specified accessibility label. # @param [String] expected_mark the expected accessibility label # @raise an rspec exception if the assertion fails # @see #view_with_mark_exists def check_view_with_mark_exists(expected_mark) check_element_exists( "view marked:'#{expected_mark}'" ) end # Assert whether there are no views in the current view heirarchy which contain the specified accessibility label. # @param [String] expected_mark the expected accessibility label # @raise an rspec exception if the assertion fails # @see #view_with_mark_exists, #check_view_with_mark_exists def check_view_with_mark_does_not_exist(expected_mark) check_element_does_not_exist( "view marked:'#{expected_mark}'" ) end # Waits for any of the specified selectors to match a view. # # Checks each selector in turn within a {http://sauceio.com/index.php/2011/04/how-to-lose-races-and-win-at-selenium/ spin assert} loop and yields the first one which is found to exist in the view heirarchy. # Raises an exception if no views could be found to match any of the provided selectors within {WaitHelper::TIMEOUT} seconds. # # @see WaitHelper#wait_until def wait_for_element_to_exist(*selectors,&block) wait_until(:message => "Waited for element matching any of #{selectors.join(', ')} to exist") do at_least_one_exists = false selectors.each do |selector| if element_exists( selector ) at_least_one_exists = true block.call(selector) if block end end at_least_one_exists end end # Waits for the specified selector to not match any views. # # Uses {WaitHelper#wait_until} to check for any matching views within a {http://sauceio.com/index.php/2011/04/how-to-lose-races-and-win-at-selenium/ spin assert} loop. # Returns as soon as no views match the specified selector. # Raises an exception if there continued to be at least one view which matched the selector by the time {WaitHelper::TIMEOUT} seconds passed. # # @see check_element_does_not_exist # @see wait_for_element_to_not_exist def wait_for_element_to_not_exist(selector) wait_until(:message => "Waited for element #{selector} to not exist") do !element_exists(selector) end end # Waits for a view to exist and then send a touch command to that view. # # @param selectors takes one or more selectors to use to search for a view. The first selector which is found to matches a view is the selector # which is then used to send a touch command. # # Raises an exception if no views could be found to match any of the provided selectors within {WaitHelper::TIMEOUT} seconds. def wait_for_element_to_exist_and_then_touch_it(*selectors) wait_for_element_to_exist(*selectors) do |sel| touch(sel) end end # Waits for there to be no views which report an isAnimated property of true. # # @param timeout [Number] number of seconds to wait for nothing to be animating before timeout out. Defaults to {WaitHelper::TIMEOUT} # # Raises an exception if there were still views animating after {timeout} seconds. def wait_for_nothing_to_be_animating( timeout = false ) wait_until :timeout => timeout do !element_exists('view isAnimating') end end # Checks that the specified selector matches at least one view, and that at least one of the matched # views has an isHidden property set to false # # a better name for this method would be element_exists_and_is_not_hidden def element_is_not_hidden(selector) matches = frankly_map( selector, 'isHidden' ) matches.delete(true) !matches.empty? end # Ask Frank to invoke the specified method on the app delegate of the iOS application under automation. # @param method_sig [String] the method signature # @param method_args the method arguments # # @example # # the same as calling # # [[[UIApplication sharedApplication] appDelegate] setServiceBaseUrl:@"http://example.com/my_api" withPort:8080] # # from your native app # app_exec( "setServiceBaseUrl:withPort:", "http://example.com/my_api", 8080 ) # # def app_exec(method_sig, *method_args) operation_map = Gateway.build_operation_map(method_sig.to_s, method_args) res = frank_server.send_post( 'app_exec', :operation => operation_map ) return Gateway.evaluate_frankly_response( res, "app_exec #{method_name}" ) end # Ask Frank to execute an arbitrary Objective-C method on each view which matches the specified selector. # # @return [Array] an array with an element for each view matched by the selector, each element in the array gives the return value from invoking the specified method on that view. def frankly_map( selector, method_name, *method_args ) operation_map = Gateway.build_operation_map(method_name.to_s, method_args) res = frank_server.send_post( 'map', :query => selector, :operation => operation_map, :selector_engine => selector_engine ) return Gateway.evaluate_frankly_response( res, "frankly_map #{selector} #{method_name}" ) end # print a JSON-formatted dump of the current view heirarchy to stdout def frankly_dump res = frank_server.send_get( 'dump' ) puts JSON.pretty_generate(JSON.parse(res)) rescue puts res #dumping a super-deep DOM causes errors end # grab a screenshot of the application under automation and save it to the specified file. # # @param filename [String] where to save the screenshot image file # @param subframe describes which section of the screen to grab. If unspecified then the entire screen will be captured. #TODO document what format this parameter takes. # @param allwindows [Boolean] If true then all UIWindows in the current UIScreen will be included in the screenshot. If false then only the main window will be captured. def frankly_screenshot(filename, subframe=nil, allwindows=true) path = 'screenshot' path += '/allwindows' if allwindows path += "/frame/" + URI.escape(subframe) if (subframe != nil) data = frank_server.send_get( path ) open(filename, "wb") do |file| file.write(data) end end # @return [Boolean] true if the device running the application currently in a portrait orientation # @note wil return false if the device is in a flat or unknown orientation. Sometimes the iOS simulator will report this state when first launched. def frankly_oriented_portrait? 'portrait' == frankly_current_orientation end # @return [Boolean] true if the device running the application currently in a landscape orientation # @note wil return false if the device is in a flat or unknown orientation. Sometimes the iOS simulator will report this state when first launched. def frankly_oriented_landscape? 'landscape' == frankly_current_orientation end # @return [String] the orientation of the device running the application under automation. # @note this is a low-level API. In most cases you should use {frankly_oriented_portrait} or {frankly_oriented_landscape} instead. def frankly_current_orientation res = frank_server.send_get( 'orientation' ) orientation = JSON.parse( res )['orientation'] puts "orientation reported as '#{orientation}'" if $DEBUG orientation end # @return [Boolean] Does the device running the application have accessibility enabled. # If accessibility is not enabled then a lot of Frank functionality will not work. def frankly_is_accessibility_enabled res = frank_server.send_get( 'accessibility_check' ) JSON.parse( res )['accessibility_enabled'] == 'true' end # wait for the application under automation to be ready to receive automation commands. # # Has some basic heuristics to cope with cases where the Frank server is intermittently available when first launching. # # @raise [Timeout::TimeoutError] if nothing is ready within 20 seconds # @raise generic error if the device hosting the application does not appear to have accessibility enabled. def wait_for_frank_to_come_up num_consec_successes = 0 num_consec_failures = 0 Timeout.timeout(20) do while num_consec_successes <= 6 if frankly_ping num_consec_failures = 0 num_consec_successes += 1 else num_consec_successes = 0 num_consec_failures += 1 if num_consec_failures >= 5 # don't show small timing errors print (num_consec_failures == 5 ) ? "\n" : "\r" print "PING FAILED" + "!"*num_consec_failures end end STDOUT.flush sleep 0.2 end if num_consec_successes < 6 print (num_consec_successes == 1 ) ? "\n" : "\r" print "FRANK!".slice(0,num_consec_successes) STDOUT.flush puts '' end if num_consec_failures >= 5 puts '' end end unless frankly_is_accessibility_enabled raise "ACCESSIBILITY DOES NOT APPEAR TO BE ENABLED ON YOUR SIMULATOR. Hit the home button, go to settings, select Accessibility, and turn the inspector on." end end # Check whether Frank is able to communicate with the application under automation def frankly_ping frank_server.ping end #@api private #@return [Frank::Cucumber::Gateway] a gateway for sending Frank commands to the application under automation def frank_server @_frank_server ||= Frank::Cucumber::Gateway.new( base_server_url ) end end end end