capybara 3.9.0 → 3.10.0

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