Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions lib/appium_lib_core/common/base/bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions lib/appium_lib_core/element.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/appium_lib_core/support/event_firing_bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ 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)
es = dispatch(:find, how, what, driver) do
@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
Expand Down
2 changes: 2 additions & 0 deletions sig/gems/selenium/common/element.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions sig/lib/appium_lib_core/common/base/bridge.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion sig/lib/appium_lib_core/element.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
52 changes: 52 additions & 0 deletions test/functional/android/android/search_context_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading