diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml
index 05368249..609e4ba5 100644
--- a/.github/workflows/functional-test.yml
+++ b/.github/workflows/functional-test.yml
@@ -14,12 +14,20 @@ concurrency:
jobs:
ios_test:
+ name: ios_test (global_driver=${{ matrix.global_driver }})
+ strategy:
+ fail-fast: false
+ matrix:
+ global_driver: ['true', 'false']
runs-on: macos-15
timeout-minutes: 90
env:
- XCODE_VERSION: 16.4
- IOS_VERSION: 18.5
- IOS_DEVICE_NAME: iPhone 16 Plus
+ APPIUM_TEST_GLOBAL_DRIVER: ${{ matrix.global_driver }}
+ XCODE_VERSION: 16.4
+ IOS_VERSION: 18.5
+ IOS_DEVICE_NAME: iPhone 16 Plus
+ # Allow WDA startup (up to 600s) and session creation to finish.
+ APPIUM_TEST_READ_TIMEOUT: 900
steps:
- uses: actions/checkout@v7
@@ -44,8 +52,6 @@ jobs:
- name: Preparing other environment
run: |
brew install ffmpeg
- brew tap wix/brew
- brew install applesimutils
# Start Appium
- name: Install appium and mjpeg-consumer
@@ -79,15 +85,21 @@ jobs:
if: ${{ always() }}
uses: actions/upload-artifact@master
with:
- name: appium-ios_test_with_other_deps.log
+ name: appium-ios_test_with_other_deps-global-${{ matrix.global_driver }}.log
path: |
appium.log
test/report/
android_test:
+ name: android_test (global_driver=${{ matrix.global_driver }})
+ strategy:
+ fail-fast: false
+ matrix:
+ global_driver: ['true', 'false']
runs-on: ubuntu-latest
timeout-minutes: 90
env:
+ APPIUM_TEST_GLOBAL_DRIVER: ${{ matrix.global_driver }}
API_LEVEL: 35
ARCH: x86_64
@@ -164,7 +176,7 @@ jobs:
if: ${{ always() }}
uses: actions/upload-artifact@master
with:
- name: appium-android_test_with_other_deps.log
+ name: appium-android_test_with_other_deps-global-${{ matrix.global_driver }}.log
path: |
appium.log
test/report/
diff --git a/android_tests/lib/android/navigation.rb b/android_tests/lib/android/navigation.rb
new file mode 100644
index 00000000..40bade11
--- /dev/null
+++ b/android_tests/lib/android/navigation.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# Helpers for functional tests that need a fresh native ApiDemos screen.
+module AndroidTestNavigation
+ def start_test_activity(component)
+ AppiumTestSession.driver.execute_script 'mobile: startActivity',
+ component: component,
+ action: 'android.intent.action.MAIN',
+ flags: '0x10200000',
+ wait: true,
+ stop: true
+ end
+
+ def reset_test_app
+ start_test_activity('io.appium.android.apis/.ApiDemos')
+ end
+end
diff --git a/android_tests/lib/android/specs/android/element/alert.rb b/android_tests/lib/android/specs/android/element/alert.rb
index 391f8a96..0072d923 100644
--- a/android_tests/lib/android/specs/android/element/alert.rb
+++ b/android_tests/lib/android/specs/android/element/alert.rb
@@ -16,16 +16,27 @@
class AndroidTest
class Element
class Alert < Minitest::Test
+ include AndroidTestNavigation
+
+ def setup
+ super
+ reset_test_app
+ wait { find('app').click }
+ wait { find('alert').click }
+ end
+
+ def teardown
+ reset_test_app
+ ensure
+ super
+ end
+
def open_alert
# trigger the alert
wait { button('OK Cancel dialog with a message').click }
end
def test_01_alert_click
- # nav to alert activity
- wait { find('app').click }
- wait { find('alert').click }
-
# test
wait { open_alert }
wait { alert_click('OK') }
@@ -57,9 +68,6 @@ def test_05_alert_dismiss_text
wait { assert_equal alert_dismiss_text.downcase, 'cancel' }
wait { alert_dismiss }
-
- # nav to start activity
- 2.times { back }
end
end
end
diff --git a/android_tests/lib/android/specs/android/element/button.rb b/android_tests/lib/android/specs/android/element/button.rb
index d10462ed..61d08ad7 100644
--- a/android_tests/lib/android/specs/android/element/button.rb
+++ b/android_tests/lib/android/specs/android/element/button.rb
@@ -16,26 +16,26 @@
class AndroidTest
class Element
class Button < Minitest::Test
- def before_first
- # nav to buttons activity
+ include AndroidTestNavigation
+
+ def setup
+ super
+ reset_test_app
wait { find('App').click }
wait { find('Activity').click }
wait { find('Animation').click }
end
- def after_last
- # nav back to home activity
- 3.times { back }
+ def teardown
+ reset_test_app
+ ensure
+ super
end
def fade_in
'Fade in'
end
- def test_01_before_first
- before_first
- end
-
def test_02_button
# by index
2.times { wait { assert_equal button(1).text.upcase, fade_in.upcase } }
@@ -65,10 +65,6 @@ def test_06_button_exact
def test_07_buttons_exact
2.times { wait { assert_equal buttons_exact(fade_in).first.text.upcase, fade_in.upcase } }
end
-
- def test_08_after_last
- after_last
- end
end
end
end
diff --git a/android_tests/lib/android/specs/android/element/text.rb b/android_tests/lib/android/specs/android/element/text.rb
index bd767308..45b25797 100644
--- a/android_tests/lib/android/specs/android/element/text.rb
+++ b/android_tests/lib/android/specs/android/element/text.rb
@@ -16,6 +16,19 @@
class AndroidTest
class Element
class Text < Minitest::Test
+ include AndroidTestNavigation
+
+ def setup
+ super
+ reset_test_app
+ end
+
+ def teardown
+ reset_test_app
+ ensure
+ super
+ end
+
def must_raise_no_element
assert_raises Selenium::WebDriver::Error::NoSuchElementError do
yield
diff --git a/android_tests/lib/android/specs/android/patch.rb b/android_tests/lib/android/specs/android/patch.rb
index 7b807b27..b89190db 100644
--- a/android_tests/lib/android/specs/android/patch.rb
+++ b/android_tests/lib/android/specs/android/patch.rb
@@ -15,6 +15,19 @@
# rake "android[android/patch]"
class AndroidTest
class Patch < Minitest::Test
+ include AndroidTestNavigation
+
+ def setup
+ super
+ reset_test_app
+ end
+
+ def teardown
+ reset_test_app
+ ensure
+ super
+ end
+
def test_type
# nav to textfield
wait { find('app').click }
diff --git a/android_tests/lib/android/specs/common/device.rb b/android_tests/lib/android/specs/common/device.rb
index 52065231..e3a3909e 100644
--- a/android_tests/lib/android/specs/common/device.rb
+++ b/android_tests/lib/android/specs/common/device.rb
@@ -16,36 +16,43 @@
class AndroidTest
class Common
class Device < Minitest::Test
+ include AndroidTestNavigation
+
+ def setup
+ super
+ reset_test_app
+ end
+
+ def teardown
+ reset_test_app
+ ensure
+ super
+ end
+
def test_01_get_performance_data_types
expected = %w(batteryinfo cpuinfo memoryinfo networkinfo)
- assert_equal get_performance_data_types.sort, expected
+ assert_equal expected, get_performance_data_types.sort
+ end
- assert_equal get_performance_data(
- package_name: 'io.appium.android.apis', data_type: 'cpuinfo'
- )[0], %w(user kernel)
+ def test_01_get_performance_data
+ # CPU samples may omit a newly started/idle app. Battery data exercises
+ # the same client endpoint without depending on a process sampling window.
+ data = get_performance_data(package_name: 'io.appium.android.apis', data_type: 'batteryinfo')
+ assert_equal ['power'], data.fetch(0)
+ assert_equal 1, data.fetch(1).length
+ assert_includes 0..100, Integer(data.fetch(1).fetch(0))
end
def test_02_start_activity
wait { assert_equal current_activity, '.ApiDemos' }
- start_activity(
- app_package: 'io.appium.android.apis',
- app_activity: '.accessibility.AccessibilityNodeProviderActivity'
- )
+ start_test_activity('io.appium.android.apis/.accessibility.AccessibilityNodeProviderActivity')
wait { assert_equal current_activity.include?('Node'), true }
- start_activity(
- app_package: 'com.android.settings',
- app_activity: '.Settings',
- app_wait_package: 'com.android.settings',
- app_wait_activity: '.Settings'
- )
+ start_test_activity('com.android.settings/.Settings')
wait { assert_equal current_activity.include?('Settings'), true }
end
def test_03_current_package
- start_activity(
- app_package: 'io.appium.android.apis',
- app_activity: '.accessibility.AccessibilityNodeProviderActivity'
- )
+ start_test_activity('io.appium.android.apis/.accessibility.AccessibilityNodeProviderActivity')
wait { assert_equal current_package, 'io.appium.android.apis' }
end
diff --git a/android_tests/lib/android/specs/common/device_touchaction.rb b/android_tests/lib/android/specs/common/device_touchaction.rb
index ae6064f0..f2d89ff2 100644
--- a/android_tests/lib/android/specs/common/device_touchaction.rb
+++ b/android_tests/lib/android/specs/common/device_touchaction.rb
@@ -16,6 +16,19 @@
class AndroidTest
class Common
class DeviceTouchAction < Minitest::Test
+ include AndroidTestNavigation
+
+ def setup
+ super
+ reset_test_app
+ end
+
+ def teardown
+ reset_test_app
+ ensure
+ super
+ end
+
def test_01_before_test
wait { text('Accessibility') }
end
@@ -78,7 +91,7 @@ def test_05_pinch_zoom
origin: ::Selenium::WebDriver::Interactions::PointerMove::VIEWPORT
)
f2.create_pointer_down(:left)
- f1.create_pause(0.5)
+ f2.create_pause(0.5)
f2.create_pointer_move(
duration: 3, x: 500, y: 900,
origin: ::Selenium::WebDriver::Interactions::PointerMove::VIEWPORT
diff --git a/android_tests/lib/android/specs/common/patch.rb b/android_tests/lib/android/specs/common/patch.rb
index effe44f6..a2fd17d6 100644
--- a/android_tests/lib/android/specs/common/patch.rb
+++ b/android_tests/lib/android/specs/common/patch.rb
@@ -36,7 +36,7 @@ def test_01_element_method_name
def test_02_element_method_location_rel
wait do
- loc = first_text.location_rel($driver)
+ loc = first_text.location_rel(AppiumTestSession.driver)
assert_equal loc.x.class, String
assert_equal loc.y.class, String
end
@@ -45,13 +45,14 @@ def test_02_element_method_location_rel
# By default, the webdriver gem will return message instead of origValue
def test_03_common_patch_id_error_message
value = ''
+ previous_wait = driver.manage.timeouts.implicit_wait
begin
set_wait 0
find_element(:id, 'ok')
rescue StandardError => e
value = e.message
ensure
- set_wait 30
+ set_wait previous_wait
end
exp = 'An element could not be located on the page using the given search parameters.'
assert value.start_with? exp
diff --git a/android_tests/lib/android/specs/driver.rb b/android_tests/lib/android/specs/driver.rb
index f9e2212e..38e7edb2 100644
--- a/android_tests/lib/android/specs/driver.rb
+++ b/android_tests/lib/android/specs/driver.rb
@@ -76,7 +76,7 @@ def test_05_attributes_verify_all_attributes
assert_equal caps_with_json['appPackage'], 'io.appium.android.apis'
assert_equal caps_with_json['appActivity'], 'io.appium.android.apis.ApiDemos'
assert_equal caps_with_json['deviceName'], 'Android emulator'
- assert_equal caps_with_json['someCapability'], 'some_capability'
+ assert_equal 'some_capability', caps_with_json['some_capability']
assert_equal actual[:caps][:platformName], 'android'
assert_equal actual[:caps][:app], expected_app
@@ -92,11 +92,11 @@ def test_05_attributes_verify_all_attributes
end
def test_06_driver_class
- assert_equal $driver.class, Appium::Driver
+ assert_equal AppiumTestSession.driver.class, Appium::Driver
end
def absolute_app_path(path)
- $driver.class.absolute_app_path(caps: { app: path })
+ AppiumTestSession.driver.class.absolute_app_path(caps: { app: path })
end
def validate_path(path)
diff --git a/android_tests/lib/android/specs/install.rb b/android_tests/lib/android/specs/install.rb
index 6dd03956..d726ec3a 100644
--- a/android_tests/lib/android/specs/install.rb
+++ b/android_tests/lib/android/specs/install.rb
@@ -15,7 +15,15 @@
# rake "android[install]"
class AndroidTest
class Install < Minitest::Test
- # To Do: Blocked on https://github.com/appium/appium/issues/3032
+ include AndroidTestNavigation
+
+ def teardown
+ install_app caps[:app] unless app_installed?(pkg)
+ reset_test_app
+ ensure
+ super
+ end
+
def pkg
'io.appium.android.apis'
end
@@ -35,9 +43,5 @@ def test_install_uninstall
install_app caps[:app]
installed
end
-
- # no way to launch apk after it's uninstalled/installed
- # blocked on: https://github.com/appium/appium/issues/2969
- # launch_app
end
end
diff --git a/android_tests/lib/run.rb b/android_tests/lib/run.rb
index b97505f4..4634c016 100644
--- a/android_tests/lib/run.rb
+++ b/android_tests/lib/run.rb
@@ -35,8 +35,18 @@
# Run only the view album test:
# ruby run.rb ios view_album
+module AppiumTestSession
+ class << self
+ attr_accessor :driver
+ end
+end
+
def start_driver(caps)
- driver = Appium::Driver.new(caps, true)
+ global_driver = ENV.fetch('APPIUM_TEST_GLOBAL_DRIVER', 'false')
+ raise 'APPIUM_TEST_GLOBAL_DRIVER must be true or false' unless %w(true false).include?(global_driver)
+
+ driver = Appium::Driver.new(caps, global_driver == 'true')
+ AppiumTestSession.driver = driver
# Tests expect methods defined on the minispec object
Appium.promote_appium_methods Minitest::Test, driver
@@ -66,6 +76,12 @@ def start_driver(caps)
caps.merge(appium_lib: { debug: true, wait: 1 })
end
+if device == 'ios'
+ caps[:caps][:app] = ENV['IOS_APP'] if ENV['IOS_APP']
+ caps[:caps][:platformVersion] = ENV['IOS_VERSION'] if ENV['IOS_VERSION']
+ caps[:caps][:deviceName] = ENV['IOS_DEVICE_NAME'] if ENV['IOS_DEVICE_NAME']
+end
+
if ENV['LOCAL_PREBUILT_WDA']
caps[:caps][:usePreinstalledWDA] = true
caps[:caps][:prebuiltWDAPath] = ENV['LOCAL_PREBUILT_WDA']
@@ -118,4 +134,4 @@ def start_driver(caps)
end
# Exit after tests.
-Minitest.after_run { $driver&.x }
+Minitest.after_run { AppiumTestSession.driver&.driver_quit }
diff --git a/android_tests/readme.md b/android_tests/readme.md
index b2fc58b1..ab013fba 100644
--- a/android_tests/readme.md
+++ b/android_tests/readme.md
@@ -3,6 +3,10 @@ ruby_lib_android
ruby_lib's Android tests. Requires `Ruby 2.2+` or better.
+Tests use non-global drivers by default. To check legacy global mode, run
+`APPIUM_TEST_GLOBAL_DRIVER=true bundle exec rake android`. Both modes use the
+same test cases and explicitly close their session after the run.
+
- `rake install` Install gems required to run the tests.
- `rake android` Run all tests.
- `rake android['android/element/generic']` Run a single test.
diff --git a/appium_lib.gemspec b/appium_lib.gemspec
index 1c306d7e..bfefb0ed 100644
--- a/appium_lib.gemspec
+++ b/appium_lib.gemspec
@@ -18,6 +18,7 @@ Gem::Specification.new do |s|
s.add_dependency 'appium_lib_core', '>= 13.0.0', '< 14.0'
s.add_dependency 'nokogiri', '~> 1.8', '>= 1.8.1'
+ s.add_dependency 'ostruct', '~> 0.6'
s.add_dependency 'tomlrb', '>= 1.1', '< 4.0'
s.files = Dir.glob('lib/**/*.rb', base: __dir__) + %w[appium_lib.gemspec readme.md LICENSE-2.0.txt CHANGELOG.md]
diff --git a/docs/docs.md b/docs/docs.md
index 12521239..a9477c99 100644
--- a/docs/docs.md
+++ b/docs/docs.md
@@ -34,7 +34,9 @@ apk = {
appActivity: '.Settings',
appWaitActivity: '.Settings'
}
-Appium::Driver.new({caps: apk}, false).start_driver
+appium = Appium::Driver.new({caps: apk}, false)
+appium.start_driver
+appium.find_element(:id, 'target') # use the wrapper for subsequent commands
```
Example use of Appium's mobile gesture.
@@ -117,14 +119,15 @@ require 'json' # for .to_json
require 'sauce_whisk'
After do |scenario|
- # end the test session, ignoring any exceptions.
- ignore { $driver.driver_quit }
+ # Keep @appium_driver from setup and capture the ID before closing its session.
+ session_id = @appium_driver.session_id
+ @appium_driver.driver_quit
user = ENV['SAUCE_USERNAME']
key = ENV['SAUCE_ACCESS_KEY']
if user && !user.empty? && key && !key.empty?
passed = ! scenario.failed?
- SauceWhisk::Jobs.change_status $driver.driver.session_id, passed
+ SauceWhisk::Jobs.change_status session_id, passed
end
end
```
diff --git a/docs/parallel.md b/docs/parallel.md
index 138fbb7b..d868077c 100644
--- a/docs/parallel.md
+++ b/docs/parallel.md
@@ -1,3 +1,66 @@
+# Using drivers without a global reference
+
+`Appium::Driver.new(opts)` uses a non-global driver by default. You can also
+select this explicitly with `Appium::Driver.new(opts, false)` or
+`Appium::Driver.new(opts.merge(global_driver: false))`. An explicit second
+argument takes precedence over the hash option; `nil` uses the hash option.
+The constructor does not remove the option from the caller's hash.
+
+Keep the ruby_lib wrapper to use its helpers. `start_driver` returns the
+underlying core driver, not the wrapper:
+
+```ruby
+appium = Appium::Driver.new(opts, false)
+begin
+ appium.start_driver
+ appium.find_element(:id, 'target').click
+ensure
+ appium.driver_quit
+end
+```
+
+Each wrapper starts, restarts, and quits only its own session. Non-global
+wrappers can coexist with a legacy `$driver`; their page inspection and
+coordinate helpers use their own driver. App strings are cached per session
+and are loaded again after a restart.
+
+For multiple devices, keep one wrapper per device and configure distinct
+server-side device identifiers and ports where required by the Appium driver.
+Do not concurrently operate on the same wrapper from multiple threads.
+
+For Page Objects, hold the driver on each instance:
+
+```ruby
+class LoginPage
+ def initialize(appium)
+ @appium = appium
+ end
+
+ def submit
+ @appium.find_element(:id, 'submit').click
+ end
+end
+
+first_page = LoginPage.new(first_driver)
+second_page = LoginPage.new(second_driver)
+```
+
+`Appium.promote_appium_methods(MyTests, appium)` and
+`Appium.promote_singleton_appium_methods([MyPageModule], appium)` also work
+without `$driver`. Promotion binds a whole class or module to a driver, not
+individual Page Object instances. Re-promoting a class changes the driver for
+all its instances. Use distinct targets or instance-held drivers for concurrent
+sessions. Omitting the driver argument still uses `$driver` for compatibility.
+
+## Legacy global mode
+
+`Appium::Driver.new(opts, true)` continues to register `$driver` and close the
+previous global session when replacing it. Existing implicit promotion calls
+remain available in this mode. Starting a non-global wrapper no longer closes
+the global session, and restarting any wrapper only replaces its own session.
+
+## Existing parallel examples
+
Appium 1.7.0+ supports single server and multiple sessions, and it requires Xcode 9 for iOS.
# Example
diff --git a/ios_tests/appium.txt b/ios_tests/appium.txt
index d88c0ad5..9598eacb 100644
--- a/ios_tests/appium.txt
+++ b/ios_tests/appium.txt
@@ -7,6 +7,7 @@ app = "../test_apps/UICatalog.app.zip"
someCapability = "some_capability"
simulatorStartupTimeout = 600000
wdaLaunchTimeout = 600000
+includeSafariInWebviews = true
[appium_lib]
wait = 30
diff --git a/ios_tests/lib/common.rb b/ios_tests/lib/common.rb
index 867db930..b81e9580 100644
--- a/ios_tests/lib/common.rb
+++ b/ios_tests/lib/common.rb
@@ -31,7 +31,7 @@ def go_to_textfields
end
def screen
- $driver.find_element(:class, ui_ios.navbar).name
+ AppiumTestSession.driver.find_element(:class, ui_ios.navbar).name
end
def catalog
@@ -43,7 +43,28 @@ def target_bundle_id
end
def ui_ios
- UI.new($driver)
+ UI.new(AppiumTestSession.driver)
+end
+
+# Opt in for tests that must not inherit a previous test's screen or context.
+module IosTestNavigation
+ def reset_test_app
+ set_context 'NATIVE_APP'
+ driver.terminate_app target_bundle_id
+ driver.activate_app target_bundle_id
+ wait_true { screen == catalog }
+ end
+
+ def setup
+ super
+ reset_test_app
+ end
+
+ def teardown
+ reset_test_app
+ ensure
+ super
+ end
end
class UI
diff --git a/ios_tests/lib/fixture_server.rb b/ios_tests/lib/fixture_server.rb
new file mode 100644
index 00000000..60c9d930
--- /dev/null
+++ b/ios_tests/lib/fixture_server.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'socket'
+
+# A Simulator can reach the host loopback address. Keep web tests offline.
+class IosFixtureServer
+ attr_reader :url
+
+ def initialize
+ @server = TCPServer.new('127.0.0.1', 0)
+ @url = "http://127.0.0.1:#{@server.addr[1]}/"
+ @thread = Thread.new do
+ loop do
+ socket = @server.accept
+ begin
+ while (line = socket.gets) && line != "\r\n"
+ # Consume the request headers before returning the fixture.
+ end
+ body = '
ruby-lib-webviewruby-lib-webview'
+ socket.write "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n" \
+ "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
+ ensure
+ socket.close
+ end
+ end
+ end
+ end
+
+ def close
+ @thread.kill
+ @thread.join
+ @server.close
+ end
+end
diff --git a/ios_tests/lib/ios/specs/common/patch.rb b/ios_tests/lib/ios/specs/common/patch.rb
index 56b8d4f1..5ee0e284 100644
--- a/ios_tests/lib/ios/specs/common/patch.rb
+++ b/ios_tests/lib/ios/specs/common/patch.rb
@@ -32,7 +32,7 @@ def test_02_appium_core_element_method_name
end
def test_03_appium_core_element_method_location_rel
- loc = first_text.location_rel($driver)
+ loc = first_text.location_rel(AppiumTestSession.driver)
assert_equal loc.x.class, String
assert_equal loc.y.class, String
end
diff --git a/ios_tests/lib/ios/specs/common/web_context.rb b/ios_tests/lib/ios/specs/common/web_context.rb
index 95b4c796..aaaa51e5 100644
--- a/ios_tests/lib/ios/specs/common/web_context.rb
+++ b/ios_tests/lib/ios/specs/common/web_context.rb
@@ -15,36 +15,59 @@
# Tests specifically for areas where the web_context differs in behaviour
# rake "ios[common/web_context]"
+require_relative '../../../fixture_server'
+
class IosTest
class Common
class WebContext < Minitest::Test
- def test_01_before_first
- driver.terminate_app target_bundle_id
- driver.activate_app target_bundle_id
- assert_equal screen, catalog
- end
+ include IosTestNavigation
- def test_02_get_ios_inspect
- find_eles_by_predicate_include(value: 'Web').first.click
+ SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'
- wait_true { available_contexts.size >= 2 }
- web_view_context = available_contexts.find { |c| c.start_with? 'WEBVIEW' } # Get WEBVIEW_59153.1 for example.
+ def loaded_webview
+ context = nil
+ wait_true(timeout: 60) do
+ context = xcuitest_get_contexts.find do |candidate|
+ candidate['id'].start_with?('WEBVIEW_') &&
+ candidate['url'] == @fixture.url
+ end
+ end
+ context
+ end
- set_context web_view_context
- assert_equal current_context, web_view_context
- sleep 1 # Give a chance to load
- assert_equal page.start_with?("\nhtml\n"), true
+ def setup
+ super
+ @fixture = IosFixtureServer.new
+ # UICatalog's legacy UIWebView uses a protocol removed by RemoteDebugger.
+ # Safari exercises web-context inspection with a supported WebKit target.
+ driver.execute_script 'mobile: deepLink', url: @fixture.url, bundleId: SAFARI_BUNDLE_ID
end
- def test_03_xcuitest_get_contexts
- context = xcuitest_get_contexts
- assert_equal({ 'id' => 'NATIVE_APP' }, context.first)
- assert context[1]['id'].include?('WEBVIEW_')
+ def teardown
+ set_context 'NATIVE_APP'
+ driver.terminate_app SAFARI_BUNDLE_ID
+ ensure
+ @fixture&.close
+ super
end
- def test_04_after_last
+ def test_get_ios_inspect
+ webview = loaded_webview
+ set_context webview.fetch('id')
+ assert_equal webview.fetch('id'), current_context
+ wait_true { execute_script('return document.readyState') == 'complete' }
+ assert_includes page, "\nbody\n text: ruby-lib-webview"
+ assert_includes get_source, 'ruby-lib-webview'
+ ensure
set_context 'NATIVE_APP'
- back_click
+ end
+
+ def test_xcuitest_get_contexts
+ webview = loaded_webview
+ assert_includes xcuitest_get_contexts, { 'id' => 'NATIVE_APP' }
+ assert_includes available_contexts, webview.fetch('id')
+ assert_equal SAFARI_BUNDLE_ID, webview.fetch('bundleId')
+ assert_equal @fixture.url, webview.fetch('url')
end
end
end
diff --git a/ios_tests/lib/ios/specs/driver.rb b/ios_tests/lib/ios/specs/driver.rb
index 49d16d7e..22217687 100644
--- a/ios_tests/lib/ios/specs/driver.rb
+++ b/ios_tests/lib/ios/specs/driver.rb
@@ -39,7 +39,7 @@ def test_03_load_settings
def test_04_verify_all_attributes
actual = driver_attributes
caps_app_for_teardown = actual[:caps][:app]
- expected_app = File.absolute_path('../test_apps/UICatalog.app.zip')
+ expected_app = File.absolute_path(ENV.fetch('IOS_APP', '../test_apps/UICatalog.app.zip'))
# actual[:caps].to_json send to Appium server
caps_with_json = JSON.parse(actual[:caps].to_json)
@@ -89,11 +89,11 @@ def test_07_no_wait
def test_08_app_path_attr
apk_name = File.basename driver_attributes[:caps][:app]
- assert_equal apk_name, 'UICatalog.app.zip'
+ assert_equal File.basename(ENV.fetch('IOS_APP', 'UICatalog.app.zip')), apk_name
end
def test_09_driver_class
- assert_equal $driver.class, Appium::Driver
+ assert_equal AppiumTestSession.driver.class, Appium::Driver
end
def test_10_status
diff --git a/ios_tests/lib/ios/specs/ios/command/multi_app_handler.rb b/ios_tests/lib/ios/specs/ios/command/multi_app_handler.rb
index 5d186ee5..e395c67a 100644
--- a/ios_tests/lib/ios/specs/ios/command/multi_app_handler.rb
+++ b/ios_tests/lib/ios/specs/ios/command/multi_app_handler.rb
@@ -17,29 +17,29 @@ class IosTest
class Ios
class Command
class MultiAppHandler < Minitest::Test
- # Only for Xcode 9+
- def test_multip_app_handler
- driver.terminate_app target_bundle_id
- driver.activate_app target_bundle_id
+ include IosTestNavigation
+ def test_multi_app_handler
test_app_bundle = target_bundle_id
- assert_equal xcuitest_query_app_status(bundle_id: test_app_bundle), :running_in_foreground
+ assert_equal :running_in_foreground, xcuitest_query_app_status(bundle_id: test_app_bundle)
- assert_equal xcuitest_terminate_app(bundle_id: test_app_bundle), true
- assert_equal xcuitest_query_app_status(bundle_id: test_app_bundle), :not_running
+ assert_equal true, xcuitest_terminate_app(bundle_id: test_app_bundle)
+ assert_equal :not_running, xcuitest_query_app_status(bundle_id: test_app_bundle)
assert xcuitest_activate_app(bundle_id: test_app_bundle).nil?
- assert_equal xcuitest_query_app_status(bundle_id: test_app_bundle), :running_in_foreground
+ assert_equal :running_in_foreground, xcuitest_query_app_status(bundle_id: test_app_bundle)
assert xcuitest_activate_app(bundle_id: 'com.apple.Preferences').nil?
wait(timeout: 5) do
- assert_equal xcuitest_query_app_status(bundle_id: test_app_bundle), :running_in_background_suspended
+ # iOS may suspend the background app before we observe its state.
+ assert_includes %i[running_in_background running_in_background_suspended],
+ xcuitest_query_app_status(bundle_id: test_app_bundle)
end
assert xcuitest_activate_app(bundle_id: test_app_bundle).nil?
wait(timeout: 5) do
- assert_equal xcuitest_query_app_status(bundle_id: test_app_bundle), :running_in_foreground
+ assert_equal :running_in_foreground, xcuitest_query_app_status(bundle_id: test_app_bundle)
end
end
end
diff --git a/ios_tests/lib/ios/specs/ios/xcuitest_gestures.rb b/ios_tests/lib/ios/specs/ios/xcuitest_gestures.rb
index 8349939e..9ed58561 100644
--- a/ios_tests/lib/ios/specs/ios/xcuitest_gestures.rb
+++ b/ios_tests/lib/ios/specs/ios/xcuitest_gestures.rb
@@ -16,111 +16,84 @@
class IosTest
class Ios
class XcuitestGestures < Minitest::Test
- def open_alert_ok_cancel
- wait_true do
- find_element(:name, 'Okay / Cancel').click
- find_element(:name, 'A Short Title Is Best').displayed?
- end
- end
+ include IosTestNavigation
- def open_alert_custom
- wait_true do
- find_element(:name, 'Other').click
- find_element(:name, 'A Short Title Is Best').displayed?
- end
+ def open_screen(name)
+ wait { find_element(:accessibility_id, name).click }
+ wait_true { screen == name }
end
- def test_01_before_first
- driver.terminate_app target_bundle_id
- driver.activate_app target_bundle_id
- assert_equal screen, catalog
+ def open_alert(name)
+ wait { find_element(:accessibility_id, name).click }
+ wait_true { find_element(:name, 'A Short Title Is Best').displayed? }
end
- def test_02_tap
- element = text('controls')
- one_finger_tap x: 0, y: 0, element: element
-
- back_click
-
- element = text('controls')
- rect = element.rect
+ def test_tap
+ element = text_exact('Buttons')
+ one_finger_tap x: element.rect.width / 2, y: element.rect.height / 2, element: element
+ wait_true { screen == 'Buttons' }
+ rect = button_exact('Button').rect
one_finger_tap x: rect.x + rect.width / 2, y: rect.y + rect.height / 2
+ assert_equal 'Buttons', screen
end
- def test_03_double_tap
- element = button('Search')
- double_tap(element: element)
+ def test_double_tap
+ open_screen 'Buttons'
+ double_tap(element: button_exact('Button'))
+ assert_equal 'Buttons', screen
end
- def test_04_touch_and_hold
- element = button('Tools')
- touch_and_hold(element: element, duration: 4.0)
- touch_and_hold(x: 100, y: 100)
+ def test_touch_and_hold
+ open_screen 'Buttons'
+ element = button_exact('Button')
+ touch_and_hold(element: element, duration: 1.0)
+ rect = element.rect
+ touch_and_hold(x: rect.x + rect.width / 2, y: rect.y + rect.height / 2)
+ assert_equal 'Buttons', screen
end
- def test_05_swipe
- swipe direction: 'down'
- swipe direction: 'down'
-
- assert_raises Selenium::WebDriver::Error::NoSuchElementError do
- text('Toolbars')
- end
+ def test_swipe
+ table = find_element(:class_name, ui_ios.table)
+ 2.times { swipe direction: 'up', element: table }
+ wait_true { find_element(:accessibility_id, 'Toolbars').displayed? }
+ 2.times { swipe direction: 'down', element: table }
+ wait_true { find_element(:accessibility_id, 'Activity Indicators').displayed? }
end
- def test_06_drag_from_to_for_duration
+ def test_drag_from_to_for_duration
drag_from_to_for_duration from_x: 100, from_y: 100, to_x: 100, to_y: 400
+ assert_equal catalog, screen
end
- def test_07_pinch
+ def test_pinch
+ open_screen 'Image View'
pinch(scale: 0.5, velocity: -1)
+ assert_equal 'Image View', screen
end
- def test_08_back_to_top
- back_click
- end
-
- def test_09_select_picker_wheel
- element = text('Picker View')
- one_finger_tap x: 0, y: 0, element: element
-
- e = find_element :class, ui_ios.picker_wheel
- previous_value = e.text
- select_picker_wheel(element: e, order: 'next')
+ def test_select_picker_wheel
+ open_screen 'Picker View'
+ element = find_element :class, ui_ios.picker_wheel
+ previous_value = element.text
+ select_picker_wheel(element: element, order: 'next')
current_element = find_element(:class, ui_ios.picker_wheel)
- assert_equal current_element.displayed?, true
- assert current_element.text != previous_value
- end
-
- def test_10_back_to_top
- back_click
+ assert current_element.displayed?
+ refute_equal previous_value, current_element.text
end
- def test_11_alert
- wait_true do
- find_element(:name, 'Alert Views').click
- tag(ui_ios.navbar).name == 'Alert Views'
- end
-
- open_alert_ok_cancel
+ def test_alert
+ open_screen 'Alert Views'
+ open_alert 'Okay / Cancel'
alert action: 'accept'
- open_alert_ok_cancel
+ open_alert 'Okay / Cancel'
alert action: 'dismiss'
- open_alert_custom
- list = alert action: 'getButtons'
- assert_equal list, ['Choice One', 'Choice Two', 'Cancel']
+ open_alert 'Other'
+ assert_equal ['Choice One', 'Choice Two', 'Cancel'], alert(action: 'getButtons')
alert action: 'accept', button_label: 'Choice Two'
end
-
- def test_12_back_to_top
- back_click
- end
-
- def test_13_after_last
- assert_equal screen, catalog
- end
end
end
end
diff --git a/ios_tests/readme.md b/ios_tests/readme.md
index 7e3dd0e0..309de003 100644
--- a/ios_tests/readme.md
+++ b/ios_tests/readme.md
@@ -3,6 +3,10 @@ ruby_lib_ios
ruby_lib's iOS tests. Requires `Ruby 2.2+` or better.
+Tests use non-global drivers by default. To check legacy global mode, run
+`APPIUM_TEST_GLOBAL_DRIVER=true bundle exec rake ios`. Both modes use the
+same test cases and explicitly close their session after the run.
+
- `rake install` Install gems required to run the tests.
- `rake ios` Run all tests.
- `rake` Run all the test. Same as `rake ios`
@@ -29,4 +33,15 @@ Finished in 1 min 57 secs
### Tips for parallel
- https://github.com/grosser/parallel_tests
- - Usage: https://github.com/appium/ruby_lib_core#run-parallel-tests-with-parallel_tests-gem
\ No newline at end of file
+ - Usage: https://github.com/appium/ruby_lib_core#run-parallel-tests-with-parallel_tests-gem
+
+For a local Simulator with a different runtime or device, set `IOS_VERSION` and
+`IOS_DEVICE_NAME`. `IOS_APP` may point to a locally built UICatalog app with the
+same screens and bundle ID (for example, an ARM Simulator build). The default
+remains `test_apps/UICatalog.app.zip`. CI allows up to 900 seconds per HTTP request
+with `APPIUM_TEST_READ_TIMEOUT`, so the 600-second WDA startup budget can finish.
+
+Web-context tests open Safari against a temporary localhost HTML fixture. The
+bundled UICatalog uses legacy UIWebView, whose protocol is no longer supported by
+current Appium RemoteDebugger. These tests require an iOS Simulator and restore
+UICatalog after closing the fixture server.
diff --git a/lib/appium_lib/android/common/helper.rb b/lib/appium_lib/android/common/helper.rb
index 50cb291b..707429f6 100644
--- a/lib/appium_lib/android/common/helper.rb
+++ b/lib/appium_lib/android/common/helper.rb
@@ -27,9 +27,10 @@ def filter=(value)
@filter = value.to_s.downcase
end
- def initialize # rubocop:disable Lint/MissingSuper
+ def initialize(driver = $driver) # rubocop:disable Lint/MissingSuper
reset
@filter = false
+ @driver = driver
end
def reset
@@ -38,7 +39,7 @@ def reset
end
# http://nokogiri.org/Nokogiri/XML/SAX/Document.html
- def start_element(name, attrs = [], driver = $driver)
+ def start_element(name, attrs = [], driver = @driver)
return if filter && !name.downcase.include?(filter)
attributes = {}
@@ -49,7 +50,7 @@ def start_element(name, attrs = [], driver = $driver)
# scoped to: text resource-id content-desc view-tag
attributes_values = attributes.values
- strings = driver.lazy_load_strings
+ strings = driver ? driver.lazy_load_strings : {}
id_matches = strings.empty? ? [] : strings.select { |_key, value| attributes_values.include? value }
string_ids = nil
@@ -104,7 +105,7 @@ def get_android_inspect(class_name = false)
parser = if source_is_html # parse html from webview
@android_html_parser ||= Nokogiri::HTML::SAX::Parser.new(Appium::Common::HTMLElements.new)
else
- @android_native_parser ||= Nokogiri::XML::SAX::Parser.new(AndroidElements.new)
+ @android_native_parser ||= Nokogiri::XML::SAX::Parser.new(AndroidElements.new(self))
end
parser.document.reset # ensure document is reset before parsing
parser.document.filter = class_name
diff --git a/lib/appium_lib/appium.rb b/lib/appium_lib/appium.rb
index de3d2610..e58ae157 100644
--- a/lib/appium_lib/appium.rb
+++ b/lib/appium_lib/appium.rb
@@ -152,7 +152,7 @@ def expand_required_files(base_dir, file_paths)
# @param [Array] modules An array of modules
# @param [Driver] driver A driver to extend for
def promote_singleton_appium_methods(modules, driver = $driver)
- raise ArgumentError, 'Global $driver is nil' if driver.nil?
+ raise ArgumentError, 'Driver is nil' if driver.nil?
target_modules = []
@@ -167,16 +167,24 @@ def promote_singleton_appium_methods(modules, driver = $driver)
end
target_modules.each do |const|
+ promoted_methods = const.instance_variable_get(:@_appium_promoted_singleton_methods) || {}
+ const.instance_variable_set(:@_appium_promoted_singleton_methods, promoted_methods)
# noinspection RubyResolve
driver.public_methods(false).each do |m|
- # override unless there's an existing method with matching arity
- next if const.respond_to?(m) && const.method(m).arity == driver.method(m).arity
+ # Preserve user methods, but refresh wrappers that captured an earlier driver.
+ if const.respond_to?(m)
+ existing = const.method(m)
+ next if existing != promoted_methods[m] && existing.arity == driver.method(m).arity
+
+ const.singleton_class.send(:remove_method, m) if existing == promoted_methods[m]
+ end
const.send(:define_singleton_method, m) do |*args, **kwargs, &block|
super(*args, **kwargs, &block) # promote.rb
rescue NoMethodError, ::ArgumentError
driver.send m, *args, **kwargs, &block if driver.respond_to?(m)
end
+ promoted_methods[m] = const.method(m)
end
end
end
diff --git a/lib/appium_lib/common/helper.rb b/lib/appium_lib/common/helper.rb
index a457b7cb..302ad73a 100644
--- a/lib/appium_lib/common/helper.rb
+++ b/lib/appium_lib/common/helper.rb
@@ -68,6 +68,7 @@ def xpaths(xpath_str)
# json and ap are required for the source method.
require 'json'
+ require 'ostruct'
# @private
# http://nokogiri.org/Nokogiri/XML/SAX.html
@@ -162,7 +163,7 @@ def get_source
#
# px_to_window_rel x: 50, y: 150 #=> #
#
- def px_to_window_rel(opts = {}, driver = $driver)
+ def px_to_window_rel(opts = {}, driver = self)
w = driver.window_size
x = opts.fetch :x, 0
y = opts.fetch :y, 0
@@ -173,6 +174,8 @@ def px_to_window_rel(opts = {}, driver = $driver)
# @private
def lazy_load_strings
+ return {} if @driver.nil?
+
# app strings only works on local apps.
# on disk apps (ex: com.android.settings) will error
@lazy_load_strings ||= ignore { app_strings } || {}
@@ -182,24 +185,21 @@ def lazy_load_strings
# @param target [String] the target to search for in strings.xml values
# @return [Array]
def xml_keys(target)
- lazy_load_strings
- @lazy_load_strings.select { |key, _value| key.downcase.include? target.downcase }
+ lazy_load_strings.select { |key, _value| key.downcase.include? target.downcase }
end
# Search strings.xml's keys for target.
# @param target [String] the target to search for in strings.xml keys
# @return [Array]
def xml_values(target)
- lazy_load_strings
- @lazy_load_strings.select { |_key, value| value.downcase.include? target.downcase }
+ lazy_load_strings.select { |_key, value| value.downcase.include? target.downcase }
end
# Resolve id in strings.xml and return the value.
# @param id [String] the id to resolve
# @return [String]
def resolve_id(id)
- lazy_load_strings
- @lazy_load_strings[id]
+ lazy_load_strings[id]
end
# @private
diff --git a/lib/appium_lib/driver.rb b/lib/appium_lib/driver.rb
index 711c1891..62363b0f 100644
--- a/lib/appium_lib/driver.rb
+++ b/lib/appium_lib/driver.rb
@@ -130,7 +130,7 @@ def get_cap(caps, name)
# Instance of Appium::Core::Driver
attr_reader :core
- # Creates a new driver. The driver is defined as global scope by default.
+ # Creates a new driver without a global reference by default.
# We can avoid defining global driver.
#
# @example
@@ -195,15 +195,18 @@ def get_cap(caps, name)
# appium_driver.start_driver
#
# @param opts [Object] A hash containing various options.
- # @param global_driver [Bool] A bool require global driver before initialize.
+ # @param global_driver [Boolean, nil] Register as $driver when true. nil uses opts[:global_driver], default false.
# @return [Driver]
- def initialize(opts = {}, global_driver = false)
- # Capybara can't put `global_driver` as the 2nd argument.
- global_driver = opts.delete :global_driver if global_driver.nil?
+ def initialize(opts = {}, global_driver = nil)
+ raise ArgumentError, 'opts must be a hash' unless opts.is_a? Hash
- $driver&.driver_quit if global_driver
+ # Support options-only callers without changing their options hash.
+ # An explicit second argument takes precedence over the hash option.
+ opts = opts.dup
+ configured_global_driver = opts.delete(:global_driver) { opts.delete('global_driver') { false } }
+ global_driver = configured_global_driver if global_driver.nil?
- raise ArgumentError, 'opts must be a hash' unless opts.is_a? Hash
+ $driver&.driver_quit if global_driver
@core = ::Appium::Core.for(opts)
extend ::Appium::Core::Device
@@ -495,9 +498,12 @@ def element_screenshot(element, png_save_path)
# @return [void]
def driver_quit
@driver&.quit
- @driver = nil
+ nil
rescue Selenium::WebDriver::Error::WebDriverError
nil
+ ensure
+ @driver = nil
+ @lazy_load_strings = nil
end
alias quit_driver driver_quit
@@ -534,7 +540,7 @@ def window_rect
@driver.window_rect
end
- # Creates a new global driver and quits the old one if it exists.
+ # Creates a new session and quits this instance's previous session if it exists.
# You can customise http_client as the following
#
# Read http://www.rubydoc.info/github/appium/ruby_lib_core/Appium/Core/Device to understand more what the driver
@@ -562,7 +568,7 @@ def window_rect
# @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] the underlying driver for the new session
def start_driver(http_client_ops = { http_client: nil, open_timeout: 999_999, read_timeout: 999_999 })
http_client = http_client_ops[:http_client]
if http_client.nil?
@@ -570,12 +576,8 @@ def start_driver(http_client_ops = { http_client: nil, open_timeout: 999_999, re
read_timeout: http_client_ops[:read_timeout])
end
- # TODO: do not kill the previous session in the future version.
- if $driver.nil?
- driver_quit
- else
- $driver.driver_quit
- end
+ # Each wrapper owns its session, even when another wrapper is global.
+ driver_quit
# If automationName is set only in server side, then the following automation_name should be nil before
# starting driver.
diff --git a/readme.md b/readme.md
index c132d4c9..79e6fc7b 100644
--- a/readme.md
+++ b/readme.md
@@ -15,6 +15,9 @@ Helper methods for writing cross platform (iOS, Android) tests in Ruby using App
Ordinary, `ruby_lib` worked with class driver, `$driver`, mainly.
We can avoid the class driver with current `ruby_lib`, but if you'd like to implement your test cases based on instance driver, `@driver`, you can consider using `ruby_lib_core` first.
+See [non-global drivers and migration](docs/parallel.md) for instance-based usage,
+Page Objects, and coexistence with legacy global drivers.
+
# Setup
## Requirement
- [Appium](https://github.com/appium/appium#requirements)
diff --git a/test/non_global_driver_test.rb b/test/non_global_driver_test.rb
new file mode 100644
index 00000000..4903e4e1
--- /dev/null
+++ b/test/non_global_driver_test.rb
@@ -0,0 +1,371 @@
+# frozen_string_literal: true
+
+require 'minitest/autorun'
+require 'appium_lib'
+
+class NonGlobalDriverTest < Minitest::Test
+ class Session
+ attr_reader :quit_count, :calls
+ attr_accessor :strings
+
+ def initialize(name, width = 375)
+ @name = name
+ @width = width
+ @quit_count = 0
+ @calls = []
+ @strings = { 'label' => name }
+ end
+
+ def quit
+ @quit_count += 1
+ end
+
+ def session_id = @name
+ def window_size = Struct.new(:width, :height).new(@width, 667)
+ def app_strings(*) = @strings
+ def page_source = ""
+
+ def find_elements(*args)
+ @calls << [:find_elements, args]
+ []
+ end
+
+ def find_element(*args)
+ @calls << [:find_element, args]
+ @name
+ end
+
+ def update_settings(settings)
+ @calls << [:update_settings, settings]
+ settings
+ end
+
+ def execute_script(*args)
+ @calls << [:execute_script, args]
+ args
+ end
+ end
+
+ def setup
+ @previous_global = $driver
+ $driver = nil
+ end
+
+ def teardown
+ $driver = @previous_global
+ end
+
+ def build(*sessions, global: false, platform: 'Android', automation: 'UiAutomator2')
+ wrapper = Appium::Driver.new({ caps: { platformName: platform, automationName: automation } }, global)
+ queue = sessions.dup
+ wrapper.core.define_singleton_method(:start_driver) do |**options|
+ @http_client = options[:http_client_ops][:http_client]
+ @driver = queue.shift || raise('No mock session left')
+ end
+ wrapper.define_singleton_method(:appium_server_version) { {} }
+ wrapper
+ end
+
+ def test_global_driver_registration_and_replacement
+ first_session = Session.new('first')
+ first = build(first_session, global: true)
+ first.start_driver
+ assert_same first, $driver
+
+ second = build(Session.new('second'), global: true)
+ assert_equal 1, first_session.quit_count
+ assert_same second, $driver
+ second.start_driver
+ assert_equal 'second', second.session_id
+ end
+
+ def test_global_helpers_and_implicit_promotion_remain_supported
+ global = build(Session.new('legacy'), global: true)
+ global.start_driver
+ target = Class.new
+ Appium.promote_appium_methods(target)
+ assert_equal 'legacy', target.new.session_id
+ assert_equal '50.0 / 375.0', target.new.px_to_window_rel(x: 50).x
+ assert_includes target.new.get_android_inspect, 'strings.xml: label'
+ end
+
+ def test_quit_error_clears_session_and_strings_cache
+ old_session = Session.new('old')
+ def old_session.quit
+ raise Selenium::WebDriver::Error::WebDriverError, 'Session already gone'
+ end
+ local = build(old_session, Session.new('replacement'))
+ local.start_driver
+ assert_equal 'old', local.resolve_id('label')
+ assert_nil local.driver_quit
+ assert_nil local.driver
+ local.start_driver
+ assert_equal 'replacement', local.resolve_id('label')
+ end
+
+ def test_constructor_hash_option_and_explicit_false_precedence
+ opts = { caps: { platformName: 'Android', automationName: 'UiAutomator2' }, global_driver: true }.freeze
+ global = Appium::Driver.new(opts)
+ assert_same global, $driver
+ assert_equal true, opts[:global_driver]
+
+ local = Appium::Driver.new(opts, false)
+ refute_same local, $driver
+ assert_same global, $driver
+ end
+
+ def test_constructor_nil_and_string_key_options
+ opts = { caps: { platformName: 'Android', automationName: 'UiAutomator2' }, 'global_driver' => true }
+ global = Appium::Driver.new(opts, nil)
+ assert_same global, $driver
+ assert_equal true, opts['global_driver']
+ end
+
+ def test_explicit_true_overrides_hash_false
+ opts = { caps: { platformName: 'Android', automationName: 'UiAutomator2' }, global_driver: false }
+ global = Appium::Driver.new(opts, true)
+ assert_same global, $driver
+ end
+
+ def test_explicit_coordinate_driver_is_still_supported
+ local = build(Session.new('local', 375))
+ other = build(Session.new('other', 1000))
+ local.start_driver
+ other.start_driver
+ assert_equal '50.0 / 1000.0', local.px_to_window_rel({ x: 50 }, other).x
+ assert_instance_of OpenStruct, local.px_to_window_rel(x: 50)
+ end
+
+ def test_singleton_repromotion_preserves_user_replacement
+ a = build(Session.new('first'))
+ b = build(Session.new('second'))
+ target = Module.new
+ Appium.promote_singleton_appium_methods([target], a)
+ target.define_singleton_method(:find_element) { |*args| [:custom, args] }
+ Appium.promote_singleton_appium_methods([target], b)
+ assert_equal [:custom, [:id, 'target']], target.find_element(:id, 'target')
+ end
+
+ def test_default_and_explicit_false_leave_global_unset
+ opts = { caps: { platformName: 'Android', automationName: 'UiAutomator2' } }
+ Appium::Driver.new(opts.dup)
+ Appium::Driver.new(opts.dup, false)
+ Appium::Driver.new(opts.merge(global_driver: false))
+ assert_nil $driver
+ end
+
+ def test_two_non_global_sessions_are_isolated
+ first = Session.new('first')
+ second = Session.new('second')
+ a = build(first)
+ b = build(second)
+ a.start_driver
+ b.start_driver
+ assert_equal 'first', a.find_element(:id, 'target')
+ assert_equal 'second', b.find_element(:id, 'target')
+ assert_equal [0, 0], [first.quit_count, second.quit_count]
+ assert_nil $driver
+ end
+
+ def test_restart_only_closes_own_non_global_session
+ first = Session.new('first')
+ replacement = Session.new('replacement')
+ other = Session.new('other')
+ a = build(first, replacement)
+ b = build(other)
+ a.start_driver
+ b.start_driver
+ a.restart
+ assert_equal [1, 0, 0], [first.quit_count, replacement.quit_count, other.quit_count]
+ assert_equal 'replacement', a.session_id
+ assert_equal 'other', b.session_id
+ end
+
+ def test_quit_is_idempotent_and_does_not_close_other_session
+ first = Session.new('first')
+ second = Session.new('second')
+ a = build(first)
+ b = build(second)
+ a.start_driver
+ b.start_driver
+ 2.times { a.driver_quit }
+ assert_equal [1, 0], [first.quit_count, second.quit_count]
+ assert_nil a.driver
+ assert_equal 'second', b.session_id
+ end
+
+ def test_start_non_global_must_not_close_global_session
+ global_session = Session.new('global')
+ local_session = Session.new('local')
+ g = build(global_session, global: true)
+ g.start_driver
+ local = build(local_session)
+ local.start_driver
+ assert_equal 0, global_session.quit_count
+ assert_equal 'global', g.session_id
+ end
+
+ def test_start_non_global_closes_own_previous_session_even_with_global_reference
+ first = Session.new('first')
+ replacement = Session.new('replacement')
+ local = build(first, replacement)
+ local.start_driver
+ build(global: true)
+ local.start_driver
+ assert_equal 1, first.quit_count
+ assert_equal 'replacement', local.session_id
+ end
+
+ def test_local_coordinates_without_global
+ d = build(Session.new('local', 375))
+ d.start_driver
+ assert_equal '50.0 / 375.0', d.px_to_window_rel(x: 50).x
+ end
+
+ def test_local_coordinates_ignore_other_global_driver
+ local = build(Session.new('local', 375))
+ local.start_driver
+ g = build(Session.new('global', 1000), global: true)
+ g.start_driver
+ assert_equal '50.0 / 375.0', local.px_to_window_rel(x: 50).x
+ end
+
+ def test_android_inspect_isolated_from_global_and_other_local_driver
+ a = build(Session.new('first'))
+ b = build(Session.new('second'))
+ a.start_driver
+ b.start_driver
+ g = build(Session.new('global'), global: true)
+ g.start_driver
+ assert_includes a.get_android_inspect, 'text: first'
+ assert_includes a.get_android_inspect, 'strings.xml: label'
+ assert_includes b.get_android_inspect, 'text: second'
+ assert_includes b.get_android_inspect, 'strings.xml: label'
+ end
+
+ def test_android_inspect_without_any_global_driver
+ d = build(Session.new('local'))
+ d.start_driver
+ assert_includes d.get_android_inspect, 'strings.xml: label'
+ assert_nil $driver
+ end
+
+ def test_strings_cache_is_isolated_between_instances
+ a = build(Session.new('first'))
+ b = build(Session.new('second'))
+ a.start_driver
+ b.start_driver
+ assert_equal 'first', a.resolve_id('label')
+ assert_equal 'second', b.resolve_id('label')
+ end
+
+ def test_restart_invalidates_session_strings_cache
+ d = build(Session.new('first'), Session.new('replacement'))
+ d.start_driver
+ assert_equal 'first', d.resolve_id('label')
+ d.restart
+ assert_equal 'replacement', d.resolve_id('label')
+ end
+
+ def test_inspection_before_start_does_not_poison_strings_cache
+ d = build(Session.new('started'))
+ assert_empty d.lazy_load_strings
+ assert_empty d.xml_keys('label')
+ assert_empty d.xml_values('started')
+ assert_nil d.resolve_id('label')
+ d.start_driver
+ assert_equal 'started', d.resolve_id('label')
+ end
+
+ def test_common_delegation_without_global
+ s = Session.new('local')
+ d = build(s)
+ d.start_driver
+ assert_equal 'local', d.xpath('//button')
+ assert_equal({ 'setting' => true }, d.update_settings({ 'setting' => true }))
+ assert_equal ['mobile: shell', { command: 'echo', args: ['hello'] }], d.shell('echo', ['hello'])
+ end
+
+ def test_ios_gesture_without_global
+ d = build(Session.new('ios'), platform: 'iOS', automation: 'XCUITest')
+ d.start_driver
+ assert_equal ['mobile: swipe', { direction: 'down' }], d.swipe(direction: 'down')
+ assert_nil $driver
+ end
+
+ def test_espresso_helpers_without_global
+ s = Session.new('espresso')
+ d = build(s, automation: 'Espresso')
+ d.start_driver
+ assert_equal 'espresso', d.find('target')
+ assert_equal :xpath, s.calls.last[1].first
+ end
+
+ def test_explicit_promotion_without_global
+ d = build(Session.new('local'))
+ d.start_driver
+ target = Class.new
+ Appium.promote_appium_methods(target, d)
+ assert_equal 'local', target.new.session_id
+ assert_nil $driver
+ end
+
+ def test_class_promotion_retains_shared_class_semantics
+ a = build(Session.new('first'))
+ b = build(Session.new('second'))
+ a.start_driver
+ b.start_driver
+ target = Class.new
+ Appium.promote_appium_methods(target, a)
+ first_page = target.new
+ assert_equal 'first', first_page.session_id
+ Appium.promote_appium_methods(target, b)
+ assert_equal 'second', first_page.session_id
+ end
+
+ def test_singleton_promotion_can_switch_driver
+ a = build(Session.new('first'))
+ b = build(Session.new('second'))
+ a.start_driver
+ b.start_driver
+ target = Module.new
+ Appium.promote_singleton_appium_methods([target], a)
+ assert_equal 'first', target.session_id
+ Appium.promote_singleton_appium_methods([target], b)
+ assert_equal 'second', target.session_id
+ end
+
+ def test_singleton_promotion_preserves_inherited_keyword_method
+ driver = Object.new
+ driver.define_singleton_method(:command) { |**kwargs| [:driver, kwargs] }
+ inherited = Module.new do
+ def command(**kwargs) = [:inherited, kwargs]
+ end
+ target = Module.new { extend inherited }
+ Appium.promote_singleton_appium_methods([target], driver)
+ assert_equal [:inherited, { value: 1 }], target.command(value: 1)
+ end
+
+ def test_singleton_repromotion_updates_variadic_methods
+ first = Session.new('first')
+ second = Session.new('second')
+ a = build(first)
+ b = build(second)
+ a.start_driver
+ b.start_driver
+ target = Module.new
+ Appium.promote_singleton_appium_methods([target], a)
+ Appium.promote_singleton_appium_methods([target], b)
+ target.find_element(:id, 'target')
+ assert_empty first.calls
+ assert_equal [[:find_element, [:id, 'target']]], second.calls
+ end
+
+ def test_promoted_keyword_method_accepts_previous_hash_call_style
+ d = build(Session.new('ios'), platform: 'iOS', automation: 'XCUITest')
+ d.start_driver
+ target = Class.new
+ Appium.promote_appium_methods(target, d)
+ assert_equal ['mobile: swipe', { direction: 'down' }], target.new.swipe({ direction: 'down' })
+ end
+end
diff --git a/test/regression_test.rb b/test/regression_test.rb
index 87a94f67..e09aca0d 100644
--- a/test/regression_test.rb
+++ b/test/regression_test.rb
@@ -163,7 +163,7 @@ def stub_driver.lazy_load_strings
)
doc.start_element(
'androidx.compose.ui.platform.ComposeView',
- [['view-tag', 'compose_tag']],
+ [%w[view-tag compose_tag]],
stub_driver
)