require 'calabash-cucumber/core' require 'calabash-cucumber/tests_helpers' require 'calabash-cucumber/playback_helpers' require 'calabash-cucumber/environment_helpers' module Calabash module Cucumber module KeyboardHelpers include Calabash::Cucumber::TestsHelpers KEYPLANE_NAMES = { :small_letters => 'small-letters', :capital_letters => 'capital-letters', :numbers_and_punctuation => 'numbers-and-punctuation', :first_alternate => 'first-alternate', :numbers_and_punctuation_alternate => 'numbers-and-punctuation-alternate' } UIA_SUPPORTED_CHARS = { 'Delete' => '\b', 'Return' => '\n' # these are not supported yet and I am pretty sure that they # cannot be touched by passing an escaped character and instead # the must be found using UIAutomation calls. -jmoody #'Dictation' => nil, #'Shift' => nil, #'International' => nil, #'More' => nil, } # returns a query string for detecting a keyboard def _qstr_for_keyboard "view:'UIKBKeyplaneView'" end # returns +true+ if a +docked+ keyboard is visible. # # a +docked+ keyboard is pinned to the bottom of the view. # # keyboards on the iPhone and iPod are +docked+. def docked_keyboard_visible? res = query(_qstr_for_keyboard).first return false if res.nil? return true if device_family_iphone? # ipad rect = res['rect'] o = status_bar_orientation.to_sym case o when :left then rect['center_x'] == 592 and rect['center_y'] == 512 when :right then rect['center_x'] == 176 and rect['center_y'] == 512 when :up then rect['center_x'] == 384 and rect['center_y'] == 132 when :down then rect['center_x'] == 384 and rect['center_y'] == 892 else false end end # returns +true+ if an +undocked+ keyboard is visible. # # a +undocked+ keyboard is floats in the middle of the view # # returns +false+ if the device is not an iPad; all keyboards on the # iPhone and iPod are +docked+ def undocked_keyboard_visible? return false if device_family_iphone? res = query(_qstr_for_keyboard).first return false if res.nil? not docked_keyboard_visible? end # returns +true+ if a +split+ keyboard is visible. # # a +split+ keyboard is floats in the middle of the view and is split to # allow faster thumb typing # # returns +false+ if the device is not an iPad; all keyboards on the # iPhone and iPod are +docked+ def split_keyboard_visible? return false if device_family_iphone? query("view:'UIKBKeyView'").count > 0 and element_does_not_exist(_qstr_for_keyboard) end # returns true if there is a visible keyboard def keyboard_visible? docked_keyboard_visible? or undocked_keyboard_visible? or split_keyboard_visible? end # waits for a keyboard to appear and once it does appear waits for 0.3 # seconds # # raises an error if no keyboard appears def wait_for_keyboard(opts={}) default_opts = {:timeout_message => 'keyboard did not appear', :post_timeout => 0.3} opts = default_opts.merge(opts) wait_for(opts) do keyboard_visible? end end # DEPRECATED: Use wait_for_keyboard instead. def await_keyboard _deprecated('0.9.163', "use 'wait_for_keyboard' instead", :warn) wait_for_keyboard end # returns an array of possible ipad keyboard modes def _ipad_keyboard_modes [:docked, :undocked, :split] end # returns the keyboard +mode+ # # keyboard is pinned to bottom of the view #=> :docked # keyboard is floating in the middle of the view #=> :undocked # keyboard is floating and split #=> :split # no keyboard and :raise_on_no_visible_keyboard == +false+ #=> :unknown # # raises an error if the device is not an iPad # # raises an error if the :raise_on_no_visible_keyboard is +true+ # (default) and no keyboard is visible # # set :raise_on_no_visible_keyboard to +false+ to use in +wait+ # functions def ipad_keyboard_mode(opts = {}) raise 'the keyboard mode does not exist on the iphone or ipod' if device_family_iphone? default_opts = {:raise_on_no_visible_keyboard => true} opts = default_opts.merge(opts) if opts[:raise_on_no_visible_keyboard] screenshot_and_raise 'there is no visible keyboard' unless keyboard_visible? return :docked if docked_keyboard_visible? return :undocked if undocked_keyboard_visible? :split else return :docked if docked_keyboard_visible? return :undocked if undocked_keyboard_visible? return :split if split_keyboard_visible? :unknown end end # ensures that there is a keyboard to enter text # # IMPORTANT will always raise an error when the keyboard is split and # there is no run_loop i.e. +UIAutomation+ is not available # # the default options are # :screenshot +true+ raise with a screenshot # :skip +false+ skip any checking (a nop) - used when iterating over # keyplanes for keys def _ensure_can_enter_text(opts={}) default_opts = {:screenshot => true, :skip => false} opts = default_opts.merge(opts) return if opts[:skip] screenshot = opts[:screenshot] unless keyboard_visible? msg = 'no visible keyboard' if screenshot screenshot_and_raise msg else raise msg end end if split_keyboard_visible? and uia_not_available? msg = 'cannot type on a split keyboard without launching with Instruments' if screenshot screenshot_and_raise msg else raise msg end end end # use keyboard to enter +chr+ # # IMPORTANT: use the POST_ENTER_KEYBOARD environmental variable # to slow down the typing; adds a wait after each character is touched. # this can fix problems where the typing is too fast and characters are # skipped. # # there are several special 'characters', some of which do not appear on all # keyboards: # * 'Delete' # * 'Return' # # raises error if there is no visible keyboard or the keyboard is not # supported # # use the +should_screenshot+ to control whether or not to raise an error # if +chr+ is not found def keyboard_enter_char(chr, opts={}) unless opts.is_a?(Hash) msg = "you should no longer pass a boolean as the second arg; pass {:should_screenshot => '#{opts}'} hash instead" _deprecated('0.9.163', msg, :warn) opts = {:should_screenshot => opts} end default_opts = {:should_screenshot => true, # introduce a small wait to avoid skipping characters # keep this as short as possible :wait_after_char => (ENV['POST_ENTER_KEYBOARD'] || 0.05).to_f} opts = default_opts.merge(opts) should_screenshot = opts[:should_screenshot] _ensure_can_enter_text({:screenshot => should_screenshot, :skip => (not should_screenshot)}) if uia_available? if chr.length == 1 uia_type_string chr else code = UIA_SUPPORTED_CHARS[chr] unless code raise "typing character '#{chr}' is not yet supported when running with Instruments" end # on iOS 6, the Delete char code is _not_ \b # on iOS 7, the Delete char code is \b on non-numeric keyboards # on numeric keyboards, it is actually a button on the # keyboard and not a key if code.eql?(UIA_SUPPORTED_CHARS['Delete']) uia("uia.keyboard().elements().firstWithName('Delete').tap()") elsif code.eql?(UIA_SUPPORTED_CHARS['Return']) tap_keyboard_action_key else uia_type_string(code, '') end end res = {'results' => []} else res = http({:method => :post, :path => 'keyboard'}, {:key => chr, :events => load_playback_data('touch_done')}) res = JSON.parse(res) if res['outcome'] != 'SUCCESS' msg = "Keyboard enter failed failed because: #{res['reason']}\n#{res['details']}" if should_screenshot screenshot_and_raise msg else raise msg end end end if ENV['POST_ENTER_KEYBOARD'] w = ENV['POST_ENTER_KEYBOARD'].to_f if w > 0 sleep(w) end end pause = opts[:wait_after_char] sleep(pause) if pause > 0 res['results'] end # uses the keyboard to enter +text+ # # raises an error if the text cannot be entered def keyboard_enter_text(text) _ensure_can_enter_text if uia_available? text_before = _text_from_first_responder() uia_type_string(text, text_before) else text.each_char do |ch| begin keyboard_enter_char(ch, {:should_screenshot => false}) rescue _search_keyplanes_and_enter_char(ch) end end end end # touches the keyboard +action+ key # # the +action+ key depends on the keyboard. some examples include: # * Return # * Next # * Go # * Join # * Search # # not all keyboards have an +action+ key # raises an error if the key cannot be entered def tap_keyboard_action_key if uia_available? uia_type_string '\n', '', false else keyboard_enter_char 'Return' end end # touches the keyboard +action+ key # # the +action+ key depends on the keyboard. # # some examples include: # * Return # * Next # * Go # * Join # * Search # # not all keyboards have an +action+ key # raises an error if the key cannot be entered def done tap_keyboard_action_key end # returns the current keyplane def _current_keyplane kp_arr = _do_keyplane( lambda { query("view:'UIKBKeyplaneView'", 'keyplane', 'componentName') }, lambda { query("view:'UIKBKeyplaneView'", 'keyplane', 'name') }) kp_arr.first.downcase end # searches the available keyplanes for +chr+ and if it is found, types it # # this is a recursive function # # IMPORTANT: use the KEYPLANE_SEARCH_STEP_PAUSE variable to # control how quickly the next keyplane is searched. increase this value # if you encounter problems with missed keystrokes. # # raises an error if the +chr+ cannot be found def _search_keyplanes_and_enter_char(chr, visited=Set.new) cur_kp = _current_keyplane begin keyboard_enter_char(chr, {:should_screenshot => false}) return true #found rescue pause = (ENV['KEYPLANE_SEARCH_STEP_PAUSE'] || 0.2).to_f sleep (pause) if pause > 0 visited.add(cur_kp) #figure out keyplane alternates props = _do_keyplane( lambda { query("view:'UIKBKeyplaneView'", 'keyplane', 'properties') }, lambda { query("view:'UIKBKeyplaneView'", 'keyplane', 'attributes', 'dict') } ).first known = KEYPLANE_NAMES.values found = false keyplane_selection_keys = ['shift', 'more'] keyplane_selection_keys.each do |key| sleep (pause) if pause > 0 plane = props["#{key}-alternate"] if known.member?(plane) and (not visited.member?(plane)) keyboard_enter_char(key.capitalize, {:should_screenshot => false}) found = _search_keyplanes_and_enter_char(chr, visited) return true if found #not found => try with other keyplane selection key keyplane_selection_keys.delete(key) other_key = keyplane_selection_keys.last keyboard_enter_char(other_key.capitalize, {:should_screenshot => false}) found = _search_keyplanes_and_enter_char(chr, visited) return true if found end end return false end end # process a keyplane # # raises an error if there is not visible keyplane def _do_keyplane(kbtree_proc, keyplane_proc) desc = query("view:'UIKBKeyplaneView'", 'keyplane') fail('No keyplane (UIKBKeyplaneView keyplane)') if desc.empty? fail('Several keyplanes (UIKBKeyplaneView keyplane)') if desc.count > 1 kp_desc = desc.first if /^ "#{_query_uia_hide_keyboard_button}.tap()"}) else touch(_query_for_keyboard_mode_key) end opts = {:timeout_message => 'keyboard did not disappear'} wait_for(opts) do not keyboard_visible? end end # returns the activation point of the iPad keyboard +mode+ key. # # the +mode+ key is also known as the Hide keyboard key. # # raises an error when # * the device is not an iPad # * the app was not launched with instruments i.e. there is no run_loop def _point_for_ipad_keyboard_mode_key raise 'the keyboard mode does not exist on the on the iphone' if device_family_iphone? raise 'cannot detect keyboard mode key without launching with instruments' unless uia_available? res = send_uia_command({:command => "#{_query_uia_hide_keyboard_button}.rect()"}) origin = res['value']['origin'] {:x => origin['x'], :y => origin['y']} # this did not work. #size = res['value']['size'] #{:x => (origin['x'] + (size['width']/2)), :y => (origin['y'] + (size['height']/2))} end # returns a query string for touching one of the options that appears when # the iPad +mode+ key is touched and held. # # the +mode+ key is also know as the Hide keyboard key. # # valid arguments are: # top_or_bottom :top | :bottom # mode :docked | :undocked | :skipped # # use _point_for_keyboard_mode_key if there is a run_loop # available # # raises an error when # * the device is not an iPad # * the app was launched with Instruments i.e. there is a run_loop # * it is passed invalid arguments def _query_for_touch_for_keyboard_mode_option(top_or_bottom, mode) raise 'the keyboard mode does not exist on the iphone' if device_family_iphone? if uia_available? raise "UIA is available, use '_point_for_keyboard_mode_key' instead" end valid = [:top, :bottom] unless valid.include? top_or_bottom raise "expected '#{top_or_bottom}' to be one of '#{valid}'" end valid = [:split, :undocked, :docked] unless valid.include? mode raise "expected '#{mode}' to be one of '#{valid}'" end hash = {:split => {:top => 'Merge', :bottom => 'Dock and Merge'}, :undocked => {:top => 'Dock', :bottom => 'Split'}, :docked => {:top => 'Undock', :bottom => 'Split'}} mark = hash[mode][top_or_bottom] "label marked:'#{mark}'" end # returns a query for touching the iPad keyboard +mode+ key. # # the +mode+ key is also know as the Hide keyboard key. # # use _point_for_keyboard_mode_key if there is a run_loop # available # # raises an error when # * the device is not an iPad # * the app was launched with Instruments i.e. there is a run_loop def _query_for_keyboard_mode_key raise 'cannot detect keyboard mode key on iphone' if device_family_iphone? if uia_available? raise "UIA is available, use '_point_for_keyboard_mode_key' instead" end qstr = "view:'UIKBKeyView'" idx = query(qstr).count - 1 "#{qstr} index:#{idx}" end # touches the bottom option on the popup dialog that is presented when the # the iPad keyboard +mode+ key is touched and held. # # the +mode+ key is also know as the Hide keyboard key. # # the +mode+ key allows the user to undock, dock, or split the keyboard. def _touch_bottom_keyboard_mode_row mode = ipad_keyboard_mode if uia_available? start_pt = _point_for_ipad_keyboard_mode_key # there are 10 pt btw the key and the popup and the row is 50 pt y_offset = 10 + 25 end_pt = {:x => (start_pt[:x] - 40), :y => (start_pt[:y] - y_offset)} uia_pan_offset(start_pt, end_pt, {}) else pan(_query_for_keyboard_mode_key, nil, {:duration => 1.0}) touch(_query_for_touch_for_keyboard_mode_option(:bottom, mode)) sleep(0.5) end 2.times { sleep(0.5) } end # touches the top option on the popup dialog that is presented when the # the iPad keyboard +mode+ key is touched and held. # # the +mode+ key is also know as the Hide keyboard key. # # the +mode+ key allows the user to undock, dock, or split the keyboard. def _touch_top_keyboard_mode_row mode = ipad_keyboard_mode if uia_available? start_pt = _point_for_ipad_keyboard_mode_key # there are 10 pt btw the key and the popup and each row is 50 pt # NB: no amount of offsetting seems to allow touching the top row # when the keyboard is split y_offset = 10 + 50 + 25 end_pt = {:x => (start_pt[:x] - 40), :y => (start_pt[:y] - y_offset)} uia_pan_offset(start_pt, end_pt, {:duration => 1.0}) else pan(_query_for_keyboard_mode_key, nil, {}) touch(_query_for_touch_for_keyboard_mode_option(:top, mode)) sleep(0.5) end 2.times { sleep(0.5) } end # ensures that the iPad keyboard is +docked+ # # +docked+ means the keyboard is pinned to bottom of the view # # if the device is not an iPad, this is behaves like a call to # wait_for_keyboard # # raises an error when # * there is no visible keyboard or # * the +docked+ keyboard cannot be achieved def ensure_docked_keyboard wait_for_keyboard return if device_family_iphone? mode = ipad_keyboard_mode case mode when :split then _touch_bottom_keyboard_mode_row when :undocked then _touch_top_keyboard_mode_row when :docked then # already docked else screenshot_and_raise "expected '#{mode}' to be one of #{_ipad_keyboard_modes}" end begin wait_for({:post_timeout => 1.0}) do docked_keyboard_visible? end rescue mode = ipad_keyboard_mode o = status_bar_orientation screenshot_and_raise "expected keyboard to be ':docked' but found '#{mode}' in orientation '#{o}'" end end # ensures that the iPad keyboard is +undocked+ # # +undocked+ means the keyboard is floating in the middle of the view # # if the device is not an iPad, this is behaves like a call to # wait_for_keyboard # # raises an error when # * there is no visible keyboard or # * the an +undocked+ keyboard cannot be achieved def ensure_undocked_keyboard wait_for_keyboard() return if device_family_iphone? mode = ipad_keyboard_mode case mode when :split then # keep these condition separate because even though they do the same # thing, the else condition is a hack if ios5? # iOS 5 has no 'Merge' feature in split keyboard, so dock first then # undock from docked mode _touch_bottom_keyboard_mode_row _wait_for_keyboard_in_mode(:docked) else # in iOS > 5, it seems to be impossible consistently touch the # the top keyboard mode popup button, so we punt _touch_bottom_keyboard_mode_row _wait_for_keyboard_in_mode(:docked) end _touch_top_keyboard_mode_row when :undocked then # already undocked when :docked then _touch_top_keyboard_mode_row else screenshot_and_raise "expected '#{mode}' to be one of #{_ipad_keyboard_modes}" end _wait_for_keyboard_in_mode(:undocked) end # ensures that the iPad keyboard is +split+ # # +split+ means the keyboard is floating in the middle of the view and is # split into two sections to enable faster thumb typing. # # if the device is not an iPad, this is behaves like a call to # wait_for_keyboard # # raises an error when # * there is no visible keyboard or # * the an +undocked+ keyboard cannot be achieved def ensure_split_keyboard wait_for_keyboard return if device_family_iphone? mode = ipad_keyboard_mode case mode when :split then # already split when :undocked then _touch_bottom_keyboard_mode_row when :docked then _touch_bottom_keyboard_mode_row else screenshot_and_raise "expected '#{mode}' to be one of #{_ipad_keyboard_modes}" end _wait_for_keyboard_in_mode(:split) end def _wait_for_keyboard_in_mode(mode, opts={}) default_opts = {:post_timeout => 1.0} opts = default_opts.merge(opts) begin wait_for(opts) do case mode when :split then split_keyboard_visible? when :undocked undocked_keyboard_visible? when :docked docked_keyboard_visible? else screenshot_and_raise "expected '#{mode}' to be one of #{_ipad_keyboard_modes}" end end rescue actual = ipad_keyboard_mode o = status_bar_orientation screenshot_and_raise "expected keyboard to be '#{mode}' but found '#{actual}' in orientation '#{o}'" end end # used for detecting keyboards that are not normally visible to calabash # e.g. the keyboard on +'z'+ # # IMPORTANT this should only be used when the app does not respond to # keyboard_visible? # # raises an error if the there is no run_loop def uia_keyboard_visible? unless uia_available? screenshot_and_raise 'only available if there is a run_loop i.e. the app was launched with Instruments' end res = uia_query_windows(:keyboard) not res.eql?(':nil') end # waits for a keyboard that is not normally visible to calabash # e.g. the keyboard on +MFMailComposeViewController+ # # IMPORTANT this should only be used when the app does not respond to # keyboard_visible? # # raises an error if the there is no run_loop def uia_wait_for_keyboard(opts={}) unless uia_available? screenshot_and_raise 'only available if there is a run_loop i.e. the app was launched with Instruments' end default_opts = {:timeout => 10, :retry_frequency => 0.1, :post_timeout => 0.5} opts = default_opts.merge(opts) unless opts[:timeout_message] msg = "waited for '#{opts[:timeout]}' for keyboard" opts[:timeout_message] = msg end wait_for(opts) do uia_keyboard_visible? end end private # returns the the text in the first responder # # the first responder will be the +UITextField+ or +UITextView+ instance # that is associated with the visible keyboard. # # returns +nil+ if there no +textField+ or +testView+ is found to be # the first responder. it is extremely unlikely that this will happen. # # raises an exception if there is no visible keyboard def _text_from_first_responder raise 'there must be a visible keyboard' unless keyboard_visible? ['textField', 'textView'].each do |ui_class| res = query("#{ui_class} isFirstResponder:1", :text) return res.first unless res.empty? end #noinspection RubyUnnecessaryReturnStatement return nil end end end end