Skip to content

Commit

Permalink
feat: add attach to an existing session (#337)
Browse files Browse the repository at this point in the history
Added a method to attach to an existing session into the driver class so that
users can attach to an existing session for debugging.

The capabilities only has the given automationName and platformName for the internal use. W3C does not define getting a session info, so we need to give them.
  • Loading branch information
KazuCocoa authored Dec 11, 2022
1 parent ae52976 commit 4621549
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 19 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Read `release_notes.md` for commit level details.
## [Unreleased]

### Enhancements
- Add `::Appium::Core::Driver#attach_to` to generate a driver instance which has the given session id.
- The primary usage is for debugging to attach to an existing session.

### Bug fixes

Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ $ IGNORE_VERSION_SKIP=true CI=true bundle exec rake test:func:android

opts = {
capabilities: { # Append capabilities
platformName: :ios,
platformName: 'ios',
platformVersion: '11.0',
deviceName: 'iPhone Simulator',
automationName: 'XCUITest',
Expand Down Expand Up @@ -133,6 +133,15 @@ $ IGNORE_VERSION_SKIP=true CI=true bundle exec rake test:func:android

More examples are in [test/functional](test/functional)

As of version 5.8.0, the client can attach to an existing session. The main purpose is for debugging.

```ruby
# @driver is the driver instance of an existing session
attached_driver = ::Appium::Core::Driver.attach_to @driver.session_id, url: 'http://127.0.0.1:4723/wd/hub', automation_name: 'XCUITest', platform_name: 'ios'
assert attached_driver.session_id == @driver.session_id
attached_driver.page_source
```

### Capabilities

Read [Appium/Core/Driver](https://www.rubydoc.info/github/appium/ruby_lib_core/Appium/Core/Driver) to catch up with available capabilities.
Expand Down
28 changes: 27 additions & 1 deletion lib/appium_lib_core/common/base/bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,39 @@ class Bridge < ::Selenium::WebDriver::Remote::Bridge

def browser
@browser ||= begin
name = @capabilities.browser_name
name = @capabilities&.browser_name
name ? name.tr(' ', '_').downcase.to_sym : 'unknown'
rescue KeyError
APPIUM_NATIVE_BROWSER_NAME
end
end

# Appium only.
# Attach to an existing session.
#
# @param [String] The session id to attach to.
# @param [String] platform_name The platform name to keep in the dummy capabilities
# @param [String] platform_name The automation name to keep in the dummy capabilities
# @return [::Appium::Core::Base::Capabilities]
#
# @example
#
# new_driver = ::Appium::Core::Driver.attach_to(
# driver.session_id,
# url: 'http://127.0.0.1:4723/wd/hub', automation_name: 'UiAutomator2', platform_name: 'Android'
# )
#
def attach_to(session_id, platform_name, automation_name)
@available_commands = ::Appium::Core::Commands::COMMANDS.dup
@session_id = session_id

# generate a dummy capabilities instance which only has the given platformName and automationName
@capabilities = ::Appium::Core::Base::Capabilities.new(
'platformName' => platform_name,
'automationName' => automation_name
)
end

# Override
# Creates session handling.
#
Expand Down
15 changes: 14 additions & 1 deletion lib/appium_lib_core/common/base/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,26 @@ def initialize(bridge: nil, listener: nil, **opts)
# @return [::Appium::Core::Base::Bridge]
#
def create_bridge(**opts)
# for a new session request
capabilities = opts.delete(:capabilities)
bridge_opts = { http_client: opts.delete(:http_client), url: opts.delete(:url) }

# for attaching to an existing session
session_id = opts.delete(:existing_session_id)
automation_name = opts.delete(:automation_name)
platform_name = opts.delete(:platform_name)

raise ::Appium::Core::Error::ArgumentError, "Unable to create a driver with parameters: #{opts}" unless opts.empty?

bridge = ::Appium::Core::Base::Bridge.new(**bridge_opts)

bridge.create_session(capabilities)
if session_id.nil?
bridge.create_session(capabilities)
else
# attach to the existing session id
bridge.attach_to(session_id, platform_name, automation_name)
end

bridge
end

Expand Down
112 changes: 97 additions & 15 deletions lib/appium_lib_core/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,40 @@ class Driver
# @core.start_driver # start driver with 'url'. Connect to 'http://custom-host:8080/wd/hub.com'
#
def self.for(opts = {})
new(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
Expand All @@ -286,20 +319,25 @@ def delegated_target_for_test
@delegate_target
end

public

# @private
def initialize(opts = {})
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)

@custom_url = opts.delete :url
@caps = get_caps(opts)

set_appium_lib_specific_values(get_appium_lib_opts(opts))
Expand All @@ -308,8 +346,7 @@ def initialize(opts = {})
set_automation_name

extend_for(device: @device, automation_name: @automation_name)

self # rubocop:disable Lint/Void
self
end

# Creates a new global driver and quits the old one if it exists.
Expand All @@ -320,7 +357,7 @@ def initialize(opts = {})
# @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] the new global driver
# @return [Selenium::WebDriver] A new driver instance
#
# @example
#
Expand Down Expand Up @@ -406,7 +443,47 @@ def start_driver(server_url: nil,
@driver
end

private
# @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
Expand All @@ -432,8 +509,6 @@ def set_implicit_wait_by_default(wait)
{}
end

public

# Quits the driver
# @return [void]
#
Expand Down Expand Up @@ -627,17 +702,20 @@ def set_appium_device
@device = @caps[:platformName] || @caps['platformName']
return @device unless @device

@device = @device.is_a?(Symbol) ? @device.downcase : @device.downcase.strip.intern
@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 = if @automation_name
@automation_name.is_a?(Symbol) ? @automation_name.downcase : @automation_name.downcase.strip.intern
end
@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
Expand All @@ -651,6 +729,10 @@ def set_automation_name_if_nil

# @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
Expand Down
2 changes: 1 addition & 1 deletion test/unit/common_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def test_add_appium_prefix_already_have_appium_prefix
base_caps = Appium::Core::Base::Capabilities.new cap

assert_equal base_caps[:platformName], :ios
assert_equal base_caps['platformName'], nil
assert_nil base_caps['platformName']

expected = {
'platformName' => :ios,
Expand Down
57 changes: 57 additions & 0 deletions test/unit/driver_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -485,5 +485,62 @@ def test_search_context_in_element_class
windows_uiautomation: '-windows uiautomation',
tizen_uiautomation: '-tizen uiautomation' }, ::Appium::Core::Element::FINDERS)
end

def test_attach_to_an_existing_session
android_mock_create_session_w3c_direct = lambda do |core|
response = {
value: {
sessionId: '1234567890',
capabilities: {
platformName: :android,
automationName: ENV['APPIUM_DRIVER'] || 'uiautomator2',
app: 'test/functional/app/api.apk.zip',
platformVersion: '7.1.1',
deviceName: 'Android Emulator',
appPackage: 'io.appium.android.apis',
appActivity: 'io.appium.android.apis.ApiDemos',
someCapability: 'some_capability',
unicodeKeyboard: true,
resetKeyboard: true,
directConnectProtocol: 'http',
directConnectHost: 'localhost',
directConnectPort: '8888',
directConnectPath: '/wd/hub'
}
}
}.to_json

stub_request(:post, 'http://127.0.0.1:4723/wd/hub/session')
.to_return(headers: HEADER, status: 200, body: response)

stub_request(:post, 'http://localhost:8888/wd/hub/session/1234567890/timeouts')
.with(body: { implicit: 30_000 }.to_json)
.to_return(headers: HEADER, status: 200, body: { value: nil }.to_json)

driver = core.start_driver

assert_requested(:post, 'http://127.0.0.1:4723/wd/hub/session', times: 1)
assert_requested(:post, 'http://localhost:8888/wd/hub/session/1234567890/timeouts',
body: { implicit: 30_000 }.to_json, times: 1)
driver
end

core = ::Appium::Core.for(Caps.android_direct)
driver = android_mock_create_session_w3c_direct.call(core)

attached_driver = ::Appium::Core::Driver.attach_to(
driver.session_id,
url: 'http://127.0.0.1:4723/wd/hub', automation_name: 'UiAutomator2', platform_name: 'Android'
)

assert_equal driver.session_id, attached_driver.session_id
# base session
assert driver.respond_to?(:current_activity)
assert_equal driver.capabilities['automationName'], ENV['APPIUM_DRIVER'] || 'uiautomator2'

# to check the extend_for if the new driver instance also has the expected method for Android
assert attached_driver.respond_to?(:current_activity)
assert_equal attached_driver.capabilities['automationName'], 'UiAutomator2'
end
end
end

0 comments on commit 4621549

Please sign in to comment.