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
|
# frozen_string_literal: true
require 'capybara/rack_test/errors'
require 'capybara/node/whitespace_normalizer'
class Capybara::RackTest::Node < Capybara::Driver::Node
include Capybara::Node::WhitespaceNormalizer
BLOCK_ELEMENTS = %w[p h1 h2 h3 h4 h5 h6 ol ul pre address blockquote dl div fieldset form hr noscript table].freeze
def all_text
normalize_spacing(native.text)
end
def visible_text
normalize_visible_spacing(displayed_text)
end
def [](name)
string_node[name]
end
def style(_styles)
raise NotImplementedError, 'The rack_test driver does not process CSS'
end
def value
string_node.value
end
def set(value, **options)
return if disabled? || readonly?
warn "Options passed to Node#set but the RackTest driver doesn't support any - ignoring" unless options.empty?
if value.is_a?(Array) && !multiple?
raise TypeError, "Value cannot be an Array when 'multiple' attribute is not present. Not a #{value.class}"
end
if radio? then set_radio(value)
elsif checkbox? then set_checkbox(value)
elsif range? then set_range(value)
elsif input_field? then set_input(value)
elsif textarea? then native['_capybara_raw_value'] = value.to_s
end
end
def select_option
return if disabled?
deselect_options unless select_node.multiple?
native['selected'] = 'selected'
end
def unselect_option
raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.' unless select_node.multiple?
native.remove_attribute('selected')
end
def click(keys = [], **options)
options.delete(:offset)
raise ArgumentError, 'The RackTest driver does not support click options' unless keys.empty? && options.empty?
if link?
follow_link
elsif submits?
associated_form = form
Capybara::RackTest::Form.new(driver, associated_form).submit(self) if associated_form
elsif checkable?
set(!checked?)
elsif tag_name == 'label'
click_label
elsif (details = native.xpath('.//ancestor-or-self::details').last)
toggle_details(details)
end
end
def tag_name
native.node_name
end
def visible?
string_node.visible?
end
def checked?
string_node.checked?
end
def selected?
string_node.selected?
end
def disabled?
return true if string_node.disabled?
if %w[option optgroup].include? tag_name
find_xpath(OPTION_OWNER_XPATH)[0].disabled?
else
!find_xpath(DISABLED_BY_FIELDSET_XPATH).empty?
end
end
def readonly?
# readonly attribute not valid on these input types
return false if input_field? && %w[hidden range color checkbox radio file submit image reset button].include?(type)
super
end
def path
native.path
end
def find_xpath(locator, **_hints)
native.xpath(locator).map { |el| self.class.new(driver, el) }
end
def find_css(locator, **_hints)
native.css(locator, Capybara::RackTest::CSSHandlers.new).map { |el| self.class.new(driver, el) }
end
public_instance_methods(false).each do |meth_name|
alias_method "unchecked_#{meth_name}", meth_name
private "unchecked_#{meth_name}" # rubocop:disable Style/AccessModifierDeclarations
class_eval <<~METHOD, __FILE__, __LINE__ + 1
def #{meth_name}(...)
stale_check
method(:"unchecked_#{meth_name}").call(...)
end
METHOD
end
protected
# @api private
def displayed_text(check_ancestor: true)
if !string_node.visible?(check_ancestor)
''
elsif native.text?
native
.text
.delete(REMOVED_CHARACTERS)
.tr(SQUEEZED_SPACES, ' ')
.squeeze(' ')
elsif native.element?
text = native.children.map do |child|
Capybara::RackTest::Node.new(driver, child).displayed_text(check_ancestor: false)
end.join || ''
text = "\n#{text}\n" if BLOCK_ELEMENTS.include?(tag_name)
text
else # rubocop:disable Lint/DuplicateBranch
''
end
end
private
def stale_check
raise Capybara::RackTest::Errors::StaleElementReferenceError unless native.document == driver.dom
end
def deselect_options
select_node.find_xpath('.//option[@selected]').each { |node| node.native.remove_attribute('selected') }
end
def string_node
@string_node ||= Capybara::Node::Simple.new(native)
end
# a reference to the select node if this is an option node
def select_node
find_xpath('./ancestor::select[1]').first
end
def type
native[:type]
end
def form
if native[:form]
native.xpath("//form[@id='#{native[:form]}']")
else
native.ancestors('form')
end.first
end
def set_radio(_value) # rubocop:disable Naming/AccessorMethodName
other_radios_xpath = XPath.generate { |xp| xp.anywhere(:input)[xp.attr(:name) == self[:name]] }.to_s
driver.dom.xpath(other_radios_xpath).each { |node| node.remove_attribute('checked') }
native['checked'] = 'checked'
end
def set_checkbox(value) # rubocop:disable Naming/AccessorMethodName
if value && !native['checked']
native['checked'] = 'checked'
elsif !value && native['checked']
native.remove_attribute('checked')
end
end
def set_range(value) # rubocop:disable Naming/AccessorMethodName
min, max, step = (native['min'] || 0).to_f, (native['max'] || 100).to_f, (native['step'] || 1).to_f
value = value.to_f
value = value.clamp(min, max)
value = (((value - min) / step).round * step) + min
native['value'] = value.clamp(min, max)
end
def set_input(value) # rubocop:disable Naming/AccessorMethodName
if text_or_password? && attribute_is_not_blank?(:maxlength)
# Browser behavior for maxlength="0" is inconsistent, so we stick with
# Firefox, allowing no input
value = value.to_s[0...self[:maxlength].to_i]
end
if value.is_a?(Array) # Assert multiple attribute is present
value.each do |val|
new_native = native.clone
new_native.remove_attribute('value')
native.add_next_sibling(new_native)
new_native['value'] = val.to_s
end
native.remove
else
value.to_s.tap do |set_value|
if set_value.end_with?("\n") && form&.css('input, textarea')&.count == 1
native['value'] = set_value.to_s.chop
Capybara::RackTest::Form.new(driver, form).submit(self)
else
native['value'] = set_value
end
end
end
end
def attribute_is_not_blank?(attribute)
self[attribute] && !self[attribute].empty?
end
def follow_link
method = self['data-method'] || self['data-turbo-method'] if driver.options[:respect_data_method]
method ||= :get
driver.follow(method, self[:href].to_s)
end
def click_label
labelled_control = if native[:for]
find_xpath("//input[@id='#{native[:for]}']")
else
find_xpath('.//input')
end.first
labelled_control.set(!labelled_control.checked?) if checkbox_or_radio?(labelled_control)
end
def toggle_details(details = nil)
details ||= native.xpath('.//ancestor-or-self::details').last
return unless details
if details.has_attribute?('open')
details.remove_attribute('open')
else
details.set_attribute('open', 'open')
end
end
def link?
tag_name == 'a' && !self[:href].nil?
end
def submits?
(tag_name == 'input' && %w[submit image].include?(type)) || (tag_name == 'button' && [nil, 'submit'].include?(type))
end
def checkable?
tag_name == 'input' && %w[checkbox radio].include?(type)
end
protected
def checkbox_or_radio?(field = self)
field&.checkbox? || field&.radio?
end
def checkbox?
input_field? && type == 'checkbox'
end
def radio?
input_field? && type == 'radio'
end
def text_or_password?
input_field? && (type == 'text' || type == 'password')
end
def input_field?
tag_name == 'input'
end
def textarea?
tag_name == 'textarea'
end
def range?
input_field? && type == 'range'
end
OPTION_OWNER_XPATH = XPath.parent(:optgroup, :select, :datalist).to_s.freeze
DISABLED_BY_FIELDSET_XPATH = XPath.generate do |x|
x.parent(:fieldset)[
XPath.attr(:disabled)
] + x.ancestor[
~x.self(:legend) |
x.preceding_sibling(:legend)
][
x.parent(:fieldset)[
x.attr(:disabled)
]
]
end.to_s.freeze
end
|