cuprite 0.17 → 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: 96a5c4e78aa8ea75902caef045651f816880702db158b41d0b4ef3e916bf3611
4
- data.tar.gz: 0f9f1ead810f03c6ee4fbe2053ad0e6476c26a4baeb53e2315b7110e47120243
3
+ metadata.gz: 7fa19bf03d8a264421374bf813afb0f6c062e15e8aa92ca7c046d3a659122319
4
+ data.tar.gz: d12031c15dda9ad7e777f7b66a887c6979af5b4c3e27010083378af512f979d1
5
5
  SHA512:
6
- metadata.gz: 6f2ec852f926cb0e013152c227f0b49973668eb800a7c9dd4192c25d08fb31a3e477218fa5593742229de236bb80f694245648b22ba430fbf6ffa7f54680e1e9
7
- data.tar.gz: 2af0ca1ec6e7f83ef1c844fd49340f26716b96cea3cedfffbb1f4cf6744b3825905a44a8f0b33607ce3a588b52046a585a61c349188ec6d0c4c976b0ef76be49
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`.
@@ -27,12 +36,27 @@ Capybara.register_driver(:cuprite) do |app|
27
36
  end
28
37
  ```
29
38
 
30
- if you use `Docker` don't forget to pass `no-sandbox` option:
39
+ If Chrome or Chromium cannot use its sandbox inside a container—for example,
40
+ because it runs as `root`—enable Ferrum's container mode:
41
+
42
+ ```ruby
43
+ Capybara::Cuprite::Driver.new(app, dockerize: true)
44
+ ```
45
+
46
+ The `dockerize` option requires Ferrum 0.17.2 or newer. With an older Ferrum
47
+ version, pass the browser option explicitly:
31
48
 
32
49
  ```ruby
33
- Capybara::Cuprite::Driver.new(app, browser_options: { 'no-sandbox': nil })
50
+ Capybara::Cuprite::Driver.new(
51
+ app,
52
+ browser_options: { "no-sandbox" => nil }
53
+ )
34
54
  ```
35
55
 
56
+ Both approaches disable the browser's sandbox, so do not use them merely
57
+ because Docker is involved. Prefer running the browser as a non-root user with
58
+ a working sandbox when possible.
59
+
36
60
  Since Cuprite uses [Ferrum](https://github.com/rubycdp/ferrum#examples) there
37
61
  are many useful methods you can call even using this driver:
38
62
 
@@ -61,9 +85,26 @@ end
61
85
  `Cuprite`-specific options are:
62
86
 
63
87
  * options `Hash`
88
+ * `:raise_on_unhandled_modal` (Boolean) - When set to `false`, output a warning. When set to `true`, raise
89
+ `Capybara::Cuprite::UnhandledModalError` instead. The dialog is always auto-accepted either way; the raise is
90
+ deferred and surfaces on the next command sent to the browser. In practice that's almost always the same action
91
+ that triggered the dialog (a click, `visit`, `evaluate_script`, ...), since a JS dialog blocks the page until
92
+ answered, so that action's own command is what was waiting. A dialog fired with nothing in flight (e.g. a bare JS
93
+ timer) only surfaces on whatever command runs next.
64
94
  * `:url_blacklist` (Array) - array of regexes to match against requested URLs
65
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
66
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
+ ```
67
108
 
68
109
  ## Debugging
69
110
 
@@ -177,7 +218,17 @@ Besides capybara screenshot method you can get image as Base64:
177
218
 
178
219
  ## Proxy
179
220
 
180
- * `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.
181
232
 
182
233
 
183
234
  ## URL Blocklisting & Allowlisting
@@ -7,13 +7,18 @@ 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
13
15
 
14
16
  def initialize(options = nil)
17
+ device = options&.dig(:emulate)
18
+ options = Devices.fetch(device).except(:user_agent).merge(options) if device
15
19
  super
16
20
 
21
+ @options.raise_on_unhandled_modal = options&.dig(:raise_on_unhandled_modal)
17
22
  @options.url_blacklist = prepare_wildcards(options&.dig(:url_blacklist))
18
23
  @options.url_whitelist = prepare_wildcards(options&.dig(:url_whitelist))
19
24
 
@@ -35,6 +40,7 @@ module Capybara
35
40
 
36
41
  def reset
37
42
  super
43
+ ensure
38
44
  @options.reset_window_size
39
45
  @page = nil
40
46
  end
@@ -49,6 +55,14 @@ module Capybara
49
55
  super
50
56
  end
51
57
 
58
+ def raise_on_unhandled_modal
59
+ @options.raise_on_unhandled_modal
60
+ end
61
+
62
+ def raise_on_unhandled_modal=(value)
63
+ @options.raise_on_unhandled_modal = value
64
+ end
65
+
52
66
  def url_whitelist
53
67
  @options.url_whitelist
54
68
  end
@@ -98,7 +112,7 @@ module Capybara
98
112
  end
99
113
 
100
114
  def window_handles
101
- targets.keys
115
+ targets.values.select(&:page?).map(&:id)
102
116
  end
103
117
 
104
118
  def within_window(locator = nil)
@@ -112,16 +126,13 @@ module Capybara
112
126
  end
113
127
 
114
128
  def switch_to_window(target_id)
115
- target = targets[target_id]
116
- raise Ferrum::NoSuchPageError unless target
117
-
129
+ target = window_target(target_id)
118
130
  @page = attach_page(target.id)
131
+ @page.activate
119
132
  end
120
133
 
121
134
  def close_window(target_id)
122
- target = targets[target_id]
123
- raise Ferrum::NoSuchPageError unless target
124
-
135
+ target = window_target(target_id)
125
136
  @page = ClosedPage.new if @page.target_id == target.id
126
137
  target.page.close
127
138
  targets.delete(target_id) # page.close is async, delete target asap
@@ -139,18 +150,40 @@ module Capybara
139
150
  raise NotImplementedError
140
151
  end
141
152
 
142
- def drag(node, other, steps, delay = nil, scroll = true)
153
+ def drag(node, other, drop_modifiers, options = {})
154
+ steps = options.fetch(:steps, 1)
155
+ delay = options[:delay]
156
+ scroll = options.fetch(:scroll, true)
157
+ html5 = options[:html5]
158
+ drop_offset = options[:drop_offset]
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, drop_offset)
173
+ else
174
+ modifiers = keyboard.modifiers(drop_modifiers)
153
175
 
176
+ other.scroll_into_view if scroll
177
+ x2, y2 = other.find_position(**drop_offset.to_h)
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, drop_offset = nil)
185
+ keys = drop_modifiers.map(&:to_s)
186
+ evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys, drop_offset)
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
@@ -221,6 +266,10 @@ module Capybara
221
266
  private
222
267
 
223
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
224
273
  nodes = if within
225
274
  evaluate("_cuprite.find(arguments[0], arguments[1], arguments[2])", method, selector, within)
226
275
  else
@@ -252,6 +301,13 @@ module Capybara
252
301
  end
253
302
  end
254
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
+
255
311
  def attach_page(target_id = nil)
256
312
  target = targets[target_id] if target_id
257
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
@@ -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,11 +35,14 @@ 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"]
41
43
  @options[:browser_options].merge!("remote-allow-origins": "*")
42
44
  end
45
+ @options[:browser_options] = { "disable-web-security": nil }.merge(@options[:browser_options])
43
46
 
44
47
  super()
45
48
  end
@@ -108,10 +111,14 @@ module Capybara
108
111
  handle = case locator
109
112
  when Capybara::Node::Element
110
113
  locator.native.description["frameId"]
114
+ when Capybara::Cuprite::Node
115
+ locator.description["frameId"]
111
116
  when :parent, :top
112
117
  locator
113
118
  end
114
119
 
120
+ raise ArgumentError, "Unable to switch to frame from #{locator.class}" unless handle
121
+
115
122
  browser.switch_to_frame(handle)
116
123
  end
117
124
 
@@ -131,7 +138,9 @@ module Capybara
131
138
  @paper_size = nil
132
139
  browser.url_blacklist = @options[:url_blacklist]
133
140
  browser.url_whitelist = @options[:url_whitelist]
141
+ browser.raise_on_unhandled_modal = @options.fetch(:raise_on_unhandled_modal, false)
134
142
  browser.reset
143
+ ensure
135
144
  @started = false
136
145
  end
137
146
 
@@ -406,4 +415,3 @@ module Capybara
406
415
  end
407
416
  end
408
417
  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,120 @@
1
+ // HTML5 drag-and-drop emulation.
2
+ //
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].
7
+
8
+ function rectCenter(rect){
9
+ return new DOMPoint(
10
+ (rect.left + rect.right)/2,
11
+ (rect.top + rect.bottom)/2
12
+ );
13
+ }
14
+
15
+ function pointOnRect(pt, rect) {
16
+ var rectPt = rectCenter(rect);
17
+ var slope = (rectPt.y - pt.y) / (rectPt.x - pt.x);
18
+
19
+ if (pt.x <= rectPt.x) { // left side
20
+ var minXy = slope * (rect.left - pt.x) + pt.y;
21
+ if (rect.top <= minXy && minXy <= rect.bottom)
22
+ return new DOMPoint(rect.left, minXy);
23
+ }
24
+
25
+ if (pt.x >= rectPt.x) { // right side
26
+ var maxXy = slope * (rect.right - pt.x) + pt.y;
27
+ if (rect.top <= maxXy && maxXy <= rect.bottom)
28
+ return new DOMPoint(rect.right, maxXy);
29
+ }
30
+
31
+ if (pt.y <= rectPt.y) { // top side
32
+ var minYx = (rect.top - pt.y) / slope + pt.x;
33
+ if (rect.left <= minYx && minYx <= rect.right)
34
+ return new DOMPoint(minYx, rect.top);
35
+ }
36
+
37
+ if (pt.y >= rectPt.y) { // bottom side
38
+ var maxYx = (rect.bottom - pt.y) / slope + pt.x;
39
+ if (rect.left <= maxYx && maxYx <= rect.right)
40
+ return new DOMPoint(maxYx, rect.bottom);
41
+ }
42
+
43
+ return new DOMPoint(pt.x,pt.y);
44
+ }
45
+
46
+ function dragEnterTarget() {
47
+ target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
48
+ var targetRect = target.getBoundingClientRect();
49
+ var sourceCenter = rectCenter(source.getBoundingClientRect());
50
+
51
+ for (var i = 0; i < drop_modifier_keys.length; i++) {
52
+ var key = drop_modifier_keys[i];
53
+ if (key == "control"){
54
+ key = "ctrl"
55
+ }
56
+ opts[key + 'Key'] = true;
57
+ }
58
+
59
+ var dragEnterEvent = new DragEvent('dragenter', opts);
60
+ target.dispatchEvent(dragEnterEvent);
61
+
62
+ // fire 2 dragover events to simulate dragging with a direction
63
+ var entryPoint = pointOnRect(sourceCenter, targetRect)
64
+ var dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts);
65
+ var dragOverEvent = new DragEvent('dragover', dragOverOpts);
66
+ target.dispatchEvent(dragOverEvent);
67
+ window.setTimeout(dragOnTarget, step_delay);
68
+ }
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
+
77
+ function dragOnTarget() {
78
+ var point = dropPoint(target.getBoundingClientRect());
79
+ var dragOverOpts = Object.assign({clientX: point.x, clientY: point.y}, opts);
80
+ var dragOverEvent = new DragEvent('dragover', dragOverOpts);
81
+ target.dispatchEvent(dragOverEvent);
82
+ window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
83
+ }
84
+
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);
90
+ source.dispatchEvent(dragEndEvent);
91
+ callback.call(true);
92
+ }
93
+
94
+ var source = arguments[0],
95
+ target = arguments[1],
96
+ step_delay = arguments[2],
97
+ drop_modifier_keys = arguments[3],
98
+ drop_offset = arguments[4],
99
+ callback = arguments[5];
100
+
101
+ var dt = new DataTransfer();
102
+ var opts = { cancelable: true, bubbles: true, dataTransfer: dt };
103
+
104
+ while (source && !source.draggable) {
105
+ source = source.parentElement;
106
+ }
107
+
108
+ if (source.tagName == 'A'){
109
+ dt.setData('text/uri-list', source.href);
110
+ dt.setData('text', source.href);
111
+ }
112
+ if (source.tagName == 'IMG'){
113
+ dt.setData('text/uri-list', source.src);
114
+ dt.setData('text', source.src);
115
+ }
116
+
117
+ var dragEvent = new DragEvent('dragstart', opts);
118
+ source.dispatchEvent(dragEvent);
119
+
120
+ 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
 
@@ -73,12 +77,37 @@ class Cuprite {
73
77
  }
74
78
  }
75
79
 
80
+ let ancestorOpacityApplies = true;
81
+
76
82
  while (node) {
77
- style = window.getComputedStyle(node);
78
- if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0) {
79
- return false;
83
+ if (node instanceof ShadowRoot) {
84
+ node = node.host;
85
+ } else {
86
+ style = window.getComputedStyle(node);
87
+ if (style.display === "none" || style.visibility === "hidden" || (ancestorOpacityApplies && parseFloat(style.opacity) === 0)) {
88
+ return false;
89
+ }
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
+
101
+ let parent = node.parentElement;
102
+ if (parent && parent.tagName === "DETAILS" && !parent.open) {
103
+ // In a closed <details> only the first <summary> (and its subtree) is rendered.
104
+ if (node !== parent.querySelector(":scope > summary")) {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ node = parent ?? (node.getRootNode() instanceof ShadowRoot && node.getRootNode());
80
110
  }
81
- node = node.parentElement;
82
111
  }
83
112
 
84
113
  return true;
@@ -94,6 +123,10 @@ class Cuprite {
94
123
  }
95
124
 
96
125
  path(node) {
126
+ if (node.getRootNode && node.getRootNode() instanceof ShadowRoot) {
127
+ return "(: Shadow DOM element - no XPath :)";
128
+ };
129
+
97
130
  let nodes = [node];
98
131
  let parent = node.parentNode;
99
132
  while (parent !== document && parent !== null) {
@@ -153,15 +186,13 @@ class Cuprite {
153
186
 
154
187
  let valueBefore = node.value;
155
188
 
189
+ node.focus();
156
190
  this.trigger(node, "focus");
157
191
  this.setValue(node, "");
158
192
 
159
- if (node.type == "number" || node.type == "date" || node.type == "range") {
193
+ if (node.type == "number" || node.type == "date" || node.type == "range" || node.type == "time" || node.type == "month" || node.type == "week") {
160
194
  this.setValue(node, value);
161
195
  this.input(node);
162
- } else if (node.type == "time") {
163
- this.setValue(node, new Date(value).toTimeString().split(" ")[0]);
164
- this.input(node);
165
196
  } else if (node.type == "datetime-local") {
166
197
  value = new Date(value);
167
198
  let year = value.getFullYear();
@@ -189,6 +220,10 @@ class Cuprite {
189
220
 
190
221
  this.keyupdowned(node, "keyup", char, keyCode);
191
222
  }
223
+
224
+ if (valueBefore !== "" && node.value === "") {
225
+ this.input(node, "deleteContentBackward");
226
+ }
192
227
  }
193
228
 
194
229
  if (valueBefore !== node.value) {
@@ -207,8 +242,8 @@ class Cuprite {
207
242
  return nativeTextareaValueSetter.call(node, value);
208
243
  }
209
244
 
210
- input(node) {
211
- 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 });
212
247
  node.dispatchEvent(event);
213
248
  }
214
249
 
@@ -309,7 +344,7 @@ class Cuprite {
309
344
  x -= frameOffset.left;
310
345
  y -= frameOffset.top;
311
346
 
312
- let element = document.elementFromPoint(x, y);
347
+ let element = node.getRootNode().elementFromPoint(x, y);
313
348
 
314
349
  let el = element;
315
350
  while (el) {
@@ -398,13 +433,14 @@ class Cuprite {
398
433
  } else if (value == false && !node.parentNode.multiple) {
399
434
  return false;
400
435
  } else {
401
- let parentNode = node.parentNode;
402
- this.trigger(parentNode, "focus");
436
+ let selectNode = node.closest("select");
437
+ this.trigger(selectNode, "focus");
403
438
 
404
439
  node.selected = value;
440
+ selectNode.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
405
441
  this.changed(node);
406
442
 
407
- this.trigger(parentNode, "blur");
443
+ this.trigger(selectNode, "blur");
408
444
  return true;
409
445
  }
410
446
  }
@@ -445,11 +481,13 @@ class Cuprite {
445
481
  options["button"] || 0, null
446
482
  )
447
483
  } else if (EVENTS.FOCUS.indexOf(name) != -1) {
448
- event = this.obtainEvent(name);
484
+ event = new FocusEvent(name, { bubbles: true, cancelable: true });
449
485
  } else if (EVENTS.FORM.indexOf(name) != -1) {
450
486
  event = this.obtainEvent(name);
487
+ } else if (name == "input") {
488
+ event = new InputEvent(name, { inputType: "insertText", bubbles: true, composed: true });
451
489
  } else {
452
- throw "Unknown event";
490
+ event = new Event(name, { bubbles: true, cancelable: true });
453
491
  }
454
492
 
455
493
  node.dispatchEvent(event);
@@ -504,6 +542,10 @@ class Cuprite {
504
542
  window.getSelection().deleteFromDocument();
505
543
  }
506
544
 
545
+ hasFocus(node) {
546
+ return document.activeElement === node;
547
+ }
548
+
507
549
  containsSelection(node) {
508
550
  let selectedNode = document.getSelection().focusNode;
509
551
 
@@ -518,6 +560,66 @@ class Cuprite {
518
560
  return node.contains(selectedNode);
519
561
  }
520
562
 
563
+ dragMousedownTracker() {
564
+ window._cupriteMousedownPrevented = null;
565
+ document.addEventListener('mousedown', ev => {
566
+ window._cupriteMousedownPrevented = ev.defaultPrevented;
567
+ }, { once: true, passive: true });
568
+ }
569
+
570
+ legacyDragCheck(node) {
571
+ if ([true, null].indexOf(window._cupriteMousedownPrevented) >= 0) {
572
+ return true;
573
+ }
574
+
575
+ let el = node;
576
+ do {
577
+ if (el.draggable) return false;
578
+ } while (el = el.parentElement);
579
+ return true;
580
+ }
581
+
582
+ // Ported from Capybara's Selenium html5_drag.rb (DROP_STRING).
583
+ dropString(strings, target) {
584
+ let dt = new DataTransfer();
585
+ let opts = { cancelable: true, bubbles: true, dataTransfer: dt };
586
+ for (let i = 0; i < strings.length; i++) {
587
+ if (dt.items) {
588
+ dt.items.add(strings[i]["data"], strings[i]["type"]);
589
+ } else {
590
+ dt.setData(strings[i]["type"], strings[i]["data"]);
591
+ }
592
+ }
593
+ target.dispatchEvent(new DragEvent("drop", opts));
594
+ }
595
+
596
+ // Ported from Capybara's Selenium html5_drag.rb (ATTACH_FILE).
597
+ attachDropInput() {
598
+ document.getElementById("_cuprite_drop_file")?.remove();
599
+ let input = document.createElement("INPUT");
600
+ input.type = "file";
601
+ input.id = "_cuprite_drop_file";
602
+ input.multiple = true;
603
+ document.body.appendChild(input);
604
+ }
605
+
606
+ // Ported from Capybara's Selenium html5_drag.rb (DROP_FILE).
607
+ dropFile(target) {
608
+ let input = document.getElementById("_cuprite_drop_file");
609
+ let files = input.files;
610
+ let dt = new DataTransfer();
611
+ let opts = { cancelable: true, bubbles: true, dataTransfer: dt };
612
+ input.parentElement.removeChild(input);
613
+ if (dt.items) {
614
+ for (let i = 0; i < files.length; i++) {
615
+ dt.items.add(files[i]);
616
+ }
617
+ } else {
618
+ Object.defineProperty(dt, "files", { value: files, writable: false });
619
+ }
620
+ target.dispatchEvent(new DragEvent("drop", opts));
621
+ }
622
+
521
623
  // This command is purely for testing error handling
522
624
  browserError() {
523
625
  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?
@@ -160,10 +181,14 @@ module Capybara
160
181
  end
161
182
 
162
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
+
163
186
  options[:steps] ||= 1
164
187
  options[:scroll] = true unless options.key?(:scroll)
188
+ options[:drop_offset] = drop_offset(*options.values_at(:x, :y, :offset))
189
+ modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) }
165
190
 
166
- command(:drag, other.node, options[:steps], options[:delay], options[:scroll])
191
+ command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5, :drop_offset))
167
192
  end
168
193
 
169
194
  def drag_by(x, y, **options)
@@ -173,6 +198,8 @@ module Capybara
173
198
  command(:drag_by, x, y, options[:steps], options[:delay], options[:scroll])
174
199
  end
175
200
 
201
+ def drop(...) = command(:drop, ...)
202
+
176
203
  def trigger(event)
177
204
  command(:trigger, event)
178
205
  end
@@ -205,6 +232,9 @@ module Capybara
205
232
  end
206
233
 
207
234
  def send_keys(*keys)
235
+ keys = keys.reject { |key| key.nil? || key == "" }
236
+ return if keys.empty?
237
+
208
238
  command(:send_keys, keys)
209
239
  end
210
240
  alias send_key send_keys
@@ -217,6 +247,19 @@ module Capybara
217
247
  command(:obscured?)
218
248
  end
219
249
 
250
+ def shadow_root
251
+ root = driver.evaluate_script <<~JS, self
252
+ arguments[0].shadowRoot
253
+ JS
254
+ root && self.class.new(driver, root.node)
255
+ end
256
+
257
+ def rect
258
+ driver.evaluate_script <<~JS, self
259
+ arguments[0].getBoundingClientRect().toJSON()
260
+ JS
261
+ end
262
+
220
263
  def inspect
221
264
  %(#<#{self.class} @node=#{@node.inspect}>)
222
265
  end
@@ -242,6 +285,13 @@ module Capybara
242
285
  node.click(mode: mode, keys: keys, offset: offset, delay: delay)
243
286
  end
244
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
+
245
295
  def filter_text(text)
246
296
  text.gsub(/[\u200b\u200e\u200f]/, "")
247
297
  .gsub(/[\ \n\f\t\v\u2028\u2029]+/, " ")
@@ -4,7 +4,26 @@ 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
+
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
8
27
 
9
28
  def reset_window_size
10
29
  @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,6 +25,26 @@ module Capybara
24
25
  @accept_modal = []
25
26
  @modal_messages = []
26
27
  @modal_response = nil
28
+ @unhandled_modal_error = nil
29
+ super
30
+ end
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
+
47
+ def set_viewport(width: 0, height: 0, scale_factor: @options.scale_factor, mobile: @options.mobile)
27
48
  super
28
49
  end
29
50
 
@@ -49,7 +70,7 @@ module Capybara
49
70
  end
50
71
 
51
72
  def send_keys(node, keys)
52
- unless evaluate_on(node: node, expression: %(_cuprite.containsSelection(this)))
73
+ unless evaluate_on(node: node, expression: %(_cuprite.containsSelection(this) || _cuprite.hasFocus(this)))
53
74
  before_click(node, "click")
54
75
  node.click(mode: :left, keys: keys)
55
76
  end
@@ -99,6 +120,7 @@ module Capybara
99
120
  @accept_modal = []
100
121
  @modal_response = nil
101
122
  @modal_messages = []
123
+ @unhandled_modal_error = nil
102
124
  end
103
125
 
104
126
  def before_click(node, name, _keys = [], offset = {})
@@ -140,11 +162,24 @@ module Capybara
140
162
 
141
163
  private
142
164
 
165
+ def raise_pending_unhandled_modal_error!
166
+ error = @unhandled_modal_error
167
+ @unhandled_modal_error = nil
168
+ raise error if error
169
+
170
+ yield
171
+ ensure
172
+ error = @unhandled_modal_error
173
+ @unhandled_modal_error = nil
174
+ raise error if error
175
+ end
176
+
143
177
  def prepare_page
144
178
  super
145
179
 
146
180
  width, height = @options.window_size
147
- 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
148
183
 
149
184
  if @options.url_blacklist.any?
150
185
  network.blacklist = @options.url_blacklist
@@ -154,6 +189,8 @@ module Capybara
154
189
 
155
190
  on("Page.javascriptDialogOpening") do |params|
156
191
  accept_modal = @accept_modal.last
192
+ unhandled_modal_error = nil
193
+
157
194
  if [true, false].include?(accept_modal)
158
195
  @accept_modal.pop
159
196
  @modal_messages << params["message"]
@@ -161,15 +198,24 @@ module Capybara
161
198
  response = @modal_response || params["defaultPrompt"]
162
199
  else
163
200
  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"
201
+ message = "Modal window #{with_text}has been opened, but you didn't wrap " \
202
+ "your code into (`accept_prompt` | `dismiss_prompt` | " \
203
+ "`accept_confirm` | `dismiss_confirm` | `accept_alert`), " \
204
+ "accepting by default"
205
+
206
+ if @options.raise_on_unhandled_modal
207
+ unhandled_modal_error = UnhandledModalError.new(message)
208
+ else
209
+ warn message
210
+ end
211
+
168
212
  options = { accept: true }
169
213
  response = params["defaultPrompt"]
170
214
  end
171
215
  options.merge!(promptText: response) if response
172
- command("Page.handleJavaScriptDialog", **options)
216
+ ferrum_command("Page.handleJavaScriptDialog", **options)
217
+
218
+ @unhandled_modal_error = unhandled_modal_error if unhandled_modal_error
173
219
  end
174
220
  end
175
221
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Cuprite
5
- VERSION = "0.17"
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.17'
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: 2025-05-11 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.17.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.17.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,8 +51,10 @@ 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
57
+ - lib/capybara/cuprite/javascripts/drag.js
56
58
  - lib/capybara/cuprite/javascripts/index.js
57
59
  - lib/capybara/cuprite/node.rb
58
60
  - lib/capybara/cuprite/options.rb
@@ -75,7 +77,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
75
77
  requirements:
76
78
  - - ">="
77
79
  - !ruby/object:Gem::Version
78
- version: 2.7.0
80
+ version: '3.1'
79
81
  required_rubygems_version: !ruby/object:Gem::Requirement
80
82
  requirements:
81
83
  - - ">="