# frozen_string_literal: true
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require 'uri'
module Appium
module Core
module Android
autoload :Uiautomator1, 'appium_lib_core/android'
autoload :Uiautomator2, 'appium_lib_core/android_uiautomator2'
autoload :Espresso, 'appium_lib_core/android_espresso'
end
module Ios
autoload :Uiautomation, 'appium_lib_core/ios'
autoload :Xcuitest, 'appium_lib_core/ios_xcuitest'
end
autoload :Mac2, 'appium_lib_core/mac2'
autoload :Windows, 'appium_lib_core/windows'
# This options affects only client side as :appium_lib
key.
# Read {::Appium::Core::Driver} about each attribute
class Options
attr_reader :custom_url, :default_wait, :export_session, :export_session_path,
:port, :wait_timeout, :wait_interval, :listener,
:direct_connect, :enable_idempotency_header
def initialize(appium_lib_opts)
@custom_url = appium_lib_opts.fetch :server_url, nil
@default_wait = appium_lib_opts.fetch :wait, nil
@enable_idempotency_header = appium_lib_opts.fetch :enable_idempotency_header, true
# bump current session id into a particular file
@export_session = appium_lib_opts.fetch :export_session, false
@export_session_path = appium_lib_opts.fetch :export_session_path, default_tmp_appium_lib_session
@direct_connect = appium_lib_opts.fetch :direct_connect, true
@port = appium_lib_opts.fetch :port, Driver::DEFAULT_APPIUM_PORT
# timeout and interval used in ::Appium::Commn.wait/wait_true
@wait_timeout = appium_lib_opts.fetch :wait_timeout, ::Appium::Core::Wait::DEFAULT_TIMEOUT
@wait_interval = appium_lib_opts.fetch :wait_interval, ::Appium::Core::Wait::DEFAULT_INTERVAL
# to pass it in Selenium.new.
# 'listener = opts.delete(:listener)' is called in Selenium::Driver.new
@listener = appium_lib_opts.fetch :listener, nil
end
private
def default_tmp_appium_lib_session
::Appium::Core::Base.platform.windows? ? 'C:\\\\Windows\\Temp\\appium_lib_session' : '/tmp/appium_lib_session'
end
end
# DirectConnections has capabilities of directConnect
class DirectConnections
KEYS = {
protocol: 'directConnectProtocol',
host: 'directConnectHost',
port: 'directConnectPort',
path: 'directConnectPath'
}.freeze
W3C_KEYS = {
protocol: 'appium:directConnectProtocol',
host: 'appium:directConnectHost',
port: 'appium:directConnectPort',
path: 'appium:directConnectPath'
}.freeze
# @return [string] Returns a protocol such as http/https
attr_reader :protocol
# @return [string] Returns a host name such as io.appium
attr_reader :host
# @return [integer] Returns a port number such as 443
attr_reader :port
# @return [string] Returns a path for webdriver such as /hub/wd
attr_reader :path
def initialize(capabilities)
@protocol = capabilities[W3C_KEYS[:protocol]] || capabilities[KEYS[:protocol]]
@host = capabilities[W3C_KEYS[:host]] || capabilities[KEYS[:host]]
@port = capabilities[W3C_KEYS[:port]] || capabilities[KEYS[:port]]
@path = capabilities[W3C_KEYS[:path]] || capabilities[KEYS[:path]]
end
end
class Driver
include Waitable
# Selenium webdriver capabilities
# @return [Core::Base::Capabilities]
attr_reader :caps
# Return http client called in start_driver()
# @return [Appium::Core::Base::Http::Default] the http client
attr_reader :http_client
# Return if adding 'x-idempotency-key' header is enabled for each new session request.
# Following commands should not have the key.
# The key is unique for each http client instance. Defaults to true
# https://github.com/appium/appium-base-driver/pull/400
# @return [Bool]
attr_reader :enable_idempotency_header
# Device type to request from the appium server
# @return [Symbol] :android and :ios, for example
attr_reader :device
# Automation name sent to appium server or received by server.
# If automation_name is nil
, it is not set both client side and server side.
# @return [Hash]
attr_reader :automation_name
# Custom URL for the selenium server. If set this attribute, ruby_lib_core try to handshake to the custom url.
# Defaults to false. Then try to connect to http://127.0.0.1:#{port}/wd/hub
.
# @return [String]
attr_reader :custom_url
# Export session id to textfile in /tmp for 3rd party tools. False by default.
# @return [Boolean]
attr_reader :export_session
# @return [String] By default, session id is exported in '/tmp/appium_lib_session'
attr_reader :export_session_path
# Default wait time for elements to appear in Appium server side.
# Provide { appium_lib: { wait: 30 } }
to {::Appium::Core.for}
# @return [Integer]
attr_reader :default_wait
# Appium's server port. 4723 is by default. Defaults to {::Appium::Core::Driver::DEFAULT_APPIUM_PORT}.
# Provide { appium_lib: { port: 8080 } }
to {::Appium::Core.for}.
# :custom_url
is prior than :port
if :custom_url
is set.
# @return [Integer]
attr_reader :port
DEFAULT_APPIUM_PORT = 4723
# Return a time wait timeout. 30 seconds is by default {::Appium::Core::Wait::DEFAULT_TIMEOUT}.
# Wait time for {::Appium::Core::Base::Wait}, wait and wait_true.
# Provide { appium_lib: { wait_timeout: 20 } }
to {::Appium::Core.for}.
# @return [Integer]
attr_reader :wait_timeout
# Return a time to wait interval. 0.5 seconds is by default {::Appium::Core::Wait::DEFAULT_INTERVAL}.
# Wait interval time for {::Appium::Core::Base::Wait}, wait and wait_true.
# Provide { appium_lib: { wait_interval: 0.1 } }
to {::Appium::Core.for}.
# @return [Integer]
attr_reader :wait_interval
# instance of AbstractEventListener for logging support
# Nil by default
attr_reader :listener
# @return [Appium::Core::Base::Driver]
attr_reader :driver
# [Experimental feature]
# Enable an experimental feature updating Http client endpoint following below keys by Appium/Selenium server.
# This works with {Appium::Core::Base::Http::Default}.
#
# If your Selenium/Appium server decorates the new session capabilities response with the following keys:
# - directConnectProtocol
# - directConnectHost
# - directConnectPort
# - directConnectPath
#
# ignore them if this parameter is false
. Defaults to true.
# These keys can have appium:
prefix.
#
# @return [Bool]
attr_reader :direct_connect
# Creates a new driver and extend particular methods
# @param [Hash] opts A options include capabilities for the Appium Server and for the client.
# @option opts [Hash] :caps Appium capabilities.
# @option opts [Hash] :capabilities The same as :caps.
# This param is for compatibility with Selenium WebDriver format
# @option opts [Appium::Core::Options] :appium_lib Capabilities affect only ruby client
# @option opts [String] :url The same as :custom_url in :appium_lib.
# This param is for compatibility with Selenium WebDriver format
#
# @return [Driver]
#
# @example
#
# # format 1
# @core = Appium::Core.for caps: {...}, appium_lib: {...}
# # format 2. 'capabilities:' is also available instead of 'caps:'.
# @core = Appium::Core.for url: "http://127.0.0.1:8080/wd/hub", capabilities: {...}, appium_lib: {...}
#
#
# require 'rubygems'
# require 'appium_lib_core'
#
# # Start iOS driver
# opts = {
# caps: {
# platformName: :ios,
# platformVersion: '11.0',
# deviceName: 'iPhone Simulator',
# automationName: 'XCUITest',
# app: '/path/to/MyiOS.app'
# },
# appium_lib: {
# export_session: false,
# port: 8080,
# wait: 0,
# wait_timeout: 20,
# wait_interval: 0.3,
# listener: nil,
# }
# }
# @core = Appium::Core.for(opts) # create a core driver with 'opts' and extend methods into 'self'
# @core.start_driver # Connect to 'http://127.0.0.1:8080/wd/hub' because of 'port: 8080'
#
# # Start iOS driver with .zip file over HTTP
# # 'capabilities:' is also available instead of 'caps:'. Either is fine.
# opts = {
# capabilities: {
# platformName: :ios,
# platformVersion: '11.0',
# deviceName: 'iPhone Simulator',
# automationName: 'XCUITest',
# app: 'http://example.com/path/to/MyiOS.app.zip'
# },
# appium_lib: {
# server_url: 'http://custom-host:8080/wd/hub.com',
# export_session: false,
# wait: 0,
# wait_timeout: 20,
# wait_interval: 0.3,
# listener: nil,
# }
# }
# @core = Appium::Core.for(opts)
# @core.start_driver # Connect to 'http://custom-host:8080/wd/hub.com'
#
# # Start iOS driver as another format. 'url' is available like below
# opts = {
# url: "http://custom-host:8080/wd/hub.com",
# capabilities: {
# platformName: :ios,
# platformVersion: '11.0',
# deviceName: 'iPhone Simulator',
# automationName: 'XCUITest',
# app: '/path/to/MyiOS.app'
# },
# appium_lib: {
# export_session: false,
# wait: 0,
# wait_timeout: 20,
# wait_interval: 0.3,
# listener: nil,
# }
# }
# @core = Appium::Core.for(opts) # create a core driver with 'opts' and extend methods into 'self'
# @core.start_driver # start driver with 'url'. Connect to 'http://custom-host:8080/wd/hub.com'
#
def self.for(opts = {})
new.setup_for_new_session(opts)
end
# Attach to an existing session. The main usage of this method is to attach to
# an existing session for debugging. The generated driver instance has the capabilities which
# has the given automationName and platformName only since the W3C WebDriver spec does not provide
# an endpoint to get running session's capabilities.
#
#
# @param [String] The session id to attach to.
# @param [String] url The WebDriver URL to attach to with the session_id.
# @param [String] automation_name The platform name to keep in the dummy capabilities
# @param [String] platform_name The automation name to keep in the dummy capabilities
# @return [Selenium::WebDriver] A new driver instance with the given session id.
#
# @example
#
# new_driver = ::Appium::Core::Driver.attach_to(
# driver.session_id, # The 'driver' has an existing session id
# url: 'http://127.0.0.1:4723/wd/hub', automation_name: 'UiAutomator2', platform_name: 'Android'
# )
# new_driver.page_source # for example
#
def self.attach_to(
session_id, url: nil, automation_name: nil, platform_name: nil,
http_client_ops: { http_client: nil, open_timeout: 999_999, read_timeout: 999_999 }
)
new.attach_to(
session_id,
automation_name: automation_name,
platform_name: platform_name,
url: url,
http_client_ops: http_client_ops
)
end
private
# @private
# For testing purpose of delegate_from_appium_driver
def delegated_target_for_test
@delegate_target
end
# @private
def initialize
@delegate_target = self # for testing purpose
@automation_name = nil # initialise before 'set_automation_name'
end
public
# @private
# Set up for a neww session
def setup_for_new_session(opts = {})
@custom_url = opts.delete :url # to set the custom url as :url
# TODO: Remove when we implement Options
# The symbolize_keys is to keep compatiility for the legacy code, which allows capabilities to give 'string' as the key.
# The toplevel `caps`, `capabilities` and `appium_lib` are expected to be symbol.
# FIXME: First, please try to remove `nested: true` to `nested: false`.
opts = Appium.symbolize_keys(opts, nested: true)
@caps = get_caps(opts)
set_appium_lib_specific_values(get_appium_lib_opts(opts))
set_app_path
set_appium_device
set_automation_name
extend_for(device: @device, automation_name: @automation_name)
self
end
# Creates a new global driver and quits the old one if it exists.
# You can customise http_client as the following
#
# @param [String] server_url Custom server url to send to requests. Default is "http://127.0.0.1:4723/wd/hub".
# @param http_client_ops [Hash] Options for http client
# @option http_client_ops [Hash] :http_client Custom HTTP Client
# @option http_client_ops [Hash] :open_timeout Custom open timeout for http client.
# @option http_client_ops [Hash] :read_timeout Custom read timeout for http client.
# @return [Selenium::WebDriver] A new driver instance
#
# @example
#
# require 'rubygems'
# require 'appium_lib_core'
#
# # platformName takes a string or a symbol.
#
# # Start iOS driver
# opts = {
# capabilities: {
# platformName: :ios,
# platformVersion: '11.0',
# deviceName: 'iPhone Simulator',
# automationName: 'XCUITest',
# app: '/path/to/MyiOS.app'
# },
# appium_lib: {
# wait: 20,
# wait_timeout: 20,
# wait_interval: 0.3,
# }
# }
#
# @core = Appium::Core.for(opts) # create a core driver with 'opts' and extend methods into 'self'
# @driver = @core.start_driver server_url: "http://127.0.0.1:8000/wd/hub"
#
# # Attach custom HTTP client
# @driver = @core.start_driver server_url: "http://127.0.0.1:8000/wd/hub",
# http_client_ops: { http_client: Your:Http:Client.new,
# open_timeout: 1_000,
# read_timeout: 1_000 }
#
def start_driver(server_url: nil,
http_client_ops: { http_client: nil, open_timeout: 999_999, read_timeout: 999_999 })
@custom_url ||= server_url || "http://127.0.0.1:#{@port}/wd/hub"
@http_client = get_http_client http_client: http_client_ops.delete(:http_client),
open_timeout: http_client_ops.delete(:open_timeout),
read_timeout: http_client_ops.delete(:read_timeout)
if @enable_idempotency_header
if @http_client.instance_variable_defined? :@additional_headers
@http_client.additional_headers[Appium::Core::Base::Http::RequestHeaders::KEYS[:idempotency]] = SecureRandom.uuid
else
::Appium::Logger.warn 'No additional_headers attribute in this http client instance'
end
end
begin
@driver = ::Appium::Core::Base::Driver.new(listener: @listener,
http_client: @http_client,
capabilities: @caps, # ::Appium::Core::Base::Capabilities
url: @custom_url,
wait_timeout: @wait_timeout,
wait_interval: @wait_interval)
if @direct_connect
d_c = DirectConnections.new(@driver.capabilities)
@driver.update_sending_request_to(protocol: d_c.protocol, host: d_c.host, port: d_c.port, path: d_c.path)
end
# export session
write_session_id(@driver.session_id, @export_session_path) if @export_session
rescue Errno::ECONNREFUSED
raise "ERROR: Unable to connect to Appium. Is the server running on #{@custom_url}?"
end
if @http_client.instance_variable_defined? :@additional_headers
# We only need the key for a new session request. Should remove it for other following commands.
@http_client.additional_headers.delete Appium::Core::Base::Http::RequestHeaders::KEYS[:idempotency]
end
# TODO: this method can be removed after releasing Appium 2.0, and after a while
# since Appium 2.0 reuqires 'automationName'. This method won't help anymore then.
# If "automationName" is set only server side, this method set "automationName" attribute into @automation_name.
# Since @automation_name is set only client side before start_driver is called.
set_automation_name_if_nil
set_implicit_wait_by_default(@default_wait)
@driver
end
# @privvate
# Attach to an existing session
def attach_to(session_id, url: nil, automation_name: nil, platform_name: nil,
http_client_ops: { http_client: nil, open_timeout: 999_999, read_timeout: 999_999 })
raise ::Appium::Core::Error::ArgumentError, 'The :url must not be nil' if url.nil?
raise ::Appium::Core::Error::ArgumentError, 'The :automation_name must not be nil' if automation_name.nil?
raise ::Appium::Core::Error::ArgumentError, 'The :platform_name must not be nil' if platform_name.nil?
@custom_url = url
# use lowercase internally
@automation_name = convert_downcase(automation_name)
@device = convert_downcase(platform_name)
extend_for(device: @device, automation_name: @automation_name)
@http_client = get_http_client http_client: http_client_ops.delete(:http_client),
open_timeout: http_client_ops.delete(:open_timeout),
read_timeout: http_client_ops.delete(:read_timeout)
# Note that 'enable_idempotency_header' works only a new session reqeust. The attach_to method skips
# the new session request, this it does not needed.
begin
# included https://github.com/SeleniumHQ/selenium/blob/43f8b3f66e7e01124eff6a5805269ee441f65707/rb/lib/selenium/webdriver/remote/driver.rb#L29
@driver = ::Appium::Core::Base::Driver.new(http_client: @http_client,
url: @custom_url,
listener: @listener,
existing_session_id: session_id,
automation_name: automation_name,
platform_name: platform_name)
# export session
write_session_id(@driver.session_id, @export_session_path) if @export_session
rescue Errno::ECONNREFUSED
raise "ERROR: Unable to connect to Appium. Is the server running on #{@custom_url}?"
end
@driver
end
def get_http_client(http_client: nil, open_timeout: nil, read_timeout: nil)
client = http_client || Appium::Core::Base::Http::Default.new
# open_timeout and read_timeout are explicit wait.
client.open_timeout = open_timeout if open_timeout
client.read_timeout = read_timeout if read_timeout
client
end
# Ignore setting default wait if the target driver has no implementation
def set_implicit_wait_by_default(wait)
return if @default_wait.nil?
@driver.manage.timeouts.implicit_wait = wait
rescue ::Selenium::WebDriver::Error::UnknownError => e
unless e.message.include?('The operation requested is not yet implemented')
raise ::Appium::Core::Error::ServerError, e.message
end
::Appium::Logger.debug(e.message)
{}
end
# Quits the driver
# @return [void]
#
# @example
#
# @core.quit_driver
#
def quit_driver
@driver.quit
rescue # rubocop:disable Style/RescueStandardError
nil
end
# Returns the server's version info. This method calls +driver.remote_status+ internally
#
# @return [Hash]
#
# @example
#
# @core.appium_server_version
# {
# "build" => {
# "version" => "1.9.2",
# "git-sha" => "fd7c7fd19d3896719616cd970229d73e63b5271a",
# "built" => "2018-11-08 12:24:02 +0900"
# }
# }
#
# Returns blank hash in a case +driver.remote_status+ got an error
# such as Selenium Grid. It returns 500 error against 'remote_status'.
#
# @example
#
# @core.appium_server_version #=> {}
#
def appium_server_version
return {} if @driver.nil?
@driver.remote_status
rescue StandardError
# Ignore error case in a case the target appium server
# does not support `/status` API.
{}
end
# Return the platform version as an array of integers
# @return [Array]
#
# @example
#
# @core.platform_version #=> [10,1,1]
#
def platform_version
p_version = @driver.capabilities['platformVersion'] || @driver.session_capabilities['platformVersion']
p_version.split('.').map(&:to_i)
end
private
def convert_to_symbol(value)
if value.nil?
value
else
value.to_sym
end
end
# @private
def extend_for(device:, automation_name:) # rubocop:disable Metrics/CyclomaticComplexity
extend Appium::Core
extend Appium::Core::Device
sym_automation_name = convert_to_symbol(automation_name)
case convert_to_symbol(device)
when :android
case sym_automation_name
when :espresso
::Appium::Core::Android::Espresso::Bridge.for self
when :uiautomator2
::Appium::Core::Android::Uiautomator2::Bridge.for self
when :gecko
::Appium::Logger.debug('Gecko Driver for Android')
else # default and UiAutomator
::Appium::Core::Android::Uiautomator1::Bridge.for self
end
when :ios, :tvos
case sym_automation_name
when :safari
::Appium::Logger.debug('SafariDriver for iOS')
when :xcuitest
::Appium::Core::Ios::Xcuitest::Bridge.for self
else # default and UIAutomation
::Appium::Core::Ios::Uiautomation::Bridge.for self
end
when :mac
case sym_automation_name
when :safari
::Appium::Logger.debug('SafariDriver for macOS')
when :gecko
::Appium::Logger.debug('Gecko Driver for macOS')
when :mac2
::Appium::Core::Mac2::Bridge.for self
else
# no Mac specific extentions
::Appium::Logger.debug('macOS Native')
end
when :windows
case sym_automation_name
when :gecko
::Appium::Logger.debug('Gecko Driver for Windows')
else
::Appium::Core::Windows::Bridge.for self
end
when :tizen
# https://github.com/Samsung/appium-tizen-driver
::Appium::Logger.debug('tizen')
else
case sym_automation_name
when :youiengine
# https://github.com/YOU-i-Labs/appium-youiengine-driver
::Appium::Logger.debug('YouiEngine')
when :mac
# In this case also can be mac
::Appium::Logger.debug('mac')
when :gecko # other general platform
::Appium::Logger.debug('Gecko Driver')
else
::Appium::Logger.warn("No matched driver by platformName: #{device} and automationName: #{automation_name}")
end
end
self
end
# @private
def get_caps(opts)
Core::Base::Capabilities.new(opts[:caps] || opts[:capabilities] || {})
end
# @private
def get_appium_lib_opts(opts)
opts[:appium_lib] || {}
end
# @private
# Path to the .apk, .app or .app.zip.
# The path can be local, HTTP/S, Windows Share and other path like 'sauce-storage:'.
# Use @caps[:app] without modifications if the path isn't HTTP/S or local path.
def set_app_path
# FIXME: maybe `:app` should check `app` as well.
return unless @caps && @caps[:app] && !@caps[:app].empty?
return if @caps[:app] =~ URI::DEFAULT_PARSER.make_regexp
app_path = File.expand_path(@caps[:app])
@caps[:app] = if File.exist? app_path
app_path
else
::Appium::Logger.warn("Use #{@caps[:app]} directly since #{app_path} does not exist.")
@caps[:app]
end
end
# @private
# Below capabilities are set only for client side.
def set_appium_lib_specific_values(appium_lib_opts)
opts = Options.new appium_lib_opts
@custom_url ||= opts.custom_url # Keep existence capability if it's already provided
@enable_idempotency_header = opts.enable_idempotency_header
@default_wait = opts.default_wait
@export_session = opts.export_session
@export_session_path = opts.export_session_path
@port = opts.port
@wait_timeout = opts.wait_timeout
@wait_interval = opts.wait_interval
@listener = opts.listener
@direct_connect = opts.direct_connect
end
# @private
def set_appium_device
# https://code.google.com/p/selenium/source/browse/spec-draft.md?repo=mobile
# TODO: check if the Appium.symbolize_keys(opts, nested: false) enoug with this
@device = @caps[:platformName] || @caps['platformName']
return @device unless @device
@device = convert_downcase @device
end
# @private
def set_automation_name
# TODO: check if the Appium.symbolize_keys(opts, nested: false) enoug with this
candidate = @caps[:automationName] || @caps['automationName']
@automation_name = candidate if candidate
@automation_name = convert_downcase @automation_name if @automation_name
end
# @private
def convert_downcase(value)
value.is_a?(Symbol) ? value.downcase : value.downcase.strip.intern
end
# @private
def set_automation_name_if_nil
return unless @automation_name.nil?
@automation_name = if @driver.capabilities['automationName']
@driver.capabilities['automationName'].downcase.strip.intern
end
end
# @private
def write_session_id(session_id, export_path = '/tmp/appium_lib_session')
::Appium::Logger.warn(
'[DEPRECATION] export_session option will be removed. ' \
'Please save the session id by yourself with #session_id method like @driver.session_id.'
)
export_path = export_path.tr('/', '\\') if ::Appium::Core::Base.platform.windows?
File.write(export_path, session_id)
rescue IOError => e
::Appium::Logger.warn e
nil
end
end # class Driver
end # module Core
end # module Appium