capybara 3.10.1 → 3.11.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +13 -0
  3. data/README.md +2 -3
  4. data/lib/capybara.rb +16 -6
  5. data/lib/capybara/minitest.rb +8 -9
  6. data/lib/capybara/node/actions.rb +31 -28
  7. data/lib/capybara/node/base.rb +2 -1
  8. data/lib/capybara/node/document_matchers.rb +6 -2
  9. data/lib/capybara/node/element.rb +10 -10
  10. data/lib/capybara/node/finders.rb +13 -14
  11. data/lib/capybara/node/matchers.rb +1 -3
  12. data/lib/capybara/node/simple.rb +10 -2
  13. data/lib/capybara/queries/base_query.rb +7 -3
  14. data/lib/capybara/queries/selector_query.rb +60 -34
  15. data/lib/capybara/queries/style_query.rb +5 -1
  16. data/lib/capybara/queries/text_query.rb +2 -2
  17. data/lib/capybara/queries/title_query.rb +1 -1
  18. data/lib/capybara/rack_test/node.rb +16 -2
  19. data/lib/capybara/result.rb +9 -4
  20. data/lib/capybara/rspec/features.rb +4 -4
  21. data/lib/capybara/rspec/matcher_proxies.rb +3 -1
  22. data/lib/capybara/rspec/matchers.rb +25 -287
  23. data/lib/capybara/rspec/matchers/base.rb +98 -0
  24. data/lib/capybara/rspec/matchers/become_closed.rb +33 -0
  25. data/lib/capybara/rspec/matchers/compound.rb +88 -0
  26. data/lib/capybara/rspec/matchers/have_current_path.rb +29 -0
  27. data/lib/capybara/rspec/matchers/have_selector.rb +69 -0
  28. data/lib/capybara/rspec/matchers/have_style.rb +23 -0
  29. data/lib/capybara/rspec/matchers/have_text.rb +33 -0
  30. data/lib/capybara/rspec/matchers/have_title.rb +29 -0
  31. data/lib/capybara/rspec/matchers/match_selector.rb +27 -0
  32. data/lib/capybara/selector.rb +48 -20
  33. data/lib/capybara/selector/builders/xpath_builder.rb +3 -3
  34. data/lib/capybara/selector/css.rb +5 -5
  35. data/lib/capybara/selector/filters/base.rb +11 -3
  36. data/lib/capybara/selector/filters/expression_filter.rb +3 -3
  37. data/lib/capybara/selector/filters/node_filter.rb +16 -2
  38. data/lib/capybara/selector/regexp_disassembler.rb +116 -17
  39. data/lib/capybara/selector/selector.rb +52 -26
  40. data/lib/capybara/selenium/driver.rb +6 -2
  41. data/lib/capybara/selenium/node.rb +15 -14
  42. data/lib/capybara/selenium/nodes/marionette_node.rb +19 -5
  43. data/lib/capybara/selenium/patches/pause_duration_fix.rb +1 -3
  44. data/lib/capybara/server.rb +6 -1
  45. data/lib/capybara/server/animation_disabler.rb +1 -1
  46. data/lib/capybara/session.rb +4 -2
  47. data/lib/capybara/session/matchers.rb +7 -3
  48. data/lib/capybara/spec/public/test.js +5 -5
  49. data/lib/capybara/spec/session/all_spec.rb +5 -0
  50. data/lib/capybara/spec/session/has_css_spec.rb +4 -4
  51. data/lib/capybara/spec/session/has_field_spec.rb +17 -0
  52. data/lib/capybara/spec/session/node_spec.rb +45 -4
  53. data/lib/capybara/spec/spec_helper.rb +6 -1
  54. data/lib/capybara/spec/views/frame_child.erb +1 -1
  55. data/lib/capybara/spec/views/obscured.erb +44 -0
  56. data/lib/capybara/spec/views/with_html.erb +1 -1
  57. data/lib/capybara/version.rb +1 -1
  58. data/spec/rack_test_spec.rb +15 -0
  59. data/spec/regexp_dissassembler_spec.rb +88 -8
  60. data/spec/selector_spec.rb +3 -0
  61. data/spec/selenium_spec_chrome.rb +9 -15
  62. data/spec/selenium_spec_chrome_remote.rb +3 -2
  63. data/spec/selenium_spec_firefox_remote.rb +6 -2
  64. metadata +54 -3
  65. data/lib/capybara/rspec/compound.rb +0 -86
@@ -225,7 +225,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
225
225
  ::Selenium::WebDriver::Error::StaleElementReferenceError,
226
226
  ::Selenium::WebDriver::Error::UnhandledError,
227
227
  ::Selenium::WebDriver::Error::ElementNotVisibleError,
228
- ::Selenium::WebDriver::Error::InvalidSelectorError, # Work around a race condition that can occur with chromedriver and #go_back/#go_forward
228
+ ::Selenium::WebDriver::Error::InvalidSelectorError, # Work around a chromedriver go_back/go_forward race condition
229
229
  ::Selenium::WebDriver::Error::ElementNotInteractableError,
230
230
  ::Selenium::WebDriver::Error::ElementClickInterceptedError,
231
231
  ::Selenium::WebDriver::Error::InvalidElementStateError,
@@ -352,11 +352,15 @@ private
352
352
  when :chrome
353
353
  extend ChromeDriver
354
354
  when :firefox
355
- require 'capybara/selenium/patches/pause_duration_fix' if sel_driver.capabilities['moz:geckodriverVersion']&.start_with?('0.22.')
355
+ require 'capybara/selenium/patches/pause_duration_fix' if pause_broken?(sel_driver)
356
356
  extend MarionetteDriver if sel_driver.capabilities.is_a?(::Selenium::WebDriver::Remote::W3C::Capabilities)
357
357
  end
358
358
  end
359
359
 
360
+ def pause_broken?(driver)
361
+ driver.capabilities['moz:geckodriverVersion']&.start_with?('0.22.')
362
+ end
363
+
360
364
  def setup_exit_handler
361
365
  main = Process.pid
362
366
  at_exit do
@@ -170,22 +170,9 @@ class Capybara::Selenium::Node < Capybara::Driver::Node
170
170
  selector = node[:tagName]
171
171
  if node[:namespaceURI] != default_ns
172
172
  selector = XPath.child.where((XPath.local_name == selector) & (XPath.namespace_uri == node[:namespaceURI])).to_s
173
- selector
174
173
  end
175
174
 
176
- if parent
177
- siblings = parent.find_xpath(selector)
178
- selector += case siblings.size
179
- when 0
180
- '[ERROR]' # IE doesn't support full XPath (namespace-uri, etc)
181
- when 1
182
- '' # index not necessary when only one matching element
183
- else
184
- idx = siblings.index(node)
185
- # Element may not be found in the siblings if it has gone away
186
- idx.nil? ? '[ERROR]' : "[#{idx + 1}]"
187
- end
188
- end
175
+ selector += sibling_index(parent, node, selector) if parent
189
176
  result.push selector
190
177
  end
191
178
 
@@ -203,6 +190,20 @@ protected
203
190
 
204
191
  private
205
192
 
193
+ def sibling_index(parent, node, selector)
194
+ siblings = parent.find_xpath(selector)
195
+ case siblings.size
196
+ when 0
197
+ '[ERROR]' # IE doesn't support full XPath (namespace-uri, etc)
198
+ when 1
199
+ '' # index not necessary when only one matching element
200
+ else
201
+ idx = siblings.index(node)
202
+ # Element may not be found in the siblings if it has gone away
203
+ idx.nil? ? '[ERROR]' : "[#{idx + 1}]"
204
+ end
205
+ end
206
+
206
207
  def boolean_attr(val)
207
208
  val && (val != 'false')
208
209
  end
@@ -9,8 +9,9 @@ class Capybara::Selenium::MarionetteNode < Capybara::Selenium::Node
9
9
  super
10
10
  rescue ::Selenium::WebDriver::Error::ElementNotInteractableError
11
11
  if tag_name == 'tr'
12
- warn 'You are attempting to click a table row which has issues in geckodriver/marionette - see https://github.com/mozilla/geckodriver/issues/1228. ' \
13
- 'Your test should probably be clicking on a table cell like a user would. Clicking the first cell in the row instead.'
12
+ warn 'You are attempting to click a table row which has issues in geckodriver/marionette - '\
13
+ 'see https://github.com/mozilla/geckodriver/issues/1228. Your test should probably be '\
14
+ 'clicking on a table cell like a user would. Clicking the first cell in the row instead.'
14
15
  return find_css('th:first-child,td:first-child')[0].click(keys, options)
15
16
  end
16
17
  raise
@@ -26,7 +27,7 @@ class Capybara::Selenium::MarionetteNode < Capybara::Selenium::Node
26
27
  if %w[option optgroup].include? tag_name
27
28
  find_xpath('parent::*[self::optgroup or self::select]')[0].disabled?
28
29
  else
29
- !find_xpath('parent::fieldset[@disabled] | ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]').empty?
30
+ !find_xpath(DISABLED_BY_FIELDSET_XPATH).empty?
30
31
  end
31
32
  end
32
33
 
@@ -99,14 +100,27 @@ private
99
100
  return nil unless local_file
100
101
  raise ArgumentError, "You may only upload files: #{local_file.inspect}" unless File.file?(local_file)
101
102
 
102
- result = bridge.http.call(:post, "session/#{bridge.session_id}/file", file: Selenium::WebDriver::Zipper.zip_file(local_file))
103
- result['value']
103
+ file = ::Selenium::WebDriver::Zipper.zip_file(local_file)
104
+ bridge.http.call(:post, "session/#{bridge.session_id}/file", file: file)['value']
104
105
  end
105
106
 
106
107
  def browser_version
107
108
  driver.browser.capabilities[:browser_version].to_f
108
109
  end
109
110
 
111
+ DISABLED_BY_FIELDSET_XPATH = XPath.generate do |x|
112
+ x.parent(:fieldset)[
113
+ x.attr(:disabled)
114
+ ] + x.ancestor[
115
+ ~x.self(:legned) |
116
+ x.preceding_sibling(:legend)
117
+ ][
118
+ x.parent(:fieldset)[
119
+ x.attr(:disabled)
120
+ ]
121
+ ]
122
+ end.to_s.freeze
123
+
110
124
  class ModifierKeysStack
111
125
  def initialize
112
126
  @stack = []
@@ -6,6 +6,4 @@ module PauseDurationFix
6
6
  end
7
7
  end
8
8
 
9
- if defined?(::Selenium::WebDriver::Interactions::Pause)
10
- ::Selenium::WebDriver::Interactions::Pause.prepend PauseDurationFix
11
- end
9
+ ::Selenium::WebDriver::Interactions::Pause.prepend PauseDurationFix
@@ -18,7 +18,12 @@ module Capybara
18
18
 
19
19
  attr_reader :app, :port, :host
20
20
 
21
- def initialize(app, *deprecated_options, port: Capybara.server_port, host: Capybara.server_host, reportable_errors: Capybara.server_errors, extra_middleware: [])
21
+ def initialize(app,
22
+ *deprecated_options,
23
+ port: Capybara.server_port,
24
+ host: Capybara.server_host,
25
+ reportable_errors: Capybara.server_errors,
26
+ extra_middleware: [])
22
27
  warn 'Positional arguments, other than the application, to Server#new are deprecated, please use keyword arguments' unless deprecated_options.empty?
23
28
  @app = app
24
29
  @extra_middleware = extra_middleware
@@ -16,7 +16,7 @@ module Capybara
16
16
 
17
17
  def initialize(app)
18
18
  @app = app
19
- @disable_markup = format(DISABLE_MARKUP_TEMPLATE, selector: AnimationDisabler.selector_for(Capybara.disable_animation))
19
+ @disable_markup = format(DISABLE_MARKUP_TEMPLATE, selector: self.class.selector_for(Capybara.disable_animation))
20
20
  end
21
21
 
22
22
  def call(env)
@@ -43,7 +43,7 @@ module Capybara
43
43
  click_link_or_button click_button click_link
44
44
  fill_in find find_all find_button find_by_id find_field find_link
45
45
  has_content? has_text? has_css? has_no_content? has_no_text?
46
- has_no_css? has_no_xpath? resolve has_xpath? select uncheck
46
+ has_no_css? has_no_xpath? has_xpath? select uncheck
47
47
  has_link? has_no_link? has_button? has_no_button? has_field?
48
48
  has_no_field? has_checked_field? has_unchecked_field?
49
49
  has_no_table? has_table? unselect has_select? has_no_select?
@@ -819,7 +819,9 @@ module Capybara
819
819
  end
820
820
 
821
821
  def prepare_path(path, extension)
822
- File.expand_path(path || default_fn(extension), config.save_path).tap { |p_path| FileUtils.mkdir_p(File.dirname(p_path)) }
822
+ File.expand_path(path || default_fn(extension), config.save_path).tap do |p_path|
823
+ FileUtils.mkdir_p(File.dirname(p_path))
824
+ end
823
825
  end
824
826
 
825
827
  def default_fn(extension)
@@ -6,7 +6,7 @@ module Capybara
6
6
  # Asserts that the page has the given path.
7
7
  # By default, if passed a full url this will compare against the full url,
8
8
  # if passed a path only the path+query portion will be compared, if passed a regexp
9
- # the comparison will depend on the :url option
9
+ # the comparison will depend on the :url option (path+query by default)
10
10
  #
11
11
  # @!macro current_path_query_params
12
12
  # @overload $0(string, **options)
@@ -20,7 +20,9 @@ module Capybara
20
20
  # @return [true]
21
21
  #
22
22
  def assert_current_path(path, **options)
23
- _verify_current_path(path, options) { |query| raise Capybara::ExpectationNotMet, query.failure_message unless query.resolves_for?(self) }
23
+ _verify_current_path(path, options) do |query|
24
+ raise Capybara::ExpectationNotMet, query.failure_message unless query.resolves_for?(self)
25
+ end
24
26
  end
25
27
 
26
28
  ##
@@ -34,7 +36,9 @@ module Capybara
34
36
  # @return [true]
35
37
  #
36
38
  def assert_no_current_path(path, **options)
37
- _verify_current_path(path, options) { |query| raise Capybara::ExpectationNotMet, query.negative_failure_message if query.resolves_for?(self) }
39
+ _verify_current_path(path, options) do |query|
40
+ raise Capybara::ExpectationNotMet, query.negative_failure_message if query.resolves_for?(self)
41
+ end
38
42
  end
39
43
 
40
44
  ##
@@ -159,25 +159,25 @@ $(function() {
159
159
  $(this).attr('confirmed', 'false');
160
160
  }
161
161
  }
162
- })
162
+ });
163
163
  $('#delayed-page-change').click(function() {
164
164
  setTimeout(function() {
165
165
  window.location.pathname = '/with_html'
166
166
  }, 500)
167
- })
167
+ });
168
168
  $('#with-key-events').keydown(function(e){
169
169
  $('#key-events-output').append('keydown:'+e.which+' ')
170
170
  });
171
171
  $('#disable-on-click').click(function(e){
172
- var input = this
172
+ var input = this;
173
173
  setTimeout(function() {
174
174
  input.disabled = true;
175
175
  }, 500)
176
- })
176
+ });
177
177
  $('#set-storage').click(function(e){
178
178
  sessionStorage.setItem('session', 'session_value');
179
179
  localStorage.setItem('local', 'local value');
180
- })
180
+ });
181
181
  $('#multiple-file').change(function(e){
182
182
  $('body').append($('<p class="file_change"input_event_triggered">File input changed</p>'));
183
183
  })
@@ -56,6 +56,11 @@ Capybara::SpecHelper.spec '#all' do
56
56
  expect(@session.all(:xpath, '//h1').first.text).to eq('This is a test')
57
57
  expect(@session.all(:xpath, "//input[@id='test_field']").first.value).to eq('monkey')
58
58
  end
59
+
60
+ it 'should use alternated regex for :id' do
61
+ expect(@session.all(:xpath, './/h2', id: /h2/).unfiltered_size).to eq 3
62
+ expect(@session.all(:xpath, './/h2', id: /h2(one|two)/).unfiltered_size).to eq 2
63
+ end
59
64
  end
60
65
 
61
66
  context 'with css as default selector' do
@@ -50,17 +50,17 @@ Capybara::SpecHelper.spec '#has_css?' do
50
50
  it 'should be able to generate an error message if the scope is a sibling' do
51
51
  el = @session.find(:css, '#first')
52
52
  @session.within el.sibling(:css, '#second') do
53
- expect {
53
+ expect do
54
54
  expect(@session).to have_css('a#not_on_page')
55
- }.to raise_error /there were no matches/
55
+ end.to raise_error(/there were no matches/)
56
56
  end
57
57
  end
58
58
 
59
59
  it 'should be able to generate an error message if the scope is a sibling from XPath' do
60
60
  el = @session.find(:css, '#first').find(:xpath, './following-sibling::*[1]') do
61
- expect {
61
+ expect do
62
62
  expect(el).to have_css('a#not_on_page')
63
- }.to raise_error /there were no matches/
63
+ end.to raise_error(/there were no matches/)
64
64
  end
65
65
  end
66
66
  end
@@ -41,6 +41,23 @@ Capybara::SpecHelper.spec '#has_field' do
41
41
  expect(@session).not_to have_field('First Name', with: 'John')
42
42
  expect(@session).not_to have_field('First Name', with: /John|Paul|George|Ringo/)
43
43
  end
44
+
45
+ it 'should output filter errors if only one element matched the selector but failed the filters' do
46
+ @session.fill_in('First Name', with: 'Thomas')
47
+ expect do
48
+ expect(@session).to have_field('First Name', with: 'Jonas')
49
+ end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected value to be "Jonas" but was "Thomas"/)
50
+
51
+ # native boolean node filter
52
+ expect do
53
+ expect(@session).to have_field('First Name', readonly: true)
54
+ end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected readonly true but it wasn't/)
55
+
56
+ # inherited boolean node filter
57
+ expect do
58
+ expect(@session).to have_field('form_pets_cat', checked: true)
59
+ end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected checked true but it wasn't/)
60
+ end
44
61
  end
45
62
 
46
63
  context 'with type' do
@@ -411,6 +411,33 @@ Capybara::SpecHelper.spec 'node' do
411
411
  tr = @session.find(:css, '#agent_table tr:first-child').click
412
412
  expect(tr).to have_css('label', text: 'Clicked')
413
413
  end
414
+
415
+ it 'should retry clicking', requires: [:js] do
416
+ @session.visit('/obscured')
417
+ obscured = @session.find(:css, '#obscured')
418
+ @session.execute_script <<~JS
419
+ setTimeout(function(){ $('#cover').hide(); }, 1000)
420
+ JS
421
+ expect { obscured.click }.not_to raise_error
422
+ end
423
+
424
+ it 'should allow to retry longer', requires: [:js] do
425
+ @session.visit('/obscured')
426
+ obscured = @session.find(:css, '#obscured')
427
+ @session.execute_script <<~JS
428
+ setTimeout(function(){ $('#cover').hide(); }, 3000)
429
+ JS
430
+ expect { obscured.click(wait: 4) }.not_to raise_error
431
+ end
432
+
433
+ it 'should not retry clicking when wait is disabled', requires: [:js] do
434
+ @session.visit('/obscured')
435
+ obscured = @session.find(:css, '#obscured')
436
+ @session.execute_script <<~JS
437
+ setTimeout(function(){ $('#cover').hide(); }, 2000)
438
+ JS
439
+ expect { obscured.click(wait: 0) }.to(raise_error { |e| expect(e).to be_an_invalid_element_error(@session) })
440
+ end
414
441
  end
415
442
 
416
443
  describe '#double_click', requires: [:js] do
@@ -436,6 +463,15 @@ Capybara::SpecHelper.spec 'node' do
436
463
  expect(locations[:x].to_f).to be_within(1).of(10)
437
464
  expect(locations[:y].to_f).to be_within(1).of(5)
438
465
  end
466
+
467
+ it 'should retry clicking', requires: [:js] do
468
+ @session.visit('/obscured')
469
+ obscured = @session.find(:css, '#obscured')
470
+ @session.execute_script <<~JS
471
+ setTimeout(function(){ $('#cover').hide(); }, 1000)
472
+ JS
473
+ expect { obscured.double_click }.not_to raise_error
474
+ end
439
475
  end
440
476
 
441
477
  describe '#right_click', requires: [:js] do
@@ -461,6 +497,15 @@ Capybara::SpecHelper.spec 'node' do
461
497
  expect(locations[:x].to_f).to be_within(1).of(10)
462
498
  expect(locations[:y].to_f).to be_within(1).of(10)
463
499
  end
500
+
501
+ it 'should retry clicking', requires: [:js] do
502
+ @session.visit('/obscured')
503
+ obscured = @session.find(:css, '#obscured')
504
+ @session.execute_script <<~JS
505
+ setTimeout(function(){ $('#cover').hide(); }, 1000)
506
+ JS
507
+ expect { obscured.right_click }.not_to raise_error
508
+ end
464
509
  end
465
510
 
466
511
  describe '#send_keys', requires: [:send_keys] do
@@ -670,8 +715,4 @@ Capybara::SpecHelper.spec 'node' do
670
715
  end)
671
716
  end
672
717
  end
673
-
674
- def be_an_invalid_element_error(session)
675
- satisfy { |error| session.driver.invalid_element_errors.any? { |e| error.is_a? e } }
676
- end
677
718
  end
@@ -117,7 +117,12 @@ module Capybara
117
117
 
118
118
  def extract_results(session)
119
119
  expect(session).to have_xpath("//pre[@id='results']")
120
- YAML.load Nokogiri::HTML(session.body).xpath("//pre[@id='results']").first.inner_html.lstrip
120
+ # YAML.load Nokogiri::HTML(session.body).xpath("//pre[@id='results']").first.inner_html.lstrip
121
+ YAML.load Capybara::HTML(session.body).xpath("//pre[@id='results']").first.inner_html.lstrip
122
+ end
123
+
124
+ def be_an_invalid_element_error(session)
125
+ satisfy { |error| session.driver.invalid_element_errors.any? { |e| error.is_a? e } }
121
126
  end
122
127
  end
123
128
  end
@@ -4,7 +4,7 @@
4
4
  <title>This is the child frame title</title>
5
5
  <script>
6
6
  function closeWin() {
7
- var iframe = window.parent.document.getElementById('childFrame')
7
+ var iframe = window.parent.document.getElementById('childFrame');
8
8
  iframe.parentNode.removeChild(iframe)
9
9
  }
10
10
  </script>
@@ -0,0 +1,44 @@
1
+ <html>
2
+ <head>
3
+ <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
4
+ <title>with_animation</title>
5
+ <script src="/jquery.js" type="text/javascript" charset="utf-8"></script>
6
+ <style>
7
+ div {
8
+ width: 400px;
9
+ height: 400px;
10
+ position: absolute;
11
+ }
12
+ #obscured {
13
+ z-index: 1;
14
+ background-color: red;
15
+ }
16
+ #cover {
17
+ z-index: 2;
18
+ background-color: blue;
19
+ }
20
+ #offscreen {
21
+ top: 2000px;
22
+ left: 2000px;
23
+ background-color: green;
24
+ }
25
+ #offscreen_wrapper {
26
+ top: 2000px;
27
+ left: 2000px;
28
+ overflow-x: scroll;
29
+ background-color: yellow;
30
+ }
31
+ </style>
32
+ </head>
33
+
34
+ <body id="with_animation">
35
+ <div id="obscured">
36
+ <input id="obscured_input"/>
37
+ </div>
38
+ <div id="cover"></div>
39
+ <div id="offscreen_wrapper">
40
+ <div id="offscreen"></div>
41
+ </div>
42
+ </body>
43
+ </html>
44
+
@@ -10,7 +10,7 @@
10
10
  <h2 class="no text"></h2>
11
11
  <h2 class="head" id="h2one">Header Class Test One</h2>
12
12
  <h2 class="head" id="h2two">Header Class Test Two</h2>
13
- <h2 class="head">Header Class Test Three</h2>
13
+ <h2 class="head" id="h2_">Header Class Test Three</h2>
14
14
  <h2 class="head">Header Class Test Four</h2>
15
15
  <h2 class="head">Header Class Test Five</h2>
16
16