Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[rb] Initial extensibility points for Appium #12141

Merged
merged 6 commits into from
May 15, 2024
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
12 changes: 10 additions & 2 deletions rb/lib/selenium/webdriver/common/search_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ module SearchContext
xpath: 'xpath'
}.freeze

class << self
attr_accessor :extra_finders

def finders
FINDERS.merge(extra_finders || {})
end
end

#
# Find the first element matching the given arguments
#
Expand All @@ -57,7 +65,7 @@ module SearchContext
def find_element(*args)
how, what = extract_args(args)

by = FINDERS[how.to_sym]
by = SearchContext.finders[how.to_sym]
raise ArgumentError, "cannot find element by #{how.inspect}" unless by

bridge.find_element_by by, what, ref
Expand All @@ -72,7 +80,7 @@ def find_element(*args)
def find_elements(*args)
how, what = extract_args(args)

by = FINDERS[how.to_sym]
by = SearchContext.finders[how.to_sym]
raise ArgumentError, "cannot find elements by #{how.inspect}" unless by

bridge.find_elements_by by, what, ref
Expand Down
78 changes: 32 additions & 46 deletions rb/lib/selenium/webdriver/remote/bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,34 @@ module WebDriver
module Remote
class Bridge
autoload :COMMANDS, 'selenium/webdriver/remote/bridge/commands'
autoload :LocatorConverter, 'selenium/webdriver/remote/bridge/locator_converter'

include Atoms

PORT = 4444

attr_accessor :http, :file_detector
attr_reader :capabilities

class << self
attr_reader :extra_commands
attr_writer :element_class, :locator_converter

def add_command(name, verb, url, &block)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we want Selenium to use this as part of Features modules, so we aren't subclassing #commands?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which is to say, we probably should be doing the same thing with custom browser features as Appium does, either way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I would refactor Selenium after Appium approves the changes.

@extra_commands ||= {}
@extra_commands[name] = [verb, url]
define_method(name, &block)
end

def locator_converter
@locator_converter ||= LocatorConverter.new
end

def element_class
@element_class ||= Element
end
end

#
# Initializes the bridge with the given server URL
# @param [String, URI] url url for the remote server
Expand All @@ -43,6 +64,8 @@ def initialize(url:, http_client: nil)
@http = http_client || Http::Default.new
@http.server_url = uri
@file_detector = nil

@locator_converter = self.class.locator_converter
end

#
Expand Down Expand Up @@ -413,7 +436,7 @@ def submit_element(element)
"e.initEvent('submit', true, true);\n" \
"if (form.dispatchEvent(e)) { HTMLFormElement.prototype.submit.call(form) }\n"

execute_script(script, Element::ELEMENT_KEY => element)
execute_script(script, Bridge.element_class::ELEMENT_KEY => element)
rescue Error::JavascriptError
raise Error::UnsupportedOperationError, 'To submit an element, it must be nested inside a form element'
end
Expand Down Expand Up @@ -500,13 +523,13 @@ def element_value_of_css_property(element, prop)
#

def active_element
Element.new self, element_id_from(execute(:get_active_element))
Bridge.element_class.new self, element_id_from(execute(:get_active_element))
end

alias switch_to_active_element active_element

def find_element_by(how, what, parent_ref = [])
how, what = convert_locator(how, what)
how, what = @locator_converter.convert(how, what)

return execute_atom(:findElements, Support::RelativeLocator.new(what).as_json).first if how == 'relative'

Expand All @@ -520,11 +543,11 @@ def find_element_by(how, what, parent_ref = [])
execute :find_element, {}, {using: how, value: what.to_s}
end

Element.new self, element_id_from(id)
Bridge.element_class.new self, element_id_from(id)
end

def find_elements_by(how, what, parent_ref = [])
how, what = convert_locator(how, what)
how, what = @locator_converter.convert(how, what)

return execute_atom :findElements, Support::RelativeLocator.new(what).as_json if how == 'relative'

Expand All @@ -538,7 +561,7 @@ def find_elements_by(how, what, parent_ref = [])
execute :find_elements, {}, {using: how, value: what.to_s}
end

ids.map { |id| Element.new self, element_id_from(id) }
ids.map { |id| Bridge.element_class.new self, element_id_from(id) }
end

def shadow_root(element)
Expand Down Expand Up @@ -612,7 +635,7 @@ def escaper
end

def commands(command)
command_list[command]
command_list[command] || Bridge.extra_commands[command]
end

def unwrap_script_result(arg)
Expand All @@ -621,7 +644,7 @@ def unwrap_script_result(arg)
arg.map { |e| unwrap_script_result(e) }
when Hash
element_id = element_id_from(arg)
return Element.new(self, element_id) if element_id
return Bridge.element_class.new(self, element_id) if element_id

shadow_root_id = shadow_root_id_from(arg)
return ShadowRoot.new self, shadow_root_id if shadow_root_id
Expand All @@ -633,7 +656,7 @@ def unwrap_script_result(arg)
end

def element_id_from(id)
id['ELEMENT'] || id[Element::ELEMENT_KEY]
id['ELEMENT'] || id[Bridge.element_class::ELEMENT_KEY]
end

def shadow_root_id_from(id)
Expand All @@ -644,43 +667,6 @@ def prepare_capabilities_payload(capabilities)
capabilities = {alwaysMatch: capabilities} if !capabilities['alwaysMatch'] && !capabilities['firstMatch']
{capabilities: capabilities}
end

def convert_locator(how, what)
how = SearchContext::FINDERS[how.to_sym] || how

case how
when 'class name'
how = 'css selector'
what = ".#{escape_css(what.to_s)}"
when 'id'
how = 'css selector'
what = "##{escape_css(what.to_s)}"
when 'name'
how = 'css selector'
what = "*[name='#{escape_css(what.to_s)}']"
end

if what.is_a?(Hash)
what = what.each_with_object({}) do |(h, w), hash|
h, w = convert_locator(h.to_s, w)
hash[h] = w
end
end

[how, what]
end

ESCAPE_CSS_REGEXP = /(['"\\#.:;,!?+<>=~*^$|%&@`{}\-\[\]()])/
UNICODE_CODE_POINT = 30

# Escapes invalid characters in CSS selector.
# @see https://mathiasbynens.be/notes/css-escapes
def escape_css(string)
string = string.gsub(ESCAPE_CSS_REGEXP) { |match| "\\#{match}" }
string = "\\#{UNICODE_CODE_POINT + Integer(string[0])} #{string[1..]}" if string[0]&.match?(/[[:digit:]]/)

string
end
end # Bridge
end # Remote
end # WebDriver
Expand Down
76 changes: 76 additions & 0 deletions rb/lib/selenium/webdriver/remote/bridge/locator_converter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you 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.

module Selenium
module WebDriver
module Remote
class Bridge
class LocatorConverter
ESCAPE_CSS_REGEXP = /(['"\\#.:;,!?+<>=~*^$|%&@`{}\-\[\]()])/
UNICODE_CODE_POINT = 30

#
# Converts a locator to a specification compatible one.
# @param [String, Symbol] how
# @param [String] what
#

def convert(how, what)
how = SearchContext.finders[how.to_sym] || how

case how
when 'class name'
how = 'css selector'
what = ".#{escape_css(what.to_s)}"
when 'id'
how = 'css selector'
what = "##{escape_css(what.to_s)}"
when 'name'
how = 'css selector'
what = "*[name='#{escape_css(what.to_s)}']"
end

if what.is_a?(Hash)
what = what.each_with_object({}) do |(h, w), hash|
h, w = convert(h.to_s, w)
hash[h] = w
end
end

[how, what]
end

private

#
# Escapes invalid characters in CSS selector.
# @see https://mathiasbynens.be/notes/css-escapes
#

def escape_css(string)
string = string.gsub(ESCAPE_CSS_REGEXP) { |match| "\\#{match}" }
string = "\\#{UNICODE_CODE_POINT + Integer(string[0])} #{string[1..]}" if string[0]&.match?(/[[:digit:]]/)

string
end
end # LocatorConverter
end # Bridge
end # Remote
end # WebDriver
end # Selenium
24 changes: 21 additions & 3 deletions rb/lib/selenium/webdriver/remote/http/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,18 @@ class Common
CONTENT_TYPE = 'application/json'
DEFAULT_HEADERS = {
'Accept' => CONTENT_TYPE,
'Content-Type' => "#{CONTENT_TYPE}; charset=UTF-8",
'User-Agent' => "selenium/#{WebDriver::VERSION} (ruby #{Platform.os})"
'Content-Type' => "#{CONTENT_TYPE}; charset=UTF-8"
}.freeze

class << self
attr_accessor :extra_headers
attr_writer :user_agent

def user_agent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, do we need all this? Watir is just subclassing the Default client and making a tweak here. https://github.com/watir/watir/blob/main/lib/watir/http_client.rb

What is Appium doing now?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KazuCocoa Would implementing a custom HTTP client in Appium be better than this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, the watir way seems good. Appium overrides DEFAULT_HEADERS, b ut the watir way is sufficient for the usage. Then, this pr can drop the user_agent related change actually

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, I'm not saying the Watir approach is "correct," if it makes more sense to do something else, Watir will do that way. 😄 Presumably we are supposed to avoid using Http::Default in case someone wants to use Http::Curb? I don't really understand why we have that one. 😂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea. Actually, the Watir way is better than the current Appium's one at least :P

Hmm, I like this pr approach, to provide a way to extend headers explicitly, than overriding the parent class and calling super

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yes, I agree. With the approach in this PR we can just inject what the user sends regardless. Would be better to minimize subclassing.

@twalpole does capybara do anything to the user agent?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it doesn't

@user_agent ||= "selenium/#{WebDriver::VERSION} (ruby #{Platform.os})"
end
end

attr_writer :server_url

def quit_errors
Expand All @@ -42,7 +50,7 @@ def close

def call(verb, url, command_hash)
url = server_url.merge(url) unless url.is_a?(URI)
headers = DEFAULT_HEADERS.dup
headers = common_headers.dup
headers['Cache-Control'] = 'no-cache' if verb == :get

if command_hash
Expand All @@ -61,6 +69,16 @@ def call(verb, url, command_hash)

private

def common_headers
@common_headers ||= begin
headers = DEFAULT_HEADERS.dup
headers['User-Agent'] = Common.user_agent
headers = headers.merge(Common.extra_headers || {})

headers
end
end

def server_url
return @server_url if @server_url

Expand Down
Loading
Loading