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 +4 -4
- data/README.md +54 -3
- data/lib/capybara/cuprite/browser.rb +67 -11
- data/lib/capybara/cuprite/devices.rb +188 -0
- data/lib/capybara/cuprite/driver.rb +10 -2
- data/lib/capybara/cuprite/errors.rb +9 -0
- data/lib/capybara/cuprite/javascripts/drag.js +120 -0
- data/lib/capybara/cuprite/javascripts/index.js +124 -22
- data/lib/capybara/cuprite/node.rb +53 -3
- data/lib/capybara/cuprite/options.rb +20 -1
- data/lib/capybara/cuprite/page.rb +53 -7
- data/lib/capybara/cuprite/version.rb +1 -1
- data/lib/capybara/cuprite.rb +1 -0
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7fa19bf03d8a264421374bf813afb0f6c062e15e8aa92ca7c046d3a659122319
|
|
4
|
+
data.tar.gz: d12031c15dda9ad7e777f7b66a887c6979af5b4c3e27010083378af512f979d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
169
|
+
html5 = !evaluate_on(node: node, expression: "_cuprite.legacyDragCheck(this)") if html5.nil?
|
|
150
170
|
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
402
|
-
this.trigger(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/capybara/cuprite.rb
CHANGED
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
80
|
+
version: '3.1'
|
|
79
81
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
82
|
requirements:
|
|
81
83
|
- - ">="
|