cuprite 0.15.1 → 0.18

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78b7dde58d02147e2985473da0a7527d338ff086e4a4aaf0fd9302e5faf3ee11
4
- data.tar.gz: 777726c6161975950df923a305b0bdb9da54b5531b7a5339ed0d4beaf264beb6
3
+ metadata.gz: ed2641aa002e810fd4dfedc8b3128f38e5f2df0f747d59bc1cf7a81aa1295076
4
+ data.tar.gz: 7e967cb1a6a11ad9f446290cd5035d2d5d3e9c6724ba6f4df4c31e761b29545d
5
5
  SHA512:
6
- metadata.gz: 04e88a47d9d2d2df68ad11dca8224f6d24001340b1743f538e6fdb64e818381037da7987b9db25284336b469c986d9d9e883192743749c4c6cad6c43adc160a1
7
- data.tar.gz: a059dae9790af13c8a56999d6d76d5920e863e217b346a721a4051f0cc786e295a35c0e574c00e425b87e2d0c7a8b140c991183ab3246d1678566318745c9ecf
6
+ metadata.gz: dcee409e8ee24caf139c3a0c29318ccea5ddefb0759f228f0f24db76aef28864c73d46e02ecc820160e82d6f366c61648ad1dc20dd930b5d9f300c8853d17003
7
+ data.tar.gz: 5168b1c18e81936795a02154829100dc3a616c2d2c3db71c134c8e64f5f13d93fd1098e8c26aea8d84a26840bf547a80b263b63b293420dc9f18cc18a5d29632
data/README.md CHANGED
@@ -27,12 +27,27 @@ Capybara.register_driver(:cuprite) do |app|
27
27
  end
28
28
  ```
29
29
 
30
- if you use `Docker` don't forget to pass `no-sandbox` option:
30
+ If Chrome or Chromium cannot use its sandbox inside a container—for example,
31
+ because it runs as `root`—enable Ferrum's container mode:
31
32
 
32
33
  ```ruby
33
- Capybara::Cuprite::Driver.new(app, browser_options: { 'no-sandbox': nil })
34
+ Capybara::Cuprite::Driver.new(app, dockerize: true)
34
35
  ```
35
36
 
37
+ The `dockerize` option requires Ferrum 0.17.2 or newer. With an older Ferrum
38
+ version, pass the browser option explicitly:
39
+
40
+ ```ruby
41
+ Capybara::Cuprite::Driver.new(
42
+ app,
43
+ browser_options: { "no-sandbox" => nil }
44
+ )
45
+ ```
46
+
47
+ Both approaches disable the browser's sandbox, so do not use them merely
48
+ because Docker is involved. Prefer running the browser as a non-root user with
49
+ a working sandbox when possible.
50
+
36
51
  Since Cuprite uses [Ferrum](https://github.com/rubycdp/ferrum#examples) there
37
52
  are many useful methods you can call even using this driver:
38
53
 
@@ -61,24 +76,20 @@ end
61
76
  `Cuprite`-specific options are:
62
77
 
63
78
  * options `Hash`
79
+ * `:raise_on_unhandled_modal` (Boolean) - When set to `false`, output a warning. When set to `true`, raise
80
+ `Capybara::Cuprite::UnhandledModalError` instead. The dialog is always auto-accepted either way; the raise is
81
+ deferred and surfaces on the next command sent to the browser. In practice that's almost always the same action
82
+ that triggered the dialog (a click, `visit`, `evaluate_script`, ...), since a JS dialog blocks the page until
83
+ answered, so that action's own command is what was waiting. A dialog fired with nothing in flight (e.g. a bare JS
84
+ timer) only surfaces on whatever command runs next.
64
85
  * `:url_blacklist` (Array) - array of regexes to match against requested URLs
65
86
  * `:url_whitelist` (Array) - array of regexes to match against requested URLs
66
87
 
67
88
 
68
89
  ## Debugging
69
90
 
70
- If you pass `inspector` option, remote debugging will be enabled if you run
71
- tests with `INSPECTOR=true`. Then you can put `page.driver.debug` or
72
- `page.driver.debug(binding)` in your test to pause it. This will launch the
73
- browser where you can inspect the content.
74
-
75
- ```ruby
76
- Capybara.register_driver :cuprite do |app|
77
- Capybara::Cuprite::Driver.new(app, inspector: ENV['INSPECTOR'])
78
- end
79
- ```
80
-
81
- then somewhere in the test:
91
+ You can put `page.driver.debug` or `page.driver.debug(binding)` in your test to pause it.
92
+ This will launch the browser where you can inspect the content.
82
93
 
83
94
  ```ruby
84
95
  it "does something useful" do
@@ -122,13 +133,17 @@ be automatically cleared at the end of the test.
122
133
 
123
134
  ## Network traffic
124
135
 
125
- * `page.driver.network_traffic` Inspect network traffic (loaded resources) on
126
- the current page. This returns an array of request objects.
136
+ * `page.driver.network_traffic` allows you to inspect network traffic (i.e., loaded resources) on the current page. It returns an array of `Ferrum::Network::Exchange` objects, each representing a network request/response exchange. You can query both the request and response details of each exchange.
127
137
 
128
138
  ```ruby
129
- page.driver.network_traffic # => [Request, ...]
130
- request = page.driver.network_traffic.first
131
- request.response
139
+ # Retrieve all network exchanges
140
+ network_traffic = page.driver.network_traffic
141
+
142
+ # Access the first exchange
143
+ first_exchange = network_traffic.first
144
+
145
+ # Inspect the response of the first request
146
+ response = first_exchange.response
132
147
  ```
133
148
 
134
149
  * `page.driver.wait_for_network_idle` Natively waits for network idle and if
@@ -183,27 +198,29 @@ Besides capybara screenshot method you can get image as Base64:
183
198
 
184
199
  ## Proxy
185
200
 
186
- * `page.driver.set_proxy(ip, port, type, user, password)`
201
+ * `page.driver.set_proxy(ip, port, user, password)`
187
202
 
188
203
 
189
- ## URL Blacklisting & Whitelisting
204
+ ## URL Blocklisting & Allowlisting
190
205
 
191
- Cuprite supports URL blacklisting, which allows you to prevent scripts from
206
+ Cuprite supports URL blocklisting, which allows you to prevent scripts from
192
207
  running on designated domains:
193
208
 
194
209
  ```ruby
195
- page.driver.browser.url_blacklist = %r{http://www.example.com}
210
+ page.driver.browser.url_blocklist = %r{http://www.example.com}
196
211
  ```
197
212
 
198
- and also URL whitelisting, which allows scripts to only run on designated
213
+ and also URL allowlisting, which allows scripts to only run on designated
199
214
  domains:
200
215
 
201
216
  ```ruby
202
- page.driver.browser.url_whitelist = %r{http://www.example.com}
217
+ page.driver.browser.url_allowlist = %r{http://www.example.com}
203
218
  ```
204
219
 
205
- If you are experiencing slower run times, consider creating a URL whitelist of
206
- domains that are essential or a blacklist of domains that are not essential,
220
+ For legacy support, `url_blacklist=` and `url_whitelist=` continue to work respectively.
221
+
222
+ If you are experiencing slower run times, consider creating a URL allowlist of
223
+ domains that are essential or a blocklist of domains that are not essential,
207
224
  such as ad networks or analytics, to your testing environment.
208
225
 
209
226
  ## License
@@ -7,6 +7,8 @@ module Capybara
7
7
  class Browser < Ferrum::Browser
8
8
  extend Forwardable
9
9
 
10
+ DRAG_HTML5_JS = File.read(File.expand_path("javascripts/drag.js", __dir__))
11
+
10
12
  delegate %i[send_keys select set hover trigger before_click switch_to_frame
11
13
  find_modal accept_confirm dismiss_confirm accept_prompt
12
14
  dismiss_prompt reset_modals] => :page
@@ -14,6 +16,7 @@ module Capybara
14
16
  def initialize(options = nil)
15
17
  super
16
18
 
19
+ @options.raise_on_unhandled_modal = options&.dig(:raise_on_unhandled_modal)
17
20
  @options.url_blacklist = prepare_wildcards(options&.dig(:url_blacklist))
18
21
  @options.url_whitelist = prepare_wildcards(options&.dig(:url_whitelist))
19
22
 
@@ -49,6 +52,14 @@ module Capybara
49
52
  super
50
53
  end
51
54
 
55
+ def raise_on_unhandled_modal
56
+ @options.raise_on_unhandled_modal
57
+ end
58
+
59
+ def raise_on_unhandled_modal=(value)
60
+ @options.raise_on_unhandled_modal = value
61
+ end
62
+
52
63
  def url_whitelist
53
64
  @options.url_whitelist
54
65
  end
@@ -116,6 +127,7 @@ module Capybara
116
127
  raise Ferrum::NoSuchPageError unless target
117
128
 
118
129
  @page = attach_page(target.id)
130
+ @page.activate
119
131
  end
120
132
 
121
133
  def close_window(target_id)
@@ -139,29 +151,69 @@ module Capybara
139
151
  raise NotImplementedError
140
152
  end
141
153
 
142
- def drag(node, other, steps, delay = nil)
154
+ def drag(node, other, drop_modifiers, options = {})
155
+ steps = options.fetch(:steps, 1)
156
+ delay = options[:delay]
157
+ scroll = options.fetch(:scroll, true)
158
+ html5 = options[:html5]
159
+
160
+ execute("_cuprite.dragMousedownTracker()")
161
+
162
+ node.scroll_into_view if scroll
143
163
  x1, y1 = node.find_position
144
- x2, y2 = other.find_position
145
164
 
146
165
  mouse.move(x: x1, y: y1)
147
166
  mouse.down
148
167
  sleep delay if delay
149
- mouse.move(x: x2, y: y2, steps: steps)
168
+
169
+ html5 = !evaluate_on(node: node, expression: "_cuprite.legacyDragCheck(this)") if html5.nil?
170
+
171
+ if html5
172
+ drag_html5(node, other, drop_modifiers, delay)
173
+ else
174
+ modifiers = keyboard.modifiers(drop_modifiers)
175
+
176
+ other.scroll_into_view if scroll
177
+ x2, y2 = other.find_position
178
+ mouse.move(x: x2, y: y2, steps: steps)
179
+
180
+ mouse.up(modifiers: modifiers)
181
+ end
182
+ end
183
+
184
+ def drag_html5(node, other, drop_modifiers, delay)
185
+ keys = drop_modifiers.map(&:to_s)
186
+ evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys)
150
187
  mouse.up
151
188
  end
152
189
 
153
- def drag_by(node, x, y, steps, delay = nil)
190
+ def drag_by(node, dx, dy, steps, delay = nil, scroll = true)
154
191
  x1, y1 = node.find_position
155
- x2 = x1 + x
156
- y2 = y1 + y
157
192
 
158
193
  mouse.move(x: x1, y: y1)
159
194
  mouse.down
195
+
160
196
  sleep delay if delay
161
- mouse.move(x: x2, y: y2, steps: steps)
197
+
198
+ evaluate("window.scrollBy(#{dx}, #{dy})") if scroll # should be extracted to Mouse#scroll_by in ferrum
199
+
200
+ x2, y2 = node.find_position
201
+ mouse.move(x: x2 + dx, y: y2 + dy, steps: steps)
162
202
  mouse.up
163
203
  end
164
204
 
205
+ def drop(node, *args)
206
+ if args[0].is_a?(String)
207
+ execute("_cuprite.attachDropInput()")
208
+ input = find(:css, "#_cuprite_drop_file").first
209
+ select_file(input, args)
210
+ evaluate_on(node: node, expression: "_cuprite.dropFile(this)")
211
+ else
212
+ strings = args.flat_map { |arg| arg.map { |type, data| { "type" => type, "data" => data } } }
213
+ evaluate_on(node: node, expression: "_cuprite.dropString(#{strings.to_json}, this)")
214
+ end
215
+ end
216
+
165
217
  def select_file(node, value)
166
218
  node.select_file(value)
167
219
  end
@@ -203,6 +255,10 @@ module Capybara
203
255
  evaluate_on(node: node, expression: "_cuprite.path(this)")
204
256
  end
205
257
 
258
+ def obscured?(node)
259
+ evaluate_on(node: node, expression: "_cuprite.isObscured(this)")
260
+ end
261
+
206
262
  def all_text(node)
207
263
  node.text
208
264
  end
@@ -40,7 +40,7 @@ module Capybara
40
40
  end
41
41
 
42
42
  def expires
43
- Time.at(@attributes["expires"]) if (@attributes["expires"]).positive?
43
+ Time.at(@attributes["expires"]) if @attributes["expires"].positive?
44
44
  end
45
45
  end
46
46
  end
@@ -3,7 +3,7 @@
3
3
  require "uri"
4
4
  require "forwardable"
5
5
 
6
- # rubocop:disable Metrics/ClassLength
6
+ # rubocop:disable-next Metrics/ClassLength
7
7
  module Capybara
8
8
  module Cuprite
9
9
  class Driver < Capybara::Driver::Base
@@ -35,6 +35,8 @@ module Capybara
35
35
  @screen_size ||= DEFAULT_MAXIMIZE_SCREEN_SIZE
36
36
  @options[:save_path] ||= File.expand_path(Capybara.save_path) if Capybara.save_path
37
37
 
38
+ @options[:pending_connection_errors] = true unless @options.key?(:pending_connection_errors)
39
+
38
40
  # It's set for debug() to make devtools tab open correctly.
39
41
  @options[:browser_options] ||= {}
40
42
  unless @options[:browser_options][:"remote-allow-origins"]
@@ -108,10 +110,14 @@ module Capybara
108
110
  handle = case locator
109
111
  when Capybara::Node::Element
110
112
  locator.native.description["frameId"]
113
+ when Capybara::Cuprite::Node
114
+ locator.description["frameId"]
111
115
  when :parent, :top
112
116
  locator
113
117
  end
114
118
 
119
+ raise ArgumentError, "Unable to switch to frame from #{locator.class}" unless handle
120
+
115
121
  browser.switch_to_frame(handle)
116
122
  end
117
123
 
@@ -131,6 +137,7 @@ module Capybara
131
137
  @paper_size = nil
132
138
  browser.url_blacklist = @options[:url_blacklist]
133
139
  browser.url_whitelist = @options[:url_whitelist]
140
+ browser.raise_on_unhandled_modal = @options.fetch(:raise_on_unhandled_modal, false)
134
141
  browser.reset
135
142
  @started = false
136
143
  end
@@ -251,7 +258,8 @@ module Capybara
251
258
 
252
259
  def remove_cookie(name, **options)
253
260
  options[:domain] = default_domain if options.empty?
254
- browser.cookies.remove(**options.merge(name: name))
261
+ options = options.merge(name: name)
262
+ browser.cookies.remove(**options)
255
263
  end
256
264
 
257
265
  def clear_cookies
@@ -273,32 +281,8 @@ module Capybara
273
281
  end
274
282
  alias authorize basic_authorize
275
283
 
276
- def debug_url
277
- response = JSON.parse(Net::HTTP.get(URI(build_remote_debug_url(path: "/json"))))
278
-
279
- devtools_frontend_path = response[0]&.[]("devtoolsFrontendUrl")
280
- raise "Could not generate debug url for remote debugging session" unless devtools_frontend_path
281
-
282
- build_remote_debug_url(path: devtools_frontend_path)
283
- end
284
-
285
- def debug(binding = nil)
286
- if @options[:inspector]
287
- Process.spawn(browser.process.path, debug_url)
288
-
289
- if binding.respond_to?(:pry)
290
- Pry.start(binding)
291
- elsif binding.respond_to?(:irb)
292
- binding.irb
293
- else
294
- pause
295
- end
296
- else
297
- raise Error, "To use the remote debugging, you have to launch " \
298
- "the driver with `inspector: ENV['INSPECTOR']` " \
299
- "configuration option and run your test suite passing " \
300
- "env variable"
301
- end
284
+ def debug(...)
285
+ browser.debug(...)
302
286
  end
303
287
 
304
288
  def pause
@@ -377,10 +361,6 @@ module Capybara
377
361
 
378
362
  private
379
363
 
380
- def build_remote_debug_url(path:)
381
- "http://#{browser.process.host}:#{browser.process.port}#{path}"
382
- end
383
-
384
364
  def default_domain
385
365
  if @started
386
366
  URI.parse(browser.current_url).host
@@ -433,4 +413,3 @@ module Capybara
433
413
  end
434
414
  end
435
415
  end
436
- # rubocop:enable Metrics/ClassLength
@@ -43,6 +43,15 @@ module Capybara
43
43
  end
44
44
  end
45
45
 
46
+ class UnhandledModalError < Error
47
+ attr_reader :message
48
+
49
+ def initialize(message)
50
+ @message = message
51
+ super()
52
+ end
53
+ end
54
+
46
55
  class ObsoleteNode < ClientError
47
56
  attr_reader :node
48
57
 
@@ -0,0 +1,118 @@
1
+ // HTML5 drag-and-drop emulation.
2
+ //
3
+ // Ported near-verbatim from Capybara's Selenium driver
4
+ // (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) so
5
+ // Cuprite matches Capybara's HTML5 drag behaviour and its shared specs. Kept
6
+ // close to the source to ease future syncs; known upstream quirks (rectPt.top
7
+ // in pointOnRect, undeclared `key`, callback.call(true)) are preserved
8
+ // deliberately. See https://github.com/rubycdp/cuprite/issues/314.
9
+
10
+ function rectCenter(rect){
11
+ return new DOMPoint(
12
+ (rect.left + rect.right)/2,
13
+ (rect.top + rect.bottom)/2
14
+ );
15
+ }
16
+
17
+ function pointOnRect(pt, rect) {
18
+ var rectPt = rectCenter(rect);
19
+ var slope = (rectPt.y - pt.y) / (rectPt.x - pt.x);
20
+
21
+ if (pt.x <= rectPt.x) { // left side
22
+ var minXy = slope * (rect.left - pt.x) + pt.y;
23
+ if (rect.top <= minXy && minXy <= rect.bottom)
24
+ return new DOMPoint(rect.left, minXy);
25
+ }
26
+
27
+ if (pt.x >= rectPt.x) { // right side
28
+ var maxXy = slope * (rect.right - pt.x) + pt.y;
29
+ if (rect.top <= maxXy && maxXy <= rect.bottom)
30
+ return new DOMPoint(rect.right, maxXy);
31
+ }
32
+
33
+ if (pt.y <= rectPt.y) { // top side
34
+ var minYx = (rectPt.top - pt.y) / slope + pt.x;
35
+ if (rect.left <= minYx && minYx <= rect.right)
36
+ return new DOMPoint(minYx, rect.top);
37
+ }
38
+
39
+ if (pt.y >= rectPt.y) { // bottom side
40
+ var maxYx = (rect.bottom - pt.y) / slope + pt.x;
41
+ if (rect.left <= maxYx && maxYx <= rect.right)
42
+ return new DOMPoint(maxYx, rect.bottom);
43
+ }
44
+
45
+ return new DOMPoint(pt.x,pt.y);
46
+ }
47
+
48
+ function dragEnterTarget() {
49
+ target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
50
+ var targetRect = target.getBoundingClientRect();
51
+ var sourceCenter = rectCenter(source.getBoundingClientRect());
52
+
53
+ for (var i = 0; i < drop_modifier_keys.length; i++) {
54
+ key = drop_modifier_keys[i];
55
+ if (key == "control"){
56
+ key = "ctrl"
57
+ }
58
+ opts[key + 'Key'] = true;
59
+ }
60
+
61
+ var dragEnterEvent = new DragEvent('dragenter', opts);
62
+ target.dispatchEvent(dragEnterEvent);
63
+
64
+ // fire 2 dragover events to simulate dragging with a direction
65
+ var entryPoint = pointOnRect(sourceCenter, targetRect)
66
+ var dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts);
67
+ var dragOverEvent = new DragEvent('dragover', dragOverOpts);
68
+ target.dispatchEvent(dragOverEvent);
69
+ window.setTimeout(dragOnTarget, step_delay);
70
+ }
71
+
72
+ function dragOnTarget() {
73
+ var targetCenter = rectCenter(target.getBoundingClientRect());
74
+ var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
75
+ var dragOverEvent = new DragEvent('dragover', dragOverOpts);
76
+ target.dispatchEvent(dragOverEvent);
77
+ window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
78
+ }
79
+
80
+ function dragLeave(drop, dragOverOpts) {
81
+ var dragLeaveOptions = Object.assign({}, opts, dragOverOpts);
82
+ var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions);
83
+ target.dispatchEvent(dragLeaveEvent);
84
+ if (drop) {
85
+ var dropEvent = new DragEvent('drop', dragLeaveOptions);
86
+ target.dispatchEvent(dropEvent);
87
+ }
88
+ var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
89
+ source.dispatchEvent(dragEndEvent);
90
+ callback.call(true);
91
+ }
92
+
93
+ var source = arguments[0],
94
+ target = arguments[1],
95
+ step_delay = arguments[2],
96
+ drop_modifier_keys = arguments[3],
97
+ callback = arguments[4];
98
+
99
+ var dt = new DataTransfer();
100
+ var opts = { cancelable: true, bubbles: true, dataTransfer: dt };
101
+
102
+ while (source && !source.draggable) {
103
+ source = source.parentElement;
104
+ }
105
+
106
+ if (source.tagName == 'A'){
107
+ dt.setData('text/uri-list', source.href);
108
+ dt.setData('text', source.href);
109
+ }
110
+ if (source.tagName == 'IMG'){
111
+ dt.setData('text/uri-list', source.src);
112
+ dt.setData('text', source.src);
113
+ }
114
+
115
+ var dragEvent = new DragEvent('dragstart', opts);
116
+ source.dispatchEvent(dragEvent);
117
+
118
+ window.setTimeout(dragEnterTarget, step_delay);
@@ -52,13 +52,17 @@ class Cuprite {
52
52
  if (this.isVisible(node)) {
53
53
  if (node.nodeName == "TEXTAREA") {
54
54
  return node.textContent;
55
- } else {
56
- if (node instanceof SVGElement) {
57
- return node.textContent;
58
- } else {
59
- return node.innerText;
60
- }
61
55
  }
56
+ if (node instanceof SVGElement) {
57
+ return node.textContent;
58
+ }
59
+ if (node instanceof ShadowRoot) {
60
+ return Array.from(node.children)
61
+ .map(child => this.visibleText(child))
62
+ .filter(text => text)
63
+ .join(" ");
64
+ }
65
+ return node.innerText;
62
66
  }
63
67
  }
64
68
 
@@ -74,11 +78,24 @@ class Cuprite {
74
78
  }
75
79
 
76
80
  while (node) {
77
- style = window.getComputedStyle(node);
78
- if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0) {
79
- return false;
81
+ if (node instanceof ShadowRoot) {
82
+ node = node.host;
83
+ } else {
84
+ style = window.getComputedStyle(node);
85
+ if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0) {
86
+ return false;
87
+ }
88
+
89
+ let parent = node.parentElement;
90
+ if (parent && parent.tagName === "DETAILS" && !parent.open) {
91
+ // In a closed <details> only the first <summary> (and its subtree) is rendered.
92
+ if (node !== parent.querySelector(":scope > summary")) {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ node = parent ?? (node.getRootNode() instanceof ShadowRoot && node.getRootNode());
80
98
  }
81
- node = node.parentElement;
82
99
  }
83
100
 
84
101
  return true;
@@ -94,6 +111,10 @@ class Cuprite {
94
111
  }
95
112
 
96
113
  path(node) {
114
+ if (node.getRootNode && node.getRootNode() instanceof ShadowRoot) {
115
+ return "(: Shadow DOM element - no XPath :)";
116
+ };
117
+
97
118
  let nodes = [node];
98
119
  let parent = node.parentNode;
99
120
  while (parent !== document && parent !== null) {
@@ -115,6 +136,35 @@ class Cuprite {
115
136
  return `//${selectors.join("/")}`;
116
137
  }
117
138
 
139
+ /**
140
+ * Returns true if the node is obscured in the viewport.
141
+ *
142
+ * @param {Element} node
143
+ * @return {boolean} true if the node is obscured, false otherwise
144
+ */
145
+ isObscured(node) {
146
+ let win = window;
147
+ let rect = node.getBoundingClientRect();
148
+ let px = rect.left + rect.width / 2;
149
+ let py = rect.top + rect.height / 2;
150
+
151
+ while (win) {
152
+ let topNode = win.document.elementFromPoint(px, py);
153
+
154
+ if (node !== topNode && !node.contains(topNode)) return true;
155
+
156
+ node = win.frameElement;
157
+ if (!node) return false;
158
+
159
+ rect = node.getBoundingClientRect();
160
+ px = rect.left + px;
161
+ py = rect.top + py;
162
+ win = win.parent;
163
+ }
164
+
165
+ return false;
166
+ }
167
+
118
168
  set(node, value) {
119
169
  if (node.readOnly) return;
120
170
 
@@ -124,15 +174,13 @@ class Cuprite {
124
174
 
125
175
  let valueBefore = node.value;
126
176
 
177
+ node.focus();
127
178
  this.trigger(node, "focus");
128
179
  this.setValue(node, "");
129
180
 
130
- if (node.type == "number" || node.type == "date" || node.type == "range") {
181
+ if (node.type == "number" || node.type == "date" || node.type == "range" || node.type == "time" || node.type == "month" || node.type == "week") {
131
182
  this.setValue(node, value);
132
183
  this.input(node);
133
- } else if (node.type == "time") {
134
- this.setValue(node, new Date(value).toTimeString().split(" ")[0]);
135
- this.input(node);
136
184
  } else if (node.type == "datetime-local") {
137
185
  value = new Date(value);
138
186
  let year = value.getFullYear();
@@ -280,7 +328,7 @@ class Cuprite {
280
328
  x -= frameOffset.left;
281
329
  y -= frameOffset.top;
282
330
 
283
- let element = document.elementFromPoint(x, y);
331
+ let element = node.getRootNode().elementFromPoint(x, y);
284
332
 
285
333
  let el = element;
286
334
  while (el) {
@@ -416,7 +464,7 @@ class Cuprite {
416
464
  options["button"] || 0, null
417
465
  )
418
466
  } else if (EVENTS.FOCUS.indexOf(name) != -1) {
419
- event = this.obtainEvent(name);
467
+ event = new FocusEvent(name, { bubbles: true, cancelable: true });
420
468
  } else if (EVENTS.FORM.indexOf(name) != -1) {
421
469
  event = this.obtainEvent(name);
422
470
  } else {
@@ -489,6 +537,66 @@ class Cuprite {
489
537
  return node.contains(selectedNode);
490
538
  }
491
539
 
540
+ dragMousedownTracker() {
541
+ window._cupriteMousedownPrevented = null;
542
+ document.addEventListener('mousedown', ev => {
543
+ window._cupriteMousedownPrevented = ev.defaultPrevented;
544
+ }, { once: true, passive: true });
545
+ }
546
+
547
+ legacyDragCheck(node) {
548
+ if ([true, null].indexOf(window._cupriteMousedownPrevented) >= 0) {
549
+ return true;
550
+ }
551
+
552
+ let el = node;
553
+ do {
554
+ if (el.draggable) return false;
555
+ } while (el = el.parentElement);
556
+ return true;
557
+ }
558
+
559
+ // Ported from Capybara's Selenium html5_drag.rb (DROP_STRING).
560
+ dropString(strings, target) {
561
+ let dt = new DataTransfer();
562
+ let opts = { cancelable: true, bubbles: true, dataTransfer: dt };
563
+ for (let i = 0; i < strings.length; i++) {
564
+ if (dt.items) {
565
+ dt.items.add(strings[i]["data"], strings[i]["type"]);
566
+ } else {
567
+ dt.setData(strings[i]["type"], strings[i]["data"]);
568
+ }
569
+ }
570
+ target.dispatchEvent(new DragEvent("drop", opts));
571
+ }
572
+
573
+ // Ported from Capybara's Selenium html5_drag.rb (ATTACH_FILE).
574
+ attachDropInput() {
575
+ document.getElementById("_cuprite_drop_file")?.remove();
576
+ let input = document.createElement("INPUT");
577
+ input.type = "file";
578
+ input.id = "_cuprite_drop_file";
579
+ input.multiple = true;
580
+ document.body.appendChild(input);
581
+ }
582
+
583
+ // Ported from Capybara's Selenium html5_drag.rb (DROP_FILE).
584
+ dropFile(target) {
585
+ let input = document.getElementById("_cuprite_drop_file");
586
+ let files = input.files;
587
+ let dt = new DataTransfer();
588
+ let opts = { cancelable: true, bubbles: true, dataTransfer: dt };
589
+ input.parentElement.removeChild(input);
590
+ if (dt.items) {
591
+ for (let i = 0; i < files.length; i++) {
592
+ dt.items.add(files[i]);
593
+ }
594
+ } else {
595
+ Object.defineProperty(dt, "files", { value: files, writable: false });
596
+ }
597
+ target.dispatchEvent(new DragEvent("drop", opts));
598
+ }
599
+
492
600
  // This command is purely for testing error handling
493
601
  browserError() {
494
602
  throw new Error("zomg");
@@ -12,12 +12,16 @@ module Capybara
12
12
  delegate %i[description] => :node
13
13
  delegate %i[browser] => :driver
14
14
 
15
+ DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze
16
+
15
17
  def initialize(driver, node)
16
18
  super(driver, self)
17
19
  @node = node
18
20
  end
19
21
 
20
22
  def command(name, *args)
23
+ raise ObsoleteNode.new(self, nil) unless node.evaluate("this.isConnected")
24
+
21
25
  browser.send(name, node, *args)
22
26
  rescue Ferrum::NodeNotFoundError => e
23
27
  raise ObsoleteNode.new(self, e.response)
@@ -89,7 +93,7 @@ module Capybara
89
93
  command(:value)
90
94
  end
91
95
 
92
- def set(value, options = {})
96
+ def set(value, options = {}) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
93
97
  warn "Options passed to Node#set but Cuprite doesn't currently support any - ignoring" unless options.empty?
94
98
 
95
99
  if tag_name == "input"
@@ -103,6 +107,23 @@ module Capybara
103
107
  command(:select_file, files)
104
108
  when "color"
105
109
  node.evaluate("this.setAttribute('value', '#{value}')")
110
+ node.evaluate("this.dispatchEvent(new InputEvent('input'))")
111
+ node.evaluate("this.dispatchEvent(new Event('change', { bubbles: true }))")
112
+ when "date"
113
+ value = value.to_date.iso8601 if !value.is_a?(String) && value.respond_to?(:to_date)
114
+ command(:set, value.to_s)
115
+ when "time"
116
+ value = value.to_time.strftime("%H:%M") if !value.is_a?(String) && value.respond_to?(:to_time)
117
+ command(:set, value.to_s)
118
+ when "month"
119
+ value = value.to_date.strftime("%Y-%m") if !value.is_a?(String) && value.respond_to?(:to_date)
120
+ command(:set, value.to_s)
121
+ when "week"
122
+ value = value.to_date.strftime("%G-W%V") if !value.is_a?(String) && value.respond_to?(:to_date)
123
+ command(:set, value.to_s)
124
+ when "datetime-local"
125
+ value = value.to_time.strftime("%Y-%m-%dT%H:%M") if !value.is_a?(String) && value.respond_to?(:to_time)
126
+ command(:set, value.to_s)
106
127
  else
107
128
  command(:set, value.to_s)
108
129
  end
@@ -110,7 +131,7 @@ module Capybara
110
131
  command(:set, value.to_s)
111
132
  elsif self[:isContentEditable]
112
133
  command(:delete_text)
113
- send_keys(value.to_s)
134
+ click.type(value.to_s)
114
135
  end
115
136
  end
116
137
 
@@ -124,7 +145,7 @@ module Capybara
124
145
  end
125
146
 
126
147
  def tag_name
127
- @tag_name ||= description["nodeName"].downcase
148
+ @tag_name ||= description["shadowRootType"] ? "ShadowRoot" : description["nodeName"].downcase
128
149
  end
129
150
 
130
151
  def visible?
@@ -161,16 +182,21 @@ module Capybara
161
182
 
162
183
  def drag_to(other, **options)
163
184
  options[:steps] ||= 1
185
+ options[:scroll] = true unless options.key?(:scroll)
186
+ modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) }
164
187
 
165
- command(:drag, other.node, options[:steps], options[:delay])
188
+ command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5))
166
189
  end
167
190
 
168
191
  def drag_by(x, y, **options)
169
192
  options[:steps] ||= 1
193
+ options[:scroll] = true unless options.key?(:scroll)
170
194
 
171
- command(:drag_by, x, y, options[:steps], options[:delay])
195
+ command(:drag_by, x, y, options[:steps], options[:delay], options[:scroll])
172
196
  end
173
197
 
198
+ def drop(...) = command(:drop, ...)
199
+
174
200
  def trigger(event)
175
201
  command(:trigger, event)
176
202
  end
@@ -203,6 +229,9 @@ module Capybara
203
229
  end
204
230
 
205
231
  def send_keys(*keys)
232
+ keys = keys.reject { |key| key.nil? || key == "" }
233
+ return if keys.empty?
234
+
206
235
  command(:send_keys, keys)
207
236
  end
208
237
  alias send_key send_keys
@@ -211,6 +240,23 @@ module Capybara
211
240
  command(:path)
212
241
  end
213
242
 
243
+ def obscured?
244
+ command(:obscured?)
245
+ end
246
+
247
+ def shadow_root
248
+ root = driver.evaluate_script <<~JS, self
249
+ arguments[0].shadowRoot
250
+ JS
251
+ root && self.class.new(driver, root.node)
252
+ end
253
+
254
+ def rect
255
+ driver.evaluate_script <<~JS, self
256
+ arguments[0].getBoundingClientRect().toJSON()
257
+ JS
258
+ end
259
+
214
260
  def inspect
215
261
  %(#<#{self.class} @node=#{@node.inspect}>)
216
262
  end
@@ -4,7 +4,7 @@ module Ferrum
4
4
  class Browser
5
5
  class Options
6
6
  attr_writer :window_size
7
- attr_accessor :url_blacklist, :url_whitelist
7
+ attr_accessor :url_blacklist, :url_whitelist, :raise_on_unhandled_modal
8
8
 
9
9
  def reset_window_size
10
10
  @window_size = @options[:window_size]
@@ -15,6 +15,7 @@ module Capybara
15
15
  TRIGGER_CLICK_WAIT = ENV.fetch("CUPRITE_TRIGGER_CLICK_WAIT", 0.1).to_f
16
16
 
17
17
  extend Forwardable
18
+
18
19
  delegate %i[at_css at_xpath css xpath
19
20
  current_url current_title body execution_id execution_id!
20
21
  evaluate evaluate_on evaluate_async execute] => :active_frame
@@ -24,9 +25,25 @@ module Capybara
24
25
  @accept_modal = []
25
26
  @modal_messages = []
26
27
  @modal_response = nil
28
+ @unhandled_modal_error = nil
27
29
  super
28
30
  end
29
31
 
32
+ # Keep a handle to Ferrum's own implementation before overriding it
33
+ # below, so answering a dialog (see `handle_javascript_dialog`) can
34
+ # bypass our override.
35
+ alias ferrum_command command
36
+
37
+ # The `Page.javascriptDialogOpening` event is handled on Ferrum's
38
+ # background CDP dispatcher thread, so raising there wouldn't reach
39
+ # the caller and would permanently kill that thread instead. The
40
+ # dialog is always accepted immediately from that thread, and the
41
+ # error (if any) is stashed here to be raised from the main thread
42
+ # the next time it makes a command round trip.
43
+ def command(...)
44
+ raise_pending_unhandled_modal_error! { super }
45
+ end
46
+
30
47
  def set(node, value)
31
48
  object_id = command("DOM.resolveNode", nodeId: node.node_id).dig("object", "objectId")
32
49
  evaluate("_cuprite.set(arguments[0], arguments[1])", { "objectId" => object_id }, value)
@@ -99,6 +116,7 @@ module Capybara
99
116
  @accept_modal = []
100
117
  @modal_response = nil
101
118
  @modal_messages = []
119
+ @unhandled_modal_error = nil
102
120
  end
103
121
 
104
122
  def before_click(node, name, _keys = [], offset = {})
@@ -123,7 +141,6 @@ module Capybara
123
141
  @frame_stack = []
124
142
  else
125
143
  @frame_stack << handle
126
- inject_extensions
127
144
  end
128
145
  end
129
146
 
@@ -141,6 +158,18 @@ module Capybara
141
158
 
142
159
  private
143
160
 
161
+ def raise_pending_unhandled_modal_error!
162
+ error = @unhandled_modal_error
163
+ @unhandled_modal_error = nil
164
+ raise error if error
165
+
166
+ yield
167
+ ensure
168
+ error = @unhandled_modal_error
169
+ @unhandled_modal_error = nil
170
+ raise error if error
171
+ end
172
+
144
173
  def prepare_page
145
174
  super
146
175
 
@@ -155,6 +184,8 @@ module Capybara
155
184
 
156
185
  on("Page.javascriptDialogOpening") do |params|
157
186
  accept_modal = @accept_modal.last
187
+ unhandled_modal_error = nil
188
+
158
189
  if [true, false].include?(accept_modal)
159
190
  @accept_modal.pop
160
191
  @modal_messages << params["message"]
@@ -162,15 +193,24 @@ module Capybara
162
193
  response = @modal_response || params["defaultPrompt"]
163
194
  else
164
195
  with_text = params["message"] ? "with text `#{params['message']}` " : ""
165
- warn "Modal window #{with_text}has been opened, but you didn't wrap " \
166
- "your code into (`accept_prompt` | `dismiss_prompt` | " \
167
- "`accept_confirm` | `dismiss_confirm` | `accept_alert`), " \
168
- "accepting by default"
196
+ message = "Modal window #{with_text}has been opened, but you didn't wrap " \
197
+ "your code into (`accept_prompt` | `dismiss_prompt` | " \
198
+ "`accept_confirm` | `dismiss_confirm` | `accept_alert`), " \
199
+ "accepting by default"
200
+
201
+ if @options.raise_on_unhandled_modal
202
+ unhandled_modal_error = UnhandledModalError.new(message)
203
+ else
204
+ warn message
205
+ end
206
+
169
207
  options = { accept: true }
170
208
  response = params["defaultPrompt"]
171
209
  end
172
210
  options.merge!(promptText: response) if response
173
- command("Page.handleJavaScriptDialog", **options)
211
+ ferrum_command("Page.handleJavaScriptDialog", **options)
212
+
213
+ @unhandled_modal_error = unhandled_modal_error if unhandled_modal_error
174
214
  end
175
215
  end
176
216
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Cuprite
5
- VERSION = "0.15.1"
5
+ VERSION = "0.18"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cuprite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.1
4
+ version: '0.18'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Vorotilin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-15 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.15.0
33
+ version: 0.18.0
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 0.15.0
40
+ version: 0.18.0
41
41
  description: Cuprite is a driver for Capybara that allows you to run your tests on
42
42
  a headless Chrome browser
43
43
  email:
@@ -53,6 +53,7 @@ files:
53
53
  - lib/capybara/cuprite/cookie.rb
54
54
  - lib/capybara/cuprite/driver.rb
55
55
  - lib/capybara/cuprite/errors.rb
56
+ - lib/capybara/cuprite/javascripts/drag.js
56
57
  - lib/capybara/cuprite/javascripts/index.js
57
58
  - lib/capybara/cuprite/node.rb
58
59
  - lib/capybara/cuprite/options.rb
@@ -75,14 +76,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
75
76
  requirements:
76
77
  - - ">="
77
78
  - !ruby/object:Gem::Version
78
- version: 2.7.0
79
+ version: '3.1'
79
80
  required_rubygems_version: !ruby/object:Gem::Requirement
80
81
  requirements:
81
82
  - - ">="
82
83
  - !ruby/object:Gem::Version
83
84
  version: '0'
84
85
  requirements: []
85
- rubygems_version: 3.5.11
86
+ rubygems_version: 3.5.22
86
87
  signing_key:
87
88
  specification_version: 4
88
89
  summary: Headless Chrome driver for Capybara