cuprite 0.17 → 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: 96a5c4e78aa8ea75902caef045651f816880702db158b41d0b4ef3e916bf3611
4
- data.tar.gz: 0f9f1ead810f03c6ee4fbe2053ad0e6476c26a4baeb53e2315b7110e47120243
3
+ metadata.gz: ed2641aa002e810fd4dfedc8b3128f38e5f2df0f747d59bc1cf7a81aa1295076
4
+ data.tar.gz: 7e967cb1a6a11ad9f446290cd5035d2d5d3e9c6724ba6f4df4c31e761b29545d
5
5
  SHA512:
6
- metadata.gz: 6f2ec852f926cb0e013152c227f0b49973668eb800a7c9dd4192c25d08fb31a3e477218fa5593742229de236bb80f694245648b22ba430fbf6ffa7f54680e1e9
7
- data.tar.gz: 2af0ca1ec6e7f83ef1c844fd49340f26716b96cea3cedfffbb1f4cf6744b3825905a44a8f0b33607ce3a588b52046a585a61c349188ec6d0c4c976b0ef76be49
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,6 +76,12 @@ 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
 
@@ -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,18 +151,39 @@ module Capybara
139
151
  raise NotImplementedError
140
152
  end
141
153
 
142
- def drag(node, other, steps, delay = nil, scroll = true)
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
164
 
145
165
  mouse.move(x: x1, y: y1)
146
166
  mouse.down
147
167
  sleep delay if delay
148
168
 
149
- other.scroll_into_view if scroll
169
+ html5 = !evaluate_on(node: node, expression: "_cuprite.legacyDragCheck(this)") if html5.nil?
150
170
 
151
- x2, y2 = other.find_position
152
- mouse.move(x: x2, y: y2, steps: steps)
171
+ if html5
172
+ drag_html5(node, other, drop_modifiers, delay)
173
+ else
174
+ modifiers = keyboard.modifiers(drop_modifiers)
153
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)
154
187
  mouse.up
155
188
  end
156
189
 
@@ -169,6 +202,18 @@ module Capybara
169
202
  mouse.up
170
203
  end
171
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
+
172
217
  def select_file(node, value)
173
218
  node.select_file(value)
174
219
  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
@@ -406,4 +413,3 @@ module Capybara
406
413
  end
407
414
  end
408
415
  end
409
- # 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) {
@@ -153,15 +174,13 @@ class Cuprite {
153
174
 
154
175
  let valueBefore = node.value;
155
176
 
177
+ node.focus();
156
178
  this.trigger(node, "focus");
157
179
  this.setValue(node, "");
158
180
 
159
- 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") {
160
182
  this.setValue(node, value);
161
183
  this.input(node);
162
- } else if (node.type == "time") {
163
- this.setValue(node, new Date(value).toTimeString().split(" ")[0]);
164
- this.input(node);
165
184
  } else if (node.type == "datetime-local") {
166
185
  value = new Date(value);
167
186
  let year = value.getFullYear();
@@ -309,7 +328,7 @@ class Cuprite {
309
328
  x -= frameOffset.left;
310
329
  y -= frameOffset.top;
311
330
 
312
- let element = document.elementFromPoint(x, y);
331
+ let element = node.getRootNode().elementFromPoint(x, y);
313
332
 
314
333
  let el = element;
315
334
  while (el) {
@@ -445,7 +464,7 @@ class Cuprite {
445
464
  options["button"] || 0, null
446
465
  )
447
466
  } else if (EVENTS.FOCUS.indexOf(name) != -1) {
448
- event = this.obtainEvent(name);
467
+ event = new FocusEvent(name, { bubbles: true, cancelable: true });
449
468
  } else if (EVENTS.FORM.indexOf(name) != -1) {
450
469
  event = this.obtainEvent(name);
451
470
  } else {
@@ -518,6 +537,66 @@ class Cuprite {
518
537
  return node.contains(selectedNode);
519
538
  }
520
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
+
521
600
  // This command is purely for testing error handling
522
601
  browserError() {
523
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
@@ -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?
@@ -162,8 +183,9 @@ module Capybara
162
183
  def drag_to(other, **options)
163
184
  options[:steps] ||= 1
164
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) }
165
187
 
166
- command(:drag, other.node, options[:steps], options[:delay], options[:scroll])
188
+ command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5))
167
189
  end
168
190
 
169
191
  def drag_by(x, y, **options)
@@ -173,6 +195,8 @@ module Capybara
173
195
  command(:drag_by, x, y, options[:steps], options[:delay], options[:scroll])
174
196
  end
175
197
 
198
+ def drop(...) = command(:drop, ...)
199
+
176
200
  def trigger(event)
177
201
  command(:trigger, event)
178
202
  end
@@ -205,6 +229,9 @@ module Capybara
205
229
  end
206
230
 
207
231
  def send_keys(*keys)
232
+ keys = keys.reject { |key| key.nil? || key == "" }
233
+ return if keys.empty?
234
+
208
235
  command(:send_keys, keys)
209
236
  end
210
237
  alias send_key send_keys
@@ -217,6 +244,19 @@ module Capybara
217
244
  command(:obscured?)
218
245
  end
219
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
+
220
260
  def inspect
221
261
  %(#<#{self.class} @node=#{@node.inspect}>)
222
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 = {})
@@ -140,6 +158,18 @@ module Capybara
140
158
 
141
159
  private
142
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
+
143
173
  def prepare_page
144
174
  super
145
175
 
@@ -154,6 +184,8 @@ module Capybara
154
184
 
155
185
  on("Page.javascriptDialogOpening") do |params|
156
186
  accept_modal = @accept_modal.last
187
+ unhandled_modal_error = nil
188
+
157
189
  if [true, false].include?(accept_modal)
158
190
  @accept_modal.pop
159
191
  @modal_messages << params["message"]
@@ -161,15 +193,24 @@ module Capybara
161
193
  response = @modal_response || params["defaultPrompt"]
162
194
  else
163
195
  with_text = params["message"] ? "with text `#{params['message']}` " : ""
164
- warn "Modal window #{with_text}has been opened, but you didn't wrap " \
165
- "your code into (`accept_prompt` | `dismiss_prompt` | " \
166
- "`accept_confirm` | `dismiss_confirm` | `accept_alert`), " \
167
- "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
+
168
207
  options = { accept: true }
169
208
  response = params["defaultPrompt"]
170
209
  end
171
210
  options.merge!(promptText: response) if response
172
- command("Page.handleJavaScriptDialog", **options)
211
+ ferrum_command("Page.handleJavaScriptDialog", **options)
212
+
213
+ @unhandled_modal_error = unhandled_modal_error if unhandled_modal_error
173
214
  end
174
215
  end
175
216
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Cuprite
5
- VERSION = "0.17"
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.17'
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: 2025-05-11 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.17.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.17.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,7 +76,7 @@ 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
  - - ">="