capybara 3.9.0 → 3.10.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +15 -0
  3. data/License.txt +1 -1
  4. data/README.md +1 -1
  5. data/lib/capybara.rb +1 -2
  6. data/lib/capybara/helpers.rb +3 -2
  7. data/lib/capybara/minitest.rb +1 -1
  8. data/lib/capybara/minitest/spec.rb +1 -0
  9. data/lib/capybara/node/finders.rb +20 -15
  10. data/lib/capybara/node/matchers.rb +43 -10
  11. data/lib/capybara/queries/current_path_query.rb +2 -2
  12. data/lib/capybara/queries/selector_query.rb +9 -2
  13. data/lib/capybara/rack_test/browser.rb +14 -13
  14. data/lib/capybara/rack_test/form.rb +32 -27
  15. data/lib/capybara/result.rb +8 -11
  16. data/lib/capybara/rspec/compound.rb +16 -26
  17. data/lib/capybara/rspec/matcher_proxies.rb +28 -11
  18. data/lib/capybara/rspec/matchers.rb +67 -45
  19. data/lib/capybara/selector.rb +22 -25
  20. data/lib/capybara/selector/builders/css_builder.rb +3 -4
  21. data/lib/capybara/selector/builders/xpath_builder.rb +4 -6
  22. data/lib/capybara/selector/regexp_disassembler.rb +43 -44
  23. data/lib/capybara/selector/selector.rb +16 -11
  24. data/lib/capybara/selenium/driver.rb +28 -21
  25. data/lib/capybara/selenium/nodes/marionette_node.rb +6 -12
  26. data/lib/capybara/server.rb +1 -3
  27. data/lib/capybara/server/animation_disabler.rb +2 -2
  28. data/lib/capybara/session.rb +1 -1
  29. data/lib/capybara/spec/session/assert_all_of_selectors_spec.rb +29 -0
  30. data/lib/capybara/spec/session/click_button_spec.rb +6 -0
  31. data/lib/capybara/spec/session/click_link_spec.rb +1 -1
  32. data/lib/capybara/spec/session/has_all_selectors_spec.rb +1 -1
  33. data/lib/capybara/spec/session/has_any_selectors_spec.rb +25 -0
  34. data/lib/capybara/spec/session/html_spec.rb +7 -0
  35. data/lib/capybara/spec/session/node_spec.rb +5 -1
  36. data/lib/capybara/spec/session/refresh_spec.rb +4 -0
  37. data/lib/capybara/spec/session/window/switch_to_window_spec.rb +4 -0
  38. data/lib/capybara/spec/session/window/window_opened_by_spec.rb +4 -0
  39. data/lib/capybara/spec/session/window/window_spec.rb +4 -0
  40. data/lib/capybara/spec/session/window/windows_spec.rb +4 -0
  41. data/lib/capybara/spec/views/form.erb +2 -2
  42. data/lib/capybara/version.rb +1 -1
  43. data/spec/minitest_spec.rb +5 -1
  44. data/spec/minitest_spec_spec.rb +5 -1
  45. data/spec/regexp_dissassembler_spec.rb +5 -3
  46. data/spec/rspec_spec.rb +20 -0
  47. data/spec/selector_spec.rb +89 -3
  48. data/spec/selenium_spec_chrome.rb +3 -7
  49. data/spec/selenium_spec_marionette.rb +1 -4
  50. metadata +20 -6
  51. data/lib/capybara/xpath_patches.rb +0 -27
@@ -26,16 +26,14 @@ module Capybara
26
26
 
27
27
  def class_conditions(classes)
28
28
  case classes
29
- when XPath::Expression
30
- XPath.attr(:class)[classes]
31
- when Regexp
32
- XPath.attr(:class)[regexp_to_xpath_conditions(classes)]
29
+ when XPath::Expression, Regexp
30
+ attribute_conditions(class: classes)
33
31
  else
34
32
  Array(classes).map do |klass|
35
- if klass.start_with?('!')
33
+ if klass.start_with?('!') && !klass.start_with?('!!!')
36
34
  !XPath.attr(:class).contains_word(klass.slice(1..-1))
37
35
  else
38
- XPath.attr(:class).contains_word(klass)
36
+ XPath.attr(:class).contains_word(klass.sub(/^!!/, ''))
39
37
  end
40
38
  end.reduce(:&)
41
39
  end
@@ -1,66 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'regexp_parser'
4
+
3
5
  module Capybara
4
6
  class Selector
5
7
  # @api private
6
8
  class RegexpDisassembler
7
9
  def initialize(regexp)
8
10
  @regexp = regexp
9
- @regexp_source = regexp.source
10
11
  end
11
12
 
12
13
  def substrings
13
14
  @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
15
+ strs = extract_strings(Regexp::Parser.parse(@regexp), [+''])
16
+ strs.map!(&:upcase) if @regexp.casefold?
17
+ strs.reject(&:empty?).uniq
34
18
  end
35
19
  end
36
20
 
37
21
  private
38
22
 
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
23
+ def min_repeat(exp)
24
+ exp.quantifier&.min || 1
25
+ end
26
+
27
+ def fixed_repeat?(exp)
28
+ min_repeat(exp) == (exp.quantifier&.max || 1)
29
+ end
30
+
31
+ def optional?(exp)
32
+ min_repeat(exp).zero?
51
33
  end
52
34
 
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
35
+ def extract_strings(expression, strings)
36
+ expression.each do |exp|
37
+ if optional?(exp)
38
+ strings.push(+'')
39
+ next
40
+ end
41
+
42
+ if %i[meta set].include?(exp.type)
43
+ strings.push(+'')
44
+ next
45
+ end
46
+
47
+ if exp.terminal?
48
+ case exp.type
49
+ when :literal
50
+ strings.last << (exp.text * min_repeat(exp))
51
+ when :escape
52
+ strings.last << (exp.char * min_repeat(exp))
53
+ else
54
+ strings.push(+'')
55
+ end
56
+ else
57
+ min_repeat(exp).times { extract_strings(exp, strings) }
58
+ end
59
+ strings.push(+'') unless fixed_repeat?(exp)
60
+ end
61
+ strings
62
+ end
64
63
  end
65
64
  end
66
65
  end
@@ -217,7 +217,7 @@ module Capybara
217
217
  # Define a selector by an xpath expression
218
218
  #
219
219
  # @overload xpath(*expression_filters, &block)
220
- # @param [Array<Symbol>] expression_filters ([]) Names of filters that can be implemented via this expression
220
+ # @param [Array<Symbol>] expression_filters ([]) Names of filters that are implemented via this expression, if not specified the names of any keyword parameters in the block will be used
221
221
  # @yield [locator, options] The block to use to generate the XPath expression
222
222
  # @yieldparam [String] locator The locator string passed to the query
223
223
  # @yieldparam [Hash] options The options hash passed to the query
@@ -227,11 +227,7 @@ module Capybara
227
227
  # @return [#call] The block that will be called to generate the XPath expression
228
228
  #
229
229
  def xpath(*allowed_filters, &block)
230
- if block
231
- @format, @expression = :xpath, block
232
- allowed_filters.flatten.each { |ef| expression_filters[ef] = Filters::IdentityExpressionFilter.new(ef) }
233
- end
234
- format == :xpath ? @expression : nil
230
+ expression(:xpath, allowed_filters, &block)
235
231
  end
236
232
 
237
233
  ##
@@ -249,11 +245,7 @@ module Capybara
249
245
  # @return [#call] The block that will be called to generate the CSS selector
250
246
  #
251
247
  def css(*allowed_filters, &block)
252
- if block
253
- @format, @expression = :css, block
254
- allowed_filters.flatten.each { |ef| expression_filters[ef] = nil }
255
- end
256
- format == :css ? @expression : nil
248
+ expression(:css, allowed_filters, &block)
257
249
  end
258
250
 
259
251
  ##
@@ -459,6 +451,19 @@ module Capybara
459
451
  def find_by_class_attr(classes)
460
452
  Array(classes).map { |klass| XPath.attr(:class).contains_word(klass) }.reduce(:&)
461
453
  end
454
+
455
+ def parameter_names(block)
456
+ block.parameters.select { |(type, _name)| %i[key keyreq].include? type }.map { |(_type, name)| name }
457
+ end
458
+
459
+ def expression(type, allowed_filters, &block)
460
+ if block
461
+ @format, @expression = type, block
462
+ allowed_filters = parameter_names(block) if allowed_filters.empty?
463
+ allowed_filters.flatten.each { |ef| expression_filters[ef] = Filters::IdentityExpressionFilter.new(ef) }
464
+ end
465
+ format == type ? @expression : nil
466
+ end
462
467
  end
463
468
  end
464
469
 
@@ -107,33 +107,17 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
107
107
  navigated = false
108
108
  timer = Capybara::Helpers.timer(expire_in: 10)
109
109
  begin
110
- unless navigated
111
- # Only trigger a navigation if we haven't done it already, otherwise it
112
- # can trigger an endless series of unload modals
113
- clear_browser_state
114
- @browser.navigate.to('about:blank')
115
- end
110
+ # Only trigger a navigation if we haven't done it already, otherwise it
111
+ # can trigger an endless series of unload modals
112
+ reset_browser_state unless navigated
116
113
  navigated = true
117
-
118
114
  # Ensure the page is empty and trigger an UnhandledAlertError for any modals that appear during unload
119
- until find_xpath('/html/body/*').empty?
120
- raise Capybara::ExpectationNotMet, 'Timed out waiting for Selenium session reset' if timer.expired?
121
-
122
- sleep 0.05
123
- end
115
+ wait_for_empty_page(timer)
124
116
  rescue Selenium::WebDriver::Error::UnhandledAlertError, Selenium::WebDriver::Error::UnexpectedAlertOpenError
125
117
  # This error is thrown if an unhandled alert is on the page
126
118
  # Firefox appears to automatically dismiss this alert, chrome does not
127
119
  # We'll try to accept it
128
- begin
129
- @browser.switch_to.alert.accept
130
- sleep 0.25 # allow time for the modal to be handled
131
- rescue modal_error
132
- # The alert is now gone.
133
- # If navigation has not occurred attempt again and accept alert
134
- # since FF may have dismissed the alert at first attempt.
135
- navigate_with_accept('about:blank') if current_url != 'about:blank'
136
- end
120
+ accept_unhandled_reset_alert
137
121
  # try cleaning up the browser again
138
122
  retry
139
123
  end
@@ -382,6 +366,29 @@ private
382
366
  exit @exit_status if @exit_status # Force exit with stored status
383
367
  end
384
368
  end
369
+
370
+ def reset_browser_state
371
+ clear_browser_state
372
+ @browser.navigate.to('about:blank')
373
+ end
374
+
375
+ def wait_for_empty_page(timer)
376
+ until find_xpath('/html/body/*').empty?
377
+ raise Capybara::ExpectationNotMet, 'Timed out waiting for Selenium session reset' if timer.expired?
378
+
379
+ sleep 0.05
380
+ end
381
+ end
382
+
383
+ def accept_unhandled_reset_alert
384
+ @browser.switch_to.alert.accept
385
+ sleep 0.25 # allow time for the modal to be handled
386
+ rescue modal_error
387
+ # The alert is now gone.
388
+ # If navigation has not occurred attempt again and accept alert
389
+ # since FF may have dismissed the alert at first attempt.
390
+ navigate_with_accept('about:blank') if current_url != 'about:blank'
391
+ end
385
392
  end
386
393
 
387
394
  require 'capybara/selenium/driver_specializations/chrome_driver'
@@ -36,16 +36,11 @@ class Capybara::Selenium::MarionetteNode < Capybara::Selenium::Node
36
36
  return super if browser_version >= 62.0
37
37
 
38
38
  # Workaround lack of support for multiple upload by uploading one at a time
39
- path_names = value.to_s.empty? ? [] : value
40
- Array(path_names).each do |path|
41
- unless driver.browser.respond_to?(:upload)
42
- if (fd = bridge.file_detector)
43
- local_file = fd.call([path])
44
- path = upload(local_file) if local_file
45
- end
46
- end
47
- native.send_keys(path)
39
+ path_names = value.to_s.empty? ? [] : Array(value)
40
+ if (fd = bridge.file_detector) && !driver.browser.respond_to?(:upload)
41
+ path_names.map! { |path| upload(fd.call([path])) || path }
48
42
  end
43
+ path_names.each { |path| native.send_keys(path) }
49
44
  end
50
45
 
51
46
  def send_keys(*args)
@@ -101,9 +96,8 @@ private
101
96
  end
102
97
 
103
98
  def upload(local_file)
104
- unless File.file?(local_file)
105
- raise ArgumentError, "You may only upload files: #{local_file.inspect}"
106
- end
99
+ return nil unless local_file
100
+ raise ArgumentError, "You may only upload files: #{local_file.inspect}" unless File.file?(local_file)
107
101
 
108
102
  result = bridge.http.call(:post, "session/#{bridge.session_id}/file", file: Selenium::WebDriver::Zipper.zip_file(local_file))
109
103
  result['value']
@@ -48,9 +48,7 @@ module Capybara
48
48
 
49
49
  res = @checker.request { |http| http.get('/__identify__') }
50
50
 
51
- if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPRedirection)
52
- return res.body == app.object_id.to_s
53
- end
51
+ return res.body == app.object_id.to_s if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPRedirection)
54
52
  rescue SystemCallError, Net::ReadTimeout, OpenSSL::SSL::SSLError
55
53
  false
56
54
  end
@@ -16,7 +16,7 @@ module Capybara
16
16
 
17
17
  def initialize(app)
18
18
  @app = app
19
- @disable_markup = DISABLE_MARKUP_TEMPLATE % AnimationDisabler.selector_for(Capybara.disable_animation)
19
+ @disable_markup = format(DISABLE_MARKUP_TEMPLATE, selector: AnimationDisabler.selector_for(Capybara.disable_animation))
20
20
  end
21
21
 
22
22
  def call(env)
@@ -46,7 +46,7 @@ module Capybara
46
46
  DISABLE_MARKUP_TEMPLATE = <<~HTML
47
47
  <script defer>(typeof jQuery !== 'undefined') && (jQuery.fx.off = true);</script>
48
48
  <style>
49
- %s {
49
+ %<selector>s {
50
50
  transition: none !important;
51
51
  animation-duration: 0s !important;
52
52
  animation-delay: 0s !important;
@@ -49,7 +49,7 @@ module Capybara
49
49
  has_no_table? has_table? unselect has_select? has_no_select?
50
50
  has_selector? has_no_selector? click_on has_no_checked_field?
51
51
  has_no_unchecked_field? query assert_selector assert_no_selector
52
- assert_all_of_selectors assert_none_of_selectors
52
+ assert_all_of_selectors assert_none_of_selectors assert_any_of_selectors
53
53
  refute_selector assert_text assert_no_text
54
54
  ].freeze
55
55
  # @api private
@@ -19,6 +19,10 @@ Capybara::SpecHelper.spec '#assert_all_of_selectors' do
19
19
  @session.assert_all_of_selectors('p a#foo', 'h2#h2two', 'h2#h2one')
20
20
  end
21
21
 
22
+ it 'should support filter block' do
23
+ expect { @session.assert_all_of_selectors(:css, 'h2#h2one', 'h2#h2two') { |n| n[:id] == 'h2one' } }.to raise_error(Capybara::ElementNotFound, /custom filter block/)
24
+ end
25
+
22
26
  context 'should respect scopes' do
23
27
  it 'when used with `within`' do
24
28
  @session.within "//p[@id='first']" do
@@ -109,3 +113,28 @@ Capybara::SpecHelper.spec '#assert_none_of_selectors' do
109
113
  end
110
114
  end
111
115
  end
116
+
117
+ Capybara::SpecHelper.spec '#assert_any_of_selectors' do
118
+ before do
119
+ @session.visit('/with_html')
120
+ end
121
+
122
+ it 'should be true if any of the given selectors are on the page' do
123
+ @session.assert_any_of_selectors(:css, 'a#foo', 'h2#h2three')
124
+ @session.assert_any_of_selectors(:css, 'h2#h2three', 'a#foo')
125
+ end
126
+
127
+ it 'should be false if none of the given selectors are on the page' do
128
+ expect { @session.assert_any_of_selectors(:css, 'h2#h2three', 'h4#h4four') }.to raise_error(Capybara::ElementNotFound)
129
+ end
130
+
131
+ it 'should use default selector' do
132
+ Capybara.default_selector = :css
133
+ expect { @session.assert_any_of_selectors('h2#h2three', 'h5#h5five') }.to raise_error(Capybara::ElementNotFound)
134
+ @session.assert_any_of_selectors('p a#foo', 'h2#h2two', 'h2#h2one')
135
+ end
136
+
137
+ it 'should support filter block' do
138
+ expect { @session.assert_any_of_selectors(:css, 'h2#h2one', 'h2#h2two') { |_n| false } }.to raise_error(Capybara::ElementNotFound, /custom filter block/)
139
+ end
140
+ end
@@ -376,6 +376,12 @@ Capybara::SpecHelper.spec '#click_button' do
376
376
  expect(@results['no_value']).not_to be_nil
377
377
  end
378
378
 
379
+ it 'should send button in document order' do
380
+ @session.click_button('outside_button')
381
+ @results = extract_results(@session)
382
+ expect(@results.keys).to eq %w[for_form2 outside_button which_form post_count]
383
+ end
384
+
379
385
  it 'should not send image buttons that were not clicked' do
380
386
  @session.click_button('Click me!')
381
387
  @results = extract_results(@session)
@@ -211,7 +211,7 @@ Capybara::SpecHelper.spec '#click_link' do
211
211
  download_file = File.join(Capybara.save_path, 'download.csv')
212
212
  expect(File).not_to exist(download_file)
213
213
  @session.click_link('Download Me')
214
- sleep 2
214
+ sleep 2 # allow time for file to download
215
215
  expect(File).to exist(download_file)
216
216
  FileUtils.rm_rf download_file
217
217
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Capybara::SpecHelper.spec '#have_all_selectors' do
3
+ Capybara::SpecHelper.spec '#have_all_of_selectors' do
4
4
  before do
5
5
  @session.visit('/with_html')
6
6
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ Capybara::SpecHelper.spec '#have_any_of_selectors' do
4
+ before do
5
+ @session.visit('/with_html')
6
+ end
7
+
8
+ it 'should be true if any of the given selectors are on the page' do
9
+ expect(@session).to have_any_of_selectors(:css, 'p a#foo', 'h2#blah', 'h2#h2two')
10
+ end
11
+
12
+ it 'should be false if none of the given selectors are not on the page' do
13
+ expect do
14
+ expect(@session).to have_any_of_selectors(:css, 'span a#foo', 'h2#h2nope', 'h2#h2one_no')
15
+ end.to raise_error ::RSpec::Expectations::ExpectationNotMetError
16
+ end
17
+
18
+ it 'should use default selector' do
19
+ Capybara.default_selector = :css
20
+ expect(@session).to have_any_of_selectors('p a#foo', 'h2#h2two', 'a#not_on_page')
21
+ expect do
22
+ expect(@session).to have_any_of_selectors('p a#blah', 'h2#h2three')
23
+ end.to raise_error ::RSpec::Expectations::ExpectationNotMetError
24
+ end
25
+ end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Note: This file uses `sleep` to sync up parts of the tests. This is only implemented like this
4
+ # because of the methods being tested. In tests using Capybara this type of behavior should be implemented
5
+ # using Capybara provided assertions with builtin waiting behavior.
6
+
3
7
  Capybara::SpecHelper.spec '#html' do
4
8
  it 'should return the unmodified page body' do
5
9
  @session.visit('/')
@@ -8,6 +12,7 @@ Capybara::SpecHelper.spec '#html' do
8
12
 
9
13
  it 'should return the current state of the page', requires: [:js] do
10
14
  @session.visit('/with_js')
15
+ sleep 1
11
16
  expect(@session.html).to include('I changed it')
12
17
  expect(@session.html).not_to include('This is text')
13
18
  end
@@ -21,6 +26,7 @@ Capybara::SpecHelper.spec '#source' do
21
26
 
22
27
  it 'should return the current state of the page', requires: [:js] do
23
28
  @session.visit('/with_js')
29
+ sleep 1
24
30
  expect(@session.source).to include('I changed it')
25
31
  expect(@session.source).not_to include('This is text')
26
32
  end
@@ -34,6 +40,7 @@ Capybara::SpecHelper.spec '#body' do
34
40
 
35
41
  it 'should return the current state of the page', requires: [:js] do
36
42
  @session.visit('/with_js')
43
+ sleep 1
37
44
  expect(@session.body).to include('I changed it')
38
45
  expect(@session.body).not_to include('This is text')
39
46
  end