1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636
|
# frozen_string_literal: true
# Selenium specific implementation of the Capybara::Driver::Node API
require 'capybara/selenium/extensions/find'
require 'capybara/selenium/extensions/scroll'
require 'capybara/node/whitespace_normalizer'
class Capybara::Selenium::Node < Capybara::Driver::Node
include Capybara::Node::WhitespaceNormalizer
include Capybara::Selenium::Find
include Capybara::Selenium::Scroll
def visible_text
raise NotImplementedError, 'Getting visible text is not currently supported directly on shadow roots' if shadow_root?
native.text
end
def all_text
text = driver.evaluate_script('arguments[0].textContent', self) || ''
normalize_spacing(text)
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.to_h { |style| [style, native.css_value(style)] }
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 <br/>
# nil => clear via javascript <br/>
# :none => append the new value to the existing value <br/>
# :backspace => send backspace keystrokes to clear the field <br/>
# Array => an array of keys to send before the value being set, e.g. [[:command, 'a'], :backspace]
# @option options [Boolean] :rapid (nil) Whether setting text inputs should use a faster "rapid" mode<br/>
# nil => Text inputs with length greater than 30 characters will be set using a faster driver script mode<br/>
# true => Rapid mode will be used regardless of input length<br/>
# false => Sends keys via conventional mode. This may be required to avoid losing key-presses if you have certain
# Javascript interactions on form inputs<br/>
def set(value, **options)
if value.is_a?(Array) && !multiple?
raise ArgumentError, "Value cannot be an Array when 'multiple' attribute is not present. Not a #{value.class}"
end
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)
when 'range'
set_range(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?
perform_with_options(click_options) do |action|
target = click_options.coords? ? nil : native
if click_options.delay.zero?
action.click(target)
else
action.click_and_hold(target)
action_pause(action, click_options.delay)
action.release
end
end
rescue StandardError => e
if e.is_a?(::Selenium::WebDriver::Error::ElementClickInterceptedError) ||
e.message.include?('Other element would receive the click')
scroll_to_center
end
raise e
end
def right_click(keys = [], **options)
click_options = ClickOptions.new(keys, options)
perform_with_options(click_options) do |action|
target = click_options.coords? ? nil : native
if click_options.delay.zero?
action.context_click(target)
else
action.move_to(target) if target
action.pointer_down(:right).then do |act|
action_pause(act, click_options.delay)
end.pointer_up(:right)
end
end
end
def double_click(keys = [], **options)
click_options = ClickOptions.new(keys, options)
raise ArgumentError, "double_click doesn't support a delay option" unless click_options.delay.zero?
perform_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, drop_modifiers: [], **)
drop_modifiers = Array(drop_modifiers)
# 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 }
element.scroll_if_needed do
keys_down = modifiers_down(browser_action, drop_modifiers)
keys_up = modifiers_up(keys_down.move_to(element.native).release, drop_modifiers)
keys_up.perform
end
end
def drop(*_)
raise NotImplementedError, 'Out of browser drop emulation is not implemented for the current browser'
end
def tag_name
@tag_name ||=
if native.respond_to? :tag_name
native.tag_name.downcase
else
shadow_root? ? 'ShadowRoot' : 'Unknown'
end
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 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
def shadow_root
root = native.shadow_root
root && build_node(native.shadow_root)
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
# 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, rapid: 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
if rapid == true || ((value.length > auto_rapid_set_length) && rapid != false)
send_keys(value[0..3])
driver.execute_script RAPID_APPEND_TEXT, self, value[4...-3]
send_keys(value[-3..])
else
send_keys(value)
end
end
end
def auto_rapid_set_length
30
end
def perform_with_options(click_options, &block)
raise ArgumentError, 'A block must be provided' unless block
scroll_if_needed do
action_with_modifiers(click_options) do |action|
if block
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 set_range(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.coords?
if click_options.center_offset?
acts.move_to(native, *click_options.coords)
else
right_by, down_by = *click_options.coords
size = native.size
left_offset = (size[:width] / 2).to_i
top_offset = (size[:height] / 2).to_i
left = -left_offset + right_by
top = -top_offset + down_by
acts.move_to(native, left, top)
end
else
acts.move_to(native)
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) }
actions
end
def modifiers_up(actions, keys)
each_key(keys) { |key| actions.key_up(key) }
actions
end
def browser
driver.browser
end
def bridge
browser.send(:bridge)
end
def browser_action
browser.action
end
def capabilities
browser.capabilities
end
def action_pause(action, duration)
action.pause(device: action.pointer_inputs.first, duration: duration)
end
def normalize_keys(keys)
keys.map do |key|
case key
when :ctrl then :control
when :command, :cmd then :meta
else
key
end
end
end
def each_key(keys, &block)
normalize_keys(keys).each(&block)
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
def native_id
# Selenium 3 -> 4 changed the return of ref
type_or_id, id = native.ref
id || type_or_id
end
def shadow_root?
defined?(::Selenium::WebDriver::ShadowRoot) && native.is_a?(::Selenium::WebDriver::ShadowRoot)
end
GET_XPATH_SCRIPT = <<~'JS'
(function(el, xml){
var xpath = '';
var pos, tempitem2;
if (el.getRootNode && el.getRootNode() instanceof ShadowRoot) {
return "(: Shadow DOM element - no XPath :)";
};
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
RAPID_APPEND_TEXT = <<~JS
(function(el, value) {
value = el.value + value;
if (el.maxLength && el.maxLength != -1){
value = value.slice(0, el.maxLength);
}
el.value = value;
})(arguments[0], arguments[1])
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? && delay.zero?
end
def delay
options[:delay] || 0
end
end
private_constant :ClickOptions
end
|