From 3cbb1088961e2a4ebcc50b671d607aafdccec3cc Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Thu, 29 Oct 2026 00:23:37 -0700 Subject: [PATCH 1/4] feat: prepare to drop the global var --- .../lib/android/specs/common/patch.rb | 2 +- android_tests/lib/android/specs/driver.rb | 4 +- android_tests/lib/run.rb | 14 +- android_tests/readme.md | 4 + appium_lib.gemspec | 1 + docs/docs.md | 11 +- docs/parallel.md | 63 +++ ios_tests/lib/common.rb | 4 +- ios_tests/lib/ios/specs/common/patch.rb | 2 +- ios_tests/lib/ios/specs/driver.rb | 2 +- ios_tests/readme.md | 4 + lib/appium_lib/android/common/helper.rb | 9 +- lib/appium_lib/appium.rb | 14 +- lib/appium_lib/common/helper.rb | 14 +- lib/appium_lib/driver.rb | 34 +- readme.md | 3 + test/non_global_driver_test.rb | 371 ++++++++++++++++++ test/regression_test.rb | 2 +- 18 files changed, 514 insertions(+), 44 deletions(-) create mode 100644 test/non_global_driver_test.rb diff --git a/android_tests/lib/android/specs/common/patch.rb b/android_tests/lib/android/specs/common/patch.rb index effe44f6..023d9e5e 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 diff --git a/android_tests/lib/android/specs/driver.rb b/android_tests/lib/android/specs/driver.rb index f9e2212e..0ddd42a6 100644 --- a/android_tests/lib/android/specs/driver.rb +++ b/android_tests/lib/android/specs/driver.rb @@ -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/run.rb b/android_tests/lib/run.rb index b97505f4..90474bf3 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 @@ -118,4 +128,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/lib/common.rb b/ios_tests/lib/common.rb index 867db930..890d3035 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,7 @@ def target_bundle_id end def ui_ios - UI.new($driver) + UI.new(AppiumTestSession.driver) end class UI 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/driver.rb b/ios_tests/lib/ios/specs/driver.rb index 49d16d7e..c51bf6b0 100644 --- a/ios_tests/lib/ios/specs/driver.rb +++ b/ios_tests/lib/ios/specs/driver.rb @@ -93,7 +93,7 @@ def test_08_app_path_attr 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/readme.md b/ios_tests/readme.md index 7e3dd0e0..027ac974 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` 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 ) From ca6ff1a42bd7380d41eb8181105d76463858d0cd Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Thu, 29 Oct 2026 00:59:00 -0700 Subject: [PATCH 2/4] update GHA --- .github/workflows/functional-test.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 05368249..9f548a2d 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -14,12 +14,18 @@ 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 steps: - uses: actions/checkout@v7 @@ -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/ From 19a0bb4eedcaef17d4ba0cc042b6e26477d66a19 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Thu, 29 Oct 2026 08:09:19 -0700 Subject: [PATCH 3/4] test: stabilize functional tests for both driver modes Reset Android navigation between affected tests, restore implicit waits and relaunch after reinstall. Use the current startActivity extension, fix capability expectations and synchronize multitouch actions. Remove applesimutils from the iOS workflow. Validated on Android API 36 with global_driver=true and false: 100 tests and 150 assertions per mode. Unit tests: 43 tests and 100 assertions. RuboCop: 108 files clean. --- .github/workflows/functional-test.yml | 2 - android_tests/lib/android/navigation.rb | 17 ++++++++ .../android/specs/android/element/alert.rb | 22 +++++++--- .../android/specs/android/element/button.rb | 22 ++++------ .../lib/android/specs/android/element/text.rb | 13 ++++++ .../lib/android/specs/android/patch.rb | 13 ++++++ .../lib/android/specs/common/device.rb | 43 +++++++++++-------- .../specs/common/device_touchaction.rb | 15 ++++++- .../lib/android/specs/common/patch.rb | 3 +- android_tests/lib/android/specs/driver.rb | 2 +- android_tests/lib/android/specs/install.rb | 14 +++--- 11 files changed, 118 insertions(+), 48 deletions(-) create mode 100644 android_tests/lib/android/navigation.rb diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 9f548a2d..157381ec 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -50,8 +50,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 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 023d9e5e..a2fd17d6 100644 --- a/android_tests/lib/android/specs/common/patch.rb +++ b/android_tests/lib/android/specs/common/patch.rb @@ -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 0ddd42a6..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 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 From db255c5346555d2afd16eb0c2b001e9f75eb3d6e Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Thu, 29 Oct 2026 22:13:16 -0700 Subject: [PATCH 4/4] test: update iOS functional tests for current WebKit and app states Use Safari with a local HTML fixture instead of legacy UIWebView. Reset navigation for affected tests, use stable gesture targets, and accept both valid background app states. Allow the iOS HTTP timeout to cover WDA startup and support local Simulator capability overrides. Validated changed iOS classes on iOS 26.5 with a rebuilt ARM UICatalog and CI-matching Appium 3.9.0 / XCUITest 12.22.4: 36 tests and 77 assertions pass in each global-driver mode. Unit tests: 43 tests / 100 assertions. RuboCop: 109 files clean. Full iOS 18.5 suite remains for CI. --- .github/workflows/functional-test.yml | 2 + android_tests/lib/run.rb | 6 + ios_tests/appium.txt | 1 + ios_tests/lib/common.rb | 21 +++ ios_tests/lib/fixture_server.rb | 34 +++++ ios_tests/lib/ios/specs/common/web_context.rb | 61 ++++++--- ios_tests/lib/ios/specs/driver.rb | 4 +- .../specs/ios/command/multi_app_handler.rb | 20 +-- .../lib/ios/specs/ios/xcuitest_gestures.rb | 123 +++++++----------- ios_tests/readme.md | 13 +- 10 files changed, 178 insertions(+), 107 deletions(-) create mode 100644 ios_tests/lib/fixture_server.rb diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 157381ec..609e4ba5 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -26,6 +26,8 @@ jobs: 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 diff --git a/android_tests/lib/run.rb b/android_tests/lib/run.rb index 90474bf3..4634c016 100644 --- a/android_tests/lib/run.rb +++ b/android_tests/lib/run.rb @@ -76,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'] 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 890d3035..b81e9580 100644 --- a/ios_tests/lib/common.rb +++ b/ios_tests/lib/common.rb @@ -46,6 +46,27 @@ def ui_ios 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 def initialize(driver) @driver = driver 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/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 c51bf6b0..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,7 +89,7 @@ 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 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 027ac974..309de003 100644 --- a/ios_tests/readme.md +++ b/ios_tests/readme.md @@ -33,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.