require 'rubygems' require 'ap' require 'selenium-webdriver' require 'nokogiri' # patch ap require_relative 'awesome_print/ostruct' # 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 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? file = opts[:file] raise 'Must pass file' unless file verbose = opts.fetch :verbose, false 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 data = File.read toml data = TOML::Parser.new(data).parsed # TOML creates string keys. must symbolize data = Appium::symbolize_keys data ap data unless data.empty? if verbose if data && data[:caps] && data[:caps][:app] data[:caps][:app] = Appium::Driver.absolute_app_path data[:caps][:app] 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 File.exists?(file) ? file : nil end r.compact! # remove nils files = [] # 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 # Must not sort files. File order is specified in appium.txt data[:appium_lib][:require] = files end data 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 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| const = main_module.const_get(sub_module) const.send(:define_singleton_method, m) do |*args, &block| begin super(*args, &block) # promote.rb rescue NoMethodError, ArgumentError $driver.send m, *args, &block if $driver.respond_to?(m) end # override unless there's an existing method with matching arity end unless const.respond_to?(m) && const.method(m).arity == $driver.method(m).arity end end end ## # Promote appium methods to class instance methods # # @param class_array [Array] An array of classes # # 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. class_array.each do |klass| $driver.public_methods(false).each do |m| klass.class_eval do 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 rescue NoMethodError, ArgumentError $driver.send m, *args, &block if $driver.respond_to?(m) end end end end end nil # return nil end class Driver @@loaded = false # 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 # # ```ruby # require 'rubygems' # require 'appium_lib' # # # platformName takes a string or a symbol. # # # Start iOS driver # opts = { caps: { platformName: :ios, app: '/path/to/MyiOS.app' } } # Appium::Driver.new(opts).start_driver # # # Start Android driver # 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 raise 'opts must be a hash' unless opts.kind_of? Hash opts = Appium::symbolize_keys opts # default to {} to prevent nil.fetch and other nil errors @caps = opts[:caps] || {} appium_lib_opts = opts[:appium_lib] || {} # 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 # Path to the .apk, .app or .app.zip. # The path can be local or remote for Sauce. unless !@caps || @caps[:app].nil? || @caps[:app].empty? @caps[:app] = self.class.absolute_app_path @caps[:app] end # 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) # load common methods extend Appium::Common if device_is_android? # load Android specific methods extend Appium::Android else # load iOS specific methods extend Appium::Ios end # apply os specific patches patch_webdriver_element # enable debug patch # !!'constant' == true @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 # 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, } # Return duplicates so attributes are immutable attributes.each do |key, value| attributes[key] = value.duplicable? ? value.dup : value end attributes end def device_is_android? @device == :android end # 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 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:' return app_path if app_path.match(/^http/) # public URL for Sauce if app_path.match(/^(\/|[a-zA-Z]:)/) # absolute file path app_path = File.expand_path app_path unless File.exist? app_path raise "App doesn't exist. #{app_path}" unless File.exist? app_path return app_path end # if it doesn't contain a slash then it's a bundle id return app_path unless app_path.match(/[\/\\]/) # relative path that must be expanded. # absolute_app_path is called from load_appium_txt # and the txt file path is the base of the app path in that case. 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 # @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" else "http://127.0.0.1:#{@port}/wd/hub" end end # Restarts the driver # @return [Driver] the driver def restart driver_quit start_driver end # Returns the driver # @return [Driver] the driver def driver @driver end # Takes a png screenshot and saves to the target path. # # Example: screenshot '/tmp/hi.png' # # @param png_save_path [String] the full path to save the png # @return [nil] def screenshot png_save_path @driver.save_screenshot png_save_path nil end # Quits the driver # @return [void] def driver_quit # 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.timeout = 999999 begin @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 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 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 @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 # wait will be used. # # ```ruby` # set_wait 2 # set_wait 3 # set_wait # 2 # # ```` # # @param timeout [Integer] the timeout in seconds # @return [void] def set_wait timeout=nil if timeout.nil? # puts "timeout = @default_wait = @last_wait" # 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] # puts "last waits after: #{@last_waits}" end @driver.manage.timeouts.implicit_wait = timeout end # Returns the default client side wait. # This value is independent of what the server is using # @return [Integer] def default_wait @default_wait end # Returns existence of element. # # Example: # # exists { button('sign in') } ? puts('true') : puts('false') # # @param pre_check [Integer] the amount in seconds to set the # wait to before checking existance # @param post_check [Integer] the amount in seconds to set the # wait to after checking existance # @param search_block [Block] the block to call # @return [Boolean] def exists pre_check=0, post_check=@default_wait, &search_block # 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 begin search_block.call # search for element rescue exists = false # error means it's not there end # restore wait @driver.manage.timeouts.implicit_wait = post_check if post_check != pre_check exists end # The same as @driver.execute_script # @param script [String] the script to execute # @param args [*args] the args to pass to the script # @return [Object] def execute_script script, *args @driver.execute_script script, *args end # Calls @driver.find_elements # # @param args [*args] the args to use # @return [Array] Array is empty when no elements are found. def find_elements *args @driver.find_elements *args end # Calls @driver.find_elements # # @param args [*args] the args to use # @return [Element] def find_element *args @driver.find_element *args end # Quit the driver and Pry. # quit and exit are reserved by Pry. # @return [void] def x driver_quit exit # exit pry end 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)