# frozen_string_literal: true
# Selenium specific implementation of the Capybara::Driver::Node API
require 'capybara/selenium/extensions/find'
require 'capybara/selenium/extensions/scroll'
class Capybara::Selenium::Node < Capybara::Driver::Node
include Capybara::Selenium::Find
include Capybara::Selenium::Scroll
def visible_text
native.text
end
def all_text
text = driver.evaluate_script('arguments[0].textContent', self)
text.gsub(/[\u200b\u200e\u200f]/, '')
.gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ')
.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
.gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
.tr("\u00a0", ' ')
end
def [](name)
native.attribute(name.to_s)
rescue Selenium::WebDriver::Error::WebDriverError
nil
end
def value
if tag_name == 'select' && multiple?
native.find_elements(:css, 'option:checked').map { |el| el[:value] || el.text }
else
native[:value]
end
end
def style(styles)
styles.each_with_object({}) do |style, result|
result[style] = native.css_value(style)
end
end
##
#
# Set the value of the form element to the given value.
#
# @param [String] value The new value
# @param [Hash{}] options Driver specific options for how to set the value
# @option options [Symbol,Array] :clear (nil) The method used to clear the previous value
# nil => clear via javascript
# :none => append the new value to the existing value
# :backspace => send backspace keystrokes to clear the field
# Array => an array of keys to send before the value being set, e.g. [[:command, 'a'], :backspace]
def set(value, **options)
raise ArgumentError, "Value cannot be an Array when 'multiple' attribute is not present. Not a #{value.class}" if value.is_a?(Array) && !multiple?
tag_name, type = attrs(:tagName, :type).map { |val| val&.downcase }
@tag_name ||= tag_name
case tag_name
when 'input'
case type
when 'radio'
click
when 'checkbox'
click if value ^ checked?
when 'file'
set_file(value)
when 'date'
set_date(value)
when 'time'
set_time(value)
when 'datetime-local'
set_datetime_local(value)
when 'color'
set_color(value)
else
set_text(value, options)
end
when 'textarea'
set_text(value, options)
else
set_content_editable(value)
end
end
def select_option
click unless selected? || disabled?
end
def unselect_option
raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.' unless select_node.multiple?
click if selected?
end
def click(keys = [], **options)
click_options = ClickOptions.new(keys, options)
return native.click if click_options.empty?
click_with_options(click_options)
rescue StandardError => e
if e.is_a?(::Selenium::WebDriver::Error::ElementClickInterceptedError) ||
e.message.match?(/Other element would receive the click/)
scroll_to_center
end
raise e
end
def right_click(keys = [], **options)
click_options = ClickOptions.new(keys, options)
click_with_options(click_options) do |action|
click_options.coords? ? action.context_click : action.context_click(native)
end
end
def double_click(keys = [], **options)
click_options = ClickOptions.new(keys, options)
click_with_options(click_options) do |action|
click_options.coords? ? action.double_click : action.double_click(native)
end
end
def send_keys(*args)
native.send_keys(*args)
end
def hover
scroll_if_needed { browser_action.move_to(native).perform }
end
def drag_to(element, **)
# Due to W3C spec compliance - The Actions API no longer scrolls to elements when necessary
# which means Seleniums `drag_and_drop` is now broken - do it manually
scroll_if_needed { browser_action.click_and_hold(native).perform }
element.scroll_if_needed { browser_action.move_to(element.native).release.perform }
end
def drop(*_)
raise NotImplementedError, 'Out of browser drop emulation is not implemented for the current browser'
end
def tag_name
@tag_name ||= native.tag_name.downcase
end
def visible?; boolean_attr(native.displayed?); end
def readonly?; boolean_attr(self[:readonly]); end
def multiple?; boolean_attr(self[:multiple]); end
def selected?; boolean_attr(native.selected?); end
alias :checked? :selected?
def disabled?
return true unless native.enabled?
# WebDriver only defines `disabled?` for form controls but fieldset makes sense too
find_xpath('self::fieldset/ancestor-or-self::fieldset[@disabled]').any?
end
def content_editable?
native.attribute('isContentEditable') == 'true'
end
def ==(other)
native == other.native
end
def path
driver.evaluate_script GET_XPATH_SCRIPT, self
end
def obscured?(x: nil, y: nil)
res = driver.evaluate_script(OBSCURED_OR_OFFSET_SCRIPT, self, x, y)
return true if res == true
driver.frame_obscured_at?(x: res['x'], y: res['y'])
end
def rect
native.rect
end
protected
def scroll_if_needed
yield
rescue ::Selenium::WebDriver::Error::MoveTargetOutOfBoundsError
scroll_to_center
yield
end
def scroll_to_center
script = <<-'JS'
try {
arguments[0].scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
} catch(e) {
arguments[0].scrollIntoView(true);
}
JS
begin
driver.execute_script(script, self)
rescue StandardError # rubocop:disable Lint/HandleExceptions
# Swallow error if scrollIntoView with options isn't supported
end
end
private
def sibling_index(parent, node, selector)
siblings = parent.find_xpath(selector)
case siblings.size
when 0
'[ERROR]' # IE doesn't support full XPath (namespace-uri, etc)
when 1
'' # index not necessary when only one matching element
else
idx = siblings.index(node)
# Element may not be found in the siblings if it has gone away
idx.nil? ? '[ERROR]' : "[#{idx + 1}]"
end
end
def boolean_attr(val)
val && (val != 'false')
end
# a reference to the select node if this is an option node
def select_node
find_xpath(XPath.ancestor(:select)[1]).first
end
def set_text(value, clear: nil, **_unused)
value = value.to_s
if value.empty? && clear.nil?
native.clear
elsif clear == :backspace
# Clear field by sending the correct number of backspace keys.
backspaces = [:backspace] * self.value.to_s.length
send_keys(*([:end] + backspaces + [value]))
elsif clear.is_a? Array
send_keys(*clear, value)
else
driver.execute_script 'arguments[0].select()', self unless clear == :none
send_keys(value)
end
end
def click_with_options(click_options)
scroll_if_needed do
action_with_modifiers(click_options) do |action|
if block_given?
yield action
else
click_options.coords? ? action.click : action.click(native)
end
end
end
end
def set_date(value) # rubocop:disable Naming/AccessorMethodName
value = SettableValue.new(value)
return set_text(value) unless value.dateable?
# TODO: this would be better if locale can be detected and correct keystrokes sent
update_value_js(value.to_date_str)
end
def set_time(value) # rubocop:disable Naming/AccessorMethodName
value = SettableValue.new(value)
return set_text(value) unless value.timeable?
# TODO: this would be better if locale can be detected and correct keystrokes sent
update_value_js(value.to_time_str)
end
def set_datetime_local(value) # rubocop:disable Naming/AccessorMethodName
value = SettableValue.new(value)
return set_text(value) unless value.timeable?
# TODO: this would be better if locale can be detected and correct keystrokes sent
update_value_js(value.to_datetime_str)
end
def set_color(value) # rubocop:disable Naming/AccessorMethodName
update_value_js(value)
end
def update_value_js(value)
driver.execute_script(<<-JS, self, value)
if (arguments[0].readOnly) { return };
if (document.activeElement !== arguments[0]){
arguments[0].focus();
}
if (arguments[0].value != arguments[1]) {
arguments[0].value = arguments[1]
arguments[0].dispatchEvent(new InputEvent('input'));
arguments[0].dispatchEvent(new Event('change', { bubbles: true }));
}
JS
end
def set_file(value) # rubocop:disable Naming/AccessorMethodName
with_file_detector do
path_names = value.to_s.empty? ? [] : value
file_names = Array(path_names).map do |pn|
Pathname.new(pn).absolute? ? pn : File.expand_path(pn)
end.join("\n")
native.send_keys(file_names)
end
end
def with_file_detector
if driver.options[:browser] == :remote &&
bridge.respond_to?(:file_detector) &&
bridge.file_detector.nil?
begin
bridge.file_detector = lambda do |(fn, *)|
str = fn.to_s
str if File.exist?(str)
end
yield
ensure
bridge.file_detector = nil
end
else
yield
end
end
def set_content_editable(value) # rubocop:disable Naming/AccessorMethodName
# Ensure we are focused on the element
click
editable = driver.execute_script <<-JS, self
if (arguments[0].isContentEditable) {
var range = document.createRange();
var sel = window.getSelection();
arguments[0].focus();
range.selectNodeContents(arguments[0]);
sel.removeAllRanges();
sel.addRange(range);
return true;
}
return false;
JS
# The action api has a speed problem but both chrome and firefox 58 raise errors
# if we use the faster direct send_keys. For now just send_keys to the element
# we've already focused.
# native.send_keys(value.to_s)
browser_action.send_keys(value.to_s).perform if editable
end
def action_with_modifiers(click_options)
actions = browser_action.tap do |acts|
if click_options.center_offset? && click_options.coords?
acts.move_to(native).move_by(*click_options.coords)
else
acts.move_to(native, *click_options.coords)
end
end
modifiers_down(actions, click_options.keys)
yield actions
modifiers_up(actions, click_options.keys)
actions.perform
ensure
act = browser_action
act.release_actions if act.respond_to?(:release_actions)
end
def modifiers_down(actions, keys)
each_key(keys) { |key| actions.key_down(key) }
end
def modifiers_up(actions, keys)
each_key(keys) { |key| actions.key_up(key) }
end
def browser
driver.browser
end
def bridge
browser.send(:bridge)
end
def browser_action
browser.action
end
def each_key(keys)
keys.each do |key|
key = case key
when :ctrl then :control
when :command, :cmd then :meta
else
key
end
yield key
end
end
def find_context
native
end
def build_node(native_node, initial_cache = {})
self.class.new(driver, native_node, initial_cache)
end
def attrs(*attr_names)
return attr_names.map { |name| self[name.to_s] } if ENV['CAPYBARA_THOROUGH']
driver.evaluate_script <<~'JS', self, attr_names.map(&:to_s)
(function(el, names){
return names.map(function(name){
return el[name]
});
})(arguments[0], arguments[1]);
JS
end
GET_XPATH_SCRIPT = <<~'JS'
(function(el, xml){
var xpath = '';
var pos, tempitem2;
while(el !== xml.documentElement) {
pos = 0;
tempitem2 = el;
while(tempitem2) {
if (tempitem2.nodeType === 1 && tempitem2.nodeName === el.nodeName) { // If it is ELEMENT_NODE of the same name
pos += 1;
}
tempitem2 = tempitem2.previousSibling;
}
if (el.namespaceURI != xml.documentElement.namespaceURI) {
xpath = "*[local-name()='"+el.nodeName+"' and namespace-uri()='"+(el.namespaceURI===null?'':el.namespaceURI)+"']["+pos+']'+'/'+xpath;
} else {
xpath = el.nodeName.toUpperCase()+"["+pos+"]/"+xpath;
}
el = el.parentNode;
}
xpath = '/'+xml.documentElement.nodeName.toUpperCase()+'/'+xpath;
xpath = xpath.replace(/\/$/, '');
return xpath;
})(arguments[0], document)
JS
OBSCURED_OR_OFFSET_SCRIPT = <<~'JS'
(function(el, x, y) {
var box = el.getBoundingClientRect();
if (x == null) x = box.width/2;
if (y == null) y = box.height/2 ;
var px = box.left + x,
py = box.top + y,
e = document.elementFromPoint(px, py);
if (!el.contains(e))
return true;
return { x: px, y: py };
})(arguments[0], arguments[1], arguments[2])
JS
# SettableValue encapsulates time/date field formatting
class SettableValue
attr_reader :value
def initialize(value)
@value = value
end
def to_s
value.to_s
end
def dateable?
!value.is_a?(String) && value.respond_to?(:to_date)
end
def to_date_str
value.to_date.iso8601
end
def timeable?
!value.is_a?(String) && value.respond_to?(:to_time)
end
def to_time_str
value.to_time.strftime('%H:%M')
end
def to_datetime_str
value.to_time.strftime('%Y-%m-%dT%H:%M')
end
end
private_constant :SettableValue
# ClickOptions encapsulates click option logic
class ClickOptions
attr_reader :keys, :options
def initialize(keys, options)
@keys = keys
@options = options
end
def coords?
options[:x] && options[:y]
end
def coords
[options[:x], options[:y]]
end
def center_offset?
options[:offset] == :center
end
def empty?
keys.empty? && !coords?
end
end
private_constant :ClickOptions
end