ferrum 0.17.2 → 0.18.0
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 +1 -1
- data/lib/ferrum/accessibility/ax_node.rb +108 -0
- data/lib/ferrum/accessibility.rb +106 -0
- data/lib/ferrum/browser/binary.rb +41 -0
- data/lib/ferrum/browser/command.rb +20 -0
- data/lib/ferrum/browser/options/base.rb +67 -0
- data/lib/ferrum/browser/options/chrome.rb +36 -1
- data/lib/ferrum/browser/options/firefox.rb +32 -0
- data/lib/ferrum/browser/options.rb +41 -2
- data/lib/ferrum/browser/process.rb +51 -0
- data/lib/ferrum/browser/xvfb.rb +24 -0
- data/lib/ferrum/browser.rb +32 -10
- data/lib/ferrum/client/subscriber.rb +58 -0
- data/lib/ferrum/client/web_socket.rb +62 -11
- data/lib/ferrum/client.rb +199 -8
- data/lib/ferrum/context.rb +97 -4
- data/lib/ferrum/contexts.rb +119 -9
- data/lib/ferrum/cookies/cookie.rb +6 -0
- data/lib/ferrum/cookies.rb +5 -0
- data/lib/ferrum/dialog.rb +18 -2
- data/lib/ferrum/downloads.rb +52 -0
- data/lib/ferrum/errors.rb +58 -3
- data/lib/ferrum/frame/dom.rb +17 -0
- data/lib/ferrum/frame/runtime.rb +48 -7
- data/lib/ferrum/frame.rb +58 -1
- data/lib/ferrum/headers.rb +6 -0
- data/lib/ferrum/interceptable.rb +62 -0
- data/lib/ferrum/keyboard.rb +25 -0
- data/lib/ferrum/mouse.rb +6 -0
- data/lib/ferrum/network/auth_request.rb +81 -2
- data/lib/ferrum/network/error.rb +15 -0
- data/lib/ferrum/network/exchange.rb +10 -0
- data/lib/ferrum/network/intercepted_request.rb +94 -2
- data/lib/ferrum/network/request.rb +1 -1
- data/lib/ferrum/network/response.rb +2 -0
- data/lib/ferrum/network.rb +121 -9
- data/lib/ferrum/node.rb +305 -15
- data/lib/ferrum/page/animation.rb +5 -0
- data/lib/ferrum/page/frames.rb +69 -7
- data/lib/ferrum/page/screencast.rb +5 -0
- data/lib/ferrum/page/screenshot.rb +52 -20
- data/lib/ferrum/page/stream.rb +56 -0
- data/lib/ferrum/page/tracing.rb +6 -0
- data/lib/ferrum/page.rb +139 -42
- data/lib/ferrum/proxy.rb +52 -2
- data/lib/ferrum/rgba.rb +10 -0
- data/lib/ferrum/target.rb +124 -1
- data/lib/ferrum/utils/attempt.rb +20 -0
- data/lib/ferrum/utils/elapsed_time.rb +38 -0
- data/lib/ferrum/utils/event.rb +14 -0
- data/lib/ferrum/utils/platform.rb +21 -0
- data/lib/ferrum/utils/thread.rb +12 -0
- data/lib/ferrum/version.rb +1 -1
- data/lib/ferrum/worker.rb +125 -0
- data/lib/ferrum.rb +7 -0
- metadata +6 -16
data/lib/ferrum/network.rb
CHANGED
|
@@ -8,6 +8,13 @@ require "ferrum/network/request"
|
|
|
8
8
|
require "ferrum/network/response"
|
|
9
9
|
|
|
10
10
|
module Ferrum
|
|
11
|
+
#
|
|
12
|
+
# Tracks a page's network activity, exposing it as a list of {#traffic}
|
|
13
|
+
# {Network::Exchange}s built from the underlying CDP `Network.*` events.
|
|
14
|
+
# Also provides request interception/authorization (`intercept`,
|
|
15
|
+
# `authorize`, `blacklist=`/`whitelist=`) and network condition emulation
|
|
16
|
+
# (`emulate_network_conditions`, `offline_mode`).
|
|
17
|
+
#
|
|
11
18
|
class Network
|
|
12
19
|
CLEAR_TYPE = %i[traffic cache].freeze
|
|
13
20
|
AUTHORIZE_TYPE = %i[server proxy].freeze
|
|
@@ -38,6 +45,7 @@ module Ferrum
|
|
|
38
45
|
@exchange = nil
|
|
39
46
|
@blacklist = nil
|
|
40
47
|
@whitelist = nil
|
|
48
|
+
@mutex = Mutex.new
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
#
|
|
@@ -81,18 +89,45 @@ module Ferrum
|
|
|
81
89
|
raise TimeoutError unless result
|
|
82
90
|
end
|
|
83
91
|
|
|
92
|
+
#
|
|
93
|
+
# Whether the network is idle, i.e. no more than `connections`
|
|
94
|
+
# connections are still pending.
|
|
95
|
+
#
|
|
96
|
+
# @param [Integer] connections
|
|
97
|
+
# How many connections are allowed for network to be idling.
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
#
|
|
84
101
|
def idle?(connections = 0)
|
|
85
102
|
pending_connections <= connections
|
|
86
103
|
end
|
|
87
104
|
|
|
105
|
+
#
|
|
106
|
+
# Total number of network connections seen since the traffic was last
|
|
107
|
+
# cleared.
|
|
108
|
+
#
|
|
109
|
+
# @return [Integer]
|
|
110
|
+
#
|
|
88
111
|
def total_connections
|
|
89
112
|
@traffic.size
|
|
90
113
|
end
|
|
91
114
|
|
|
115
|
+
#
|
|
116
|
+
# Number of network connections that have finished, i.e. were blocked, got
|
|
117
|
+
# a loaded response, errored, or are otherwise no longer pending.
|
|
118
|
+
#
|
|
119
|
+
# @return [Integer]
|
|
120
|
+
#
|
|
92
121
|
def finished_connections
|
|
93
122
|
@traffic.count(&:finished?)
|
|
94
123
|
end
|
|
95
124
|
|
|
125
|
+
#
|
|
126
|
+
# Number of network connections that are still pending, i.e. haven't
|
|
127
|
+
# finished yet.
|
|
128
|
+
#
|
|
129
|
+
# @return [Integer]
|
|
130
|
+
#
|
|
96
131
|
def pending_connections
|
|
97
132
|
total_connections - finished_connections
|
|
98
133
|
end
|
|
@@ -164,12 +199,38 @@ module Ferrum
|
|
|
164
199
|
true
|
|
165
200
|
end
|
|
166
201
|
|
|
202
|
+
#
|
|
203
|
+
# Sets a list of patterns for URLs that should be blocked from loading.
|
|
204
|
+
# Aborts any request whose URL matches one of the given patterns, and
|
|
205
|
+
# continues all others. Can't be used together with `whitelist=`.
|
|
206
|
+
#
|
|
207
|
+
# @param [String, Regexp, Array<String, Regexp>] patterns
|
|
208
|
+
# One or more patterns matched against the request's URL, see
|
|
209
|
+
# {InterceptedRequest#match?}.
|
|
210
|
+
#
|
|
211
|
+
# @example
|
|
212
|
+
# browser.network.blacklist = /jquery/
|
|
213
|
+
# browser.go_to("https://example.com/")
|
|
214
|
+
#
|
|
167
215
|
def blacklist=(patterns)
|
|
168
216
|
@blacklist = Array(patterns)
|
|
169
217
|
blacklist_subscribe
|
|
170
218
|
end
|
|
171
219
|
alias blocklist= blacklist=
|
|
172
220
|
|
|
221
|
+
#
|
|
222
|
+
# Sets a list of patterns for URLs that are the only ones allowed to load.
|
|
223
|
+
# Continues any request whose URL matches one of the given patterns, and
|
|
224
|
+
# aborts all others. Can't be used together with `blacklist=`.
|
|
225
|
+
#
|
|
226
|
+
# @param [String, Regexp, Array<String, Regexp>] patterns
|
|
227
|
+
# One or more patterns matched against the request's URL, see
|
|
228
|
+
# {InterceptedRequest#match?}.
|
|
229
|
+
#
|
|
230
|
+
# @example
|
|
231
|
+
# browser.network.whitelist = /example/
|
|
232
|
+
# browser.go_to("https://example.com/")
|
|
233
|
+
#
|
|
173
234
|
def whitelist=(patterns)
|
|
174
235
|
@whitelist = Array(patterns)
|
|
175
236
|
whitelist_subscribe
|
|
@@ -268,6 +329,11 @@ module Ferrum
|
|
|
268
329
|
end
|
|
269
330
|
end
|
|
270
331
|
|
|
332
|
+
#
|
|
333
|
+
# Subscribes to the CDP events needed to keep track of `traffic`. Called
|
|
334
|
+
# once when the page is initialized.
|
|
335
|
+
#
|
|
336
|
+
# @api private
|
|
271
337
|
def subscribe
|
|
272
338
|
subscribe_request_will_be_sent
|
|
273
339
|
subscribe_response_received
|
|
@@ -276,6 +342,23 @@ module Ferrum
|
|
|
276
342
|
subscribe_log_entry_added
|
|
277
343
|
end
|
|
278
344
|
|
|
345
|
+
#
|
|
346
|
+
# Builds the `authChallengeResponse` sent back to Chrome for an
|
|
347
|
+
# authenticated request, used by `authorize`.
|
|
348
|
+
#
|
|
349
|
+
# @param [Array<String>] ids
|
|
350
|
+
# Request ids that were already given credentials, to avoid an infinite
|
|
351
|
+
# retry loop if the credentials are rejected.
|
|
352
|
+
#
|
|
353
|
+
# @param [String] request_id
|
|
354
|
+
#
|
|
355
|
+
# @param [String, nil] username
|
|
356
|
+
#
|
|
357
|
+
# @param [String, nil] password
|
|
358
|
+
#
|
|
359
|
+
# @return [Hash, nil]
|
|
360
|
+
#
|
|
361
|
+
# @api private
|
|
279
362
|
def authorized_response(ids, request_id, username, password)
|
|
280
363
|
if ids.include?(request_id)
|
|
281
364
|
{ response: "CancelAuth" }
|
|
@@ -286,14 +369,42 @@ module Ferrum
|
|
|
286
369
|
end
|
|
287
370
|
end
|
|
288
371
|
|
|
372
|
+
#
|
|
373
|
+
# Finds the exchanges in `traffic` with the given request id.
|
|
374
|
+
#
|
|
375
|
+
# @param [String] request_id
|
|
376
|
+
#
|
|
377
|
+
# @return [Array<Exchange>]
|
|
378
|
+
#
|
|
379
|
+
# @api private
|
|
289
380
|
def select(request_id)
|
|
290
381
|
@traffic.select { |e| e.id == request_id }
|
|
291
382
|
end
|
|
292
383
|
|
|
384
|
+
#
|
|
385
|
+
# Builds a new {Exchange} for the given request id and appends it to
|
|
386
|
+
# `traffic`.
|
|
387
|
+
#
|
|
388
|
+
# @param [String] id
|
|
389
|
+
#
|
|
390
|
+
# @return [Exchange]
|
|
391
|
+
#
|
|
392
|
+
# @api private
|
|
293
393
|
def build_exchange(id)
|
|
294
394
|
Network::Exchange.new(@page, id).tap { |e| @traffic << e }
|
|
295
395
|
end
|
|
296
396
|
|
|
397
|
+
# `Network.requestWillBeSent` and `Fetch.requestPaused` are handled on
|
|
398
|
+
# separate threads (see `Client::Subscriber`), so the "find the existing
|
|
399
|
+
# exchange for this id or build a new one" check has to be atomic,
|
|
400
|
+
# otherwise both threads can race past the `select` before either has
|
|
401
|
+
# appended, and end up building two exchanges for the same request.
|
|
402
|
+
#
|
|
403
|
+
# @api private
|
|
404
|
+
def find_or_build_exchange(id)
|
|
405
|
+
@mutex.synchronize { select(id).last || build_exchange(id) }
|
|
406
|
+
end
|
|
407
|
+
|
|
297
408
|
#
|
|
298
409
|
# Activates emulation of network conditions.
|
|
299
410
|
#
|
|
@@ -380,24 +491,25 @@ module Ferrum
|
|
|
380
491
|
|
|
381
492
|
# We can build exchange in two places, here on the event or when request
|
|
382
493
|
# is interrupted. So we have to be careful when to create new one. We
|
|
383
|
-
# create new exchange only if there's no with such id or there's, but
|
|
384
|
-
# it's filled with request which means this one is new but has response
|
|
385
|
-
# for a redirect. So we assign response from the params to previous
|
|
386
|
-
# exchange and build new exchange to assign this request to it.
|
|
387
|
-
exchange =
|
|
388
|
-
|
|
494
|
+
# create a new exchange only if there's no with such an id or there's, but
|
|
495
|
+
# it's filled with request which means this one is new but has a response
|
|
496
|
+
# for a redirect. So we assign a response from the params to the previous
|
|
497
|
+
# exchange and build a new exchange to assign this request to it.
|
|
498
|
+
exchange = @mutex.synchronize do
|
|
499
|
+
ex = select(request.id).last
|
|
500
|
+
ex.nil? || !ex.blank? ? build_exchange(request.id) : ex
|
|
501
|
+
end
|
|
389
502
|
request.headers.merge!(Hash(exchange.request_extra_info&.dig("headers")))
|
|
390
503
|
exchange.request = request
|
|
391
504
|
|
|
392
|
-
if exchange.navigation_request?(@page.main_frame
|
|
505
|
+
if exchange.navigation_request?(@page.main_frame&.id)
|
|
393
506
|
@exchange = exchange
|
|
394
507
|
classify_pending_exchanges(exchange.loader_id)
|
|
395
508
|
end
|
|
396
509
|
end
|
|
397
510
|
|
|
398
511
|
@page.on("Network.requestWillBeSentExtraInfo") do |params|
|
|
399
|
-
exchange =
|
|
400
|
-
exchange ||= build_exchange(params["requestId"])
|
|
512
|
+
exchange = find_or_build_exchange(params["requestId"])
|
|
401
513
|
exchange.request_extra_info = params
|
|
402
514
|
exchange.request&.headers&.merge!(params["headers"])
|
|
403
515
|
end
|
data/lib/ferrum/node.rb
CHANGED
|
@@ -1,36 +1,92 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
|
+
#
|
|
5
|
+
# Represents a DOM node (an element or a text node) found on a {Page} or
|
|
6
|
+
# within a {Frame}. Provides methods to inspect it (`text`, `property`,
|
|
7
|
+
# `attribute`), interact with it (`click`, `focus`, `type`, `select`) and
|
|
8
|
+
# search within it (`at_css`, `at_xpath`, `css`, `xpath`).
|
|
9
|
+
#
|
|
10
|
+
# @note Node identity is tied to the target it was found on; a `Node`
|
|
11
|
+
# fetched before a navigation cannot be used afterwards.
|
|
12
|
+
#
|
|
4
13
|
class Node
|
|
5
14
|
MOVING_WAIT_DELAY = ENV.fetch("FERRUM_NODE_MOVING_WAIT", 0.01).to_f
|
|
6
15
|
MOVING_WAIT_ATTEMPTS = ENV.fetch("FERRUM_NODE_MOVING_ATTEMPTS", 50).to_i
|
|
7
16
|
|
|
8
|
-
attr_reader :page, :target_id, :
|
|
17
|
+
attr_reader :page, :target_id, :description, :tag_name
|
|
9
18
|
|
|
10
|
-
def initialize(frame, target_id,
|
|
19
|
+
def initialize(frame, target_id, description, object_id: nil, node_id: nil)
|
|
11
20
|
@page = frame.page
|
|
12
21
|
@target_id = target_id
|
|
13
|
-
@node_id = node_id
|
|
14
22
|
@description = description
|
|
15
23
|
@tag_name = description["nodeName"].downcase
|
|
24
|
+
@object_id = object_id
|
|
25
|
+
@node_id = node_id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Frontend node id is resolved lazily, on first actual need (focus, click, scroll_into_view, etc.)
|
|
29
|
+
# We can try to subscribe to `DOM.childNodeRemoved` and `DOM.childNodeInserted` in the future
|
|
30
|
+
# to keep track of nodes.
|
|
31
|
+
def node_id
|
|
32
|
+
@node_id ||= begin
|
|
33
|
+
id = page.command("DOM.requestNode", objectId: @object_id)["nodeId"]
|
|
34
|
+
raise NodeNotFoundError, "node is not trackable" if id.zero?
|
|
35
|
+
|
|
36
|
+
id
|
|
37
|
+
rescue NoExecutionContextError
|
|
38
|
+
raise NodeNotFoundError, "node is not trackable"
|
|
39
|
+
end
|
|
16
40
|
end
|
|
17
41
|
|
|
42
|
+
# Whether this is an element node, as opposed to e.g. a text node.
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
18
45
|
def node?
|
|
19
46
|
description["nodeType"] == 1 # nodeType: 3, nodeName: "#text" e.g.
|
|
20
47
|
end
|
|
21
48
|
|
|
49
|
+
#
|
|
50
|
+
# The id of the frame this node belongs to.
|
|
51
|
+
#
|
|
52
|
+
# @return [String]
|
|
53
|
+
#
|
|
22
54
|
def frame_id
|
|
23
55
|
description["frameId"]
|
|
24
56
|
end
|
|
25
57
|
|
|
58
|
+
#
|
|
59
|
+
# The {Frame} this node belongs to. Keep using finder methods (`at_css`,
|
|
60
|
+
# `at_xpath`, etc.) on it to search within that frame, e.g. inside an
|
|
61
|
+
# `iframe`.
|
|
62
|
+
#
|
|
63
|
+
# @return [Frame, nil]
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# frame = page.at_xpath("//iframe").frame # => Frame
|
|
67
|
+
# frame.at_css("//a[text() = 'Log in']") # => Node
|
|
68
|
+
#
|
|
26
69
|
def frame
|
|
27
70
|
page.frame_by(id: frame_id)
|
|
28
71
|
end
|
|
29
72
|
|
|
73
|
+
#
|
|
74
|
+
# Focuses the node.
|
|
75
|
+
#
|
|
76
|
+
# @return [self]
|
|
77
|
+
#
|
|
78
|
+
# @example
|
|
79
|
+
# input = page.at_css("input[name='q']")
|
|
80
|
+
# input.focus
|
|
81
|
+
#
|
|
30
82
|
def focus
|
|
31
83
|
tap { page.command("DOM.focus", slowmoable: true, nodeId: node_id) }
|
|
32
84
|
end
|
|
33
85
|
|
|
86
|
+
# Whether the node can receive focus. Attempts to {#focus} the node to
|
|
87
|
+
# find out.
|
|
88
|
+
#
|
|
89
|
+
# @return [Boolean]
|
|
34
90
|
def focusable?
|
|
35
91
|
focus
|
|
36
92
|
true
|
|
@@ -38,6 +94,20 @@ module Ferrum
|
|
|
38
94
|
e.message == "Element is not focusable" ? false : raise
|
|
39
95
|
end
|
|
40
96
|
|
|
97
|
+
#
|
|
98
|
+
# Waits until the node's position stops changing, retrying up to
|
|
99
|
+
# `attempts` times. Raises {NodeMovingError} if the node is still moving
|
|
100
|
+
# after the last attempt.
|
|
101
|
+
#
|
|
102
|
+
# @param [Float] delay
|
|
103
|
+
# Seconds to wait between two position checks.
|
|
104
|
+
#
|
|
105
|
+
# @param [Integer] attempts
|
|
106
|
+
# Maximum number of attempts before raising.
|
|
107
|
+
#
|
|
108
|
+
# @return [Array]
|
|
109
|
+
# The content quads of the node once it has stopped moving.
|
|
110
|
+
#
|
|
41
111
|
def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS)
|
|
42
112
|
Utils::Attempt.with_retry(errors: NodeMovingError, max: attempts, wait: 0) do
|
|
43
113
|
previous, current = content_quads_with(delay: delay)
|
|
@@ -47,15 +117,39 @@ module Ferrum
|
|
|
47
117
|
end
|
|
48
118
|
end
|
|
49
119
|
|
|
120
|
+
# Checks whether the node's position has stopped changing, by comparing
|
|
121
|
+
# two content-quad snapshots taken `delay` seconds apart.
|
|
122
|
+
#
|
|
123
|
+
# @param [Float] delay
|
|
124
|
+
# Seconds to wait between the two position checks.
|
|
125
|
+
#
|
|
126
|
+
# @return [Boolean]
|
|
50
127
|
def moving?(delay: MOVING_WAIT_DELAY)
|
|
51
128
|
previous, current = content_quads_with(delay: delay)
|
|
52
129
|
previous == current
|
|
53
130
|
end
|
|
54
131
|
|
|
132
|
+
#
|
|
133
|
+
# Removes focus from the node.
|
|
134
|
+
#
|
|
135
|
+
# @return [self]
|
|
136
|
+
#
|
|
55
137
|
def blur
|
|
56
138
|
tap { evaluate("this.blur()") }
|
|
57
139
|
end
|
|
58
140
|
|
|
141
|
+
#
|
|
142
|
+
# Sends keystrokes to the currently focused element via the page's
|
|
143
|
+
# keyboard. Typically chained after {#focus} or `click`.
|
|
144
|
+
#
|
|
145
|
+
# @param [Array<String, Symbol, (Symbol, String)>] keys
|
|
146
|
+
# The keys to type, e.g. `"Input"`, `[:Shift, "s"], "tring"`.
|
|
147
|
+
#
|
|
148
|
+
# @return [self]
|
|
149
|
+
#
|
|
150
|
+
# @example
|
|
151
|
+
# input.focus.type("Input")
|
|
152
|
+
#
|
|
59
153
|
def type(*keys)
|
|
60
154
|
tap { page.keyboard.type(*keys) }
|
|
61
155
|
end
|
|
@@ -67,32 +161,46 @@ module Ferrum
|
|
|
67
161
|
x, y = find_position(**offset)
|
|
68
162
|
modifiers = page.keyboard.modifiers(keys)
|
|
69
163
|
|
|
164
|
+
# `:right` and `:double` pass `wait: 0` to preserve the historical
|
|
165
|
+
# no-network-wait default of `Mouse#up` and `Mouse#down`
|
|
70
166
|
case mode
|
|
71
167
|
when :right
|
|
72
|
-
page.mouse.
|
|
73
|
-
page.mouse.down(button: :right, modifiers: modifiers)
|
|
74
|
-
sleep(delay)
|
|
75
|
-
page.mouse.up(button: :right, modifiers: modifiers)
|
|
168
|
+
page.mouse.click(x:, y:, modifiers:, delay:, button: :right, wait: 0)
|
|
76
169
|
when :double
|
|
77
|
-
page.mouse.
|
|
78
|
-
page.mouse.down(modifiers: modifiers, count: 2)
|
|
79
|
-
sleep(delay)
|
|
80
|
-
page.mouse.up(modifiers: modifiers, count: 2)
|
|
170
|
+
page.mouse.click(x:, y:, modifiers:, delay:, count: 2, wait: 0)
|
|
81
171
|
when :left
|
|
82
|
-
page.mouse.click(x
|
|
172
|
+
page.mouse.click(x:, y:, modifiers:, delay:)
|
|
83
173
|
end
|
|
84
174
|
|
|
85
175
|
self
|
|
86
176
|
end
|
|
87
177
|
|
|
178
|
+
# Not currently implemented.
|
|
179
|
+
#
|
|
180
|
+
# @raise [NotImplementedError] always
|
|
88
181
|
def hover
|
|
89
182
|
raise NotImplementedError
|
|
90
183
|
end
|
|
91
184
|
|
|
185
|
+
#
|
|
186
|
+
# Scrolls the node into view if it is not already visible.
|
|
187
|
+
#
|
|
188
|
+
# @return [self]
|
|
189
|
+
#
|
|
190
|
+
# @example
|
|
191
|
+
# page.at_css("#footer").scroll_into_view
|
|
192
|
+
#
|
|
92
193
|
def scroll_into_view
|
|
93
194
|
tap { page.command("DOM.scrollIntoViewIfNeeded", nodeId: node_id) }
|
|
94
195
|
end
|
|
95
196
|
|
|
197
|
+
# Whether the node's bounding rect is fully within the viewport (or,
|
|
198
|
+
# when `of:` is given, within that scoping element's bounds).
|
|
199
|
+
#
|
|
200
|
+
# @param [Node, nil] of
|
|
201
|
+
# An element to use as the visible bounds instead of the window.
|
|
202
|
+
#
|
|
203
|
+
# @return [Boolean]
|
|
96
204
|
def in_viewport?(of: nil)
|
|
97
205
|
function = <<~JS
|
|
98
206
|
function(element, scope) {
|
|
@@ -109,26 +217,94 @@ module Ferrum
|
|
|
109
217
|
page.evaluate_func(function, self, of)
|
|
110
218
|
end
|
|
111
219
|
|
|
220
|
+
#
|
|
221
|
+
# Sets files on a file input node.
|
|
222
|
+
#
|
|
223
|
+
# @param [String, Array<String>] value
|
|
224
|
+
# Path or paths to the file(s) to upload.
|
|
225
|
+
#
|
|
226
|
+
# @return [Hash{String => Object}]
|
|
227
|
+
#
|
|
228
|
+
# @example
|
|
229
|
+
# page.at_css("input[type=file]").select_file("/path/to/file.png")
|
|
230
|
+
#
|
|
112
231
|
def select_file(value)
|
|
113
|
-
page.command(
|
|
114
|
-
|
|
115
|
-
|
|
232
|
+
page.command(
|
|
233
|
+
"DOM.setFileInputFiles",
|
|
234
|
+
slowmoable: true,
|
|
235
|
+
backendNodeId: description["backendNodeId"],
|
|
236
|
+
files: Array(value)
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
#
|
|
241
|
+
# Finds a node by xpath, scoped to search within this node. Runs
|
|
242
|
+
# `document.evaluate` within this node.
|
|
243
|
+
#
|
|
244
|
+
# @param [String] selector
|
|
245
|
+
#
|
|
246
|
+
# @return [Node, nil]
|
|
247
|
+
#
|
|
248
|
+
# @example
|
|
249
|
+
# page.at_xpath("//iframe").at_xpath(".//a") # => Node
|
|
250
|
+
#
|
|
116
251
|
def at_xpath(selector)
|
|
117
252
|
page.at_xpath(selector, within: self)
|
|
118
253
|
end
|
|
119
254
|
|
|
255
|
+
#
|
|
256
|
+
# Finds a node by CSS selector, scoped to search within this node. Runs
|
|
257
|
+
# `querySelector` within this node.
|
|
258
|
+
#
|
|
259
|
+
# @param [String] selector
|
|
260
|
+
#
|
|
261
|
+
# @return [Node, nil]
|
|
262
|
+
#
|
|
263
|
+
# @example
|
|
264
|
+
# page.at_css("form").at_css("input[name='q']") # => Node
|
|
265
|
+
#
|
|
120
266
|
def at_css(selector)
|
|
121
267
|
page.at_css(selector, within: self)
|
|
122
268
|
end
|
|
123
269
|
|
|
270
|
+
#
|
|
271
|
+
# Finds nodes by xpath, scoped to search within this node. Runs
|
|
272
|
+
# `document.evaluate` within this node.
|
|
273
|
+
#
|
|
274
|
+
# @param [String] selector
|
|
275
|
+
#
|
|
276
|
+
# @return [Array<Node>]
|
|
277
|
+
#
|
|
278
|
+
# @example
|
|
279
|
+
# page.at_css("ul").xpath(".//li") # => [Node]
|
|
280
|
+
#
|
|
124
281
|
def xpath(selector)
|
|
125
282
|
page.xpath(selector, within: self)
|
|
126
283
|
end
|
|
127
284
|
|
|
285
|
+
#
|
|
286
|
+
# Finds nodes by CSS selector, scoped to search within this node. Runs
|
|
287
|
+
# `querySelectorAll` within this node.
|
|
288
|
+
#
|
|
289
|
+
# @param [String] selector
|
|
290
|
+
#
|
|
291
|
+
# @return [Array<Node>]
|
|
292
|
+
#
|
|
293
|
+
# @example
|
|
294
|
+
# page.at_css("ul").css("li") # => [Node]
|
|
295
|
+
#
|
|
128
296
|
def css(selector)
|
|
129
297
|
page.css(selector, within: self)
|
|
130
298
|
end
|
|
131
299
|
|
|
300
|
+
#
|
|
301
|
+
# The node's text content, i.e. `textContent`.
|
|
302
|
+
#
|
|
303
|
+
# @return [String]
|
|
304
|
+
#
|
|
305
|
+
# @example
|
|
306
|
+
# page.at_css("a > h3").text # => "rubycdp/ferrum: Ruby Chrome/Chromium driver - GitHub"
|
|
307
|
+
#
|
|
132
308
|
def text
|
|
133
309
|
evaluate("this.textContent")
|
|
134
310
|
end
|
|
@@ -138,19 +314,52 @@ module Ferrum
|
|
|
138
314
|
evaluate("this.innerText")
|
|
139
315
|
end
|
|
140
316
|
|
|
317
|
+
#
|
|
318
|
+
# The node's `value` property. Useful for form elements such as `input`,
|
|
319
|
+
# `select` and `textarea`.
|
|
320
|
+
#
|
|
321
|
+
# @return [Object]
|
|
322
|
+
#
|
|
141
323
|
def value
|
|
142
324
|
evaluate("this.value")
|
|
143
325
|
end
|
|
144
326
|
|
|
327
|
+
#
|
|
328
|
+
# Returns the given JavaScript property of the node.
|
|
329
|
+
#
|
|
330
|
+
# @param [String] name
|
|
331
|
+
#
|
|
332
|
+
# @return [Object]
|
|
333
|
+
#
|
|
334
|
+
# @example
|
|
335
|
+
# page.at_css("input").property("value") # => "Foo"
|
|
336
|
+
#
|
|
145
337
|
def property(name)
|
|
146
338
|
evaluate("this['#{name}']")
|
|
147
339
|
end
|
|
148
340
|
alias [] property
|
|
149
341
|
|
|
342
|
+
#
|
|
343
|
+
# Returns the value of the given HTML attribute, i.e.
|
|
344
|
+
# `getAttribute(name)`. Unlike {#property}, it reads the attribute as
|
|
345
|
+
# defined in markup rather than the live DOM property.
|
|
346
|
+
#
|
|
347
|
+
# @param [String] name
|
|
348
|
+
#
|
|
349
|
+
# @return [String, nil]
|
|
350
|
+
#
|
|
351
|
+
# @example
|
|
352
|
+
# page.at_css("input").attribute("value") # => "Foo"
|
|
353
|
+
#
|
|
150
354
|
def attribute(name)
|
|
151
355
|
evaluate("this.getAttribute('#{name}')")
|
|
152
356
|
end
|
|
153
357
|
|
|
358
|
+
#
|
|
359
|
+
# Returns the selected `option` nodes of a `select` element.
|
|
360
|
+
#
|
|
361
|
+
# @return [Array<Node>]
|
|
362
|
+
#
|
|
154
363
|
def selected
|
|
155
364
|
function = <<~JS
|
|
156
365
|
function(element) {
|
|
@@ -163,6 +372,29 @@ module Ferrum
|
|
|
163
372
|
page.evaluate_func(function, self, on: self)
|
|
164
373
|
end
|
|
165
374
|
|
|
375
|
+
#
|
|
376
|
+
# (chainable) Selects options of a `select` element by the given
|
|
377
|
+
# attribute.
|
|
378
|
+
#
|
|
379
|
+
# @param [Array<String>] values
|
|
380
|
+
# The value(s) to select. Accepts a string, multiple strings, or an
|
|
381
|
+
# array of strings.
|
|
382
|
+
#
|
|
383
|
+
# @param [Symbol] by
|
|
384
|
+
# The `option` attribute to match `values` against, e.g. `:value` or
|
|
385
|
+
# `:text`.
|
|
386
|
+
#
|
|
387
|
+
# @return [self]
|
|
388
|
+
#
|
|
389
|
+
# @example
|
|
390
|
+
# page.at_xpath("//*[select]").select(["1"]) # => Node (select)
|
|
391
|
+
# page.at_xpath("//*[select]").select(["text"], by: :text) # => Node (select)
|
|
392
|
+
#
|
|
393
|
+
# @example Accepts a string, multiple strings or an array of strings:
|
|
394
|
+
# page.at_xpath("//*[select]").select("1")
|
|
395
|
+
# page.at_xpath("//*[select]").select("1", "2")
|
|
396
|
+
# page.at_xpath("//*[select]").select(["1", "2"])
|
|
397
|
+
#
|
|
166
398
|
def select(*values, by: :value)
|
|
167
399
|
tap do
|
|
168
400
|
function = <<~JS
|
|
@@ -184,10 +416,29 @@ module Ferrum
|
|
|
184
416
|
end
|
|
185
417
|
end
|
|
186
418
|
|
|
419
|
+
#
|
|
420
|
+
# Evaluates the given JavaScript expression with `this` bound to the
|
|
421
|
+
# node.
|
|
422
|
+
#
|
|
423
|
+
# @param [String] expression
|
|
424
|
+
#
|
|
425
|
+
# @return [Object]
|
|
426
|
+
#
|
|
427
|
+
# @example
|
|
428
|
+
# page.at_css("input").evaluate("this.value")
|
|
429
|
+
#
|
|
187
430
|
def evaluate(expression)
|
|
188
431
|
page.evaluate_on(node: self, expression: expression)
|
|
189
432
|
end
|
|
190
433
|
|
|
434
|
+
#
|
|
435
|
+
# Two nodes are equal when they belong to the same target and share the
|
|
436
|
+
# same backend node id.
|
|
437
|
+
#
|
|
438
|
+
# @param [Object] other
|
|
439
|
+
#
|
|
440
|
+
# @return [Boolean]
|
|
441
|
+
#
|
|
191
442
|
def ==(other)
|
|
192
443
|
return false unless other.is_a?(Node)
|
|
193
444
|
|
|
@@ -197,10 +448,30 @@ module Ferrum
|
|
|
197
448
|
target_id == other.target_id && description["backendNodeId"] == other.description["backendNodeId"]
|
|
198
449
|
end
|
|
199
450
|
|
|
451
|
+
#
|
|
452
|
+
# A developer-friendly string representation of the node.
|
|
453
|
+
#
|
|
454
|
+
# @return [String]
|
|
455
|
+
#
|
|
200
456
|
def inspect
|
|
201
457
|
%(#<#{self.class} @target_id=#{@target_id.inspect} @node_id=#{@node_id} @description=#{@description.inspect}>)
|
|
202
458
|
end
|
|
203
459
|
|
|
460
|
+
#
|
|
461
|
+
# Finds the x, y coordinates to click or hover on the node.
|
|
462
|
+
#
|
|
463
|
+
# @param [Integer, nil] x
|
|
464
|
+
# Horizontal offset from the reference point.
|
|
465
|
+
#
|
|
466
|
+
# @param [Integer, nil] y
|
|
467
|
+
# Vertical offset from the reference point.
|
|
468
|
+
#
|
|
469
|
+
# @param [Symbol] position
|
|
470
|
+
# `:top` to offset from the node's top-left corner, `:center` to offset
|
|
471
|
+
# from its center.
|
|
472
|
+
#
|
|
473
|
+
# @return [(Integer, Integer)]
|
|
474
|
+
#
|
|
204
475
|
def find_position(x: nil, y: nil, position: :top)
|
|
205
476
|
points = wait_for_stop_moving.map { |q| to_points(q) }.first
|
|
206
477
|
get_position(points, x, y, position)
|
|
@@ -218,10 +489,29 @@ module Ferrum
|
|
|
218
489
|
.each_with_object({}) { |style, memo| memo.merge!(style["name"] => style["value"]) }
|
|
219
490
|
end
|
|
220
491
|
|
|
492
|
+
# Returns the computed accessibility node for the element, or nil if the
|
|
493
|
+
# element is ignored by the accessibility tree.
|
|
494
|
+
#
|
|
495
|
+
# @return [Accessibility::AXNode, nil]
|
|
496
|
+
def axnode
|
|
497
|
+
page.accessibility.node_for(self)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
#
|
|
501
|
+
# Removes the node from the DOM.
|
|
502
|
+
#
|
|
503
|
+
# @return [Hash{String => Object}]
|
|
504
|
+
#
|
|
505
|
+
# @example
|
|
506
|
+
# page.at_css("#ad").remove
|
|
507
|
+
#
|
|
221
508
|
def remove
|
|
222
509
|
page.command("DOM.removeNode", nodeId: node_id)
|
|
223
510
|
end
|
|
224
511
|
|
|
512
|
+
# Whether the node still exists in the DOM.
|
|
513
|
+
#
|
|
514
|
+
# @return [Boolean]
|
|
225
515
|
def exists?
|
|
226
516
|
page.command("DOM.resolveNode", nodeId: node_id)
|
|
227
517
|
true
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
4
|
class Page
|
|
5
|
+
#
|
|
6
|
+
# Controls the playback of CSS animations on the page via the CDP
|
|
7
|
+
# [Animation](https://chromedevtools.github.io/devtools-protocol/tot/Animation/)
|
|
8
|
+
# domain, allowing animations to be sped up, slowed down, or paused.
|
|
9
|
+
#
|
|
5
10
|
module Animation
|
|
6
11
|
#
|
|
7
12
|
# Returns playback rate for CSS animations, defaults to `1`.
|