capybara 3.8.2 → 3.9.0

Sign up to get free protection for your applications and to get access to all the features.
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