capybara 3.8.2 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +10 -0
  3. data/lib/capybara.rb +32 -10
  4. data/lib/capybara/config.rb +1 -0
  5. data/lib/capybara/dsl.rb +2 -2
  6. data/lib/capybara/helpers.rb +1 -0
  7. data/lib/capybara/node/actions.rb +4 -0
  8. data/lib/capybara/node/base.rb +1 -0
  9. data/lib/capybara/node/element.rb +3 -0
  10. data/lib/capybara/node/finders.rb +2 -0
  11. data/lib/capybara/node/simple.rb +1 -0
  12. data/lib/capybara/queries/base_query.rb +1 -0
  13. data/lib/capybara/queries/match_query.rb +1 -0
  14. data/lib/capybara/queries/selector_query.rb +34 -37
  15. data/lib/capybara/queries/text_query.rb +2 -0
  16. data/lib/capybara/rack_test/browser.rb +1 -0
  17. data/lib/capybara/rack_test/driver.rb +5 -0
  18. data/lib/capybara/rack_test/node.rb +2 -0
  19. data/lib/capybara/result.rb +2 -0
  20. data/lib/capybara/rspec/compound.rb +2 -0
  21. data/lib/capybara/rspec/matchers.rb +1 -0
  22. data/lib/capybara/selector.rb +14 -27
  23. data/lib/capybara/selector/builders/css_builder.rb +49 -0
  24. data/lib/capybara/selector/builders/xpath_builder.rb +56 -0
  25. data/lib/capybara/selector/filter_set.rb +1 -0
  26. data/lib/capybara/selector/filters/base.rb +2 -0
  27. data/lib/capybara/selector/regexp_disassembler.rb +66 -0
  28. data/lib/capybara/selector/selector.rb +25 -5
  29. data/lib/capybara/selenium/driver.rb +8 -1
  30. data/lib/capybara/selenium/driver_specializations/chrome_driver.rb +19 -1
  31. data/lib/capybara/selenium/driver_specializations/marionette_driver.rb +1 -0
  32. data/lib/capybara/selenium/node.rb +7 -0
  33. data/lib/capybara/selenium/nodes/chrome_node.rb +2 -0
  34. data/lib/capybara/selenium/nodes/marionette_node.rb +37 -20
  35. data/lib/capybara/server.rb +4 -0
  36. data/lib/capybara/server/animation_disabler.rb +1 -0
  37. data/lib/capybara/session.rb +5 -0
  38. data/lib/capybara/session/config.rb +2 -0
  39. data/lib/capybara/spec/session/has_css_spec.rb +16 -0
  40. data/lib/capybara/spec/session/has_field_spec.rb +4 -0
  41. data/lib/capybara/spec/session/node_spec.rb +6 -0
  42. data/lib/capybara/spec/session/node_wrapper_spec.rb +1 -1
  43. data/lib/capybara/spec/session/reset_session_spec.rb +15 -1
  44. data/lib/capybara/spec/session/selectors_spec.rb +12 -2
  45. data/lib/capybara/spec/views/form.erb +15 -0
  46. data/lib/capybara/version.rb +1 -1
  47. data/lib/capybara/xpath_patches.rb +27 -0
  48. data/spec/dsl_spec.rb +15 -1
  49. data/spec/rack_test_spec.rb +6 -1
  50. data/spec/regexp_dissassembler_spec.rb +154 -0
  51. data/spec/selector_spec.rb +37 -2
  52. data/spec/selenium_spec_chrome.rb +2 -2
  53. data/spec/selenium_spec_firefox_remote.rb +2 -0
  54. data/spec/selenium_spec_marionette.rb +11 -0
  55. data/spec/shared_selenium_session.rb +20 -0
  56. data/spec/spec_helper.rb +4 -0
  57. metadata +7 -2
@@ -93,6 +93,7 @@ module Capybara
93
93
  min, max = between.minmax
94
94
  size = load_up_to(max + 1)
95
95
  return 0 if between.include? size
96
+
96
97
  return size <=> min
97
98
  end
98
99
 
@@ -130,6 +131,7 @@ module Capybara
130
131
  def load_up_to(num)
131
132
  loop do
132
133
  break if @result_cache.size >= num
134
+
133
135
  @result_cache << @results_enum.next
134
136
  end
135
137
  @result_cache.size
@@ -43,6 +43,7 @@ if defined?(::RSpec::Expectations::Version)
43
43
  syncer.synchronize do
44
44
  @evaluator.reset
45
45
  raise ::Capybara::ElementNotFound unless [matcher_1_matches?, matcher_2_matches?].all?
46
+
46
47
  true
47
48
  end
48
49
  rescue StandardError
@@ -71,6 +72,7 @@ if defined?(::RSpec::Expectations::Version)
71
72
  syncer.synchronize do
72
73
  @evaluator.reset
73
74
  raise ::Capybara::ElementNotFound unless [matcher_1_matches?, matcher_2_matches?].any?
75
+
74
76
  true
75
77
  end
76
78
  rescue StandardError
@@ -242,6 +242,7 @@ module Capybara
242
242
  timer = Capybara::Helpers.timer(expire_in: @wait_time)
243
243
  while window.exists?
244
244
  return false if timer.expired?
245
+
245
246
  sleep 0.05
246
247
  end
247
248
  true
@@ -38,8 +38,11 @@ Capybara.add_selector(:id) do
38
38
  end
39
39
 
40
40
  Capybara.add_selector(:field) do
41
+ visible { |options| :hidden if options[:type] == 'hidden' }
41
42
  xpath do |locator, **options|
42
- xpath = XPath.descendant(:input, :textarea, :select)[!XPath.attr(:type).one_of('submit', 'image', 'hidden')]
43
+ invalid_types = %w[submit image]
44
+ invalid_types << 'hidden' unless options[:type] == 'hidden'
45
+ xpath = XPath.descendant(:input, :textarea, :select)[!XPath.attr(:type).one_of(*invalid_types)]
43
46
  locate_field(xpath, locator, options)
44
47
  end
45
48
 
@@ -87,18 +90,7 @@ end
87
90
  Capybara.add_selector(:link) do
88
91
  xpath(:title, :alt) do |locator, href: true, alt: nil, title: nil, **|
89
92
  xpath = XPath.descendant(:a)
90
- xpath = xpath[
91
- case href
92
- when nil, false
93
- !XPath.attr(:href)
94
- when true
95
- XPath.attr(:href)
96
- when Regexp
97
- nil # needs to be handled in filter
98
- else
99
- XPath.attr(:href) == href.to_s
100
- end
101
- ]
93
+ xpath = xpath[@href_conditions = builder.attribute_conditions(href: href)]
102
94
 
103
95
  unless locator.nil?
104
96
  locator = locator.to_s
@@ -132,13 +124,19 @@ Capybara.add_selector(:link) do
132
124
 
133
125
  describe_expression_filters do |**options|
134
126
  desc = +''
135
- desc << " with href #{options[:href].inspect}" if options[:href] && !options[:href].is_a?(Regexp)
127
+ if (href = options[:href])
128
+ if !href.is_a?(Regexp)
129
+ desc << " with href #{href.inspect}"
130
+ elsif @href_conditions
131
+ desc << " with href matching #{href.inspect}"
132
+ end
133
+ end
136
134
  desc << ' with no href attribute' if options.fetch(:href, true).nil?
137
135
  desc
138
136
  end
139
137
 
140
138
  describe_node_filters do |href: nil, **|
141
- " with href matching #{href.inspect}" if href.is_a? Regexp
139
+ " with href matching #{href.inspect}" if href.is_a?(Regexp) && @href_conditions.nil?
142
140
  end
143
141
  end
144
142
 
@@ -472,18 +470,7 @@ Capybara.add_selector(:element) do
472
470
  end
473
471
 
474
472
  expression_filter(:attributes, matcher: /.+/) do |xpath, name, val|
475
- case val
476
- when Regexp
477
- xpath
478
- when true
479
- xpath[XPath.attr(name)]
480
- when false
481
- xpath[!XPath.attr(name)]
482
- when XPath::Expression
483
- xpath[XPath.attr(name)[val]]
484
- else
485
- xpath[XPath.attr(name.to_sym) == val]
486
- end
473
+ xpath[builder.attribute_conditions(name => val)]
487
474
  end
488
475
 
489
476
  node_filter(:attributes, matcher: /.+/) do |node, name, val|
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xpath'
4
+
5
+ module Capybara
6
+ class Selector
7
+ # @api private
8
+ class CSSBuilder
9
+ class << self
10
+ def attribute_conditions(attributes)
11
+ attributes.map do |attribute, value|
12
+ case value
13
+ when XPath::Expression
14
+ raise ArgumentError, "XPath expressions are not supported for the :#{attribute} filter with CSS based selectors"
15
+ when Regexp
16
+ Selector::RegexpDisassembler.new(value).substrings.map do |str|
17
+ "[#{attribute}*='#{str}'#{' i' if value.casefold?}]"
18
+ end.join
19
+ when true
20
+ "[#{attribute}]"
21
+ when false
22
+ ':not([attribute])'
23
+ else
24
+ if attribute == :id
25
+ "##{::Capybara::Selector::CSS.escape(value)}"
26
+ else
27
+ "[#{attribute}='#{value}']"
28
+ end
29
+ end
30
+ end.join
31
+ end
32
+
33
+ def class_conditions(classes)
34
+ case classes
35
+ when XPath::Expression
36
+ raise ArgumentError, 'XPath expressions are not supported for the :class filter with CSS based selectors'
37
+ when Regexp
38
+ strs = Selector::RegexpDisassembler.new(classes).substrings
39
+ strs.map { |str| "[class*='#{str}'#{' i' if classes.casefold?}]" }.join
40
+ else
41
+ cls = Array(classes).group_by { |cl| cl.start_with? '!' }
42
+ (cls[false].to_a.map { |cl| ".#{Capybara::Selector::CSS.escape(cl)}" } +
43
+ cls[true].to_a.map { |cl| ":not(.#{Capybara::Selector::CSS.escape(cl.slice(1..-1))})" }).join
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xpath'
4
+
5
+ module Capybara
6
+ class Selector
7
+ # @api private
8
+ class XPathBuilder
9
+ class << self
10
+ def attribute_conditions(attributes)
11
+ attributes.map do |attribute, value|
12
+ case value
13
+ when XPath::Expression
14
+ XPath.attr(attribute)[value]
15
+ when Regexp
16
+ XPath.attr(attribute)[regexp_to_xpath_conditions(value)]
17
+ when true
18
+ XPath.attr(attribute)
19
+ when false, nil
20
+ !XPath.attr(attribute)
21
+ else
22
+ XPath.attr(attribute) == value.to_s
23
+ end
24
+ end.reduce(:&)
25
+ end
26
+
27
+ def class_conditions(classes)
28
+ case classes
29
+ when XPath::Expression
30
+ XPath.attr(:class)[classes]
31
+ when Regexp
32
+ XPath.attr(:class)[regexp_to_xpath_conditions(classes)]
33
+ else
34
+ Array(classes).map do |klass|
35
+ if klass.start_with?('!')
36
+ !XPath.attr(:class).contains_word(klass.slice(1..-1))
37
+ else
38
+ XPath.attr(:class).contains_word(klass)
39
+ end
40
+ end.reduce(:&)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def regexp_to_xpath_conditions(regexp)
47
+ condition = XPath.current
48
+ condition = condition.uppercase if regexp.casefold?
49
+ Selector::RegexpDisassembler.new(regexp).substrings.map do |str|
50
+ condition.contains(str)
51
+ end.reduce(:&)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -106,6 +106,7 @@ module Capybara
106
106
  def add_filter(name, filter_class, *types, matcher: nil, **options, &block)
107
107
  types.each { |type| options[type] = true }
108
108
  raise 'ArgumentError', ':default option is not supported for filters with a :matcher option' if matcher && options[:default]
109
+
109
110
  if filter_class <= Filters::ExpressionFilter
110
111
  @expression_filters[name] = filter_class.new(name, matcher, block, options)
111
112
  else
@@ -41,6 +41,7 @@ module Capybara
41
41
  def apply(subject, name, value, skip_value)
42
42
  return skip_value if skip?(value)
43
43
  raise ArgumentError, "Invalid value #{value.inspect} passed to #{self.class.name.split('::').last} #{name}#{" : #{@name}" if @name.is_a?(Regexp)}" unless valid_value?(value)
44
+
44
45
  if @block.arity == 2
45
46
  @block.call(subject, value)
46
47
  else
@@ -50,6 +51,7 @@ module Capybara
50
51
 
51
52
  def valid_value?(value)
52
53
  return true unless @options.key?(:valid_values)
54
+
53
55
  Array(@options[:valid_values]).any? { |valid| valid === value } # rubocop:disable Style/CaseEquality
54
56
  end
55
57
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ class Selector
5
+ # @api private
6
+ class RegexpDisassembler
7
+ def initialize(regexp)
8
+ @regexp = regexp
9
+ @regexp_source = regexp.source
10
+ end
11
+
12
+ def substrings
13
+ @substrings ||= begin
14
+ source = @regexp_source.dup
15
+ source.gsub!(/\\[^pgk]/, '.') # replace escaped characters with wildcard
16
+ source.gsub!(/\\[gk](?:<[^>]*>)?/, '.') # replace sub expressions and back references with wildcard
17
+ source.gsub!(/\\p\{[[:alpha:]]+\}?/, '.') # replace character properties with wildcard
18
+ source.gsub!(/\[\[:[a-z]+:\]\]/, '.') # replace posix classes with wildcard
19
+ while source.gsub!(/\[(?:[^\[\]]+)\]/, '.'); end # replace character classes with wildcard
20
+ source.gsub!(/\(\?<?[=!][^)]*\)/, '') # remove lookahead/lookbehind assertions
21
+ source.gsub!(/\(\?(?:<[^>]+>|>|:)/, '(') # replace named, atomic, and non-matching groups with unnamed matching groups
22
+
23
+ while source.gsub!(GROUP_REGEX) { |_m| simplify_group(Regexp.last_match) }; end
24
+ source.gsub!(/.[*?]\??/, '.') # replace optional character with wildcard
25
+ source.gsub!(/(.)\+\??/, '\1.') # replace one or more with character plus wildcard
26
+ source.gsub!(/(?<char>.)#{COUNTED_REP_REGEX.source}/) do |_m| # repeat counted characters
27
+ (Regexp.last_match[:char] * Regexp.last_match[:min_rep].to_i).tap { |str| str << '.' if Regexp.last_match[:max_rep] }
28
+ end
29
+ return [] if source.include?('|') # can't handle alternation here
30
+
31
+ strs = source.match(/\A\^?(.*?)\$?\Z/).captures[0].split('.').reject(&:empty?).uniq
32
+ strs = strs.map(&:upcase) if @regexp.casefold?
33
+ strs
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def simplify_group(matches)
40
+ if matches[:group].include?('|') # no support for alternation in groups
41
+ '.'
42
+ elsif matches[:one_or_more] # required but may repeat becomes text + wildcard
43
+ matches[:group][1..-2] + '.'
44
+ elsif matches[:optional] # optional group becomes wildcard
45
+ '.'
46
+ elsif matches[:min_rep]
47
+ (matches[:group] * matches[:min_rep].to_i).tap { |r| r << '.' if matches[:max_rep] }
48
+ else
49
+ matches[:group][1..-2]
50
+ end
51
+ end
52
+
53
+ COUNTED_REP_REGEX = /\{(?<min_rep>\d*)(?:,(?<max_rep>\d*))?\}/
54
+ GROUP_REGEX = /
55
+ (?<group>\([^()]*\))
56
+ (?:
57
+ (?:
58
+ (?<optional>[*?]) |
59
+ (?<one_or_more>\+) |
60
+ (?:#{COUNTED_REP_REGEX.source})
61
+ )\??
62
+ )?
63
+ /x
64
+ end
65
+ end
66
+ end
@@ -4,6 +4,9 @@
4
4
 
5
5
  require 'capybara/selector/filter_set'
6
6
  require 'capybara/selector/css'
7
+ require 'capybara/selector/regexp_disassembler'
8
+ require 'capybara/selector/builders/xpath_builder'
9
+ require 'capybara/selector/builders/css_builder'
7
10
 
8
11
  module Capybara
9
12
  #
@@ -381,13 +384,29 @@ module Capybara
381
384
  # * :all - finds visible and invisible elements.
382
385
  # * :hidden - only finds invisible elements.
383
386
  # * :visible - only finds visible elements.
384
- def visible(default_visibility)
385
- @default_visibility = default_visibility
387
+ def visible(default_visibility = nil, &block)
388
+ @default_visibility = block || default_visibility
386
389
  end
387
390
 
388
- def default_visibility(fallback = Capybara.ignore_hidden_elements)
389
- return @default_visibility unless @default_visibility.nil?
390
- fallback
391
+ def default_visibility(fallback = Capybara.ignore_hidden_elements, options = {})
392
+ vis = if @default_visibility&.respond_to?(:call)
393
+ @default_visibility.call(options)
394
+ else
395
+ @default_visibility
396
+ end
397
+ vis.nil? ? fallback : vis
398
+ end
399
+
400
+ # @api private
401
+ def builder
402
+ case format
403
+ when :css
404
+ Capybara::Selector::CSSBuilder
405
+ when :xpath
406
+ Capybara::Selector::XPathBuilder
407
+ else
408
+ raise NotImplementedError, "No builder exists for selector of type #{format}"
409
+ end
391
410
  end
392
411
 
393
412
  private
@@ -402,6 +421,7 @@ module Capybara
402
421
 
403
422
  def locate_field(xpath, locator, **_options)
404
423
  return xpath if locator.nil?
424
+
405
425
  locate_xpath = xpath # Need to save original xpath for the label wrap
406
426
  locator = locator.to_s
407
427
  attr_matchers = [XPath.attr(:id) == locator,
@@ -17,6 +17,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
17
17
  warn "Warning: You're using an unsupported version of selenium-webdriver, please upgrade." if Gem.loaded_specs['selenium-webdriver'].version < Gem::Version.new('3.5.0')
18
18
  rescue LoadError => err
19
19
  raise err if err.message !~ /selenium-webdriver/
20
+
20
21
  raise LoadError, "Capybara's selenium driver is unable to load `selenium-webdriver`, please install the gem and add `gem 'selenium-webdriver'` to your Gemfile if you are using bundler."
21
22
  end
22
23
 
@@ -117,6 +118,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
117
118
  # Ensure the page is empty and trigger an UnhandledAlertError for any modals that appear during unload
118
119
  until find_xpath('/html/body/*').empty?
119
120
  raise Capybara::ExpectationNotMet, 'Timed out waiting for Selenium session reset' if timer.expired?
121
+
120
122
  sleep 0.05
121
123
  end
122
124
  rescue Selenium::WebDriver::Error::UnhandledAlertError, Selenium::WebDriver::Error::UnexpectedAlertOpenError
@@ -184,6 +186,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
184
186
 
185
187
  def close_window(handle)
186
188
  raise ArgumentError, 'Not allowed to close the primary window' if handle == window_handles.first
189
+
187
190
  within_given_window(handle) do
188
191
  browser.close
189
192
  end
@@ -260,7 +263,7 @@ private
260
263
  end
261
264
 
262
265
  def clear_browser_state
263
- @browser.manage.delete_all_cookies
266
+ delete_all_cookies
264
267
  clear_storage
265
268
  rescue Selenium::WebDriver::Error::UnhandledError # rubocop:disable Lint/HandleExceptions
266
269
  # delete_all_cookies fails when we've previously gone
@@ -268,6 +271,10 @@ private
268
271
  # instead.
269
272
  end
270
273
 
274
+ def delete_all_cookies
275
+ @browser.manage.delete_all_cookies
276
+ end
277
+
271
278
  def clear_storage
272
279
  clear_session_storage if options[:clear_session_storage]
273
280
  clear_local_storage if options[:clear_local_storage]
@@ -9,7 +9,7 @@ module Capybara::Selenium::Driver::ChromeDriver
9
9
  super
10
10
  rescue NoMethodError => err
11
11
  raise unless err.message =~ /full_screen_window/
12
- bridge = browser.send(:bridge)
12
+
13
13
  result = bridge.http.call(:post, "session/#{bridge.session_id}/window/fullscreen", {})
14
14
  result['value']
15
15
  end
@@ -20,6 +20,7 @@ module Capybara::Selenium::Driver::ChromeDriver
20
20
  super
21
21
  rescue Selenium::WebDriver::Error::UnknownError => err
22
22
  raise unless err.message =~ /failed to change window state/
23
+
23
24
  # Chromedriver doesn't wait long enough for state to change when coming out of fullscreen
24
25
  # and raises unnecessary error. Wait a bit and try again.
25
26
  sleep 0.5
@@ -37,7 +38,24 @@ module Capybara::Selenium::Driver::ChromeDriver
37
38
 
38
39
  private
39
40
 
41
+ def delete_all_cookies
42
+ execute_cdp('Network.clearBrowserCookies')
43
+ rescue Selenium::WebDriver::Error::UnhandledError, Selenium::WebDriver::Error::WebDriverError
44
+ # If the CDP clear isn't supported do original limited clear
45
+ super
46
+ end
47
+
48
+ def execute_cdp(cmd, params = {})
49
+ args = { cmd: cmd, params: params }
50
+ result = bridge.http.call(:post, "session/#{bridge.session_id}/goog/cdp/execute", args)
51
+ result['value']
52
+ end
53
+
40
54
  def build_node(native_node)
41
55
  ::Capybara::Selenium::ChromeNode.new(self, native_node)
42
56
  end
57
+
58
+ def bridge
59
+ browser.send(:bridge)
60
+ end
43
61
  end