diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 7d86a98a..621f416e 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -223,6 +223,13 @@ jobs: - target: test/functional/android/android/mjpeg_server_test.rb,test/functional/android/android/image_comparison_test.rb automation_name: espresso name: test10 + # FIXME: rever the comment out after https://github.com/appium/appium/pull/21468 + # - target: test/functional/android/webdriver/bidi_test.rb + # automation_name: uiautomator2 + # name: test11 + # - target: test/functional/android/webdriver/bidi_test.rb + # automation_name: espresso + # name: test12 env: API_LEVEL: 36 diff --git a/lib/appium_lib_core/common/base.rb b/lib/appium_lib_core/common/base.rb index 490fb722..3738565c 100644 --- a/lib/appium_lib_core/common/base.rb +++ b/lib/appium_lib_core/common/base.rb @@ -30,8 +30,9 @@ require_relative 'device/orientation' # The following files have selenium-webdriver related stuff. -require_relative 'base/driver' require_relative 'base/bridge' +require_relative 'base/bidi_bridge' +require_relative 'base/driver' require_relative 'base/capabilities' require_relative 'base/http_default' require_relative 'base/search_context' diff --git a/lib/appium_lib_core/common/base/bidi_bridge.rb b/lib/appium_lib_core/common/base/bidi_bridge.rb new file mode 100644 index 00000000..349805b9 --- /dev/null +++ b/lib/appium_lib_core/common/base/bidi_bridge.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require_relative 'bridge' + +module Appium + module Core + class Base + class BiDiBridge < ::Appium::Core::Base::Bridge + attr_reader :bidi + + # Override + # Creates session handling. + # + # @param [::Appium::Core::Base::Capabilities, Hash] capabilities A capability + # @return [::Appium::Core::Base::Capabilities] + # + # @example + # + # opts = { + # caps: { + # platformName: :android, + # automationName: 'uiautomator2', + # platformVersion: '15', + # deviceName: 'Android', + # webSocketUrl: true, + # }, + # appium_lib: { + # wait: 30 + # } + # } + # core = ::Appium::Core.for(caps) + # driver = core.start_driver + # + def create_session(capabilities) + super + + return @capabilities if @capabilities.nil? + + begin + socket_url = @capabilities[:web_socket_url] + @bidi = ::Selenium::WebDriver::BiDi.new(url: socket_url) if socket_url + rescue StandardError => e + ::Appium::Logger.warn "WebSocket connection to #{socket_url} for BiDi failed. Error #{e}" + raise + end + + @capabilities + end + + def get(url) + browsing_context.navigate(url) + end + + def go_back + browsing_context.traverse_history(-1) + end + + def go_forward + browsing_context.traverse_history(1) + end + + def refresh + browsing_context.reload + end + + def quit + super + ensure + bidi.close + end + + def close + execute(:close_window).tap { |handles| bidi.close if handles.empty? } + end + + private + + def browsing_context + @browsing_context ||= ::Selenium::WebDriver::BiDi::BrowsingContext.new(self) + end + end # class BiDiBridge + end # class Base + end # module Core +end # module Appium diff --git a/lib/appium_lib_core/common/base/driver.rb b/lib/appium_lib_core/common/base/driver.rb index b66e3648..8437667c 100644 --- a/lib/appium_lib_core/common/base/driver.rb +++ b/lib/appium_lib_core/common/base/driver.rb @@ -54,7 +54,9 @@ def initialize(bridge: nil, listener: nil, **opts) # rubocop:disable Lint/Missin @devtools = nil @bidi = nil - # in the selenium webdriver as well + # internal use + @has_bidi = false + ::Selenium::WebDriver::Remote::Bridge.element_class = ::Appium::Core::Element bridge ||= create_bridge(**opts) add_extensions(bridge.browser) @@ -79,7 +81,9 @@ def create_bridge(**opts) raise ::Appium::Core::Error::ArgumentError, "Unable to create a driver with parameters: #{opts}" unless opts.empty? - bridge = ::Appium::Core::Base::Bridge.new(**bridge_opts) + @has_bidi = capabilities && capabilities['webSocketUrl'] + bridge_clzz = @has_bidi ? ::Appium::Core::Base::BiDiBridge : ::Appium::Core::Base::Bridge + bridge = bridge_clzz.new(**bridge_opts) if session_id.nil? bridge.create_session(capabilities) @@ -996,6 +1000,28 @@ def execute_driver(script: '', type: 'webdriverio', timeout_ms: nil) def convert_to_element(response_id) @bridge.convert_to_element response_id end + + # Return bidi instance + # @return [::Selenium::WebDriver::BiDi] + # + # @example + # + # log_entries = [] + # driver.bidi.send_cmd('session.subscribe', 'events': ['log.entryAdded'], 'contexts': ['NATIVE_APP']) + # subscribe_id = driver.bidi.add_callback('log.entryAdded') do |params| + # log_entries << params + # end + # driver.page_source + # + # driver.bidi.remove_callback('log.entryAdded', subscribe_id) + # driver.bidi.send_cmd('session.unsubscribe', 'events': ['log.entryAdded'], 'contexts': ['NATIVE_APP']) + # + def bidi + return @bridge.bidi if @has_bidi + + msg = 'BiDi must be enabled by providing webSocketUrl capability to true' + raise(::Selenium::WebDriver::Error::WebDriverError, msg) + end end # class Driver end # class Base end # module Core diff --git a/lib/appium_lib_core/driver.rb b/lib/appium_lib_core/driver.rb index df531801..634a2a0e 100644 --- a/lib/appium_lib_core/driver.rb +++ b/lib/appium_lib_core/driver.rb @@ -421,8 +421,8 @@ def start_driver(server_url: nil, d_c = DirectConnections.new(@driver.capabilities) @driver.update_sending_request_to(protocol: d_c.protocol, host: d_c.host, port: d_c.port, path: d_c.path) end - rescue Errno::ECONNREFUSED - raise "ERROR: Unable to connect to Appium. Is the server running on #{@custom_url}?" + rescue Errno::ECONNREFUSED => e + raise "ERROR: Unable to connect to Appium. Is the server running on #{@custom_url}? Error: #{e}" end if @http_client.instance_variable_defined? :@additional_headers diff --git a/sig/lib/appium_lib_core/common/base/bidi_bridge.rbs b/sig/lib/appium_lib_core/common/base/bidi_bridge.rbs new file mode 100644 index 00000000..0a16a16e --- /dev/null +++ b/sig/lib/appium_lib_core/common/base/bidi_bridge.rbs @@ -0,0 +1,25 @@ +module Appium + module Core + class Base + class BiDiBridge < ::Appium::Core::Base::Bridge + @bidi: ::Selenium::WebDriver::BiDi + + def attach_to: (untyped session_id, untyped platform_name, untyped automation_name) -> untyped + + def create_session: (untyped capabilities) -> ::Appium::Core::Base::Capabilities + + def get: (string url) -> untyped + + def go_back: () -> untyped + + def go_forward: () -> untyped + + def refresh: () -> untyped + + def quit: () -> untyped + + def close: () -> untyped + end + end + end +end diff --git a/sig/lib/appium_lib_core/common/base/bridge.rbs b/sig/lib/appium_lib_core/common/base/bridge.rbs index 35714957..59c86e32 100644 --- a/sig/lib/appium_lib_core/common/base/bridge.rbs +++ b/sig/lib/appium_lib_core/common/base/bridge.rbs @@ -94,7 +94,7 @@ module Appium # core = ::Appium::Core.for(caps) # driver = core.start_driver # - def create_session: (untyped capabilities) -> untyped + def create_session: (untyped capabilities) -> ::Appium::Core::Base::Capabilities # Append +appium:+ prefix for Appium following W3C spec # https://www.w3.org/TR/webdriver/#dfn-validate-capabilities diff --git a/sig/lib/appium_lib_core/common/base/search_context.rbs b/sig/lib/appium_lib_core/common/base/search_context.rbs index 3bcda524..9e72ef78 100644 --- a/sig/lib/appium_lib_core/common/base/search_context.rbs +++ b/sig/lib/appium_lib_core/common/base/search_context.rbs @@ -1 +1,9 @@ -APPIUM_EXTRA_FINDERS: { accessibility_id: "accessibility id", image: "-image", custom: "-custom", uiautomator: "-android uiautomator", viewtag: "-android viewtag", data_matcher: "-android datamatcher", view_matcher: "-android viewmatcher", predicate: "-ios predicate string", class_chain: "-ios class chain" } +module Appium + module Core + class Base + module SearchContext + APPIUM_EXTRA_FINDERS: { Symbol => String } + end + end + end +end diff --git a/test/functional/android/webdriver/bidi_test.rb b/test/functional/android/webdriver/bidi_test.rb new file mode 100644 index 00000000..40eb4bc1 --- /dev/null +++ b/test/functional/android/webdriver/bidi_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'test_helper' + +# $ rake test:func:android TEST=test/functional/android/webdriver/bidi_test.rb +class AppiumLibCoreTest + module WebDriver + class BidiTest < AppiumLibCoreTest::Function::TestCase + def test_bidi + caps = Caps.android + caps[:capabilities]['webSocketUrl'] = true + core = ::Appium::Core.for(caps) + + driver = core.start_driver + assert !driver.capabilities.nil? + + log_entries = [] + + driver.bidi.send_cmd('session.subscribe', 'events': ['log.entryAdded'], 'contexts': ['NATIVE_APP']) + subscribe_id = driver.bidi.add_callback('log.entryAdded') do |params| + log_entries << params + end + + driver.page_source + + begin + driver.bidi.remove_callback('log.entryAdded', subscribe_id) + driver.bidi.send_cmd('session.unsubscribe', 'events': ['log.entryAdded'], 'contexts': ['NATIVE_APP']) + rescue StandardError + # ignore + end + + driver&.quit + end + end + end +end diff --git a/test/unit/driver_test.rb b/test/unit/driver_test.rb index 70c04c93..6dd47272 100644 --- a/test/unit/driver_test.rb +++ b/test/unit/driver_test.rb @@ -700,6 +700,53 @@ def test_listener_with_custom_listener_elements assert_equal ::Appium::Core::Element, c_el.first.class end + def test_bidi_bridge + # Mock the BiDi WebSocket connection using Minitest + mock_bidi = Minitest::Mock.new + mock_bidi.expect(:close, nil) + + android_mock_create_session_w3c_direct = lambda do |core| + response = { + value: { + sessionId: '1234567890', + capabilities: { + platformName: :android, + automationName: ENV['APPIUM_DRIVER'] || 'uiautomator2', + deviceName: 'Android Emulator', + webSocketUrl: 'ws://127.0.0.1:4723/bidi/fbed26aa-e104-42fc-9f5e-b401dc6cc2bc' + } + } + }.to_json + + stub_request(:post, 'http://127.0.0.1:4723/session') + .to_return(headers: HEADER, status: 200, body: response) + + driver = nil + ::Selenium::WebDriver::BiDi.stub(:new, mock_bidi) do + driver = core.start_driver + end + + assert_requested(:post, 'http://127.0.0.1:4723/session', times: 1) + driver + end + + capabilities = Caps.android[:capabilities] + capabilities['webSocketUrl'] = true + + core = ::Appium::Core.for capabilities: capabilities + driver = android_mock_create_session_w3c_direct.call(core) + + assert_equal driver.send(:bridge).class, Appium::Core::Base::BiDiBridge + assert !driver.send(:bridge).respond_to?(:driver) + + stub_request(:delete, 'http://127.0.0.1:4723/session/1234567890') + .to_return(headers: HEADER, status: 200, body: { value: nil }.to_json) + + driver.quit + # Verify that close was called exactly once + mock_bidi.verify + end + def test_elements driver = android_mock_create_session