ferrum 0.11 → 0.13

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +174 -30
  4. data/lib/ferrum/browser/binary.rb +46 -0
  5. data/lib/ferrum/browser/client.rb +17 -16
  6. data/lib/ferrum/browser/command.rb +10 -12
  7. data/lib/ferrum/browser/options/base.rb +2 -11
  8. data/lib/ferrum/browser/options/chrome.rb +29 -18
  9. data/lib/ferrum/browser/options/firefox.rb +13 -9
  10. data/lib/ferrum/browser/options.rb +84 -0
  11. data/lib/ferrum/browser/process.rb +45 -40
  12. data/lib/ferrum/browser/subscriber.rb +1 -3
  13. data/lib/ferrum/browser/version_info.rb +71 -0
  14. data/lib/ferrum/browser/web_socket.rb +9 -12
  15. data/lib/ferrum/browser/xvfb.rb +4 -8
  16. data/lib/ferrum/browser.rb +193 -47
  17. data/lib/ferrum/context.rb +9 -4
  18. data/lib/ferrum/contexts.rb +12 -10
  19. data/lib/ferrum/cookies/cookie.rb +126 -0
  20. data/lib/ferrum/cookies.rb +93 -55
  21. data/lib/ferrum/dialog.rb +30 -0
  22. data/lib/ferrum/errors.rb +115 -0
  23. data/lib/ferrum/frame/dom.rb +177 -0
  24. data/lib/ferrum/frame/runtime.rb +58 -75
  25. data/lib/ferrum/frame.rb +118 -23
  26. data/lib/ferrum/headers.rb +30 -2
  27. data/lib/ferrum/keyboard.rb +56 -13
  28. data/lib/ferrum/mouse.rb +92 -7
  29. data/lib/ferrum/network/auth_request.rb +7 -2
  30. data/lib/ferrum/network/exchange.rb +97 -12
  31. data/lib/ferrum/network/intercepted_request.rb +10 -8
  32. data/lib/ferrum/network/request.rb +69 -0
  33. data/lib/ferrum/network/response.rb +85 -3
  34. data/lib/ferrum/network.rb +285 -36
  35. data/lib/ferrum/node.rb +69 -23
  36. data/lib/ferrum/page/animation.rb +16 -1
  37. data/lib/ferrum/page/frames.rb +111 -30
  38. data/lib/ferrum/page/screenshot.rb +142 -65
  39. data/lib/ferrum/page/stream.rb +38 -0
  40. data/lib/ferrum/page/tracing.rb +97 -0
  41. data/lib/ferrum/page.rb +224 -60
  42. data/lib/ferrum/proxy.rb +147 -0
  43. data/lib/ferrum/{rbga.rb → rgba.rb} +4 -2
  44. data/lib/ferrum/target.rb +7 -4
  45. data/lib/ferrum/utils/attempt.rb +20 -0
  46. data/lib/ferrum/utils/elapsed_time.rb +27 -0
  47. data/lib/ferrum/utils/platform.rb +28 -0
  48. data/lib/ferrum/version.rb +1 -1
  49. data/lib/ferrum.rb +4 -146
  50. metadata +63 -51
@@ -14,20 +14,56 @@ module Ferrum
14
14
  RESOURCE_TYPES = %w[Document Stylesheet Image Media Font Script TextTrack
15
15
  XHR Fetch EventSource WebSocket Manifest
16
16
  SignedExchange Ping CSPViolationReport Other].freeze
17
-
17
+ AUTHORIZE_BLOCK_MISSING = "Block is missing, call `authorize(...) { |r| r.continue } " \
18
+ "or subscribe to `on(:request)` events before calling it"
19
+ AUTHORIZE_TYPE_WRONG = ":type should be in #{AUTHORIZE_TYPE}"
20
+ ALLOWED_CONNECTION_TYPE = %w[none cellular2g cellular3g cellular4g bluetooth ethernet wifi wimax other].freeze
21
+
22
+ # Network traffic.
23
+ #
24
+ # @return [Array<Exchange>]
25
+ # Returns all information about network traffic as {Exchange}
26
+ # instance which in general is a wrapper around `request`, `response` and
27
+ # `error`.
28
+ #
29
+ # @example
30
+ # browser.go_to("https://github.com/")
31
+ # browser.network.traffic # => [#<Ferrum::Network::Exchange, ...]
18
32
  attr_reader :traffic
19
33
 
20
34
  def initialize(page)
21
35
  @page = page
22
36
  @traffic = []
23
37
  @exchange = nil
38
+ @blacklist = nil
39
+ @whitelist = nil
24
40
  end
25
41
 
42
+ #
43
+ # Waits for network idle or raises {Ferrum::TimeoutError} error.
44
+ #
45
+ # @param [Integer] connections
46
+ # how many connections are allowed for network to be idling,
47
+ #
48
+ # @param [Float] duration
49
+ # Sleep for given amount of time and check again.
50
+ #
51
+ # @param [Float] timeout
52
+ # During what time we try to check idle.
53
+ #
54
+ # @raise [Ferrum::TimeoutError]
55
+ #
56
+ # @example
57
+ # browser.go_to("https://example.com/")
58
+ # browser.at_xpath("//a[text() = 'No UI changes button']").click
59
+ # browser.network.wait_for_idle
60
+ #
26
61
  def wait_for_idle(connections: 0, duration: 0.05, timeout: @page.browser.timeout)
27
- start = Ferrum.monotonic_time
62
+ start = Utils::ElapsedTime.monotonic_time
28
63
 
29
64
  until idle?(connections)
30
- raise TimeoutError if Ferrum.timeout?(start, timeout)
65
+ raise TimeoutError if Utils::ElapsedTime.timeout?(start, timeout)
66
+
31
67
  sleep(duration)
32
68
  end
33
69
  end
@@ -48,22 +84,63 @@ module Ferrum
48
84
  total_connections - finished_connections
49
85
  end
50
86
 
87
+ #
88
+ # Page request of the main frame.
89
+ #
90
+ # @return [Request]
91
+ #
92
+ # @example
93
+ # browser.go_to("https://github.com/")
94
+ # browser.network.request # => #<Ferrum::Network::Request...
95
+ #
51
96
  def request
52
97
  @exchange&.request
53
98
  end
54
99
 
100
+ #
101
+ # Page response of the main frame.
102
+ #
103
+ # @return [Response, nil]
104
+ #
105
+ # @example
106
+ # browser.go_to("https://github.com/")
107
+ # browser.network.response # => #<Ferrum::Network::Response...
108
+ #
55
109
  def response
56
110
  @exchange&.response
57
111
  end
58
112
 
113
+ #
114
+ # Contains the status code of the main page response (e.g., 200 for a
115
+ # success). This is just a shortcut for `response.status`.
116
+ #
117
+ # @return [Integer, nil]
118
+ #
119
+ # @example
120
+ # browser.go_to("https://github.com/")
121
+ # browser.network.status # => 200
122
+ #
59
123
  def status
60
124
  response&.status
61
125
  end
62
126
 
127
+ #
128
+ # Clear browser's cache or collected traffic.
129
+ #
130
+ # @param [:traffic, :cache] type
131
+ # The type of traffic to clear.
132
+ #
133
+ # @return [true]
134
+ #
135
+ # @example
136
+ # traffic = browser.network.traffic # => []
137
+ # browser.go_to("https://github.com/")
138
+ # traffic.size # => 51
139
+ # browser.network.clear(:traffic)
140
+ # traffic.size # => 0
141
+ #
63
142
  def clear(type)
64
- unless CLEAR_TYPE.include?(type)
65
- raise ArgumentError, ":type should be in #{CLEAR_TYPE}"
66
- end
143
+ raise ArgumentError, ":type should be in #{CLEAR_TYPE}" unless CLEAR_TYPE.include?(type)
67
144
 
68
145
  if type == :traffic
69
146
  @traffic.clear
@@ -74,23 +151,77 @@ module Ferrum
74
151
  true
75
152
  end
76
153
 
154
+ def blacklist=(patterns)
155
+ @blacklist = Array(patterns)
156
+ blacklist_subscribe
157
+ end
158
+ alias blocklist= blacklist=
159
+
160
+ def whitelist=(patterns)
161
+ @whitelist = Array(patterns)
162
+ whitelist_subscribe
163
+ end
164
+ alias allowlist= whitelist=
165
+
166
+ #
167
+ # Set request interception for given options. This method is only sets
168
+ # request interception, you should use `on` callback to catch requests and
169
+ # abort or continue them.
170
+ #
171
+ # @param [String] pattern
172
+ #
173
+ # @param [Symbol, nil] resource_type
174
+ # One of the [resource types](https://chromedevtools.github.io/devtools-protocol/tot/Network#type-ResourceType)
175
+ #
176
+ # @example
177
+ # browser = Ferrum::Browser.new
178
+ # browser.network.intercept
179
+ # browser.on(:request) do |request|
180
+ # if request.match?(/bla-bla/)
181
+ # request.abort
182
+ # elsif request.match?(/lorem/)
183
+ # request.respond(body: "Lorem ipsum")
184
+ # else
185
+ # request.continue
186
+ # end
187
+ # end
188
+ # browser.go_to("https://google.com")
189
+ #
77
190
  def intercept(pattern: "*", resource_type: nil)
78
191
  pattern = { urlPattern: pattern }
79
- if resource_type && RESOURCE_TYPES.include?(resource_type.to_s)
80
- pattern[:resourceType] = resource_type
81
- end
192
+ pattern[:resourceType] = resource_type if resource_type && RESOURCE_TYPES.include?(resource_type.to_s)
82
193
 
83
194
  @page.command("Fetch.enable", handleAuthRequests: true, patterns: [pattern])
84
195
  end
85
196
 
197
+ #
198
+ # Sets HTTP Basic-Auth credentials.
199
+ #
200
+ # @param [String] user
201
+ # The username to send.
202
+ #
203
+ # @param [String] password
204
+ # The password to send.
205
+ #
206
+ # @param [:server, :proxy] type
207
+ # Specifies whether the credentials are for a website or a proxy.
208
+ #
209
+ # @yield [request]
210
+ # The given block will be passed each authenticated request and can allow
211
+ # or deny the request.
212
+ #
213
+ # @yieldparam [Request] request
214
+ # An HTTP request.
215
+ #
216
+ # @example
217
+ # browser.network.authorize(user: "login", password: "pass") { |req| req.continue }
218
+ # browser.go_to("http://example.com/authenticated")
219
+ # puts browser.network.status # => 200
220
+ # puts browser.body # => Welcome, authenticated client
221
+ #
86
222
  def authorize(user:, password:, type: :server, &block)
87
- unless AUTHORIZE_TYPE.include?(type)
88
- raise ArgumentError, ":type should be in #{AUTHORIZE_TYPE}"
89
- end
90
-
91
- if !block_given? && !@page.subscribed?("Fetch.requestPaused")
92
- raise ArgumentError, "Block is missing, call `authorize(...) { |r| r.continue } or subscribe to `on(:request)` events before calling it"
93
- end
223
+ raise ArgumentError, AUTHORIZE_TYPE_WRONG unless AUTHORIZE_TYPE.include?(type)
224
+ raise ArgumentError, AUTHORIZE_BLOCK_MISSING if !block_given? && !@page.subscribed?("Fetch.requestPaused")
94
225
 
95
226
  @authorized_ids ||= {}
96
227
  @authorized_ids[type] ||= []
@@ -116,6 +247,92 @@ module Ferrum
116
247
  end
117
248
 
118
249
  def subscribe
250
+ subscribe_request_will_be_sent
251
+ subscribe_response_received
252
+ subscribe_loading_finished
253
+ subscribe_loading_failed
254
+ subscribe_log_entry_added
255
+ end
256
+
257
+ def authorized_response(ids, request_id, username, password)
258
+ if ids.include?(request_id)
259
+ { response: "CancelAuth" }
260
+ elsif username && password
261
+ { response: "ProvideCredentials",
262
+ username: username,
263
+ password: password }
264
+ end
265
+ end
266
+
267
+ def select(request_id)
268
+ @traffic.select { |e| e.id == request_id }
269
+ end
270
+
271
+ def build_exchange(id)
272
+ Network::Exchange.new(@page, id).tap { |e| @traffic << e }
273
+ end
274
+
275
+ #
276
+ # Activates emulation of network conditions.
277
+ #
278
+ # @param [Boolean] offline
279
+ # Emulate internet disconnection,
280
+ #
281
+ # @param [Integer] latency
282
+ # Minimum latency from request sent to response headers received (ms).
283
+ #
284
+ # @param [Integer] download_throughput
285
+ # Maximal aggregated download throughput (bytes/sec).
286
+ #
287
+ # @param [Integer] upload_throughput
288
+ # Maximal aggregated upload throughput (bytes/sec).
289
+ #
290
+ # @param [String, nil] connection_type
291
+ # Connection type if known:
292
+ # * `"none"`
293
+ # * `"cellular2g"`
294
+ # * `"cellular3g"`
295
+ # * `"cellular4g"`
296
+ # * `"bluetooth"`
297
+ # * `"ethernet"`
298
+ # * `"wifi"`
299
+ # * `"wimax"`
300
+ # * `"other"`
301
+ #
302
+ # @example
303
+ # browser.network.emulate_network_conditions(connection_type: "cellular2g")
304
+ # browser.go_to("https://github.com/")
305
+ #
306
+ def emulate_network_conditions(offline: false, latency: 0,
307
+ download_throughput: -1, upload_throughput: -1,
308
+ connection_type: nil)
309
+ params = {
310
+ offline: offline, latency: latency,
311
+ downloadThroughput: download_throughput,
312
+ uploadThroughput: upload_throughput
313
+ }
314
+
315
+ params[:connectionType] = connection_type if connection_type && ALLOWED_CONNECTION_TYPE.include?(connection_type)
316
+
317
+ @page.command("Network.emulateNetworkConditions", **params)
318
+ true
319
+ end
320
+
321
+ #
322
+ # Activates offline mode for a page.
323
+ #
324
+ # @example
325
+ # browser.network.offline_mode
326
+ # browser.go_to("https://github.com/") # => Ferrum::StatusError (Request to https://github.com/ failed to reach
327
+ # server, check DNS and server status)
328
+ #
329
+ def offline_mode
330
+ emulate_network_conditions(offline: true, latency: 0, download_throughput: 0, upload_throughput: 0)
331
+ end
332
+
333
+ private
334
+
335
+ def subscribe_request_will_be_sent
119
336
  @page.on("Network.requestWillBeSent") do |params|
120
337
  request = Network::Request.new(params)
121
338
 
@@ -140,25 +357,29 @@ module Ferrum
140
357
 
141
358
  exchange.request = request
142
359
 
143
- if exchange.navigation_request?(@page.main_frame.id)
144
- @exchange = exchange
145
- end
360
+ @exchange = exchange if exchange.navigation_request?(@page.main_frame.id)
146
361
  end
362
+ end
147
363
 
364
+ def subscribe_response_received
148
365
  @page.on("Network.responseReceived") do |params|
149
- if exchange = select(params["requestId"]).last
366
+ exchange = select(params["requestId"]).last
367
+
368
+ if exchange
150
369
  response = Network::Response.new(@page, params)
151
370
  exchange.response = response
152
371
  end
153
372
  end
373
+ end
154
374
 
375
+ def subscribe_loading_finished
155
376
  @page.on("Network.loadingFinished") do |params|
156
377
  exchange = select(params["requestId"]).last
157
- if exchange && exchange.response
158
- exchange.response.body_size = params["encodedDataLength"]
159
- end
378
+ exchange.response.body_size = params["encodedDataLength"] if exchange&.response
160
379
  end
380
+ end
161
381
 
382
+ def subscribe_loading_failed
162
383
  @page.on("Network.loadingFailed") do |params|
163
384
  exchange = select(params["requestId"]).last
164
385
  exchange.error ||= Network::Error.new
@@ -169,7 +390,9 @@ module Ferrum
169
390
  exchange.error.monotonic_time = params["timestamp"]
170
391
  exchange.error.canceled = params["canceled"]
171
392
  end
393
+ end
172
394
 
395
+ def subscribe_log_entry_added
173
396
  @page.on("Log.entryAdded") do |params|
174
397
  entry = params["entry"] || {}
175
398
  if entry["source"] == "network" && entry["level"] == "error"
@@ -184,24 +407,50 @@ module Ferrum
184
407
  end
185
408
  end
186
409
 
187
- def authorized_response(ids, request_id, username, password)
188
- if ids.include?(request_id)
189
- { response: "CancelAuth" }
190
- elsif username && password
191
- { response: "ProvideCredentials",
192
- username: username,
193
- password: password }
194
- else
195
- { response: "CancelAuth" }
410
+ def blacklist_subscribe
411
+ return unless blacklist?
412
+ raise ArgumentError, "You can't use blacklist along with whitelist" if whitelist?
413
+
414
+ @blacklist_subscribe ||= begin
415
+ intercept
416
+
417
+ @page.on(:request) do |request|
418
+ if @blacklist.any? { |p| request.match?(p) }
419
+ request.abort
420
+ else
421
+ request.continue
422
+ end
423
+ end
424
+
425
+ true
196
426
  end
197
427
  end
198
428
 
199
- def select(request_id)
200
- @traffic.select { |e| e.id == request_id }
429
+ def whitelist_subscribe
430
+ return unless whitelist?
431
+ raise ArgumentError, "You can't use whitelist along with blacklist" if blacklist?
432
+
433
+ @whitelist_subscribe ||= begin
434
+ intercept
435
+
436
+ @page.on(:request) do |request|
437
+ if @whitelist.any? { |p| request.match?(p) }
438
+ request.continue
439
+ else
440
+ request.abort
441
+ end
442
+ end
443
+
444
+ true
445
+ end
201
446
  end
202
447
 
203
- def build_exchange(id)
204
- Network::Exchange.new(@page, id).tap { |e| @traffic << e }
448
+ def blacklist?
449
+ Array(@blacklist).any?
450
+ end
451
+
452
+ def whitelist?
453
+ Array(@whitelist).any?
205
454
  end
206
455
  end
207
456
  end
data/lib/ferrum/node.rb CHANGED
@@ -10,7 +10,8 @@ module Ferrum
10
10
  def initialize(frame, target_id, node_id, description)
11
11
  @page = frame.page
12
12
  @target_id = target_id
13
- @node_id, @description = node_id, description
13
+ @node_id = node_id
14
+ @description = description
14
15
  @tag_name = description["nodeName"].downcase
15
16
  end
16
17
 
@@ -38,15 +39,16 @@ module Ferrum
38
39
  end
39
40
 
40
41
  def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS)
41
- Ferrum.with_attempts(errors: NodeMovingError, max: attempts, wait: 0) do
42
- previous, current = get_content_quads_with(delay: delay)
42
+ Utils::Attempt.with_retry(errors: NodeMovingError, max: attempts, wait: 0) do
43
+ previous, current = content_quads_with(delay: delay)
43
44
  raise NodeMovingError.new(self, previous, current) if previous != current
45
+
44
46
  current
45
47
  end
46
48
  end
47
49
 
48
50
  def moving?(delay: MOVING_WAIT_DELAY)
49
- previous, current = get_content_quads_with(delay: delay)
51
+ previous, current = content_quads_with(delay: delay)
50
52
  previous == current
51
53
  end
52
54
 
@@ -122,17 +124,52 @@ module Ferrum
122
124
  def property(name)
123
125
  evaluate("this['#{name}']")
124
126
  end
127
+ alias [] property
125
128
 
126
129
  def attribute(name)
127
130
  evaluate("this.getAttribute('#{name}')")
128
131
  end
129
132
 
133
+ def selected
134
+ function = <<~JS
135
+ function(element) {
136
+ if (element.nodeName.toLowerCase() !== 'select') {
137
+ throw new Error('Element is not a <select> element.');
138
+ }
139
+ return Array.from(element).filter(option => option.selected);
140
+ }
141
+ JS
142
+ page.evaluate_func(function, self, on: self)
143
+ end
144
+
145
+ def select(*values, by: :value)
146
+ tap do
147
+ function = <<~JS
148
+ function(element, values, by) {
149
+ if (element.nodeName.toLowerCase() !== 'select') {
150
+ throw new Error('Element is not a <select> element.');
151
+ }
152
+ const options = Array.from(element.options);
153
+ element.value = undefined;
154
+ for (const option of options) {
155
+ option.selected = values.some((value) => option[by] === value);
156
+ if (option.selected && !element.multiple) break;
157
+ }
158
+ element.dispatchEvent(new Event('input', { bubbles: true }));
159
+ element.dispatchEvent(new Event('change', { bubbles: true }));
160
+ }
161
+ JS
162
+ page.evaluate_func(function, self, values.flatten, by, on: self)
163
+ end
164
+ end
165
+
130
166
  def evaluate(expression)
131
167
  page.evaluate_on(node: self, expression: expression)
132
168
  end
133
169
 
134
170
  def ==(other)
135
171
  return false unless other.is_a?(Node)
172
+
136
173
  # We compare backendNodeId because once nodeId is sent to frontend backend
137
174
  # never returns same nodeId sending 0. In other words frontend is
138
175
  # responsible for keeping track of node ids.
@@ -147,30 +184,39 @@ module Ferrum
147
184
  points = wait_for_stop_moving.map { |q| to_points(q) }.first
148
185
  get_position(points, x, y, position)
149
186
  rescue CoordinatesNotFoundError
150
- x, y = get_bounding_rect_coordinates
151
- raise if x == 0 && y == 0
187
+ x, y = bounding_rect_coordinates
188
+ raise if x.zero? && y.zero?
189
+
152
190
  [x, y]
153
191
  end
154
192
 
193
+ # Returns a hash of the computed styles for the node
194
+ def computed_style
195
+ page
196
+ .command("CSS.getComputedStyleForNode", nodeId: node_id)["computedStyle"]
197
+ .each_with_object({}) { |style, memo| memo.merge!(style["name"] => style["value"]) }
198
+ end
199
+
155
200
  private
156
201
 
157
- def get_bounding_rect_coordinates
202
+ def bounding_rect_coordinates
158
203
  evaluate <<~JS
159
204
  [this.getBoundingClientRect().left + window.pageXOffset + (this.offsetWidth / 2),
160
205
  this.getBoundingClientRect().top + window.pageYOffset + (this.offsetHeight / 2)]
161
206
  JS
162
207
  end
163
208
 
164
- def get_content_quads
209
+ def content_quads
165
210
  quads = page.command("DOM.getContentQuads", nodeId: node_id)["quads"]
166
- raise CoordinatesNotFoundError, "Node is either not visible or not an HTMLElement" if quads.size == 0
211
+ raise CoordinatesNotFoundError, "Node is either not visible or not an HTMLElement" if quads.size.zero?
212
+
167
213
  quads
168
214
  end
169
215
 
170
- def get_content_quads_with(delay: MOVING_WAIT_DELAY)
171
- previous = get_content_quads
216
+ def content_quads_with(delay: MOVING_WAIT_DELAY)
217
+ previous = content_quads
172
218
  sleep(delay)
173
- current = get_content_quads
219
+ current = content_quads
174
220
  [previous, current]
175
221
  end
176
222
 
@@ -182,28 +228,28 @@ module Ferrum
182
228
  x = point[:x] + offset_x.to_i
183
229
  y = point[:y] + offset_y.to_i
184
230
  else
185
- x, y = points.inject([0, 0]) do |memo, point|
186
- [memo[0] + point[:x],
187
- memo[1] + point[:y]]
231
+ x, y = points.inject([0, 0]) do |memo, coordinate|
232
+ [memo[0] + coordinate[:x],
233
+ memo[1] + coordinate[:y]]
188
234
  end
189
235
 
190
- x = x / 4
191
- y = y / 4
236
+ x /= 4
237
+ y /= 4
192
238
  end
193
239
 
194
240
  if offset_x && offset_y && position == :center
195
- x = x + offset_x.to_i
196
- y = y + offset_y.to_i
241
+ x += offset_x.to_i
242
+ y += offset_y.to_i
197
243
  end
198
244
 
199
245
  [x, y]
200
246
  end
201
247
 
202
248
  def to_points(quad)
203
- [{x: quad[0], y: quad[1]},
204
- {x: quad[2], y: quad[3]},
205
- {x: quad[4], y: quad[5]},
206
- {x: quad[6], y: quad[7]}]
249
+ [{ x: quad[0], y: quad[1] },
250
+ { x: quad[2], y: quad[3] },
251
+ { x: quad[4], y: quad[5] },
252
+ { x: quad[6], y: quad[7] }]
207
253
  end
208
254
  end
209
255
  end
@@ -3,11 +3,26 @@
3
3
  module Ferrum
4
4
  class Page
5
5
  module Animation
6
+ #
7
+ # Returns playback rate for CSS animations, defaults to `1`.
8
+ #
9
+ # @return [Integer]
10
+ #
6
11
  def playback_rate
7
12
  command("Animation.getPlaybackRate")["playbackRate"]
8
13
  end
9
14
 
10
-
15
+ #
16
+ # Sets playback rate of CSS animations.
17
+ #
18
+ # @param [Integer] value
19
+ #
20
+ # @example
21
+ # browser = Ferrum::Browser.new
22
+ # browser.playback_rate = 2000
23
+ # browser.go_to("https://google.com")
24
+ # browser.playback_rate # => 2000
25
+ #
11
26
  def playback_rate=(value)
12
27
  command("Animation.setPlaybackRate", playbackRate: value)
13
28
  end