capybara 3.19.1 → 3.20.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +18 -0
  3. data/README.md +1 -1
  4. data/lib/capybara/driver/node.rb +4 -0
  5. data/lib/capybara/node/actions.rb +3 -2
  6. data/lib/capybara/node/element.rb +11 -0
  7. data/lib/capybara/node/finders.rb +4 -1
  8. data/lib/capybara/queries/selector_query.rb +19 -5
  9. data/lib/capybara/selector.rb +2 -2
  10. data/lib/capybara/selector/definition/label.rb +27 -10
  11. data/lib/capybara/selector/definition/link.rb +3 -2
  12. data/lib/capybara/selenium/atoms/getAttribute.min.js +1 -0
  13. data/lib/capybara/selenium/atoms/isDisplayed.min.js +1 -0
  14. data/lib/capybara/selenium/atoms/src/getAttribute.js +161 -0
  15. data/lib/capybara/selenium/atoms/src/isDisplayed.js +454 -0
  16. data/lib/capybara/selenium/driver.rb +14 -1
  17. data/lib/capybara/selenium/driver_specializations/firefox_driver.rb +1 -1
  18. data/lib/capybara/selenium/driver_specializations/internet_explorer_driver.rb +1 -1
  19. data/lib/capybara/selenium/driver_specializations/safari_driver.rb +1 -1
  20. data/lib/capybara/selenium/node.rb +25 -1
  21. data/lib/capybara/selenium/nodes/safari_node.rb +19 -6
  22. data/lib/capybara/selenium/patches/atoms.rb +18 -0
  23. data/lib/capybara/spec/session/all_spec.rb +23 -0
  24. data/lib/capybara/spec/session/click_link_spec.rb +11 -0
  25. data/lib/capybara/spec/session/node_spec.rb +78 -5
  26. data/lib/capybara/spec/session/selectors_spec.rb +8 -0
  27. data/lib/capybara/spec/views/animated.erb +49 -0
  28. data/lib/capybara/spec/views/frame_one.erb +1 -0
  29. data/lib/capybara/spec/views/obscured.erb +9 -9
  30. data/lib/capybara/version.rb +1 -1
  31. data/spec/sauce_spec_chrome.rb +1 -0
  32. data/spec/selenium_spec_chrome.rb +4 -2
  33. data/spec/selenium_spec_chrome_remote.rb +4 -2
  34. data/spec/selenium_spec_edge.rb +4 -2
  35. data/spec/selenium_spec_firefox.rb +4 -11
  36. data/spec/selenium_spec_firefox_remote.rb +4 -2
  37. data/spec/selenium_spec_ie.rb +5 -6
  38. data/spec/selenium_spec_safari.rb +7 -12
  39. data/spec/server_spec.rb +4 -2
  40. data/spec/shared_selenium_node.rb +29 -0
  41. metadata +23 -2
@@ -18,6 +18,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
18
18
  def load_selenium
19
19
  require 'selenium-webdriver'
20
20
  require 'capybara/selenium/logger_suppressor'
21
+ require 'capybara/selenium/patches/atoms'
21
22
  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')
22
23
  rescue LoadError => e
23
24
  raise e unless e.message.match?(/selenium-webdriver/)
@@ -135,6 +136,18 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
135
136
  end
136
137
  end
137
138
 
139
+ def frame_obscured_at?(x:, y:)
140
+ frame = @frame_handles[current_window_handle].last
141
+ return false unless frame
142
+
143
+ switch_to_frame(:parent)
144
+ begin
145
+ return frame.base.obscured?(x: x, y: y)
146
+ ensure
147
+ switch_to_frame(frame)
148
+ end
149
+ end
150
+
138
151
  def switch_to_frame(frame)
139
152
  handles = @frame_handles[current_window_handle]
140
153
  case frame
@@ -145,7 +158,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
145
158
  handles.pop
146
159
  browser.switch_to.parent_frame
147
160
  else
148
- handles << frame.native
161
+ handles << frame
149
162
  browser.switch_to.frame(frame.native)
150
163
  end
151
164
  end
@@ -60,7 +60,7 @@ module Capybara::Selenium::Driver::W3CFirefoxDriver
60
60
  # so we have to move to the default_content and iterate back through the frames
61
61
  handles = @frame_handles[current_window_handle]
62
62
  browser.switch_to.default_content
63
- handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh) }
63
+ handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh.native) }
64
64
  end
65
65
 
66
66
  private
@@ -10,7 +10,7 @@ module Capybara::Selenium::Driver::InternetExplorerDriver
10
10
  # so we have to move to the default_content and iterate back through the frames
11
11
  handles = @frame_handles[current_window_handle]
12
12
  browser.switch_to.default_content
13
- handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh) }
13
+ handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh.native) }
14
14
  end
15
15
 
16
16
  private
@@ -10,7 +10,7 @@ module Capybara::Selenium::Driver::SafariDriver
10
10
  # behaves like switch_to_frame(:top)
11
11
  handles = @frame_handles[current_window_handle]
12
12
  browser.switch_to.default_content
13
- handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh) }
13
+ handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh.native) }
14
14
  end
15
15
 
16
16
  private
@@ -155,7 +155,7 @@ class Capybara::Selenium::Node < Capybara::Driver::Node
155
155
  end
156
156
 
157
157
  def content_editable?
158
- native.attribute('isContentEditable')
158
+ native.attribute('isContentEditable') == 'true'
159
159
  end
160
160
 
161
161
  def ==(other)
@@ -166,6 +166,13 @@ class Capybara::Selenium::Node < Capybara::Driver::Node
166
166
  driver.evaluate_script GET_XPATH_SCRIPT, self
167
167
  end
168
168
 
169
+ def obscured?(x: nil, y: nil)
170
+ res = driver.evaluate_script(OBSCURED_OR_OFFSET_SCRIPT, self, x, y)
171
+ return true if res == true
172
+
173
+ driver.frame_obscured_at?(x: res['x'], y: res['y'])
174
+ end
175
+
169
176
  protected
170
177
 
171
178
  def scroll_if_needed
@@ -401,6 +408,23 @@ private
401
408
  })(arguments[0], document)
402
409
  JS
403
410
 
411
+ OBSCURED_OR_OFFSET_SCRIPT = <<~'JS'
412
+ (function(el, x, y) {
413
+ var box = el.getBoundingClientRect();
414
+ if (x == null) x = box.width/2;
415
+ if (y == null) y = box.height/2 ;
416
+
417
+ var px = box.left + x,
418
+ py = box.top + y,
419
+ e = document.elementFromPoint(px, py);
420
+
421
+ if (!el.contains(e))
422
+ return true;
423
+
424
+ return { x: px, y: py };
425
+ })(arguments[0], arguments[1], arguments[2])
426
+ JS
427
+
404
428
  # SettableValue encapsulates time/date field formatting
405
429
  class SettableValue
406
430
  attr_reader :value
@@ -16,6 +16,10 @@ class Capybara::Selenium::SafariNode < Capybara::Selenium::Node
16
16
  return find_css('th:first-child,td:first-child')[0].click(keys, options)
17
17
  end
18
18
  raise
19
+ rescue ::Selenium::WebDriver::Error::WebDriverError
20
+ # Safari doesn't return a specific error here - assume it's an ElementNotInteractableError
21
+ raise ::Selenium::WebDriver::Error::ElementNotInteractableError,
22
+ 'Non distinct error raised in #click, translated to ElementNotInteractableError for retry'
19
23
  end
20
24
 
21
25
  def select_option
@@ -54,7 +58,9 @@ class Capybara::Selenium::SafariNode < Capybara::Selenium::Node
54
58
  end
55
59
 
56
60
  def send_keys(*args)
57
- return super(*args.map { |arg| arg == :space ? ' ' : arg }) if args.none? { |arg| arg.is_a? Array }
61
+ if args.none? { |arg| arg.is_a?(Array) || (arg.is_a?(Symbol) && MODIFIER_KEYS.include?(arg)) }
62
+ return super(*args.map { |arg| arg == :space ? ' ' : arg })
63
+ end
58
64
 
59
65
  native.click
60
66
  _send_keys(args).perform
@@ -74,6 +80,11 @@ class Capybara::Selenium::SafariNode < Capybara::Selenium::Node
74
80
  end
75
81
  end
76
82
 
83
+ def hover
84
+ # Workaround issue where hover would sometimes fail - possibly due to mouse not having moved
85
+ scroll_if_needed { browser_action.move_to(native, 0, 0).move_to(native).perform }
86
+ end
87
+
77
88
  private
78
89
 
79
90
  def bridge
@@ -95,11 +106,7 @@ private
95
106
 
96
107
  def _send_keys(keys, actions = browser_action, down_keys = ModifierKeysStack.new)
97
108
  case keys
98
- when :control, :left_control, :right_control,
99
- :alt, :left_alt, :right_alt,
100
- :shift, :left_shift, :right_shift,
101
- :meta, :left_meta, :right_meta,
102
- :command
109
+ when *MODIFIER_KEYS
103
110
  down_keys.press(keys)
104
111
  actions.key_down(keys)
105
112
  when String
@@ -117,6 +124,12 @@ private
117
124
  actions
118
125
  end
119
126
 
127
+ MODIFIER_KEYS = %i[control left_control right_control
128
+ alt left_alt right_alt
129
+ shift left_shift right_shift
130
+ meta left_meta right_meta
131
+ command].freeze
132
+
120
133
  class ModifierKeysStack
121
134
  def initialize
122
135
  @stack = []
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CapybaraAtoms
4
+ private # rubocop:disable Layout/IndentationWidth
5
+
6
+ def read_atom(function)
7
+ @atoms ||= Hash.new do |hash, key|
8
+ hash[key] = begin
9
+ File.read(File.expand_path("../../atoms/#{key}.min.js", __FILE__))
10
+ rescue Errno::ENOENT
11
+ super
12
+ end
13
+ end
14
+ @atoms[function]
15
+ end
16
+ end
17
+
18
+ ::Selenium::WebDriver::Remote::Bridge.prepend CapybaraAtoms unless ENV['DISABLE_CAPYBARA_SELENIUM_OPTIMIZATIONS']
@@ -100,6 +100,29 @@ Capybara::SpecHelper.spec '#all' do
100
100
  end
101
101
  end
102
102
 
103
+ context 'with obscured filter', requires: [:css] do
104
+ it 'should only find nodes on top in the viewport when false' do
105
+ expect(@session.all(:css, 'a.simple', obscured: false).size).to eq(1)
106
+ end
107
+
108
+ it 'should not find nodes on top outside the viewport when false' do
109
+ expect(@session.all(:link, 'Download Me', obscured: false).size).to eq(0)
110
+ @session.scroll_to(@session.find_link('Download Me'))
111
+ expect(@session.all(:link, 'Download Me', obscured: false).size).to eq(1)
112
+ end
113
+
114
+ it 'should find top nodes outside the viewport when true' do
115
+ expect(@session.all(:link, 'Download Me', obscured: true).size).to eq(1)
116
+ @session.scroll_to(@session.find_link('Download Me'))
117
+ expect(@session.all(:link, 'Download Me', obscured: true).size).to eq(0)
118
+ end
119
+
120
+ it 'should only find non-top nodes when true' do
121
+ # Also need visible: false so visibility is ignored
122
+ expect(@session.all(:css, 'a.simple', visible: false, obscured: true).size).to eq(1)
123
+ end
124
+ end
125
+
103
126
  context 'with element count filters' do
104
127
  context ':count' do
105
128
  it 'should succeed when the number of elements founds matches the expectation' do
@@ -118,6 +118,17 @@ Capybara::SpecHelper.spec '#click_link' do
118
118
  expect { @session.click_link('Normal Anchor', href: nil) }.to raise_error(Capybara::ElementNotFound, /with no href attribute/)
119
119
  end
120
120
  end
121
+
122
+ context 'href: false' do
123
+ it 'should not raise an error on links with no href attribute' do
124
+ expect { @session.click_link('No Href', href: false) }.not_to raise_error
125
+ end
126
+
127
+ it 'should not raise an error if href attribute exists' do
128
+ expect { @session.click_link('Blank Href', href: false) }.not_to raise_error
129
+ expect { @session.click_link('Normal Anchor', href: false) }.not_to raise_error
130
+ end
131
+ end
121
132
  end
122
133
 
123
134
  it 'should follow relative links' do
@@ -251,6 +251,79 @@ Capybara::SpecHelper.spec 'node' do
251
251
  end
252
252
  end
253
253
 
254
+ describe '#obscured?', requires: [:css] do
255
+ it 'should see non visible elements as obscured' do
256
+ Capybara.ignore_hidden_elements = false
257
+ expect(@session.find('//div[@id="hidden"]')).to be_obscured
258
+ expect(@session.find('//div[@id="hidden_via_ancestor"]')).to be_obscured
259
+ expect(@session.find('//div[@id="hidden_attr"]')).to be_obscured
260
+ expect(@session.find('//a[@id="hidden_attr_via_ancestor"]')).to be_obscured
261
+ expect(@session.find('//input[@id="hidden_input"]')).to be_obscured
262
+ end
263
+
264
+ it 'should see non-overlapped elements as not obscured' do
265
+ @session.visit('/obscured')
266
+ expect(@session.find(:css, '#cover')).not_to be_obscured
267
+ end
268
+
269
+ it 'should see elements only overlapped by descendants as not obscured' do
270
+ expect(@session.first(:css, 'p:not(.para)')).not_to be_obscured
271
+ end
272
+
273
+ it 'should see elements outside the viewport as obscured' do
274
+ @session.visit('/obscured')
275
+ off = @session.find(:css, '#offscreen')
276
+ off_wrapper = @session.find(:css, '#offscreen_wrapper')
277
+ expect(off).to be_obscured
278
+ expect(off_wrapper).to be_obscured
279
+ @session.scroll_to(off_wrapper)
280
+ expect(off_wrapper).not_to be_obscured
281
+ expect(off).to be_obscured
282
+ off_wrapper.scroll_to(off)
283
+ expect(off).not_to be_obscured
284
+ expect(off_wrapper).not_to be_obscured
285
+ end
286
+
287
+ it 'should see overlapped elements as obscured' do
288
+ @session.visit('/obscured')
289
+ expect(@session.find(:css, '#obscured')).to be_obscured
290
+ end
291
+
292
+ it 'should be boolean' do
293
+ Capybara.ignore_hidden_elements = false
294
+ expect(@session.first('//a').obscured?).to be false
295
+ expect(@session.find('//div[@id="hidden"]').obscured?).to be true
296
+ end
297
+
298
+ it 'should work in frames' do
299
+ @session.visit('/obscured')
300
+ frame = @session.find(:css, '#frameOne')
301
+ @session.within_frame(frame) do
302
+ div = @session.find(:css, '#divInFrameOne')
303
+ expect(div).to be_obscured
304
+ @session.scroll_to div
305
+ expect(div).not_to be_obscured
306
+ end
307
+ end
308
+
309
+ it 'should work in nested iframes' do
310
+ @session.visit('/obscured')
311
+ frame = @session.find(:css, '#nestedFrames')
312
+ @session.within_frame(frame) do
313
+ @session.within_frame(:css, '#childFrame') do
314
+ gcframe = @session.find(:css, '#grandchildFrame2')
315
+ @session.within_frame(gcframe) do
316
+ expect(@session.find(:css, '#divInFrameTwo')).to be_obscured
317
+ end
318
+ @session.scroll_to(gcframe)
319
+ @session.within_frame(gcframe) do
320
+ expect(@session.find(:css, '#divInFrameTwo')).not_to be_obscured
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
326
+
254
327
  describe '#checked?' do
255
328
  it 'should extract node checked state' do
256
329
  @session.visit('/form')
@@ -441,7 +514,7 @@ Capybara::SpecHelper.spec 'node' do
441
514
  end
442
515
 
443
516
  it 'should retry clicking', requires: [:js] do
444
- @session.visit('/obscured')
517
+ @session.visit('/animated')
445
518
  obscured = @session.find(:css, '#obscured')
446
519
  @session.execute_script <<~JS
447
520
  setTimeout(function(){ $('#cover').hide(); }, 700)
@@ -450,7 +523,7 @@ Capybara::SpecHelper.spec 'node' do
450
523
  end
451
524
 
452
525
  it 'should allow to retry longer', requires: [:js] do
453
- @session.visit('/obscured')
526
+ @session.visit('/animated')
454
527
  obscured = @session.find(:css, '#obscured')
455
528
  @session.execute_script <<~JS
456
529
  setTimeout(function(){ $('#cover').hide(); }, 3000)
@@ -459,7 +532,7 @@ Capybara::SpecHelper.spec 'node' do
459
532
  end
460
533
 
461
534
  it 'should not retry clicking when wait is disabled', requires: [:js] do
462
- @session.visit('/obscured')
535
+ @session.visit('/animated')
463
536
  obscured = @session.find(:css, '#obscured')
464
537
  @session.execute_script <<~JS
465
538
  setTimeout(function(){ $('#cover').hide(); }, 2000)
@@ -493,7 +566,7 @@ Capybara::SpecHelper.spec 'node' do
493
566
  end
494
567
 
495
568
  it 'should retry clicking', requires: [:js] do
496
- @session.visit('/obscured')
569
+ @session.visit('/animated')
497
570
  obscured = @session.find(:css, '#obscured')
498
571
  @session.execute_script <<~JS
499
572
  setTimeout(function(){ $('#cover').hide(); }, 700)
@@ -527,7 +600,7 @@ Capybara::SpecHelper.spec 'node' do
527
600
  end
528
601
 
529
602
  it 'should retry clicking', requires: [:js] do
530
- @session.visit('/obscured')
603
+ @session.visit('/animated')
531
604
  obscured = @session.find(:css, '#obscured')
532
605
  @session.execute_script <<~JS
533
606
  setTimeout(function(){ $('#cover').hide(); }, 700)
@@ -14,10 +14,18 @@ Capybara::SpecHelper.spec Capybara::Selector do
14
14
  expect(@session.find(:label, for: 'form_other_title')['for']).to eq 'form_other_title'
15
15
  end
16
16
 
17
+ it 'finds a label for for attribute regex' do
18
+ expect(@session.find(:label, for: /_other_title/)['for']).to eq 'form_other_title'
19
+ end
20
+
17
21
  it 'finds a label from nested input using :for filter with id string' do
18
22
  expect(@session.find(:label, for: 'nested_label').text).to eq 'Nested Label'
19
23
  end
20
24
 
25
+ it 'finds a label from nested input using :for filter with id regexp' do
26
+ expect(@session.find(:label, for: /nested_lab/).text).to eq 'Nested Label'
27
+ end
28
+
21
29
  it 'finds a label from nested input using :for filter with element' do
22
30
  input = @session.find(:id, 'nested_label')
23
31
  expect(@session.find(:label, for: input).text).to eq 'Nested Label'
@@ -0,0 +1,49 @@
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
+ <script>
7
+ $(document).on('contextmenu', function(e){ e.preventDefault(); });
8
+ </script>
9
+ <style>
10
+ div {
11
+ width: 400px;
12
+ height: 400px;
13
+ position: absolute;
14
+ }
15
+ #obscured {
16
+ z-index: 1;
17
+ background-color: red;
18
+ }
19
+ #cover {
20
+ z-index: 2;
21
+ background-color: blue;
22
+ }
23
+ #offscreen {
24
+ top: 2000px;
25
+ left: 2000px;
26
+ background-color: green;
27
+ }
28
+ #offscreen_wrapper {
29
+ top: 2000px;
30
+ left: 2000px;
31
+ overflow-x: scroll;
32
+ background-color: yellow;
33
+ }
34
+ </style>
35
+ </head>
36
+
37
+ <body id="with_animation">
38
+ <div id="obscured">
39
+ <input id="obscured_input"/>
40
+ </div>
41
+ <div id="cover"></div>
42
+ <div id="offscreen_wrapper">
43
+ <div id="offscreen"></div>
44
+ </div>
45
+ </body>
46
+
47
+ <iframe id="frameOne" src="/frame_one"></iframe>
48
+ </html>
49
+
@@ -5,5 +5,6 @@
5
5
  </head>
6
6
  <body>
7
7
  <div id="divInFrameOne">This is the text of divInFrameOne</div>
8
+ <div id="otherDivInFrameOne">Some other text</div>
8
9
  </body>
9
10
  </html>
@@ -1,15 +1,13 @@
1
1
  <html>
2
2
  <head>
3
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
- <script>
7
- $(document).on('contextmenu', function(e){ e.preventDefault(); });
8
- </script>
4
+ <title>Obscured</title>
9
5
  <style>
10
6
  div {
11
- width: 400px;
12
- height: 400px;
7
+ width: 200px;
8
+ height: 200px;
9
+ }
10
+ #cover, #offscreen, #offscreen_wrapper {
13
11
  position: absolute;
14
12
  }
15
13
  #obscured {
@@ -17,6 +15,7 @@
17
15
  background-color: red;
18
16
  }
19
17
  #cover {
18
+ top: 0px;
20
19
  z-index: 2;
21
20
  background-color: blue;
22
21
  }
@@ -33,12 +32,13 @@
33
32
  }
34
33
  </style>
35
34
  </head>
36
-
37
- <body id="with_animation">
35
+ <body>
38
36
  <div id="obscured">
39
37
  <input id="obscured_input"/>
40
38
  </div>
41
39
  <div id="cover"></div>
40
+ <iframe id="frameOne" height="10px" src="/frame_one"></iframe>
41
+ <iframe id="nestedFrames" src="/frame_parent"></iframe>
42
42
  <div id="offscreen_wrapper">
43
43
  <div id="offscreen"></div>
44
44
  </div>