capybara 3.19.0 → 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 +27 -2
  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/definition/label.rb +27 -10
  10. data/lib/capybara/selector/definition/link.rb +3 -2
  11. data/lib/capybara/selector.rb +2 -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 +15 -2
  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 +17 -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
@@ -0,0 +1,454 @@
1
+ (function(){
2
+ var OverflowState = {
3
+ NONE: "none",
4
+ HIDDEN: "hidden",
5
+ SCROLL: "scroll"
6
+ };
7
+
8
+ function isShown_(elem, ignoreOpacity, parentsDisplayedFn) {
9
+ // By convention, BODY element is always shown: BODY represents the document
10
+ // and even if there's nothing rendered in there, user can always see there's
11
+ // the document.
12
+ var elemTagName = elem.tagName.toUpperCase();
13
+ if (elemTagName == "BODY") {
14
+ return true;
15
+ }
16
+
17
+ // Child of DETAILS element is not shown unless the DETAILS element is open
18
+ // or the child is a SUMMARY element.
19
+
20
+ var parent = getParentElement(elem);
21
+ if (parent && parent.tagName && (parent.tagName.toUpperCase() == "DETAILS") &&
22
+ !parent.open && !(elemTagName == "SUMMARY")) {
23
+ return false;
24
+ }
25
+
26
+ // Option or optgroup is shown if enclosing select is shown (ignoring the
27
+ // select's opacity).
28
+ if ((elemTagName == "OPTION") ||
29
+ (elemTagName == "OPTGROUP")) {
30
+ var select = getAncestor(elem, function(e) {
31
+ return e.tagName.toUpperCase() == "SELECT";
32
+ });
33
+ return !!select && isShown_(select, true, parentsDisplayedFn);
34
+ }
35
+
36
+ // Image map elements are shown if image that uses it is shown, and
37
+ // the area of the element is positive.
38
+ var imageMap = maybeFindImageMap_(elem);
39
+ if (imageMap) {
40
+ return !!imageMap.image &&
41
+ imageMap.rect.width > 0 && imageMap.rect.height > 0 &&
42
+ isShown_(imageMap.image, ignoreOpacity, parentsDisplayedFn);
43
+ }
44
+
45
+ // Any hidden input is not shown.
46
+ if ((elemTagName == "INPUT") && (elem.type.toLowerCase() == "hidden")) {
47
+ return false;
48
+ }
49
+
50
+ // Any NOSCRIPT element is not shown.
51
+ if (elemTagName == "NOSCRIPT") {
52
+ return false;
53
+ }
54
+
55
+ // Any element with hidden/collapsed visibility is not shown.
56
+ var visibility = window.getComputedStyle(elem)["visibility"];
57
+ if (visibility == "collapse" || visibility == "hidden") {
58
+ return false;
59
+ }
60
+
61
+ if (!parentsDisplayedFn(elem)) {
62
+ return false;
63
+ }
64
+
65
+ // Any transparent element is not shown.
66
+ if (!ignoreOpacity && getOpacity(elem) == 0) {
67
+ return false;
68
+ }
69
+
70
+ // Any element without positive size dimensions is not shown.
71
+ function positiveSize(e) {
72
+ var rect = getClientRect(e);
73
+ if (rect.height > 0 && rect.width > 0) {
74
+ return true;
75
+ }
76
+ // A vertical or horizontal SVG Path element will report zero width or
77
+ // height but is "shown" if it has a positive stroke-width.
78
+ if ((e.tagName.toUpperCase() == "PATH") && (rect.height > 0 || rect.width > 0)) {
79
+ var strokeWidth = window.getComputedStyle(e)["stroke-width"];
80
+ return !!strokeWidth && (parseInt(strokeWidth, 10) > 0);
81
+ }
82
+ // Zero-sized elements should still be considered to have positive size
83
+ // if they have a child element or text node with positive size, unless
84
+ // the element has an 'overflow' style of "hidden".
85
+ return window.getComputedStyle(e)["overflow"] != "hidden" &&
86
+ Array.prototype.slice.call(e.childNodes).some(function(n) {
87
+ return (n.nodeType == Node.TEXT_NODE) ||
88
+ ((n.nodeType == Node.ELEMENT_NODE) && positiveSize(n));
89
+ });
90
+ }
91
+
92
+ if (!positiveSize(elem)) {
93
+ return false;
94
+ }
95
+
96
+ // Elements that are hidden by overflow are not shown.
97
+ function hiddenByOverflow(e) {
98
+ return getOverflowState(e) == OverflowState.HIDDEN &&
99
+ Array.prototype.slice.call(e.childNodes).every(function(n) {
100
+ return (n.nodeType != Node.ELEMENT_NODE) || hiddenByOverflow(n) ||
101
+ !positiveSize(n);
102
+ });
103
+ }
104
+ return !hiddenByOverflow(elem);
105
+ }
106
+
107
+ function getClientRegion(elem) {
108
+ var region = getClientRect(elem);
109
+ return { left: region.left,
110
+ right: region.left + region.width,
111
+ top: region.top,
112
+ bottom: region.top + region.height };
113
+ }
114
+
115
+ function getParentElement(node) {
116
+ return node.parentElement
117
+ }
118
+
119
+ function getOverflowState(elem) {
120
+ var region = getClientRegion(elem);
121
+ var ownerDoc = elem.ownerDocument;
122
+ var htmlElem = ownerDoc.documentElement;
123
+ var bodyElem = ownerDoc.body;
124
+ var htmlOverflowStyle = window.getComputedStyle(htmlElem)["overflow"];
125
+ var treatAsFixedPosition;
126
+
127
+ // Return the closest ancestor that the given element may overflow.
128
+ function getOverflowParent(e) {
129
+ function canBeOverflowed(container) {
130
+ // The HTML element can always be overflowed.
131
+ if (container == htmlElem) {
132
+ return true;
133
+ }
134
+ var containerStyle = window.getComputedStyle(container);
135
+ // An element cannot overflow an element with an inline or contents display style.
136
+ var containerDisplay = containerStyle["display"];
137
+ if ((containerDisplay.indexOf("inline") == 0) ||
138
+ (containerDisplay == "contents")) {
139
+ return false;
140
+ }
141
+ // An absolute-positioned element cannot overflow a static-positioned one.
142
+ if ((position == "absolute") && (containerStyle["position"] == "static")) {
143
+ return false;
144
+ }
145
+ return true;
146
+ }
147
+
148
+ var position = window.getComputedStyle(e)["position"];
149
+ if (position == "fixed") {
150
+ treatAsFixedPosition = true;
151
+ // Fixed-position element may only overflow the viewport.
152
+ return e == htmlElem ? null : htmlElem;
153
+ } else {
154
+ var parent = getParentElement(e);
155
+ while (parent && !canBeOverflowed(parent)) {
156
+ parent = getParentElement(parent);
157
+ }
158
+ return parent;
159
+ }
160
+ };
161
+
162
+ // Return the x and y overflow styles for the given element.
163
+ function getOverflowStyles(e) {
164
+ // When the <html> element has an overflow style of 'visible', it assumes
165
+ // the overflow style of the body, and the body is really overflow:visible.
166
+ var overflowElem = e;
167
+ if (htmlOverflowStyle == "visible") {
168
+ // Note: bodyElem will be null/undefined in SVG documents.
169
+ if (e == htmlElem && bodyElem) {
170
+ overflowElem = bodyElem;
171
+ } else if (e == bodyElem) {
172
+ return {x: "visible", y: "visible"};
173
+ }
174
+ }
175
+ var overflowElemStyle = window.getComputedStyle(overflowElem);
176
+ var overflow = {
177
+ x: overflowElemStyle["overflow-x"],
178
+ y: overflowElemStyle["overflow-y"]
179
+ };
180
+ // The <html> element cannot have a genuine 'visible' overflow style,
181
+ // because the viewport can't expand; 'visible' is really 'auto'.
182
+ if (e == htmlElem) {
183
+ overflow.x = overflow.x == "visible" ? "auto" : overflow.x;
184
+ overflow.y = overflow.y == "visible" ? "auto" : overflow.y;
185
+ }
186
+ return overflow;
187
+ };
188
+
189
+ // Returns the scroll offset of the given element.
190
+ function getScroll(e) {
191
+ if (e == htmlElem) {
192
+ return { x: window.scrollX, y: window.scrollY }
193
+ }
194
+ return { x: e.scrollLeft, y: e.scrollTop }
195
+ }
196
+
197
+ // Check if the element overflows any ancestor element.
198
+ for (var container = getOverflowParent(elem);
199
+ !!container;
200
+ container = getOverflowParent(container)) {
201
+ var containerOverflow = getOverflowStyles(container);
202
+
203
+ // If the container has overflow:visible, the element cannot overflow it.
204
+ if (containerOverflow.x == "visible" && containerOverflow.y == "visible") {
205
+ continue;
206
+ }
207
+
208
+ var containerRect = getClientRect(container);
209
+
210
+ // Zero-sized containers without overflow:visible hide all descendants.
211
+ if (containerRect.width == 0 || containerRect.height == 0) {
212
+ return OverflowState.HIDDEN;
213
+ }
214
+
215
+ // Check "underflow": if an element is to the left or above the container
216
+ var underflowsX = region.right < containerRect.left;
217
+ var underflowsY = region.bottom < containerRect.top;
218
+ if ((underflowsX && containerOverflow.x == "hidden") ||
219
+ (underflowsY && containerOverflow.y == "hidden")) {
220
+ return OverflowState.HIDDEN;
221
+ } else if ((underflowsX && containerOverflow.x != "visible") ||
222
+ (underflowsY && containerOverflow.y != "visible")) {
223
+ // When the element is positioned to the left or above a container, we
224
+ // have to distinguish between the element being completely outside the
225
+ // container and merely scrolled out of view within the container.
226
+ var containerScroll = getScroll(container);
227
+ var unscrollableX = region.right < containerRect.left - containerScroll.x;
228
+ var unscrollableY = region.bottom < containerRect.top - containerScroll.y;
229
+ if ((unscrollableX && containerOverflow.x != "visible") ||
230
+ (unscrollableY && containerOverflow.x != "visible")) {
231
+ return OverflowState.HIDDEN;
232
+ }
233
+ var containerState = getOverflowState(container);
234
+ return containerState == OverflowState.HIDDEN ?
235
+ OverflowState.HIDDEN : OverflowState.SCROLL;
236
+ }
237
+
238
+ // Check "overflow": if an element is to the right or below a container
239
+ var overflowsX = region.left >= containerRect.left + containerRect.width;
240
+ var overflowsY = region.top >= containerRect.top + containerRect.height;
241
+ if ((overflowsX && containerOverflow.x == "hidden") ||
242
+ (overflowsY && containerOverflow.y == "hidden")) {
243
+ return OverflowState.HIDDEN;
244
+ } else if ((overflowsX && containerOverflow.x != "visible") ||
245
+ (overflowsY && containerOverflow.y != "visible")) {
246
+ // If the element has fixed position and falls outside the scrollable area
247
+ // of the document, then it is hidden.
248
+ if (treatAsFixedPosition) {
249
+ var docScroll = getScroll(container);
250
+ if ((region.left >= htmlElem.scrollWidth - docScroll.x) ||
251
+ (region.right >= htmlElem.scrollHeight - docScroll.y)) {
252
+ return OverflowState.HIDDEN;
253
+ }
254
+ }
255
+ // If the element can be scrolled into view of the parent, it has a scroll
256
+ // state; unless the parent itself is entirely hidden by overflow, in
257
+ // which it is also hidden by overflow.
258
+ var containerState = getOverflowState(container);
259
+ return containerState == OverflowState.HIDDEN ?
260
+ OverflowState.HIDDEN : OverflowState.SCROLL;
261
+ }
262
+ }
263
+
264
+ // Does not overflow any ancestor.
265
+ return OverflowState.NONE;
266
+ }
267
+
268
+ function getViewportSize(win) {
269
+ var el = win.document.documentElement;
270
+ return { width: el.clientWidth, height: el.clientHeight };
271
+ }
272
+
273
+ function rect_(x, y, w, h){
274
+ return { left: x, top: y, width: w, height: h };
275
+ }
276
+
277
+ function getClientRect(elem) {
278
+ var imageMap = maybeFindImageMap_(elem);
279
+ if (imageMap) {
280
+ return imageMap.rect;
281
+ } else if (elem.tagName.toUpperCase() == "HTML") {
282
+ // Define the client rect of the <html> element to be the viewport.
283
+ var doc = elem.ownerDocument;
284
+ // TODO: Is this too simplified???
285
+ var viewportSize = getViewportSize(window);
286
+ return rect_(0, 0, viewportSize.width, viewportSize.height);
287
+ } else {
288
+ var nativeRect;
289
+ try {
290
+ nativeRect = elem.getBoundingClientRect();
291
+ } catch (e) {
292
+ return rect_(0, 0, 0, 0);
293
+ }
294
+
295
+ return rect_(nativeRect.left, nativeRect.top,
296
+ nativeRect.right - nativeRect.left, nativeRect.bottom - nativeRect.top);
297
+ }
298
+ }
299
+
300
+ function getOpacity(elem) {
301
+ // By default the element is opaque.
302
+ var elemOpacity = 1;
303
+
304
+ var opacityStyle = window.getComputedStyle(elem)["opacity"];
305
+ if (opacityStyle) {
306
+ elemOpacity = Number(opacityStyle);
307
+ }
308
+
309
+ // Let's apply the parent opacity to the element.
310
+ var parentElement = getParentElement(elem);
311
+ if (parentElement && parentElement.nodeType == Node.ELEMENT_NODE) {
312
+ elemOpacity = elemOpacity * getOpacity(parentElement);
313
+ }
314
+ return elemOpacity;
315
+ }
316
+
317
+ function getAreaRelativeRect_(area) {
318
+ var shape = area.shape.toLowerCase();
319
+ var coords = area.coords.split(",");
320
+ if (shape == "rect" && coords.length == 4) {
321
+ var x = coords[0], y = coords[1];
322
+ return rect_(x, y, coords[2] - x, coords[3] - y);
323
+ } else if (shape == "circle" && coords.length == 3) {
324
+ var centerX = coords[0], centerY = coords[1], radius = coords[2];
325
+ return rect_(centerX - radius, centerY - radius, 2 * radius, 2 * radius);
326
+ } else if (shape == "poly" && coords.length > 2) {
327
+ var minX = coords[0], minY = coords[1], maxX = minX, maxY = minY;
328
+ for (var i = 2; i + 1 < coords.length; i += 2) {
329
+ minX = Math.min(minX, coords[i]);
330
+ maxX = Math.max(maxX, coords[i]);
331
+ minY = Math.min(minY, coords[i + 1]);
332
+ maxY = Math.max(maxY, coords[i + 1]);
333
+ }
334
+ return rect_(minX, minY, maxX - minX, maxY - minY);
335
+ }
336
+ return rect_(0, 0, 0, 0);
337
+ }
338
+
339
+ function maybeFindImageMap_(elem) {
340
+ // If not a <map> or <area>, return null indicating so.
341
+ var elemTagName = elem.tagName.toUpperCase();
342
+ var isMap = elemTagName == "MAP";
343
+ if (!isMap && (elemTagName != "AREA")) {
344
+ return null;
345
+ }
346
+
347
+ // Get the <map> associated with this element, or null if none.
348
+ var map = isMap ? elem :
349
+ ((getParentElement(elem).tagName.toUpperCase() == "MAP") ?
350
+ getParentElement(elem) : null);
351
+
352
+ var image = null, rect = null;
353
+ if (map && map.name) {
354
+ var mapDoc = map.ownerDocument;
355
+
356
+ image = mapDoc.querySelector("*[usemap='#" + map.name + "']");
357
+
358
+ if (image) {
359
+ rect = getClientRect(image);
360
+ if (!isMap && elem.shape.toLowerCase() != "default") {
361
+ // Shift and crop the relative area rectangle to the map.
362
+ var relRect = getAreaRelativeRect_(elem);
363
+ var relX = Math.min(Math.max(relRect.left, 0), rect.width);
364
+ var relY = Math.min(Math.max(relRect.top, 0), rect.height);
365
+ var w = Math.min(relRect.width, rect.width - relX);
366
+ var h = Math.min(relRect.height, rect.height - relY);
367
+ rect = rect_(relX + rect.left, relY + rect.top, w, h);
368
+ }
369
+ }
370
+ }
371
+
372
+ return {image: image, rect: rect || rect_(0, 0, 0, 0)};
373
+ }
374
+
375
+ function getAncestor(element, matcher) {
376
+ if (element) {
377
+ element = getParentElement(element);
378
+ }
379
+ while (element) {
380
+ if (matcher(element)) {
381
+ return element;
382
+ }
383
+ element = getParentElement(element);
384
+ }
385
+ // Reached the root of the DOM without a match
386
+ return null;
387
+ }
388
+
389
+
390
+ function isElement(node, opt_tagName) {
391
+ // because we call this with deprecated tags such as SHADOW
392
+ if (opt_tagName && (typeof opt_tagName !== "string")) {
393
+ opt_tagName = opt_tagName.toString();
394
+ }
395
+ return !!node && node.nodeType == Node.ELEMENT_NODE &&
396
+ (!opt_tagName || node.tagName.toUpperCase() == opt_tagName);
397
+ }
398
+
399
+ function getParentNodeInComposedDom(node) {
400
+ var /**@type {Node}*/ parent = node.parentNode;
401
+
402
+ // Shadow DOM v1
403
+ if (parent && parent.shadowRoot && node.assignedSlot !== undefined) {
404
+ // Can be null on purpose, meaning it has no parent as
405
+ // it hasn't yet been slotted
406
+ return node.assignedSlot ? node.assignedSlot.parentNode : null;
407
+ }
408
+
409
+ // Shadow DOM V0 (deprecated)
410
+ if (node.getDestinationInsertionPoints) {
411
+ var destinations = node.getDestinationInsertionPoints();
412
+ if (destinations.length > 0) {
413
+ return destinations[destinations.length - 1];
414
+ }
415
+ }
416
+
417
+ return parent;
418
+ }
419
+
420
+ return function isShown(elem, opt_ignoreOpacity) {
421
+ /**
422
+ * Determines whether an element or its parents have `display: none` set
423
+ * @param {!Node} e the element
424
+ * @return {boolean}
425
+ */
426
+ function displayed(e) {
427
+ if (window.getComputedStyle(e)["display"] == "none"){
428
+ return false;
429
+ }
430
+
431
+ var parent = getParentNodeInComposedDom(e);
432
+
433
+ if ((typeof ShadowRoot === "function") && (parent instanceof ShadowRoot)) {
434
+ if (parent.host.shadowRoot !== parent) {
435
+ // There is a younger shadow root, which will take precedence over
436
+ // the shadow this element is in, thus this element won't be
437
+ // displayed.
438
+ return false;
439
+ } else {
440
+ parent = parent.host;
441
+ }
442
+ }
443
+
444
+ if (parent && (parent.nodeType == Node.DOCUMENT_NODE ||
445
+ parent.nodeType == Node.DOCUMENT_FRAGMENT_NODE)) {
446
+ return true;
447
+ }
448
+
449
+ return parent && displayed(parent);
450
+ }
451
+
452
+ return isShown_(elem, !!opt_ignoreOpacity, displayed);
453
+ };
454
+ })()
@@ -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
@@ -414,7 +427,7 @@ private
414
427
 
415
428
  def specialize_driver
416
429
  browser_type = browser.browser
417
- self.class.specializations.select { |k, _v| k === browser_type }.each_value do |specialization| # rubocop:disable Style/CaseEquality
430
+ Capybara::Selenium::Driver.specializations.select { |k, _v| k === browser_type }.each_value do |specialization| # rubocop:disable Style/CaseEquality
418
431
  extend specialization
419
432
  end
420
433
  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