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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  4. data/lib/ferrum/accessibility.rb +106 -0
  5. data/lib/ferrum/browser/binary.rb +41 -0
  6. data/lib/ferrum/browser/command.rb +20 -0
  7. data/lib/ferrum/browser/options/base.rb +67 -0
  8. data/lib/ferrum/browser/options/chrome.rb +36 -1
  9. data/lib/ferrum/browser/options/firefox.rb +32 -0
  10. data/lib/ferrum/browser/options.rb +41 -2
  11. data/lib/ferrum/browser/process.rb +51 -0
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +32 -10
  14. data/lib/ferrum/client/subscriber.rb +58 -0
  15. data/lib/ferrum/client/web_socket.rb +62 -11
  16. data/lib/ferrum/client.rb +199 -8
  17. data/lib/ferrum/context.rb +97 -4
  18. data/lib/ferrum/contexts.rb +119 -9
  19. data/lib/ferrum/cookies/cookie.rb +6 -0
  20. data/lib/ferrum/cookies.rb +5 -0
  21. data/lib/ferrum/dialog.rb +18 -2
  22. data/lib/ferrum/downloads.rb +52 -0
  23. data/lib/ferrum/errors.rb +58 -3
  24. data/lib/ferrum/frame/dom.rb +17 -0
  25. data/lib/ferrum/frame/runtime.rb +48 -7
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +62 -0
  29. data/lib/ferrum/keyboard.rb +25 -0
  30. data/lib/ferrum/mouse.rb +6 -0
  31. data/lib/ferrum/network/auth_request.rb +81 -2
  32. data/lib/ferrum/network/error.rb +15 -0
  33. data/lib/ferrum/network/exchange.rb +10 -0
  34. data/lib/ferrum/network/intercepted_request.rb +94 -2
  35. data/lib/ferrum/network/request.rb +1 -1
  36. data/lib/ferrum/network/response.rb +2 -0
  37. data/lib/ferrum/network.rb +121 -9
  38. data/lib/ferrum/node.rb +305 -15
  39. data/lib/ferrum/page/animation.rb +5 -0
  40. data/lib/ferrum/page/frames.rb +69 -7
  41. data/lib/ferrum/page/screencast.rb +5 -0
  42. data/lib/ferrum/page/screenshot.rb +52 -20
  43. data/lib/ferrum/page/stream.rb +56 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +139 -42
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +124 -1
  49. data/lib/ferrum/utils/attempt.rb +20 -0
  50. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  51. data/lib/ferrum/utils/event.rb +14 -0
  52. data/lib/ferrum/utils/platform.rb +21 -0
  53. data/lib/ferrum/utils/thread.rb +12 -0
  54. data/lib/ferrum/version.rb +1 -1
  55. data/lib/ferrum/worker.rb +125 -0
  56. data/lib/ferrum.rb +7 -0
  57. metadata +6 -16
@@ -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 = select(request.id).last
388
- exchange = build_exchange(request.id) if exchange.nil? || !exchange.blank?
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.id)
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 = select(params["requestId"]).last
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, :node_id, :description, :tag_name
17
+ attr_reader :page, :target_id, :description, :tag_name
9
18
 
10
- def initialize(frame, target_id, node_id, description)
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.move(x: x, y: y)
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.move(x: x, y: y)
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: x, y: y, modifiers: modifiers, delay: delay)
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("DOM.setFileInputFiles", slowmoable: true, nodeId: node_id, files: Array(value))
114
- end
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`.