lib/appium_lib/driver.rb in appium_lib-0.24.1 vs lib/appium_lib/driver.rb in appium_lib-1.0.0

- old
+ new

@@ -1,149 +1,155 @@ -# encoding: utf-8 -=begin -Based on simple_test.rb -https://github.com/appium/appium/blob/82995f47408530c80c3376f4e07a1f649d96ba22/sample-code/examples/ruby/simple_test.rb -https://github.com/appium/appium/blob/c58eeb66f2d6fa3b9a89d188a2e657cca7cb300f/LICENSE -=end - require 'rubygems' require 'ap' +require 'selenium-webdriver' +require 'nokogiri' -# Support OpenStruct in Awesome Print -# /awesome_print/lib/awesome_print/formatter.rb -# upstream issue: https://github.com/michaeldv/awesome_print/pull/36 -class AwesomePrint::Formatter - remove_const :CORE if defined?(CORE) - CORE = [ :array, :hash, :class, :file, :dir, :bigdecimal, :rational, :struct, :openstruct, :method, :unboundmethod ] +# patch ap +require_relative 'awesome_print/ostruct' - def awesome_openstruct target - awesome_hash target.marshal_dump +# common +require_relative 'common/helper' +require_relative 'common/patch' +require_relative 'common/version' +require_relative 'common/element/window' + +# ios +require_relative 'ios/helper' +require_relative 'ios/patch' + +require_relative 'ios/element/alert' +require_relative 'ios/element/button' +require_relative 'ios/element/generic' +require_relative 'ios/element/textfield' +require_relative 'ios/element/text' +require_relative 'ios/mobile_methods' + +# android +require_relative 'android/dynamic' +require_relative 'android/helper' +require_relative 'android/patch' +require_relative 'android/element/alert' +require_relative 'android/element/button' +require_relative 'android/element/generic' +require_relative 'android/element/textfield' +require_relative 'android/element/text' +require_relative 'android/mobile_methods' + +# device methods +require_relative 'device/device' +require_relative 'device/touch_actions' +require_relative 'device/multi_touch' + +# Fix uninitialized constant Minitest (NameError) +module Minitest + # Fix superclass mismatch for class Spec + class Runnable end + class Test < Runnable + end + class Spec < Test + end end -# Load appium.txt (toml format) into system ENV -# the basedir of this file + appium.txt is what's used -# @param opts [Hash] file: '/path/to/appium.txt', verbose: true -# @return [Array<String>] the require files. nil if require doesn't exist -def load_appium_txt opts - raise 'opts must be a hash' unless opts.kind_of? Hash - opts.each_pair { |k,v| opts[k.to_s.downcase.strip.intern] = v } - opts = {} if opts.nil? - file = opts.fetch :file, nil - raise 'Must pass file' unless file - verbose = opts.fetch :verbose, false - # Check for env vars in .txt - parent_dir = File.dirname file - toml = File.expand_path File.join parent_dir, 'appium.txt' - puts "appium.txt path: #{toml}" if verbose - # @private - def update data, *args - args.each do |name| - var = data[name] - ENV[name] = var if var - end - end +module Appium + # Load appium.txt (toml format) + # the basedir of this file + appium.txt is what's used + # + # ``` + # [caps] + # app = "path/to/app" + # + # [appium_lib] + # port = 8080 + # ``` + # + # :app is expanded + # :require is expanded + # all keys are converted to symbols + # + # @param opts [Hash] file: '/path/to/appium.txt', verbose: true + # @return [hash] the symbolized hash with updated :app and :require keys + def self.load_appium_txt opts={} + raise 'opts must be a hash' unless opts.kind_of? Hash + raise 'opts must not be empty' if opts.empty? - toml_exists = File.exists? toml - puts "Exists? #{toml_exists}" if verbose - data = nil + file = opts[:file] + raise 'Must pass file' unless file + verbose = opts.fetch :verbose, false - if toml_exists + parent_dir = File.dirname file + toml = File.expand_path File.join parent_dir, 'appium.txt' + puts "appium.txt path: #{toml}" if verbose + + toml_exists = File.exists? toml + puts "Exists? #{toml_exists}" if verbose + + raise "toml doesn't exist #{toml}" unless toml_exists require 'toml' puts "Loading #{toml}" if verbose - # bash requires A="OK" - # toml requires A = "OK" - # - # A="OK" => A = "OK" data = File.read toml - - data = data.split("\n").map do |line| - line.sub /([^\s])\=/, "\\1 = " - end.join "\n" - data = TOML::Parser.new(data).parsed + # TOML creates string keys. must symbolize + data = Appium::symbolize_keys data ap data unless data.empty? if verbose - update data, 'APP_PATH', 'APP_APK', 'APP_PACKAGE', - 'APP_ACTIVITY', 'APP_WAIT_ACTIVITY', - 'DEVICE' + if data && data[:caps] && data[:caps][:app] + data[:caps][:app] = Appium::Driver.absolute_app_path data[:caps][:app] + end - # ensure app path is resolved correctly from the context of the .txt file - ENV['APP_PATH'] = Appium::Driver.absolute_app_path ENV['APP_PATH'] - end + # return list of require files as an array + # nil if require doesn't exist + if data && data[:appium_lib] && data[:appium_lib][:require] + r = data[:appium_lib][:require] + r = r.kind_of?(Array) ? r : [r] + # ensure files are absolute + r.map! do |file| + file = File.exists?(file) ? file : + File.join(parent_dir, file) + file = File.expand_path file - # return list of require files as an array - # nil if require doesn't exist - if data && data['require'] - r = data['require'] - r = r.kind_of?(Array) ? r : [ r ] - # ensure files are absolute - r.map! do |file| - file = file.include?(File::Separator) ? file : - File.join(parent_dir, file) - file = File.expand_path file + File.exists?(file) ? file : nil + end + r.compact! # remove nils - File.exists?(file) ? file : nil - end - r.compact! # remove nils + files = [] - files = [] - - # now expand dirs - r.each do |item| - unless File.directory? item - # save file - files << item - next # only look inside folders + # now expand dirs + r.each do |item| + unless File.directory? item + # save file + files << item + next # only look inside folders + end + Dir.glob(File.expand_path(File.join(item, '**', '*.rb'))) do |file| + # do not add folders to the file list + files << File.expand_path(file) unless File.directory? file + end end - Dir.glob(File.join(item, '**/*.rb')) do |file| - # do not add folders to the file list - files << File.expand_path(file) unless File.directory? file - end + + # Must not sort files. File order is specified in appium.txt + data[:appium_lib][:require] = files end - files + data end -end -# Fix uninitialized constant Minitest (NameError) -module Minitest - # Fix superclass mismatch for class Spec - class Runnable; end - class Test < Runnable; end - class Spec < Test; end -end + # convert all keys (including nested) to symbols + # + # based on deep_symbolize_keys & deep_transform_keys from rails + # https://github.com/rails/docrails/blob/a3b1105ada3da64acfa3843b164b14b734456a50/activesupport/lib/active_support/core_ext/hash/keys.rb#L84 + def self.symbolize_keys hash + raise 'symbolize_keys requires a hash' unless hash.is_a? Hash + result = {} + hash.each do |key, value| + key = key.to_sym rescue key + result[key] = value.is_a?(Hash) ? symbolize_keys(value) : value + end + result + end -module Appium - add_to_path __FILE__ - - require 'selenium-webdriver' - - # common - require_relative 'common/helper' - require_relative 'common/patch' - require_relative 'common/version' - require_relative 'common/element/button' - require_relative 'common/element/text' - require_relative 'common/element/window' - - # ios - require_relative 'ios/helper' - require_relative 'ios/patch' - require_relative 'ios/element/alert' - require_relative 'ios/element/generic' - require_relative 'ios/element/textfield' - - # android - require_relative 'android/dynamic' - require_relative 'android/helper' - require_relative 'android/patch' - require_relative 'android/element/alert' - require_relative 'android/element/generic' - require_relative 'android/element/textfield' - def self.promote_singleton_appium_methods main_module raise 'Driver is nil' if $driver.nil? main_module.constants.each do |sub_module| #noinspection RubyResolve $driver.public_methods(false).each do |m| @@ -169,11 +175,10 @@ # To promote methods to all classes: # # ```ruby # Appium.promote_appium_methods Object # ``` - def self.promote_appium_methods class_array raise 'Driver is nil' if $driver.nil? # Wrap single class into an array class_array = [class_array] unless class_array.class == Array # Promote Appium driver methods to class instance methods. @@ -183,13 +188,13 @@ define_method m do |*args, &block| begin # Prefer existing method. # super will invoke method missing on driver super(*args, &block) - # minitest also defines a name method, - # so rescue argument error - # and call the name method on $driver + # minitest also defines a name method, + # so rescue argument error + # and call the name method on $driver rescue NoMethodError, ArgumentError $driver.send m, *args, &block if $driver.respond_to?(m) end end end @@ -199,118 +204,70 @@ end class Driver @@loaded = false - attr_reader :default_wait, :app_path, :app_name, :device, - :app_package, :app_activity, :app_wait_activity, - :sauce_username, :sauce_access_key, :port, :debug, - :export_session, :device_cap, :compress_xml, :custom_url + # attr readers are promoted to global scope. To avoid clobbering, they're + # made available via the driver_attributes method # The amount to sleep in seconds before every webdriver http call. attr_accessor :global_webdriver_http_sleep - # Creates a new driver. - # :device is :android, :ios, or :selendroid + + # Creates a new driver # # ```ruby - # # Options include: - # :app_path, :app_name, :app_package, :app_activity, - # :app_wait_activity, :sauce_username, :sauce_access_key, - # :port, :os, :debug - # # require 'rubygems' # require 'appium_lib' # + # # platformName takes a string or a symbol. + # # # Start iOS driver - # app = { device: :ios, app_path: '/path/to/MyiOS.app'} - # Appium::Driver.new(app).start_driver + # opts = { caps: { platformName: :ios, app: '/path/to/MyiOS.app' } } + # Appium::Driver.new(opts).start_driver # # # Start Android driver - # apk = { device: :android - # app_path: '/path/to/the.apk', - # app_package: 'com.example.pkg', - # app_activity: 'act.Start', - # app_wait_activity: 'act.Start' - # } - # + # opts = { caps: { platformName: :android, app: '/path/to/my.apk' } } # Appium::Driver.new(apk).start_driver # ``` # # @param opts [Object] A hash containing various options. # @return [Driver] def initialize opts={} # quit last driver $driver.driver_quit if $driver - opts = {} if opts.nil? - tmp_opts = {} + raise 'opts must be a hash' unless opts.kind_of? Hash - # convert to downcased symbols - opts.each_pair { |k,v| tmp_opts[k.to_s.downcase.strip.intern] = v } - opts = tmp_opts + opts = Appium::symbolize_keys opts - @raw_capabilities = opts.fetch(:raw, {}) + # default to {} to prevent nil.fetch and other nil errors + @caps = opts[:caps] || {} + appium_lib_opts = opts[:appium_lib] || {} - @custom_url = opts.fetch :server_url, false + # appium_lib specific values + @custom_url = appium_lib_opts.fetch :server_url, false + @export_session = appium_lib_opts.fetch :export_session, false + @default_wait = appium_lib_opts.fetch :wait, 30 + @last_waits = [@default_wait] + @sauce_username = appium_lib_opts.fetch :sauce_username, ENV['SAUCE_USERNAME'] + @sauce_access_key = appium_lib_opts.fetch :sauce_access_key, ENV['SAUCE_ACCESS_KEY'] + @port = appium_lib_opts.fetch :port, 4723 - @compress_xml = opts[:compress_xml] ? true : false - - @export_session = opts.fetch :export_session, false - - @default_wait = opts.fetch :wait, 30 - @last_waits = [@default_wait] - # Path to the .apk, .app or .app.zip. # The path can be local or remote for Sauce. - @app_path = opts.fetch :app_path, ENV['APP_PATH'] - raise 'APP_PATH must be set.' if @app_path.nil? + unless !@caps || @caps[:app].nil? || @caps[:app].empty? + @caps[:app] = self.class.absolute_app_path @caps[:app] + end - # The name to use for the test run on Sauce. - @app_name = opts.fetch :app_name, ENV['APP_NAME'] + # https://code.google.com/p/selenium/source/browse/spec-draft.md?repo=mobile + @device = @caps[:platformName] + @device = @device.is_a?(Symbol) ? @device : @device.downcase.strip.intern if @device + raise "platformName must be set. Not found in options: #{opts}" unless @device + raise 'platformName must be Android or iOS' unless [:android, :ios].include?(@device) - # Android app package - @app_package = opts.fetch :app_package, ENV['APP_PACKAGE'] - - # Android app starting activity. - @app_activity = opts.fetch :app_activity, ENV['APP_ACTIVITY'] - - # Android app waiting activity - @app_wait_activity = opts.fetch :app_wait_activity, ENV['APP_WAIT_ACTIVITY'] - - @android_coverage = opts.fetch :android_coverage, ENV['ANDROID_COVERAGE'] - # init to empty hash because it's merged later as an optional desired cap. - @android_coverage = @android_coverage ? { androidCoverage: @android_coverage} : {} - - # Sauce Username - @sauce_username = opts.fetch :sauce_username, ENV['SAUCE_USERNAME'] - - # Sauce Key - @sauce_access_key = opts.fetch :sauce_access_key, ENV['SAUCE_ACCESS_KEY'] - - @port = opts.fetch :port, ENV['PORT'] || 4723 - - # 'iPhone Simulator' - # 'iPad Simulator' - # 'Android' - # 'Selendroid' - # - # :ios, :android, :selendroid - @device = opts.fetch :device, ENV['DEVICE'] - raise 'Device must be set' unless @device - - @device_type = opts.fetch :device_type, 'tablet' - @device_orientation = opts.fetch :device_orientation, 'portrait' - - @full_reset = opts.fetch :full_reset, true - @no_reset = opts.fetch :no_reset, false - - # no_reset/full_reset are mutually exclusive - @no_reset = false if @full_reset - @full_reset = false if @no_reset - # load common methods extend Appium::Common - if @device.downcase == 'android' + if device_is_android? # load Android specific methods extend Appium::Android else # load iOS specific methods extend Appium::Ios @@ -319,97 +276,78 @@ # apply os specific patches patch_webdriver_element # enable debug patch # !!'constant' == true - @debug = opts.fetch :debug, !!defined?(Pry) + @debug = appium_lib_opts.fetch :debug, !!defined?(Pry) puts "Debug is: #{@debug}" if @debug ap opts unless opts.empty? puts "Device is: #{@device}" patch_webdriver_bridge end + # Save global reference to last created Appium driver for top level methods. $driver = self # Promote exactly once the first time the driver is created. # Subsequent drivers do not trigger promotion. unless @@loaded @@loaded = true + # load device methods exactly once + extend Appium::Device + # Promote only on Minitest::Spec (minitest 5) by default Appium.promote_appium_methods ::Minitest::Spec end self # return newly created driver - end # def initialize - - # Returns the status payload - # - # ```ruby - # {"status"=>0, - # "value"=> - # {"build"=> - # {"version"=>"0.8.2", - # "revision"=>"f2a2bc3782e4b0370d97a097d7e04913cf008995"}}, - # "sessionId"=>"8f4b34a7-a9a9-4ac5-b125-36258143446a"} - # ``` - # - # Discover the Appium rev running on the server. - # - # `status["value"]["build"]["revision"]` - # `f2a2bc3782e4b0370d97a097d7e04913cf008995` - # - # @return [JSON] - def status - driver.status.payload end - # Returns the server's version string - # @return [String] - def server_version - status['value']['build']['version'] - end + # Returns a hash of the driver attributes + def driver_attributes + attributes = { caps: @caps, + custom_url: @custom_url, + export_session: @export_session, + default_wait: @default_wait, + last_waits: @last_waits, + sauce_username: @sauce_username, + sauce_access_key: @sauce_access_key, + port: @port, + device: @device, + debug: @debug, + } - # @private - # WebDriver capabilities. Must be valid for Sauce to work. - # https://github.com/jlipps/appium/blob/master/app/android.js - def android_capabilities - { - compressXml: @compress_xml, - platform: 'Linux', - platformName: @device, - fullReset: @full_reset, - noReset: @no_reset, - :'device-type' => @device_type, - :'device-orientation' => @device_orientation, - name: @app_name || 'Ruby Console Android Appium', - :'app-package' => @app_package, - :'app-activity' => @app_activity, - :'app-wait-activity' => @app_wait_activity || @app_activity, - }.merge(@android_coverage).merge(@raw_capabilities) + # Return duplicates so attributes are immutable + attributes.each do |key, value| + attributes[key] = value.duplicable? ? value.dup : value + end + attributes end - # @private - # WebDriver capabilities. Must be valid for Sauce to work. - def ios_capabilities - { - platform: 'OS X 10.9', - platformName: @device, - name: @app_name || 'Ruby Console iOS Appium', - :'device-orientation' => @device_orientation - }.merge(@raw_capabilities) + def device_is_android? + @device == :android end - # @private - def capabilities - caps = @device.downcase === 'android' ? android_capabilities : ios_capabilities - caps[:app] = self.class.absolute_app_path(@app_path) unless @app_path.nil? || @app_path.empty? - caps + # Returns the server's version info + # + # ```ruby + # { + # "build" => { + # "version" => "0.18.1", + # "revision" => "d242ebcfd92046a974347ccc3a28f0e898595198" + # } + # } + # ``` + # + # @return [Hash] + def appium_server_version + driver.remote_status end - # Converts environment variable APP_PATH to an absolute path. + # Converts app_path to an absolute path. # @return [String] APP_PATH as an absolute path def self.absolute_app_path app_path raise 'APP_PATH not set!' if app_path.nil? || app_path.empty? # Sauce storage API. http://saucelabs.com/docs/rest#storage return app_path if app_path.start_with? 'sauce-storage:' @@ -429,11 +367,11 @@ app_path = File.expand_path app_path raise "App doesn't exist #{app_path}" unless File.exist? app_path app_path end - # Get the server url for sauce or local based on env vars. + # Get the server url # @return [String] the server url def server_url return @custom_url if @custom_url if !@sauce_username.nil? && !@sauce_access_key.nil? "http://#{@sauce_username}:#{@sauce_access_key}@ondemand.saucelabs.com:80/wd/hub" @@ -467,54 +405,46 @@ end # Quits the driver # @return [void] def driver_quit - # rescue NoSuchDriverError - begin; @driver.quit unless @driver.nil?; rescue; end + # rescue NoSuchDriverError or nil driver + @driver.quit rescue nil end # Creates a new global driver and quits the old one if it exists. # # @return [Selenium::WebDriver] the new global driver def start_driver - @client = @client || Selenium::WebDriver::Remote::Http::Default.new + @client = @client || Selenium::WebDriver::Remote::Http::Default.new @client.timeout = 999999 begin - @driver = Selenium::WebDriver.for :remote, http_client: @client, desired_capabilities: capabilities, url: server_url - # Load touch methods. Required for Selendroid. + @driver = Selenium::WebDriver.for :remote, http_client: @client, desired_capabilities: @caps, url: server_url + # Load touch methods. @driver.extend Selenium::WebDriver::DriverExtensions::HasTouchScreen # export session if @export_session - begin - File.open('/tmp/appium_lib_session', 'w') do |f| - f.puts @driver.session_id - end - rescue - end + File.open('/tmp/appium_lib_session', 'w') do |f| + f.puts @driver.session_id + end rescue nil end rescue Errno::ECONNREFUSED raise 'ERROR: Unable to connect to Appium. Is the server running?' end - # Set timeout to a large number so that Appium doesn't quit - # when no commands are entered after 60 seconds. - # broken on selendroid: https://github.com/appium/appium/issues/513 - mobile :setCommandTimeout, timeout: 9999 unless @device == 'Selendroid' - # Set implicit wait by default unless we're using Pry. @driver.manage.timeouts.implicit_wait = @default_wait unless defined? Pry @driver end # Set implicit wait and default_wait to zero. def no_wait - @last_waits = [@default_wait, 0] - @default_wait = 0 + @last_waits = [@default_wait, 0] + @default_wait = 0 @driver.manage.timeouts.implicit_wait = 0 end # Set implicit wait and default_wait to timeout, defaults to 30. # if set_wait is called without a param then the second to last @@ -535,11 +465,11 @@ # puts "timeout = @default_wait = #{@last_waits}" timeout = @default_wait = @last_waits.first else @default_wait = timeout # puts "last waits before: #{@last_waits}" - @last_waits = [@last_waits.last, @default_wait] + @last_waits = [@last_waits.last, @default_wait] # puts "last waits after: #{@last_waits}" end @driver.manage.timeouts.implicit_wait = timeout end @@ -567,11 +497,11 @@ # do not uset set_wait here. # it will cause problems with other methods reading the default_wait of 0 # which then gets converted to a 1 second wait. @driver.manage.timeouts.implicit_wait = pre_check # the element exists unless an error is raised. - exists = true + exists = true begin search_block.call # search for element rescue exists = false # error means it's not there @@ -589,29 +519,10 @@ # @return [Object] def execute_script script, *args @driver.execute_script script, *args end - # Helper method for mobile gestures - # - # https://github.com/appium/appium/wiki/Automating-mobile-gestures - # - # driver.execute_script 'mobile: swipe', endX: 100, endY: 100, duration: 0.01 - # - # becomes - # - # mobile :swipe, endX: 100, endY: 100, duration: 0.01 - # @param method [String, Symbol] the method to execute - # @param args [*args] the args to pass to the method - # @return [Object] - def mobile method, *args - raise 'Method must not be nil' if method.nil? - raise 'Method must have .to_s' unless method.respond_to? :to_s - - @driver.execute_script "mobile: #{method.to_s}", *args - end - # Calls @driver.find_elements # # @param args [*args] the args to use # @return [Array<Element>] Array is empty when no elements are found. def find_elements *args @@ -631,12 +542,12 @@ # @return [void] def x driver_quit exit # exit pry end - end # end class Driver -end # end module Appium + end # class Driver +end # module Appium # Paging in Pry is annoying :q required to exit. # With pager disabled, the output is similar to IRB # Only set if Pry is defined. -Pry.config.pager = false if defined?(Pry) \ No newline at end of file +Pry.config.pager = false if defined?(Pry)