cuprite 0.18 → 0.19

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: ed2641aa002e810fd4dfedc8b3128f38e5f2df0f747d59bc1cf7a81aa1295076
4
- data.tar.gz: 7e967cb1a6a11ad9f446290cd5035d2d5d3e9c6724ba6f4df4c31e761b29545d
3
+ metadata.gz: 7fa19bf03d8a264421374bf813afb0f6c062e15e8aa92ca7c046d3a659122319
4
+ data.tar.gz: d12031c15dda9ad7e777f7b66a887c6979af5b4c3e27010083378af512f979d1
5
5
  SHA512:
6
- metadata.gz: dcee409e8ee24caf139c3a0c29318ccea5ddefb0759f228f0f24db76aef28864c73d46e02ecc820160e82d6f366c61648ad1dc20dd930b5d9f300c8853d17003
7
- data.tar.gz: 5168b1c18e81936795a02154829100dc3a616c2d2c3db71c134c8e64f5f13d93fd1098e8c26aea8d84a26840bf547a80b263b63b293420dc9f18cc18a5d29632
6
+ metadata.gz: c85ee6afd497cfdc03375c111cc58dbcd6463f41c63bd745a6229a31a4d4e6bb6dc03fb8eddc0f8a7f93079c2cdf81524a3febca3494031e002eb869e9943188
7
+ data.tar.gz: 0d066fa1a7fb851a9ce0d3d9a9e4891a6c76bd3c7e558a74fa021035b8e2316d860fce4674f3054134fe03dc05c7c622124d8b88ded27d81bfeede3d75e28e7f
data/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Cuprite - Headless Chrome driver for Capybara
2
2
 
3
+ ## [Documentation](https://docs.rubycdp.com/docs/cuprite/introduction)
4
+
5
+ <img align="right"
6
+ width="160" height="160"
7
+ alt="Cuprite logo"
8
+ src="https://raw.githubusercontent.com/rubycdp/cuprite/main/logo.svg?sanitize=true">
9
+
3
10
  Cuprite is a pure Ruby driver (read as _no_ Selenium/WebDriver/ChromeDriver
4
11
  dependency) for [Capybara](https://github.com/teamcapybara/capybara). It allows
5
12
  you to run Capybara tests on a headless Chrome or Chromium. Under the hood it
@@ -7,6 +14,8 @@ uses [Ferrum](https://github.com/rubycdp/ferrum#index) which is high-level API
7
14
  to the browser by CDP protocol.
8
15
 
9
16
 
17
+ <br clear="right">
18
+
10
19
  ## Install
11
20
 
12
21
  Add this to your `Gemfile` and run `bundle install`.
@@ -84,7 +93,18 @@ end
84
93
  timer) only surfaces on whatever command runs next.
85
94
  * `:url_blacklist` (Array) - array of regexes to match against requested URLs
86
95
  * `:url_whitelist` (Array) - array of regexes to match against requested URLs
96
+ * `:emulate` (Symbol) - device from Chrome DevTools' list to emulate, e.g. `:iphone_16`, `:pixel_9`, `:ipad_mini`
97
+ * `:mobile` (Boolean) - emulate a mobile device: viewport meta tag and touch events
98
+ * `:scale_factor` (Numeric) - device scale factor to emulate
87
99
 
100
+ `emulate:` sets the device's `:window_size`, `:scale_factor`, `:mobile` and user agent, options passed explicitly
101
+ take precedence:
102
+
103
+ ```ruby
104
+ Capybara.register_driver(:cuprite) do |app|
105
+ Capybara::Cuprite::Driver.new(app, emulate: :iphone_14)
106
+ end
107
+ ```
88
108
 
89
109
  ## Debugging
90
110
 
@@ -198,7 +218,17 @@ Besides capybara screenshot method you can get image as Base64:
198
218
 
199
219
  ## Proxy
200
220
 
201
- * `page.driver.set_proxy(ip, port, user, password)`
221
+ * `page.driver.set_proxy(ip, port, user, password, bypass)`
222
+
223
+ The proxy is passed to Chrome as a command-line flag, so it has to be set before the browser starts: either with the
224
+ `:proxy` driver option or by calling `set_proxy` before the browser is first used. Calling it once the browser is
225
+ running has no effect.
226
+
227
+ When connecting to a remote browser with the `:url` option (browserless, Selenoid, etc.), Cuprite doesn't launch
228
+ Chrome and the proxy flag is never applied, so configure the proxy on the remote browser itself. The `user` and
229
+ `password` are still used to answer the proxy's authentication challenge.
230
+
231
+ The `bypass` argument is a list of hosts that skip the proxy, so don't pass the host you want to go through it.
202
232
 
203
233
 
204
234
  ## URL Blocklisting & Allowlisting
@@ -14,6 +14,8 @@ module Capybara
14
14
  dismiss_prompt reset_modals] => :page
15
15
 
16
16
  def initialize(options = nil)
17
+ device = options&.dig(:emulate)
18
+ options = Devices.fetch(device).except(:user_agent).merge(options) if device
17
19
  super
18
20
 
19
21
  @options.raise_on_unhandled_modal = options&.dig(:raise_on_unhandled_modal)
@@ -38,6 +40,7 @@ module Capybara
38
40
 
39
41
  def reset
40
42
  super
43
+ ensure
41
44
  @options.reset_window_size
42
45
  @page = nil
43
46
  end
@@ -109,7 +112,7 @@ module Capybara
109
112
  end
110
113
 
111
114
  def window_handles
112
- targets.keys
115
+ targets.values.select(&:page?).map(&:id)
113
116
  end
114
117
 
115
118
  def within_window(locator = nil)
@@ -123,17 +126,13 @@ module Capybara
123
126
  end
124
127
 
125
128
  def switch_to_window(target_id)
126
- target = targets[target_id]
127
- raise Ferrum::NoSuchPageError unless target
128
-
129
+ target = window_target(target_id)
129
130
  @page = attach_page(target.id)
130
131
  @page.activate
131
132
  end
132
133
 
133
134
  def close_window(target_id)
134
- target = targets[target_id]
135
- raise Ferrum::NoSuchPageError unless target
136
-
135
+ target = window_target(target_id)
137
136
  @page = ClosedPage.new if @page.target_id == target.id
138
137
  target.page.close
139
138
  targets.delete(target_id) # page.close is async, delete target asap
@@ -156,6 +155,7 @@ module Capybara
156
155
  delay = options[:delay]
157
156
  scroll = options.fetch(:scroll, true)
158
157
  html5 = options[:html5]
158
+ drop_offset = options[:drop_offset]
159
159
 
160
160
  execute("_cuprite.dragMousedownTracker()")
161
161
 
@@ -169,21 +169,21 @@ module Capybara
169
169
  html5 = !evaluate_on(node: node, expression: "_cuprite.legacyDragCheck(this)") if html5.nil?
170
170
 
171
171
  if html5
172
- drag_html5(node, other, drop_modifiers, delay)
172
+ drag_html5(node, other, drop_modifiers, delay, drop_offset)
173
173
  else
174
174
  modifiers = keyboard.modifiers(drop_modifiers)
175
175
 
176
176
  other.scroll_into_view if scroll
177
- x2, y2 = other.find_position
177
+ x2, y2 = other.find_position(**drop_offset.to_h)
178
178
  mouse.move(x: x2, y: y2, steps: steps)
179
179
 
180
180
  mouse.up(modifiers: modifiers)
181
181
  end
182
182
  end
183
183
 
184
- def drag_html5(node, other, drop_modifiers, delay)
184
+ def drag_html5(node, other, drop_modifiers, delay, drop_offset = nil)
185
185
  keys = drop_modifiers.map(&:to_s)
186
- evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys)
186
+ evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys, drop_offset)
187
187
  mouse.up
188
188
  end
189
189
 
@@ -266,6 +266,10 @@ module Capybara
266
266
  private
267
267
 
268
268
  def find_all(method, selector, within = nil)
269
+ # Capybara appends its text-match hint as `(expr)[...]`, but with `exact_text: true` the hint
270
+ # is empty and the result `(expr)[]` is invalid XPath. An empty predicate is never valid, so
271
+ # dropping it can't change a valid selector. Remove once teamcapybara/capybara#2860 is released.
272
+ selector = selector.delete_suffix("[]") if method == :xpath
269
273
  nodes = if within
270
274
  evaluate("_cuprite.find(arguments[0], arguments[1], arguments[2])", method, selector, within)
271
275
  else
@@ -297,6 +301,13 @@ module Capybara
297
301
  end
298
302
  end
299
303
 
304
+ def window_target(target_id)
305
+ target = targets[target_id]
306
+ raise Ferrum::NoSuchPageError unless target&.page?
307
+
308
+ target
309
+ end
310
+
300
311
  def attach_page(target_id = nil)
301
312
  target = targets[target_id] if target_id
302
313
  target ||= default_context.default_target
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Cuprite
5
+ module Devices
6
+ IPHONE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
7
+ SAMSUNG_GALAXY_S8_PLUS_USER_AGENT = "Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
8
+ WINDOWS_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Safari/537.36"
9
+
10
+ LIST = {
11
+ iphone_se: {
12
+ window_size: [375, 667], scale_factor: 2, mobile: true,
13
+ user_agent: IPHONE_USER_AGENT
14
+ },
15
+ iphone_xr: {
16
+ window_size: [414, 896], scale_factor: 2, mobile: true,
17
+ user_agent: IPHONE_USER_AGENT
18
+ },
19
+ iphone_12_pro: {
20
+ window_size: [390, 844], scale_factor: 3, mobile: true,
21
+ user_agent: IPHONE_USER_AGENT
22
+ },
23
+ iphone_14: {
24
+ window_size: [390, 844], scale_factor: 3, mobile: true,
25
+ user_agent: IPHONE_USER_AGENT
26
+ },
27
+ iphone_14_plus: {
28
+ window_size: [428, 926], scale_factor: 3, mobile: true,
29
+ user_agent: IPHONE_USER_AGENT
30
+ },
31
+ iphone_14_pro: {
32
+ window_size: [393, 852], scale_factor: 3, mobile: true,
33
+ user_agent: IPHONE_USER_AGENT
34
+ },
35
+ iphone_14_pro_max: {
36
+ window_size: [430, 932], scale_factor: 3, mobile: true,
37
+ user_agent: IPHONE_USER_AGENT
38
+ },
39
+ iphone_15: {
40
+ window_size: [393, 852], scale_factor: 3, mobile: true,
41
+ user_agent: IPHONE_USER_AGENT
42
+ },
43
+ iphone_15_plus: {
44
+ window_size: [430, 932], scale_factor: 3, mobile: true,
45
+ user_agent: IPHONE_USER_AGENT
46
+ },
47
+ iphone_15_pro: {
48
+ window_size: [393, 852], scale_factor: 3, mobile: true,
49
+ user_agent: IPHONE_USER_AGENT
50
+ },
51
+ iphone_15_pro_max: {
52
+ window_size: [430, 932], scale_factor: 3, mobile: true,
53
+ user_agent: IPHONE_USER_AGENT
54
+ },
55
+ iphone_16e: {
56
+ window_size: [390, 844], scale_factor: 3, mobile: true,
57
+ user_agent: IPHONE_USER_AGENT
58
+ },
59
+ iphone_16: {
60
+ window_size: [393, 852], scale_factor: 3, mobile: true,
61
+ user_agent: IPHONE_USER_AGENT
62
+ },
63
+ iphone_16_plus: {
64
+ window_size: [430, 932], scale_factor: 3, mobile: true,
65
+ user_agent: IPHONE_USER_AGENT
66
+ },
67
+ iphone_16_pro: {
68
+ window_size: [402, 874], scale_factor: 3, mobile: true,
69
+ user_agent: IPHONE_USER_AGENT
70
+ },
71
+ iphone_16_pro_max: {
72
+ window_size: [440, 956], scale_factor: 3, mobile: true,
73
+ user_agent: IPHONE_USER_AGENT
74
+ },
75
+ ipad_mini: {
76
+ window_size: [768, 1024], scale_factor: 2, mobile: true,
77
+ user_agent: "Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
78
+ },
79
+ ipad_pro_13: {
80
+ window_size: [1032, 1376], scale_factor: 2, mobile: true,
81
+ user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15"
82
+ },
83
+
84
+ pixel_7: {
85
+ window_size: [412, 915], scale_factor: 2.625, mobile: true,
86
+ user_agent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
87
+ },
88
+ pixel_8: {
89
+ window_size: [412, 915], scale_factor: 2.625, mobile: true,
90
+ user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
91
+ },
92
+ pixel_8_pro: {
93
+ window_size: [448, 997], scale_factor: 3, mobile: true,
94
+ user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
95
+ },
96
+ pixel_8a: {
97
+ window_size: [412, 915], scale_factor: 2.625, mobile: true,
98
+ user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 8a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
99
+ },
100
+ pixel_9: {
101
+ window_size: [412, 924], scale_factor: 2.625, mobile: true,
102
+ user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
103
+ },
104
+ pixel_9_pro: {
105
+ window_size: [427, 952], scale_factor: 3, mobile: true,
106
+ user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
107
+ },
108
+ pixel_9_pro_xl: {
109
+ window_size: [448, 997], scale_factor: 3, mobile: true,
110
+ user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
111
+ },
112
+ pixel_10: {
113
+ window_size: [412, 924], scale_factor: 2.625, mobile: true,
114
+ user_agent: "Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
115
+ },
116
+ pixel_9_pro_fold: {
117
+ window_size: [412, 922], scale_factor: 2.625, mobile: true,
118
+ user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro Fold) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
119
+ },
120
+
121
+ samsung_galaxy_s8_plus: {
122
+ window_size: [360, 740], scale_factor: 4, mobile: true,
123
+ user_agent: SAMSUNG_GALAXY_S8_PLUS_USER_AGENT
124
+ },
125
+ samsung_galaxy_s20_ultra: {
126
+ window_size: [412, 915], scale_factor: 3.5, mobile: true,
127
+ user_agent: "Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
128
+ },
129
+ samsung_galaxy_a55: {
130
+ window_size: [360, 800], scale_factor: 2.25, mobile: true,
131
+ user_agent: "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
132
+ },
133
+ galaxy_z_fold_6: {
134
+ window_size: [412, 968], scale_factor: 2.625, mobile: true,
135
+ user_agent: "Mozilla/5.0 (Linux; Android 14; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
136
+ },
137
+ galaxy_z_fold_5: {
138
+ window_size: [344, 882], scale_factor: 2.625, mobile: true,
139
+ user_agent: "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
140
+ },
141
+ samsung_galaxy_a51_71: {
142
+ window_size: [412, 914], scale_factor: 2.625, mobile: true,
143
+ user_agent: SAMSUNG_GALAXY_S8_PLUS_USER_AGENT
144
+ },
145
+ galaxy_tab_s4: {
146
+ window_size: [712, 1138], scale_factor: 2.25, mobile: true,
147
+ user_agent: "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Safari/537.36"
148
+ },
149
+
150
+ surface_pro_7: {
151
+ window_size: [912, 1368], scale_factor: 2, mobile: true,
152
+ user_agent: WINDOWS_USER_AGENT
153
+ },
154
+ surface_pro_10: {
155
+ window_size: [960, 1440], scale_factor: 2, mobile: true,
156
+ user_agent: WINDOWS_USER_AGENT
157
+ },
158
+ surface_duo: {
159
+ window_size: [540, 720], scale_factor: 2.5, mobile: true,
160
+ user_agent: "Mozilla/5.0 (Linux; Android 11.0; Surface Duo) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
161
+ },
162
+
163
+ nest_hub_max: {
164
+ window_size: [1280, 800], scale_factor: 2, mobile: true,
165
+ user_agent: "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Safari/537.36 CrKey/1.54.250320"
166
+ },
167
+ jiophone_2: {
168
+ window_size: [240, 320], scale_factor: 1, mobile: true,
169
+ user_agent: "Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5"
170
+ },
171
+ moto_g4: {
172
+ window_size: [360, 640], scale_factor: 3, mobile: true,
173
+ user_agent: "Mozilla/5.0 (Linux; Android 6.0.1; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
174
+ },
175
+ moto_g_power: {
176
+ window_size: [412, 823], scale_factor: 1.75, mobile: true,
177
+ user_agent: "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%<version>s Mobile Safari/537.36"
178
+ }
179
+ }.freeze
180
+
181
+ def self.fetch(name)
182
+ LIST.fetch(name.to_sym) do
183
+ raise ArgumentError, "Unknown device to emulate: #{name.inspect}, use one of #{LIST.keys.join(', ')}"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -42,6 +42,7 @@ module Capybara
42
42
  unless @options[:browser_options][:"remote-allow-origins"]
43
43
  @options[:browser_options].merge!("remote-allow-origins": "*")
44
44
  end
45
+ @options[:browser_options] = { "disable-web-security": nil }.merge(@options[:browser_options])
45
46
 
46
47
  super()
47
48
  end
@@ -139,6 +140,7 @@ module Capybara
139
140
  browser.url_whitelist = @options[:url_whitelist]
140
141
  browser.raise_on_unhandled_modal = @options.fetch(:raise_on_unhandled_modal, false)
141
142
  browser.reset
143
+ ensure
142
144
  @started = false
143
145
  end
144
146
 
@@ -1,11 +1,9 @@
1
1
  // HTML5 drag-and-drop emulation.
2
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.
3
+ // Ported from Capybara's Selenium driver (capybara/selenium/extensions/html5_drag.rb,
4
+ // HTML5_DRAG_DROP_SCRIPT), see https://github.com/rubycdp/cuprite/issues/314.
5
+ // Unlike the original, a drag ends the way it does in a browser: with `drop` if
6
+ // the target accepted it, otherwise with `dragleave`, never both [#325].
9
7
 
10
8
  function rectCenter(rect){
11
9
  return new DOMPoint(
@@ -31,7 +29,7 @@ function pointOnRect(pt, rect) {
31
29
  }
32
30
 
33
31
  if (pt.y <= rectPt.y) { // top side
34
- var minYx = (rectPt.top - pt.y) / slope + pt.x;
32
+ var minYx = (rect.top - pt.y) / slope + pt.x;
35
33
  if (rect.left <= minYx && minYx <= rect.right)
36
34
  return new DOMPoint(minYx, rect.top);
37
35
  }
@@ -51,7 +49,7 @@ function dragEnterTarget() {
51
49
  var sourceCenter = rectCenter(source.getBoundingClientRect());
52
50
 
53
51
  for (var i = 0; i < drop_modifier_keys.length; i++) {
54
- key = drop_modifier_keys[i];
52
+ var key = drop_modifier_keys[i];
55
53
  if (key == "control"){
56
54
  key = "ctrl"
57
55
  }
@@ -69,23 +67,26 @@ function dragEnterTarget() {
69
67
  window.setTimeout(dragOnTarget, step_delay);
70
68
  }
71
69
 
70
+ function dropPoint(rect) {
71
+ if (!drop_offset) return rectCenter(rect);
72
+
73
+ var origin = drop_offset.position == 'center' ? rectCenter(rect) : new DOMPoint(rect.left, rect.top);
74
+ return new DOMPoint(origin.x + drop_offset.x, origin.y + drop_offset.y);
75
+ }
76
+
72
77
  function dragOnTarget() {
73
- var targetCenter = rectCenter(target.getBoundingClientRect());
74
- var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
78
+ var point = dropPoint(target.getBoundingClientRect());
79
+ var dragOverOpts = Object.assign({clientX: point.x, clientY: point.y}, opts);
75
80
  var dragOverEvent = new DragEvent('dragover', dragOverOpts);
76
81
  target.dispatchEvent(dragOverEvent);
77
- window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
82
+ window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
78
83
  }
79
84
 
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);
85
+ function dragEnd(drop, dragOverOpts) {
86
+ var dragEndOptions = Object.assign({}, opts, dragOverOpts);
87
+ var dropOrLeaveEvent = new DragEvent(drop ? 'drop' : 'dragleave', dragEndOptions);
88
+ target.dispatchEvent(dropOrLeaveEvent);
89
+ var dragEndEvent = new DragEvent('dragend', dragEndOptions);
89
90
  source.dispatchEvent(dragEndEvent);
90
91
  callback.call(true);
91
92
  }
@@ -94,7 +95,8 @@ var source = arguments[0],
94
95
  target = arguments[1],
95
96
  step_delay = arguments[2],
96
97
  drop_modifier_keys = arguments[3],
97
- callback = arguments[4];
98
+ drop_offset = arguments[4],
99
+ callback = arguments[5];
98
100
 
99
101
  var dt = new DataTransfer();
100
102
  var opts = { cancelable: true, bubbles: true, dataTransfer: dt };
@@ -77,15 +77,27 @@ class Cuprite {
77
77
  }
78
78
  }
79
79
 
80
+ let ancestorOpacityApplies = true;
81
+
80
82
  while (node) {
81
83
  if (node instanceof ShadowRoot) {
82
84
  node = node.host;
83
85
  } else {
84
86
  style = window.getComputedStyle(node);
85
- if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0) {
87
+ if (style.display === "none" || style.visibility === "hidden" || (ancestorOpacityApplies && parseFloat(style.opacity) === 0)) {
86
88
  return false;
87
89
  }
88
90
 
91
+ // A node matching :modal or :popover-open is a top-layer element, so
92
+ // an ancestor's opacity no longer applies to it once shown.
93
+ if (ancestorOpacityApplies) {
94
+ try {
95
+ if (node.matches(":modal, :popover-open")) {
96
+ ancestorOpacityApplies = false;
97
+ }
98
+ } catch (e) {}
99
+ }
100
+
89
101
  let parent = node.parentElement;
90
102
  if (parent && parent.tagName === "DETAILS" && !parent.open) {
91
103
  // In a closed <details> only the first <summary> (and its subtree) is rendered.
@@ -208,6 +220,10 @@ class Cuprite {
208
220
 
209
221
  this.keyupdowned(node, "keyup", char, keyCode);
210
222
  }
223
+
224
+ if (valueBefore !== "" && node.value === "") {
225
+ this.input(node, "deleteContentBackward");
226
+ }
211
227
  }
212
228
 
213
229
  if (valueBefore !== node.value) {
@@ -226,8 +242,8 @@ class Cuprite {
226
242
  return nativeTextareaValueSetter.call(node, value);
227
243
  }
228
244
 
229
- input(node) {
230
- let event = new InputEvent("input", { inputType: "insertText", bubbles: true, cancelable: false });
245
+ input(node, inputType = "insertText") {
246
+ let event = new InputEvent("input", { inputType: inputType, bubbles: true, cancelable: false });
231
247
  node.dispatchEvent(event);
232
248
  }
233
249
 
@@ -417,13 +433,14 @@ class Cuprite {
417
433
  } else if (value == false && !node.parentNode.multiple) {
418
434
  return false;
419
435
  } else {
420
- let parentNode = node.parentNode;
421
- this.trigger(parentNode, "focus");
436
+ let selectNode = node.closest("select");
437
+ this.trigger(selectNode, "focus");
422
438
 
423
439
  node.selected = value;
440
+ selectNode.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
424
441
  this.changed(node);
425
442
 
426
- this.trigger(parentNode, "blur");
443
+ this.trigger(selectNode, "blur");
427
444
  return true;
428
445
  }
429
446
  }
@@ -467,8 +484,10 @@ class Cuprite {
467
484
  event = new FocusEvent(name, { bubbles: true, cancelable: true });
468
485
  } else if (EVENTS.FORM.indexOf(name) != -1) {
469
486
  event = this.obtainEvent(name);
487
+ } else if (name == "input") {
488
+ event = new InputEvent(name, { inputType: "insertText", bubbles: true, composed: true });
470
489
  } else {
471
- throw "Unknown event";
490
+ event = new Event(name, { bubbles: true, cancelable: true });
472
491
  }
473
492
 
474
493
  node.dispatchEvent(event);
@@ -523,6 +542,10 @@ class Cuprite {
523
542
  window.getSelection().deleteFromDocument();
524
543
  }
525
544
 
545
+ hasFocus(node) {
546
+ return document.activeElement === node;
547
+ }
548
+
526
549
  containsSelection(node) {
527
550
  let selectedNode = document.getSelection().focusNode;
528
551
 
@@ -181,11 +181,14 @@ module Capybara
181
181
  end
182
182
 
183
183
  def drag_to(other, **options)
184
+ raise ArgumentError, "You must specify both x: and y: for a drop offset" if options[:x].nil? ^ options[:y].nil?
185
+
184
186
  options[:steps] ||= 1
185
187
  options[:scroll] = true unless options.key?(:scroll)
188
+ options[:drop_offset] = drop_offset(*options.values_at(:x, :y, :offset))
186
189
  modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) }
187
190
 
188
- command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5))
191
+ command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5, :drop_offset))
189
192
  end
190
193
 
191
194
  def drag_by(x, y, **options)
@@ -282,6 +285,13 @@ module Capybara
282
285
  node.click(mode: mode, keys: keys, offset: offset, delay: delay)
283
286
  end
284
287
 
288
+ def drop_offset(x, y, position)
289
+ return unless x
290
+
291
+ position ||= driver.session_options.w3c_click_offset ? :center : :top
292
+ { x: x, y: y, position: position.to_sym }
293
+ end
294
+
285
295
  def filter_text(text)
286
296
  text.gsub(/[\u200b\u200e\u200f]/, "")
287
297
  .gsub(/[\ \n\f\t\v\u2028\u2029]+/, " ")
@@ -6,6 +6,25 @@ module Ferrum
6
6
  attr_writer :window_size
7
7
  attr_accessor :url_blacklist, :url_whitelist, :raise_on_unhandled_modal
8
8
 
9
+ def mobile
10
+ @options.fetch(:mobile, false)
11
+ end
12
+
13
+ def scale_factor
14
+ @options.fetch(:scale_factor, 0)
15
+ end
16
+
17
+ def device_user_agent
18
+ Capybara::Cuprite::Devices.fetch(@options[:emulate])[:user_agent] if @options[:emulate]
19
+ end
20
+
21
+ def default_user_agent
22
+ return @default_user_agent unless device_user_agent
23
+
24
+ chrome_version = @default_user_agent.to_s[%r{Chrome/(\S+)}, 1].to_s
25
+ format(device_user_agent, version: chrome_version)
26
+ end
27
+
9
28
  def reset_window_size
10
29
  @window_size = @options[:window_size]
11
30
  end
@@ -44,6 +44,10 @@ module Capybara
44
44
  raise_pending_unhandled_modal_error! { super }
45
45
  end
46
46
 
47
+ def set_viewport(width: 0, height: 0, scale_factor: @options.scale_factor, mobile: @options.mobile)
48
+ super
49
+ end
50
+
47
51
  def set(node, value)
48
52
  object_id = command("DOM.resolveNode", nodeId: node.node_id).dig("object", "objectId")
49
53
  evaluate("_cuprite.set(arguments[0], arguments[1])", { "objectId" => object_id }, value)
@@ -66,7 +70,7 @@ module Capybara
66
70
  end
67
71
 
68
72
  def send_keys(node, keys)
69
- unless evaluate_on(node: node, expression: %(_cuprite.containsSelection(this)))
73
+ unless evaluate_on(node: node, expression: %(_cuprite.containsSelection(this) || _cuprite.hasFocus(this)))
70
74
  before_click(node, "click")
71
75
  node.click(mode: :left, keys: keys)
72
76
  end
@@ -174,7 +178,8 @@ module Capybara
174
178
  super
175
179
 
176
180
  width, height = @options.window_size
177
- resize(width: width, height: height)
181
+ width && height ? resize(width: width, height: height) : set_viewport
182
+ command("Network.setUserAgentOverride", userAgent: default_user_agent) if @options.device_user_agent
178
183
 
179
184
  if @options.url_blacklist.any?
180
185
  network.blacklist = @options.url_blacklist
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Cuprite
5
- VERSION = "0.18"
5
+ VERSION = "0.19"
6
6
  end
7
7
  end
@@ -8,6 +8,7 @@ require "capybara/cuprite/driver"
8
8
  require "capybara/cuprite/browser"
9
9
  require "capybara/cuprite/page"
10
10
  require "capybara/cuprite/options"
11
+ require "capybara/cuprite/devices"
11
12
  require "capybara/cuprite/node"
12
13
  require "capybara/cuprite/errors"
13
14
 
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.18'
4
+ version: '0.19'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Vorotilin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-03 00:00:00.000000000 Z
11
+ date: 2026-10-16 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.18.0
33
+ version: 0.19.1
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.18.0
40
+ version: 0.19.1
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:
@@ -51,6 +51,7 @@ files:
51
51
  - lib/capybara/cuprite.rb
52
52
  - lib/capybara/cuprite/browser.rb
53
53
  - lib/capybara/cuprite/cookie.rb
54
+ - lib/capybara/cuprite/devices.rb
54
55
  - lib/capybara/cuprite/driver.rb
55
56
  - lib/capybara/cuprite/errors.rb
56
57
  - lib/capybara/cuprite/javascripts/drag.js