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 )