diff --git a/README.md b/README.md index afbc45e3..61b16914 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,31 @@ assert attached_driver.session_id == @driver.session_id attached_driver.page_source ``` +### Element response attributes + +When the server is configured with `shouldUseCompactResponses: false` and +`elementResponseAttributes`, returned attributes are available through `element.cached`: + +```ruby +element = @driver.find_element(:accessibility_id, 'some accessibility') +element.cached.text # text returned with this element +element.cached['content-desc'] # also accepts attribute/content-desc +element.cached.attributes # returned attributes, excluding element IDs +element.text # fetches current text; does not update cached +``` + +`cached` only reads the response snapshot. It cannot find elements or send commands. +Standard boolean fields (`enabled`, `displayed`, `selected`) normalize `"true"` and +`"false"` to booleans. `cached.rect`, `cached.location`, and `cached.size` return +Selenium Rectangle, Point, and Dimension objects; `cached.tag_name` reads `name`. +These conversions also apply to `cached[name]`. Explicit `cached['attribute/...']` +lookups and custom attributes retain their server types. `cached.attributes` +exposes the original server values. +Use `cached[name]` for optional attributes; it returns `nil` when absent. Dynamic +methods such as `cached.text` are available only for attributes in the response. +Standard accessors such as `cached.enabled?` and `cached.rect` return `nil` when +their data is absent. + ### Capabilities Read [Appium/Core/Driver](https://rubydoc.info/github/appium/ruby_lib_core/Appium/Core/Driver) to catch up with available capabilities. diff --git a/lib/appium_lib_core/common/base/bridge.rb b/lib/appium_lib_core/common/base/bridge.rb index 48aefc3f..72c2951e 100644 --- a/lib/appium_lib_core/common/base/bridge.rb +++ b/lib/appium_lib_core/common/base/bridge.rb @@ -148,6 +148,13 @@ def add_appium_prefix(capabilities) private + # Preserve the response until Element initialization, including for script results. + # Selenium calls this immediately before constructing an element. Keep nil for + # non-element hashes so ordinary script results and shadow roots stay intact. + def element_id_from(response) + response if super + end + def selenium_bridge_accepts_url? ::Selenium::WebDriver::Remote::Bridge.instance_method(:initialize).parameters.any? do |_, name| name == :url diff --git a/lib/appium_lib_core/element.rb b/lib/appium_lib_core/element.rb index 2fefb228..15fbaf6a 100644 --- a/lib/appium_lib_core/element.rb +++ b/lib/appium_lib_core/element.rb @@ -17,6 +17,86 @@ module Core # Implement useful features for element. # Patch for Selenium Webdriver. class Element < ::Selenium::WebDriver::Element + # Attributes returned with an element response. This object has no bridge: + # reading it never sends a command or refreshes the returned values. + class Cache + attr_reader :attributes + + def initialize(attributes = {}) + @attributes = attributes.dup.freeze + end + + # Normalize standard response fields; arbitrary attribute/... values stay raw. + # Missing attributes return nil. The original values remain in attributes. + def attribute(name) + key = attribute_key(name) + value = @attributes[key] + case name.to_s + when 'enabled', 'displayed', 'selected' + return true if value == 'true' + return false if value == 'false' + when 'rect' + return ::Selenium::WebDriver::Rectangle.new(value['x'], value['y'], value['width'], value['height']) if value.is_a?(Hash) + end + value + end + + alias [] attribute + + def enabled? + attribute('enabled') + end + + alias enabled enabled? + + def displayed? + attribute('displayed') + end + + alias displayed displayed? + + def selected? + attribute('selected') + end + + alias selected selected? + + def rect + attribute('rect') + end + + def location + rectangle = rect + ::Selenium::WebDriver::Point.new(rectangle.x, rectangle.y) if rectangle + end + + def size + rectangle = rect + ::Selenium::WebDriver::Dimension.new(rectangle.width, rectangle.height) if rectangle + end + + def tag_name + attribute('name') + end + + def method_missing(name, *args, &block) + return super unless args.empty? && block.nil? && respond_to_missing?(name) + + attribute(name.to_s.delete_suffix('?').tr('_', '-')) + end + + def respond_to_missing?(name, include_private = false) + @attributes.key?(attribute_key(name.to_s.delete_suffix('?').tr('_', '-'))) || super + end + + private + + def attribute_key(name) + key = name.to_s + @attributes.key?("attribute/#{key}") ? "attribute/#{key}" : key + end + end + include ::Appium::Core::Base::TakesScreenshot # steep:ignore:start @@ -32,6 +112,24 @@ class Element < ::Selenium::WebDriver::Element # attr_reader :id + def initialize(bridge, id, cached = nil) + if id.is_a?(Hash) + # Preserve any extra attributes included in the element response. + cached = Cache.new(id.except('ELEMENT', ELEMENT_KEY)) + id = id['ELEMENT'] || id[ELEMENT_KEY] + end + super(bridge, id) + @cached = cached + end + + # Snapshot from elementResponseAttributes with shouldUseCompactResponses=false. + # Normal element calls still query the server and do not update this snapshot. + # Standard fields use Element API types; cached.attributes holds server values. + # @return [Appium::Core::Element::Cache] + def cached + @cached ||= Cache.new + end + # Returns the value of attributes like below. Read each platform to know more details. # # uiautomator2: https://github.com/appium/appium-uiautomator2-server/blob/203cc7e57ce477f3cff5d95b135d1b3450a6033a/app/src/main/java/io/appium/uiautomator2/utils/Attribute.java#L19 diff --git a/lib/appium_lib_core/support/event_firing_bridge.rb b/lib/appium_lib_core/support/event_firing_bridge.rb index b9751484..d99314e1 100644 --- a/lib/appium_lib_core/support/event_firing_bridge.rb +++ b/lib/appium_lib_core/support/event_firing_bridge.rb @@ -29,7 +29,7 @@ def find_element_by(how, what, parent = nil) @delegate.find_element_by how, what, parent end - ::Appium::Core::Element.new self, e.ref.last + ::Appium::Core::Element.new self, e.ref.last, e.cached end def find_elements_by(how, what, parent = nil) @@ -37,7 +37,7 @@ def find_elements_by(how, what, parent = nil) @delegate.find_elements_by(how, what, parent) end - es.map { |e| ::Appium::Core::Element.new self, e.ref.last } + es.map { |e| ::Appium::Core::Element.new self, e.ref.last, e.cached } end private diff --git a/sig/gems/selenium/common/element.rbs b/sig/gems/selenium/common/element.rbs index d8b51c14..c1b732fb 100644 --- a/sig/gems/selenium/common/element.rbs +++ b/sig/gems/selenium/common/element.rbs @@ -3,6 +3,8 @@ module Selenium class Element ELEMENT_KEY: ::String + def initialize: (untyped bridge, untyped id) -> void + def inspect: () -> untyped def ==: (untyped other) -> bool diff --git a/sig/lib/appium_lib_core/common/base/bridge.rbs b/sig/lib/appium_lib_core/common/base/bridge.rbs index 60acb8fe..ec013f9a 100644 --- a/sig/lib/appium_lib_core/common/base/bridge.rbs +++ b/sig/lib/appium_lib_core/common/base/bridge.rbs @@ -105,6 +105,8 @@ module Appium private + def element_id_from: (untyped response) -> untyped + def selenium_bridge_accepts_url?: () -> bool def camel_case: (untyped str_or_sym) -> untyped diff --git a/sig/lib/appium_lib_core/element.rbs b/sig/lib/appium_lib_core/element.rbs index 7a76c0dd..aa170b6e 100644 --- a/sig/lib/appium_lib_core/element.rbs +++ b/sig/lib/appium_lib_core/element.rbs @@ -2,9 +2,37 @@ module Appium module Core # Element class inheriting from Selenium::WebDriver::Element class Element < ::Selenium::WebDriver::Element + class Cache + attr_reader attributes: Hash[String, untyped] + + def initialize: (?Hash[String, untyped] attributes) -> void + def attribute: (String | Symbol name) -> untyped + alias [] attribute + def enabled?: () -> bool? + alias enabled enabled? + def displayed?: () -> bool? + alias displayed displayed? + def selected?: () -> bool? + alias selected selected? + def rect: () -> untyped + def location: () -> untyped + def size: () -> untyped + def tag_name: () -> String? + def method_missing: (Symbol name, *untyped args) ?{ (?) -> untyped } -> untyped + def respond_to_missing?: (Symbol name, ?bool include_private) -> bool + + private + + def attribute_key: (String | Symbol name) -> String + end + include ::Appium::Core::Base::TakesScreenshot - def initialize: (untyped bridge, untyped id) -> void + @cached: Cache? + + def initialize: (untyped bridge, untyped id, ?Cache? cached) -> void + + def cached: () -> Cache attr_reader id: Array[untyped] diff --git a/test/functional/android/android/search_context_test.rb b/test/functional/android/android/search_context_test.rb index fb72f36b..edd2eb6c 100644 --- a/test/functional/android/android/search_context_test.rb +++ b/test/functional/android/android/search_context_test.rb @@ -37,6 +37,46 @@ def test_uiautomator assert_equal ::Appium::Core::Element, e.class end + def test_cached_element_attributes_remain_a_snapshot + with_element_response_attributes do + @@core.wait { @driver.find_element(:accessibility_id, 'Views') }.click + @@core.wait { @driver.find_element(:accessibility_id, 'Buttons') }.click + + element = @driver.find_element(:id, 'io.appium.android.apis:id/button_toggle') + assert_equal 'OFF', element.cached.text + assert_equal element.enabled?, element.cached.enabled? + assert_equal element.rect, element.cached.rect + assert_equal element.location, element.cached.location + assert_equal element.size, element.cached.size + + element.click + @@core.wait { assert_equal 'ON', element.text } + assert_equal 'OFF', element.cached.text + assert_equal 'ON', @driver.find_element(:id, 'io.appium.android.apis:id/button_toggle').cached.text + end + end + + def test_cached_attributes_for_multiple_elements + with_element_response_attributes do + elements = @driver.find_elements(:class_name, 'android.widget.TextView') + refute_empty elements + elements.each do |element| + assert_equal element.text, element.cached.text + assert_equal element.enabled?, element.cached.enabled? + # The response and the attribute endpoint can encode null differently. + # Cache access preserves the value from the element response. + description = element.cached.attributes['attribute/content-desc'] + if description.nil? + assert_nil element.cached['content-desc'] + else + assert_equal description, element.cached['content-desc'] + end + refute element.cached.attributes.key?(::Appium::Core::Element::ELEMENT_KEY) + refute element.cached.attributes.key?('ELEMENT') + end + end + end + def test_viewtag skip 'UiAutomator2 does not support viewtag' if @@core.automation_name != :espresso @@ -99,6 +139,18 @@ def test_viewmatcher assert_equal "Access'ibility", e.text assert_equal ::Appium::Core::Element, e.class end + + private + + def with_element_response_attributes + skip 'These response settings require UiAutomator2' if @@core.automation_name == :espresso + + original_settings = @driver.get_settings.slice('shouldUseCompactResponses', 'elementResponseAttributes') + @driver.update_settings(shouldUseCompactResponses: false, elementResponseAttributes: 'text,rect,enabled,attribute/content-desc') + yield + ensure + @driver.update_settings(original_settings) if original_settings + end end end end diff --git a/test/unit/common/element_cache_test.rb b/test/unit/common/element_cache_test.rb new file mode 100644 index 00000000..ea076c33 --- /dev/null +++ b/test/unit/common/element_cache_test.rb @@ -0,0 +1,165 @@ +# 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' +require 'webmock/minitest' + +class AppiumLibCoreTest + class ElementCacheTest < Minitest::Test + include AppiumLibCoreTest::Mock + + def setup + @core = Appium::Core.for(Caps.android) + @driver = android_mock_create_session_w3c + end + + def test_snapshot_and_normal_operation + stub_value(:post, '/element', response) + e = @driver.find_element(id: 'example') + assert_equal 'original', e.cached.text + assert_equal false, e.cached.enabled? + assert_equal 'label', e.cached.content_desc + assert_nil e.cached['missing'] + refute e.cached.respond_to?(:find_element) + assert_raises(NoMethodError) { e.cached.find_element(id: 'x') } + assert_equal({ Appium::Core::Element::ELEMENT_KEY => 'id' }, e.as_json) + stub_value(:get, '/element/id/text', 'updated') + assert_equal 'updated', e.text + assert_equal 'original', e.cached.text + assert_requested(:get, "#{SESSION}/element/id/text", times: 1) + assert_requested(:post, "#{SESSION}/element", times: 1) + end + + def test_find_contexts + parent = Appium::Core::Element.new(@driver.bridge, 'parent') + shadow = Selenium::WebDriver::ShadowRoot.new(@driver.bridge, 'shadow') + [[@driver, ''], [parent, '/element/parent'], [shadow, '/shadow/shadow']].each do |context, prefix| + stub_value(:post, "#{prefix}/element", response) + stub_value(:post, "#{prefix}/elements", [response('one', 'first'), response('two', 'second')]) + assert_equal 'original', context.find_element(id: 'example').cached.text + es = context.find_elements(id: 'example') + assert_equal %w(one two), es.map(&:id) + assert_equal(%w(first second), es.map { |e| e.cached.text }) + end + end + + def test_active_script_and_conversion + stub_value(:get, '/element/active', response) + assert_equal 'original', @driver.switch_to.active_element.cached.text + assert_equal 'original', @driver.bridge.switch_to_active_element.cached.text + assert_equal 'original', @driver.convert_to_element(response).cached.text + stub_value(:post, '/execute/sync', [response, { 'text' => 'plain' }, { Selenium::WebDriver::ShadowRoot::ROOT_KEY => 's' }]) + values = @driver.execute_script('return results') + assert_equal 'original', values[0].cached.text + assert_equal({ 'text' => 'plain' }, values[1]) + assert_instance_of Selenium::WebDriver::ShadowRoot, values[2] + end + + def test_listener + bridge = Appium::Support::EventFiringBridge.new(@driver.bridge, Selenium::WebDriver::Support::AbstractEventListener.new) + stub_value(:post, '/element', response) + stub_value(:post, '/elements', [response]) + e = bridge.find_element_by(:id, 'example') + assert_equal 'original', e.cached.text + assert_equal 'original', bridge.find_elements_by(:id, 'example').first.cached.text + stub_value(:post, '/element/id/click', nil) + e.click + assert_requested(:post, "#{SESSION}/element/id/click", times: 1) + end + + def test_compact_and_legacy + [Appium::Core::Element::ELEMENT_KEY, 'ELEMENT'].each do |key| + stub_value(:post, '/element', { key => 'id' }) + e = @driver.find_element(id: 'example') + assert_equal 'id', e.id + assert_empty e.cached.attributes + end + end + + def test_standard_boolean_fields_match_element_api + %w(enabled displayed selected).each do |name| + { 'true' => true, 'false' => false, true => true, false => false }.each do |raw, expected| + stub_value(:post, '/element', response.merge(name => raw)) + stub_value(:get, "/element/id/#{name}", expected) + element = @driver.find_element(id: 'example') + assert_equal element.public_send("#{name}?"), element.cached.public_send("#{name}?") + assert_equal expected, element.cached.public_send(name) + assert_equal expected, element.cached[name] + assert_equal expected, element.cached.attribute(name.to_sym) + assert_equal raw, element.cached.attributes[name] + end + end + end + + def test_geometry_and_tag_name_match_element_api + rect = { 'x' => 10, 'y' => 20, 'width' => 100, 'height' => 50 } + stub_value(:post, '/element', response.merge('rect' => rect, 'name' => 'android.widget.Button')) + stub_value(:get, '/element/id/rect', rect) + stub_value(:get, '/element/id/name', 'android.widget.Button') + element = @driver.find_element(id: 'example') + assert_equal element.rect, element.cached.rect + assert_equal element.location, element.cached.location + assert_equal element.size, element.cached.size + assert_equal element.tag_name, element.cached.tag_name + assert_equal element.cached.rect, element.cached['rect'] + assert_equal rect, element.cached.attributes['rect'] + assert_requested(:get, "#{SESSION}/element/id/rect", times: 3) + assert_requested(:get, "#{SESSION}/element/id/name", times: 1) + end + + def test_missing_standard_fields_are_not_false + cache = Appium::Core::Element::Cache.new + [:enabled?, :displayed?, :selected?, :rect, :location, :size, :tag_name].each do |name| + assert_nil cache.public_send(name) + end + end + + def test_arbitrary_attributes_keep_server_types + cache = Appium::Core::Element::Cache.new('attribute/custom' => 'false', 'attribute/enabled' => 'true') + assert_equal 'false', cache.custom + assert_equal 'false', cache['custom'] + assert_equal 'true', cache['attribute/enabled'] + assert_equal true, cache.enabled? + assert_equal true, cache['enabled'] + end + + def test_custom_attribute_preserves_json_null_and_string_null + [nil, 'null'].each do |value| + stub_value(:post, '/element', response.merge('attribute/content-desc' => value)) + cache = @driver.find_element(id: 'example').cached + assert cache.attributes.key?('attribute/content-desc') + + if value.nil? + assert_nil cache.attributes['attribute/content-desc'] + assert_nil cache['content-desc'] + assert_nil cache.content_desc + else + assert_equal value, cache.attributes['attribute/content-desc'] + assert_equal value, cache['content-desc'] + assert_equal value, cache.content_desc + end + end + end + + private + + def response(id = 'id', text = 'original') + { Appium::Core::Element::ELEMENT_KEY => id, 'text' => text, 'enabled' => false, 'attribute/content-desc' => 'label' } + end + + def stub_value(method, path, value) + stub_request(method, "#{SESSION}#{path}").to_return(headers: HEADER, body: { value: value }.to_json) + end + end +end