lib/calabash-cucumber/keyboard_helpers.rb in calabash-cucumber-0.9.163.pre11 vs lib/calabash-cucumber/keyboard_helpers.rb in calabash-cucumber-0.9.163

- old
+ new

@@ -1,14 +1,14 @@ 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 - include Calabash::Cucumber::PlaybackHelpers KEYPLANE_NAMES = { :small_letters => 'small-letters', :capital_letters => 'capital-letters', :numbers_and_punctuation => 'numbers-and-punctuation', @@ -16,35 +16,232 @@ :numbers_and_punctuation_alternate => 'numbers-and-punctuation-alternate' } UIA_SUPPORTED_CHARS = { - 'Dictation' => nil, - 'Shift' => nil, - 'Delete' => '\b', - 'International' => nil, - 'More' => nil, - 'Return' => '\n' + '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, } - #Possible values - # 'Dictation' - # 'Shift' - # 'Delete' - # 'International' - # 'More' - # 'Return' - def keyboard_enter_char(chr, should_screenshot=true) - #map(nil, :keyboard, load_playback_data("touch_done"), chr) - if uia? + + + # 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 + + # <b>DEPRECATED:</b> Use <tt>wait_for_keyboard</tt> 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 <tt>:raise_on_no_visible_keyboard</tt> is +true+ + # (default) and no keyboard is visible + # + # set <tt>:raise_on_no_visible_keyboard</tt> 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 <tt>run_loop</tt> 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 <tt>POST_ENTER_KEYBOARD</tt> 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] - if code - uia_type_string code + + unless code + raise "Char #{chr} is not yet supported in when typing with Instruments" + end + + # on iOS 6, the char code is _not_ \b + # + # as an aside, on iOS 7, the same approach (tap the 'Delete' key) also works + if ios6? and code.eql?(UIA_SUPPORTED_CHARS['Delete']) + uia("uia.keyboard().keys().firstWithName('Delete').tap()") else - raise "Char #{chr} is not yet supported in iOS7" + uia_type_string(code) end end res = {'results' => []} else res = http({:method => :post, :path => 'keyboard'}, @@ -64,108 +261,472 @@ 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 - def done - if uia? + # 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? + uia_type_string(text) + 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' 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 - - def current_keyplane + # returns the current keyplane + def _current_keyplane kp_arr = _do_keyplane( - lambda { query("view:'UIKBKeyplaneView'", "keyplane", "componentName") }, - lambda { query("view:'UIKBKeyplaneView'", "keyplane", "name") }) + lambda { query("view:'UIKBKeyplaneView'", 'keyplane', 'componentName') }, + lambda { query("view:'UIKBKeyplaneView'", 'keyplane', 'name') }) kp_arr.first.downcase end - def search_keyplanes_and_enter_char(chr, visited=Set.new) - cur_kp = current_keyplane + # searches the available keyplanes for +chr+ and if it is found, types it + # + # this is a recursive function + # + # IMPORTANT: use the <tt>KEYPLANE_SEARCH_STEP_PAUSE</tt> 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, false) + 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") } + 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 = ['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, false) - found = search_keyplanes_and_enter_char(chr, visited) + 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, false) - found = search_keyplanes_and_enter_char(chr, visited) + 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 - def await_keyboard - #deprecated inconsistent wait naming - # use wait_for_keyboard - wait_for_keyboard + # 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 /^<UIKBTree/.match(kp_desc) + #ios5+ + kbtree_proc.call + elsif /^<UIKBKeyplane/.match(kp_desc) + #ios4 + keyplane_proc.call + end end - #noinspection RubyLiteralArrayInspection - def wait_for_keyboard - wait_for_elements_exist(["view:'UIKBKeyplaneView'"], :timeout_message => 'No visible keyboard') - sleep(0.5) + # returns a query string for finding the iPad 'Hide keyboard' button + def _query_uia_hide_keyboard_button + "uia.keyboard().buttons()['Hide keyboard']" end - def keyboard_enter_text(text) - wait_for_keyboard if element_does_not_exist("view:'UIKBKeyplaneView'") + # dismisses a iPad keyboard by touching the 'Hide keyboard' button and waits + # for the keyboard to disappear + # + # raises an error if the device is not an iPad. the dismiss keyboard + # key does not exist on the iPhone or iPod + def dismiss_ipad_keyboard + screenshot_and_raise 'cannot dismiss keyboard on iphone' if device_family_iphone? - if uia? - uia_type_string(text) + if uia_available? + send_uia_command({:command => "#{_query_uia_hide_keyboard_button}.tap()"}) else - text.each_char do |ch| - begin - keyboard_enter_char(ch, false) - rescue - search_keyplanes_and_enter_char(ch) - end + 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 <tt>Hide keyboard</tt> key. + # + # raises an error when + # * the device is not an iPad + # * the app was not launched with instruments i.e. there is no <tt>run_loop</tt> + 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 <tt>Hide keyboard</tt> key. + # + # valid arguments are: + # top_or_bottom :top | :bottom + # mode :docked | :undocked | :skipped + # + # use <tt>_point_for_keyboard_mode_key</tt> if there is a <tt>run_loop</tt> + # available + # + # raises an error when + # * the device is not an iPad + # * the app was launched with Instruments i.e. there is a <tt>run_loop</tt> + # * 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 <tt>Hide keyboard</tt> key. + # + # use <tt>_point_for_keyboard_mode_key</tt> if there is a <tt>run_loop</tt> + # available + # + # raises an error when + # * the device is not an iPad + # * the app was launched with Instruments i.e. there is a <tt>run_loop</tt> + 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 <tt>Hide keyboard</tt> 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 <tt>Hide keyboard</tt> 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 + # <tt>wait_for_keyboard</tt> + # + # 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 + # <tt>wait_for_keyboard</tt> + # + # 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 - 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 /^<UIKBTree/.match(kp_desc) - #ios5+ - kbtree_proc.call - elsif /^<UIKBKeyplane/.match(kp_desc) - #ios4 - keyplane_proc.call + # 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 + # <tt>wait_for_keyboard</tt> + # + # 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 + # <tt>keyboard_visible?</tt> + # + # raises an error if the there is no <tt>run_loop</tt> + 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 + # TODO refactor keyboard detection to use uia() function conventions (instead of UIATarget...) + res = uia('UIATarget.localTarget().frontMostApp().keyboard()')['value'] + 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 + # <tt>keyboard_visible?</tt> + # + # raises an error if the there is no <tt>run_loop</tt> + 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 end end