ferrum 0.16 → 0.17
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 +67 -6
- data/lib/ferrum/browser/command.rb +0 -4
- data/lib/ferrum/browser/options/base.rb +9 -0
- data/lib/ferrum/browser/options/chrome.rb +6 -10
- data/lib/ferrum/browser/options.rb +2 -1
- data/lib/ferrum/browser/process.rb +1 -1
- data/lib/ferrum/browser.rb +33 -2
- data/lib/ferrum/client/subscriber.rb +9 -3
- data/lib/ferrum/client.rb +8 -0
- data/lib/ferrum/context.rb +34 -11
- data/lib/ferrum/contexts.rb +20 -4
- data/lib/ferrum/frame/dom.rb +18 -2
- data/lib/ferrum/frame.rb +17 -0
- data/lib/ferrum/keyboard.rb +0 -1
- data/lib/ferrum/mouse.rb +43 -7
- data/lib/ferrum/network/exchange.rb +33 -3
- data/lib/ferrum/network/request.rb +9 -0
- data/lib/ferrum/network.rb +29 -4
- data/lib/ferrum/node.rb +1 -0
- data/lib/ferrum/page/frames.rb +6 -2
- data/lib/ferrum/page/screencast.rb +102 -0
- data/lib/ferrum/page.rb +16 -1
- data/lib/ferrum/target.rb +10 -1
- data/lib/ferrum/utils/elapsed_time.rb +4 -0
- data/lib/ferrum/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f586cea5a9987e2a53bfb860fb503d36fc9e2f4e16b7288dafe68b2e62f20ede
|
4
|
+
data.tar.gz: a33d17e091b0d7b506f0934dd19f056ab15c72891659cc227f03bccad8d90cdd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 031a4dcb48ba0d22c1070ab8e905881f07369e7c19b61f03ef747d4ff3813be097a8f8bc65f692f4ed186687ba1e4e83c9de4482939a963b2541364c0d745b16
|
7
|
+
data.tar.gz: 207975dde463249331095cada046454110ee80d8a4a42fdc7f51898ad514e0aa5395946e037abd5c5599a7fa2d570c279925555822d432c4470c6e955f37b85b
|
data/README.md
CHANGED
@@ -34,6 +34,7 @@ based on Ferrum and Mechanize.
|
|
34
34
|
* [Navigation](https://github.com/rubycdp/ferrum#navigation)
|
35
35
|
* [Finders](https://github.com/rubycdp/ferrum#finders)
|
36
36
|
* [Screenshots](https://github.com/rubycdp/ferrum#screenshots)
|
37
|
+
* [Screencast](https://github.com/rubycdp/ferrum#screencast)
|
37
38
|
* [Network](https://github.com/rubycdp/ferrum#network)
|
38
39
|
* [Downloads](https://github.com/rubycdp/ferrum#downloads)
|
39
40
|
* [Proxy](https://github.com/rubycdp/ferrum#proxy)
|
@@ -136,7 +137,7 @@ browser.quit
|
|
136
137
|
In docker as root you must pass the no-sandbox browser option:
|
137
138
|
|
138
139
|
```ruby
|
139
|
-
Ferrum::Browser.new(browser_options: {
|
140
|
+
Ferrum::Browser.new(browser_options: { "no-sandbox": nil })
|
140
141
|
```
|
141
142
|
|
142
143
|
It has also been reported that the Chrome process repeatedly crashes when running inside a Docker container on an M1 Mac preventing Ferrum from working. Ferrum should work as expected when deployed to a Docker container on a non-M1 Mac.
|
@@ -150,8 +151,8 @@ Ferrum::Browser.new(options)
|
|
150
151
|
```
|
151
152
|
|
152
153
|
* options `Hash`
|
153
|
-
* `:headless` (
|
154
|
-
|
154
|
+
* `:headless` (Boolean) - Set browser as headless or not, `true` by default.
|
155
|
+
* `:incognito` (Boolean) - Create an incognito profile for the browser startup window, `true` by default.
|
155
156
|
* `:xvfb` (Boolean) - Run browser in a virtual framebuffer, `false` by default.
|
156
157
|
* `:flatten` (Boolean) - Use one websocket connection to the browser and all the pages in flatten mode.
|
157
158
|
* `:window_size` (Array) - The dimensions of the browser window in which to
|
@@ -416,7 +417,7 @@ page.screenshot(path: "google.jpg") # => 30902
|
|
416
417
|
# Save to Base64 the whole page not only viewport and reduce quality
|
417
418
|
page.screenshot(full: true, quality: 60, encoding: :base64) # "iVBORw0KGgoAAAANSUhEUgAABAAAAAMACAYAAAC6uhUNAAAAAXNSR0IArs4c6Q...
|
418
419
|
# Save on the disk with the selected element in PNG
|
419
|
-
page.screenshot(path: "google.png", selector:
|
420
|
+
page.screenshot(path: "google.png", selector: "textarea") # => 11340
|
420
421
|
# Save to Base64 with an area of the page in PNG
|
421
422
|
page.screenshot(path: "google.png", area: { x: 0, y: 0, width: 400, height: 300 }) # => 54239
|
422
423
|
# Save with specific background color
|
@@ -458,6 +459,58 @@ page.go_to("https://google.com/")
|
|
458
459
|
page.mhtml(path: "google.mhtml") # => 87742
|
459
460
|
```
|
460
461
|
|
462
|
+
## Screencast
|
463
|
+
|
464
|
+
#### start_screencast(\*\*options) { |data, metadata, session_id| ... }
|
465
|
+
|
466
|
+
Starts sending frames to record screencast to the given block.
|
467
|
+
|
468
|
+
* options `Hash`
|
469
|
+
* :format `Symbol` `:jpeg` | `:png` The format the image should be returned in.
|
470
|
+
* :quality `Integer` The image quality. **Note:** 0-100 works for JPEG only.
|
471
|
+
* :max_width `Integer` Maximum screencast frame width.
|
472
|
+
* :max_height `Integer` Maximum screencast frame height.
|
473
|
+
* :every_nth_frame `Integer` Send every n-th frame.
|
474
|
+
|
475
|
+
* Block inputs:
|
476
|
+
* data `String` Base64-encoded compressed image.
|
477
|
+
* metadata `Hash` Screencast frame metadata.
|
478
|
+
* "offsetTop" `Integer` Top offset in DIP.
|
479
|
+
* "pageScaleFactor" `Integer` Page scale factor.
|
480
|
+
* "deviceWidth" `Integer` Device screen width in DIP.
|
481
|
+
* "deviceHeight" `Integer` Device screen height in DIP.
|
482
|
+
* "scrollOffsetX" `Integer` Position of horizontal scroll in CSS pixels.
|
483
|
+
* "scrollOffsetY" `Integer` Position of vertical scroll in CSS pixels.
|
484
|
+
* "timestamp" `Float` (optional) Frame swap timestamp in seconds since Unix epoch.
|
485
|
+
* session_id `Integer` Frame number.
|
486
|
+
|
487
|
+
```ruby
|
488
|
+
require "base64"
|
489
|
+
|
490
|
+
page.go_to("https://apple.com/ipad")
|
491
|
+
|
492
|
+
page.start_screencast(format: :jpeg, quality: 75) do |data, metadata|
|
493
|
+
timestamp = (metadata["timestamp"] * 1000).to_i
|
494
|
+
File.binwrite("image_#{timestamp}.jpg", Base64.decode64(data))
|
495
|
+
end
|
496
|
+
|
497
|
+
sleep 10
|
498
|
+
|
499
|
+
page.stop_screencast
|
500
|
+
```
|
501
|
+
|
502
|
+
> ### 📝 NOTE
|
503
|
+
>
|
504
|
+
> Chrome only sends new frames while page content is changing. For example, if
|
505
|
+
> there is an animation or a video on the page, Chrome sends frames at the rate
|
506
|
+
> requested. On the other hand, if the page is nothing but a wall of static text,
|
507
|
+
> Chrome sends frames while the page renders. Once Chrome has finished rendering
|
508
|
+
> the page, it sends no more frames until something changes (e.g., navigating to
|
509
|
+
> another location).
|
510
|
+
|
511
|
+
#### stop_screencast
|
512
|
+
|
513
|
+
Stops sending frames.
|
461
514
|
|
462
515
|
## Network
|
463
516
|
|
@@ -1085,9 +1138,17 @@ Frame's unique id.
|
|
1085
1138
|
|
1086
1139
|
Parent frame id if this one is nested in another one.
|
1087
1140
|
|
1141
|
+
#### parent : `Frame | nil`
|
1142
|
+
|
1143
|
+
Parent frame if this one is nested in another one.
|
1144
|
+
|
1145
|
+
#### frame_element : `Node | nil`
|
1146
|
+
|
1147
|
+
Returns the element in which the window is embedded.
|
1148
|
+
|
1088
1149
|
#### execution_id : `Integer`
|
1089
1150
|
|
1090
|
-
Execution context id which is used by JS, each frame has
|
1151
|
+
Execution context id which is used by JS, each frame has its own context in
|
1091
1152
|
which JS evaluates.
|
1092
1153
|
|
1093
1154
|
#### name : `String | nil`
|
@@ -1322,7 +1383,7 @@ Closes browser tabs opened by the `Browser` instance.
|
|
1322
1383
|
|
1323
1384
|
```ruby
|
1324
1385
|
# connect to a long-running Chrome process
|
1325
|
-
browser = Ferrum::Browser.new(url:
|
1386
|
+
browser = Ferrum::Browser.new(url: "http://localhost:9222")
|
1326
1387
|
|
1327
1388
|
browser.go_to("https://github.com/")
|
1328
1389
|
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "singleton"
|
4
|
+
require "open3"
|
4
5
|
|
5
6
|
module Ferrum
|
6
7
|
class Browser
|
@@ -12,6 +13,14 @@ module Ferrum
|
|
12
13
|
instance
|
13
14
|
end
|
14
15
|
|
16
|
+
# @return [String, nil]
|
17
|
+
def self.version
|
18
|
+
out, = Open3.capture2(instance.detect_path, "--version")
|
19
|
+
out.strip
|
20
|
+
rescue Errno::ENOENT
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
|
15
24
|
def to_h
|
16
25
|
self.class::DEFAULT_OPTIONS
|
17
26
|
end
|
@@ -38,8 +38,9 @@ module Ferrum
|
|
38
38
|
"safebrowsing-disable-auto-update" => nil,
|
39
39
|
"password-store" => "basic",
|
40
40
|
"no-startup-window" => nil,
|
41
|
-
"remote-allow-origins" => "*"
|
42
|
-
|
41
|
+
"remote-allow-origins" => "*",
|
42
|
+
"disable-blink-features" => "AutomationControlled"
|
43
|
+
# NOTE: --no-sandbox is not needed if you properly set up a user in the container.
|
43
44
|
# https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
|
44
45
|
# "no-sandbox" => nil,
|
45
46
|
}.freeze
|
@@ -75,15 +76,10 @@ module Ferrum
|
|
75
76
|
end
|
76
77
|
|
77
78
|
def merge_default(flags, options)
|
78
|
-
defaults =
|
79
|
-
when false
|
80
|
-
except("headless", "disable-gpu")
|
81
|
-
when "new"
|
82
|
-
except("headless").merge("headless" => "new")
|
83
|
-
end
|
84
|
-
|
79
|
+
defaults = except("headless", "disable-gpu") if options.headless == false
|
85
80
|
defaults ||= DEFAULT_OPTIONS
|
86
|
-
|
81
|
+
defaults.delete("no-startup-window") if options.incognito == false
|
82
|
+
# On Windows, the --disable-gpu flag is a temporary workaround for a few bugs.
|
87
83
|
# See https://bugs.chromium.org/p/chromium/issues/detail?id=737678 for more information.
|
88
84
|
defaults = defaults.merge("disable-gpu" => nil) if Utils::Platform.windows?
|
89
85
|
# Use Metal on Apple Silicon
|
@@ -14,7 +14,7 @@ module Ferrum
|
|
14
14
|
attr_reader :window_size, :logger, :ws_max_receive_size,
|
15
15
|
:js_errors, :base_url, :slowmo, :pending_connection_errors,
|
16
16
|
:url, :ws_url, :env, :process_timeout, :browser_name, :browser_path,
|
17
|
-
:save_path, :proxy, :port, :host, :headless, :browser_options,
|
17
|
+
:save_path, :proxy, :port, :host, :headless, :incognito, :browser_options,
|
18
18
|
:ignore_default_browser_options, :xvfb, :flatten
|
19
19
|
attr_accessor :timeout, :default_user_agent
|
20
20
|
|
@@ -27,6 +27,7 @@ module Ferrum
|
|
27
27
|
@window_size = @options.fetch(:window_size, WINDOW_SIZE)
|
28
28
|
@js_errors = @options.fetch(:js_errors, false)
|
29
29
|
@headless = @options.fetch(:headless, true)
|
30
|
+
@incognito = @options.fetch(:incognito, true)
|
30
31
|
@flatten = @options.fetch(:flatten, true)
|
31
32
|
@pending_connection_errors = @options.fetch(:pending_connection_errors, true)
|
32
33
|
@process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
|
data/lib/ferrum/browser.rb
CHANGED
@@ -23,6 +23,7 @@ module Ferrum
|
|
23
23
|
headers cookies network downloads
|
24
24
|
mouse keyboard
|
25
25
|
screenshot pdf mhtml viewport_size device_pixel_ratio
|
26
|
+
start_screencast stop_screencast
|
26
27
|
frames frame_by main_frame
|
27
28
|
evaluate evaluate_on evaluate_async execute evaluate_func
|
28
29
|
add_script_tag add_style_tag bypass_csp
|
@@ -44,6 +45,9 @@ module Ferrum
|
|
44
45
|
# @option options [Boolean] :headless (true)
|
45
46
|
# Set browser as headless or not.
|
46
47
|
#
|
48
|
+
# @option options [Boolean] :incognito (true)
|
49
|
+
# Create an incognito profile for the browser startup window.
|
50
|
+
#
|
47
51
|
# @option options [Boolean] :xvfb (false)
|
48
52
|
# Run browser in a virtual framebuffer.
|
49
53
|
#
|
@@ -245,8 +249,22 @@ module Ferrum
|
|
245
249
|
VersionInfo.new(command("Browser.getVersion"))
|
246
250
|
end
|
247
251
|
|
248
|
-
|
249
|
-
|
252
|
+
#
|
253
|
+
# Opens headless session in the browser devtools frontend.
|
254
|
+
#
|
255
|
+
# @return [void]
|
256
|
+
#
|
257
|
+
# @since 0.16
|
258
|
+
#
|
259
|
+
def debug(bind = nil)
|
260
|
+
::Process.spawn(process.path, debug_url)
|
261
|
+
|
262
|
+
bind ||= binding
|
263
|
+
if bind.respond_to?(:pry)
|
264
|
+
Pry.start(bind)
|
265
|
+
else
|
266
|
+
bind.irb
|
267
|
+
end
|
250
268
|
end
|
251
269
|
|
252
270
|
private
|
@@ -266,5 +284,18 @@ module Ferrum
|
|
266
284
|
raise
|
267
285
|
end
|
268
286
|
end
|
287
|
+
|
288
|
+
def debug_url
|
289
|
+
response = JSON.parse(Net::HTTP.get(URI(build_remote_debug_url(path: "/json"))))
|
290
|
+
|
291
|
+
devtools_frontend_path = response[0]&.[]("devtoolsFrontendUrl")
|
292
|
+
raise "Could not generate debug url for remote debugging session" unless devtools_frontend_path
|
293
|
+
|
294
|
+
build_remote_debug_url(path: devtools_frontend_path)
|
295
|
+
end
|
296
|
+
|
297
|
+
def build_remote_debug_url(path:)
|
298
|
+
"http://#{process.host}:#{process.port}#{path}"
|
299
|
+
end
|
269
300
|
end
|
270
301
|
end
|
@@ -8,7 +8,7 @@ module Ferrum
|
|
8
8
|
def initialize
|
9
9
|
@regular = Queue.new
|
10
10
|
@priority = Queue.new
|
11
|
-
@on = Concurrent::Hash.new
|
11
|
+
@on = Concurrent::Hash.new
|
12
12
|
|
13
13
|
start
|
14
14
|
end
|
@@ -22,7 +22,13 @@ module Ferrum
|
|
22
22
|
end
|
23
23
|
|
24
24
|
def on(event, &block)
|
25
|
+
@on[event] ||= Concurrent::Array.new
|
25
26
|
@on[event] << block
|
27
|
+
@on[event].index(block)
|
28
|
+
end
|
29
|
+
|
30
|
+
def off(event, id)
|
31
|
+
@on[event].delete_at(id)
|
26
32
|
true
|
27
33
|
end
|
28
34
|
|
@@ -65,8 +71,8 @@ module Ferrum
|
|
65
71
|
method, session_id, params = message.values_at("method", "sessionId", "params")
|
66
72
|
event = SessionClient.event_name(method, session_id)
|
67
73
|
|
68
|
-
total = @on[event].
|
69
|
-
@on[event]
|
74
|
+
total = @on[event]&.size.to_i
|
75
|
+
@on[event]&.each_with_index do |block, index|
|
70
76
|
# In case of multiple callbacks we provide current index and total
|
71
77
|
block.call(params, index, total)
|
72
78
|
end
|
data/lib/ferrum/client.rb
CHANGED
@@ -28,6 +28,10 @@ module Ferrum
|
|
28
28
|
@client.on(event_name(event), &block)
|
29
29
|
end
|
30
30
|
|
31
|
+
def off(event, id)
|
32
|
+
@client.off(event_name(event), id)
|
33
|
+
end
|
34
|
+
|
31
35
|
def subscribed?(event)
|
32
36
|
@client.subscribed?(event_name(event))
|
33
37
|
end
|
@@ -101,6 +105,10 @@ module Ferrum
|
|
101
105
|
@subscriber.on(event, &block)
|
102
106
|
end
|
103
107
|
|
108
|
+
def off(event, id)
|
109
|
+
@subscriber.off(event, id)
|
110
|
+
end
|
111
|
+
|
104
112
|
def subscribed?(event)
|
105
113
|
@subscriber.subscribed?(event)
|
106
114
|
end
|
data/lib/ferrum/context.rb
CHANGED
@@ -13,7 +13,7 @@ module Ferrum
|
|
13
13
|
@client = client
|
14
14
|
@contexts = contexts
|
15
15
|
@targets = Concurrent::Map.new
|
16
|
-
@pendings = Concurrent::
|
16
|
+
@pendings = Concurrent::Map.new
|
17
17
|
end
|
18
18
|
|
19
19
|
def default_target
|
@@ -25,11 +25,11 @@ module Ferrum
|
|
25
25
|
end
|
26
26
|
|
27
27
|
def pages
|
28
|
-
@targets.values.map(&:page)
|
28
|
+
@targets.values.reject(&:iframe?).map(&:page)
|
29
29
|
end
|
30
30
|
|
31
31
|
# When we call `page` method on target it triggers ruby to connect to given
|
32
|
-
# page by WebSocket, if there are many opened windows but we need only one
|
32
|
+
# page by WebSocket, if there are many opened windows, but we need only one
|
33
33
|
# it makes more sense to get and connect to the needed one only which
|
34
34
|
# usually is the last one.
|
35
35
|
def windows(pos = nil, size = 1)
|
@@ -46,19 +46,27 @@ module Ferrum
|
|
46
46
|
end
|
47
47
|
|
48
48
|
def create_target
|
49
|
-
@client.command("Target.createTarget", browserContextId: @id, url: "about:blank")
|
50
|
-
target = @pendings.take(@client.timeout)
|
51
|
-
raise NoSuchTargetError unless target.is_a?(Target)
|
49
|
+
target_id = @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")["targetId"]
|
52
50
|
|
53
|
-
|
51
|
+
new_pending = Concurrent::IVar.new
|
52
|
+
pending = @pendings.put_if_absent(target_id, new_pending) || new_pending
|
53
|
+
resolved = pending.value(@client.timeout)
|
54
|
+
raise NoSuchTargetError unless resolved
|
55
|
+
|
56
|
+
@pendings.delete(target_id)
|
57
|
+
@targets[target_id]
|
54
58
|
end
|
55
59
|
|
56
60
|
def add_target(params:, session_id: nil)
|
57
61
|
new_target = Target.new(@client, session_id, params)
|
58
|
-
|
59
|
-
target
|
60
|
-
@
|
61
|
-
|
62
|
+
# `put_if_absent` returns nil if added a new value or existing if there was one already
|
63
|
+
target = @targets.put_if_absent(new_target.id, new_target) || new_target
|
64
|
+
@default_target ||= target
|
65
|
+
|
66
|
+
new_pending = Concurrent::IVar.new
|
67
|
+
pending = @pendings.put_if_absent(target.id, new_pending) || new_pending
|
68
|
+
pending.try_set(true)
|
69
|
+
true
|
62
70
|
end
|
63
71
|
|
64
72
|
def update_target(target_id, params)
|
@@ -69,6 +77,21 @@ module Ferrum
|
|
69
77
|
@targets.delete(target_id)
|
70
78
|
end
|
71
79
|
|
80
|
+
def attach_target(target_id)
|
81
|
+
target = @targets[target_id]
|
82
|
+
raise NoSuchTargetError unless target
|
83
|
+
|
84
|
+
session = @client.command("Target.attachToTarget", targetId: target_id, flatten: true)
|
85
|
+
target.session_id = session["sessionId"]
|
86
|
+
true
|
87
|
+
end
|
88
|
+
|
89
|
+
def find_target
|
90
|
+
@targets.each_value { |t| return t if yield(t) }
|
91
|
+
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
72
95
|
def close_targets_connection
|
73
96
|
@targets.each_value do |target|
|
74
97
|
next unless target.connected?
|
data/lib/ferrum/contexts.rb
CHANGED
@@ -4,6 +4,8 @@ require "ferrum/context"
|
|
4
4
|
|
5
5
|
module Ferrum
|
6
6
|
class Contexts
|
7
|
+
ALLOWED_TARGET_TYPES = %w[page iframe].freeze
|
8
|
+
|
7
9
|
include Enumerable
|
8
10
|
|
9
11
|
attr_reader :contexts
|
@@ -67,12 +69,19 @@ module Ferrum
|
|
67
69
|
|
68
70
|
private
|
69
71
|
|
72
|
+
# rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
70
73
|
def subscribe
|
71
74
|
@client.on("Target.attachedToTarget") do |params|
|
72
75
|
info, session_id = params.values_at("targetInfo", "sessionId")
|
73
|
-
next unless info["type"]
|
76
|
+
next unless ALLOWED_TARGET_TYPES.include?(info["type"])
|
74
77
|
|
75
78
|
context_id = info["browserContextId"]
|
79
|
+
unless @contexts[context_id]
|
80
|
+
context = Context.new(@client, self, context_id)
|
81
|
+
@contexts[context_id] = context
|
82
|
+
@default_context ||= context
|
83
|
+
end
|
84
|
+
|
76
85
|
@contexts[context_id]&.add_target(session_id: session_id, params: info)
|
77
86
|
if params["waitingForDebugger"]
|
78
87
|
@client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true)
|
@@ -81,15 +90,21 @@ module Ferrum
|
|
81
90
|
|
82
91
|
@client.on("Target.targetCreated") do |params|
|
83
92
|
info = params["targetInfo"]
|
84
|
-
next unless info["type"]
|
93
|
+
next unless ALLOWED_TARGET_TYPES.include?(info["type"])
|
85
94
|
|
86
95
|
context_id = info["browserContextId"]
|
87
|
-
|
96
|
+
|
97
|
+
if info["type"] == "iframe" &&
|
98
|
+
(target = @contexts[context_id].find_target { |t| t.connected? && t.page.frame_by(id: info["targetId"]) })
|
99
|
+
@contexts[context_id]&.add_target(session_id: target.page.client.session_id, params: info)
|
100
|
+
else
|
101
|
+
@contexts[context_id]&.add_target(params: info)
|
102
|
+
end
|
88
103
|
end
|
89
104
|
|
90
105
|
@client.on("Target.targetInfoChanged") do |params|
|
91
106
|
info = params["targetInfo"]
|
92
|
-
next unless info["type"]
|
107
|
+
next unless ALLOWED_TARGET_TYPES.include?(info["type"])
|
93
108
|
|
94
109
|
context_id, target_id = info.values_at("browserContextId", "targetId")
|
95
110
|
@contexts[context_id]&.update_target(target_id, info)
|
@@ -105,6 +120,7 @@ module Ferrum
|
|
105
120
|
context&.delete_target(params["targetId"])
|
106
121
|
end
|
107
122
|
end
|
123
|
+
# rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
108
124
|
|
109
125
|
def discover
|
110
126
|
@client.command("Target.setDiscoverTargets", discover: true)
|
data/lib/ferrum/frame/dom.rb
CHANGED
@@ -14,7 +14,7 @@
|
|
14
14
|
# any updates, for example, the node may be destroyed without any notification.
|
15
15
|
# This is a way to keep a reference to the Node, when you don't necessarily want
|
16
16
|
# to keep track of it. One example would be linking to the node from performance
|
17
|
-
# data (e.g.
|
17
|
+
# data (e.g. re-layout root node). BackendNodeId may be either resolved to
|
18
18
|
# inspected node (DOM.pushNodesByBackendIdsToFrontend) or described in more
|
19
19
|
# details (DOM.describeNode).
|
20
20
|
module Ferrum
|
@@ -92,7 +92,23 @@ module Ferrum
|
|
92
92
|
# browser.body # => '<html itemscope="" itemtype="http://schema.org/WebPage" lang="ru"><head>...
|
93
93
|
#
|
94
94
|
def body
|
95
|
-
evaluate("document.documentElement
|
95
|
+
evaluate("document.documentElement?.outerHTML") || ""
|
96
|
+
end
|
97
|
+
|
98
|
+
#
|
99
|
+
# Returns the element in which the window is embedded.
|
100
|
+
#
|
101
|
+
# @return [Node, nil]
|
102
|
+
# The element in which the window is embedded.
|
103
|
+
#
|
104
|
+
# @example
|
105
|
+
# browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
|
106
|
+
# frame = browser.frames.last
|
107
|
+
# frame.frame_element # => [Node]
|
108
|
+
# frame.parent.parent.parent.frame_element # => nil
|
109
|
+
#
|
110
|
+
def frame_element
|
111
|
+
evaluate("window.frameElement")
|
96
112
|
end
|
97
113
|
|
98
114
|
#
|
data/lib/ferrum/frame.rb
CHANGED
@@ -94,6 +94,23 @@ module Ferrum
|
|
94
94
|
@parent_id.nil?
|
95
95
|
end
|
96
96
|
|
97
|
+
#
|
98
|
+
# Returns the parent frame if this frame is nested in another one.
|
99
|
+
#
|
100
|
+
# @return [Frame, nil]
|
101
|
+
#
|
102
|
+
# @example
|
103
|
+
# browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
|
104
|
+
# frame = browser.frames.last
|
105
|
+
# frame.url # => "https://www.openstreetmap.org/export/embed.html?bbox=-0.004017949104309083%2C51.47612752641776%2C0.00030577182769775396%2C51.478569861898606&layer=mapnik"
|
106
|
+
# frame.parent.main? # => false
|
107
|
+
# frame.parent.parent.main? # => false
|
108
|
+
# frame.parent.parent.parent.main? # => true
|
109
|
+
#
|
110
|
+
def parent
|
111
|
+
@page.frame_by(id: @parent_id) if @parent_id
|
112
|
+
end
|
113
|
+
|
97
114
|
#
|
98
115
|
# Sets a content of a given frame.
|
99
116
|
#
|
data/lib/ferrum/keyboard.rb
CHANGED
data/lib/ferrum/mouse.rb
CHANGED
@@ -3,11 +3,36 @@
|
|
3
3
|
module Ferrum
|
4
4
|
class Mouse
|
5
5
|
CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.1).to_f
|
6
|
-
|
6
|
+
BUTTON_MASKS = {
|
7
|
+
"none" => 0,
|
8
|
+
"left" => 1,
|
9
|
+
"right" => 2,
|
10
|
+
"middle" => 4,
|
11
|
+
"back" => 8,
|
12
|
+
"forward" => 16
|
13
|
+
}.freeze
|
7
14
|
|
8
15
|
def initialize(page)
|
9
16
|
@page = page
|
10
17
|
@x = @y = 0
|
18
|
+
@buttons = 0
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Scroll page by the given amount x, y.
|
23
|
+
#
|
24
|
+
# @param [Integer] x
|
25
|
+
# The horizontal pixel value that you want to scroll by.
|
26
|
+
#
|
27
|
+
# @param [Integer] y
|
28
|
+
# The vertical pixel value that you want to scroll by.
|
29
|
+
#
|
30
|
+
# @example
|
31
|
+
# browser.go_to("https://www.google.com/search?q=Ruby+headless+driver+for+Capybara")
|
32
|
+
# browser.mouse.scroll_by(0, 400)
|
33
|
+
#
|
34
|
+
def scroll_by(x, y)
|
35
|
+
tap { @page.execute("window.scrollBy(#{x}, #{y})") }
|
11
36
|
end
|
12
37
|
|
13
38
|
#
|
@@ -107,9 +132,9 @@ module Ferrum
|
|
107
132
|
#
|
108
133
|
# Mouse move to given x and y.
|
109
134
|
#
|
110
|
-
# @param [
|
135
|
+
# @param [Number] x
|
111
136
|
#
|
112
|
-
# @param [
|
137
|
+
# @param [Number] y
|
113
138
|
#
|
114
139
|
# @param [Integer] steps
|
115
140
|
# Sends intermediate mousemove events.
|
@@ -129,8 +154,9 @@ module Ferrum
|
|
129
154
|
@page.command("Input.dispatchMouseEvent",
|
130
155
|
slowmoable: true,
|
131
156
|
type: "mouseMoved",
|
132
|
-
x: new_x
|
133
|
-
y: new_y
|
157
|
+
x: new_x,
|
158
|
+
y: new_y,
|
159
|
+
buttons: @buttons)
|
134
160
|
end
|
135
161
|
|
136
162
|
self
|
@@ -140,16 +166,26 @@ module Ferrum
|
|
140
166
|
|
141
167
|
def mouse_event(type:, button: :left, count: 1, modifiers: nil, wait: 0)
|
142
168
|
button = validate_button(button)
|
143
|
-
|
169
|
+
register_event_button(type, button)
|
170
|
+
options = { x: @x, y: @y, type: type, button: button, buttons: @buttons, clickCount: count }
|
144
171
|
options.merge!(modifiers: modifiers) if modifiers
|
145
172
|
@page.command("Input.dispatchMouseEvent", wait: wait, slowmoable: true, **options)
|
146
173
|
end
|
147
174
|
|
148
175
|
def validate_button(button)
|
149
176
|
button = button.to_s
|
150
|
-
raise "Invalid button: #{button}" unless
|
177
|
+
raise "Invalid button: #{button}" unless BUTTON_MASKS.key?(button)
|
151
178
|
|
152
179
|
button
|
153
180
|
end
|
181
|
+
|
182
|
+
def register_event_button(type, button)
|
183
|
+
case type
|
184
|
+
when "mousePressed"
|
185
|
+
@buttons |= BUTTON_MASKS[button]
|
186
|
+
when "mouseReleased"
|
187
|
+
@buttons &= ~BUTTON_MASKS[button]
|
188
|
+
end
|
189
|
+
end
|
154
190
|
end
|
155
191
|
end
|
@@ -28,6 +28,15 @@ module Ferrum
|
|
28
28
|
# @return [Error, nil]
|
29
29
|
attr_accessor :error
|
30
30
|
|
31
|
+
# Determines if the network exchange is unknown due to
|
32
|
+
# a lost of its context
|
33
|
+
#
|
34
|
+
# @return Boolean
|
35
|
+
attr_accessor :unknown
|
36
|
+
|
37
|
+
# @api private
|
38
|
+
attr_accessor :request_extra_info
|
39
|
+
|
31
40
|
#
|
32
41
|
# Initializes the network exchange.
|
33
42
|
#
|
@@ -40,6 +49,8 @@ module Ferrum
|
|
40
49
|
@page = page
|
41
50
|
@intercepted_request = nil
|
42
51
|
@request = @response = @error = nil
|
52
|
+
@request_extra_info = nil
|
53
|
+
@unknown = false
|
43
54
|
end
|
44
55
|
|
45
56
|
#
|
@@ -54,6 +65,15 @@ module Ferrum
|
|
54
65
|
request&.type?(:document) && request&.frame_id == frame_id
|
55
66
|
end
|
56
67
|
|
68
|
+
#
|
69
|
+
# The loader ID of the request.
|
70
|
+
#
|
71
|
+
# @return [String, nil]
|
72
|
+
#
|
73
|
+
def loader_id
|
74
|
+
request&.loader_id
|
75
|
+
end
|
76
|
+
|
57
77
|
#
|
58
78
|
# Determines if the network exchange has a request.
|
59
79
|
#
|
@@ -74,12 +94,12 @@ module Ferrum
|
|
74
94
|
|
75
95
|
#
|
76
96
|
# Determines if the request was blocked, a response was returned, or if an
|
77
|
-
# error occurred.
|
97
|
+
# error occurred or the exchange is unknown and cannot be inferred.
|
78
98
|
#
|
79
99
|
# @return [Boolean]
|
80
100
|
#
|
81
101
|
def finished?
|
82
|
-
blocked? || response&.loaded? || !error.nil? || ping?
|
102
|
+
blocked? || response&.loaded? || !error.nil? || ping? || blob? || unknown
|
83
103
|
end
|
84
104
|
|
85
105
|
#
|
@@ -127,6 +147,15 @@ module Ferrum
|
|
127
147
|
!!request&.ping?
|
128
148
|
end
|
129
149
|
|
150
|
+
#
|
151
|
+
# Determines if the exchange is blob.
|
152
|
+
#
|
153
|
+
# @return [Boolean]
|
154
|
+
#
|
155
|
+
def blob?
|
156
|
+
!!url&.start_with?("blob:")
|
157
|
+
end
|
158
|
+
|
130
159
|
#
|
131
160
|
# Returns request's URL.
|
132
161
|
#
|
@@ -156,7 +185,8 @@ module Ferrum
|
|
156
185
|
"@intercepted_request=#{@intercepted_request.inspect} " \
|
157
186
|
"@request=#{@request.inspect} " \
|
158
187
|
"@response=#{@response.inspect} " \
|
159
|
-
"@error=#{@error.inspect}>"
|
188
|
+
"@error=#{@error.inspect}> " \
|
189
|
+
"@unknown=#{@unknown.inspect}>"
|
160
190
|
end
|
161
191
|
end
|
162
192
|
end
|
data/lib/ferrum/network.rb
CHANGED
@@ -100,7 +100,7 @@ module Ferrum
|
|
100
100
|
#
|
101
101
|
# Page request of the main frame.
|
102
102
|
#
|
103
|
-
# @return [Request]
|
103
|
+
# @return [Request, nil]
|
104
104
|
#
|
105
105
|
# @example
|
106
106
|
# browser.go_to("https://github.com/")
|
@@ -380,15 +380,26 @@ module Ferrum
|
|
380
380
|
|
381
381
|
# We can build exchange in two places, here on the event or when request
|
382
382
|
# 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
|
383
|
+
# create new exchange only if there's no with such id or there's, but
|
384
384
|
# it's filled with request which means this one is new but has response
|
385
385
|
# for a redirect. So we assign response from the params to previous
|
386
386
|
# exchange and build new exchange to assign this request to it.
|
387
387
|
exchange = select(request.id).last
|
388
|
-
exchange = build_exchange(request.id)
|
388
|
+
exchange = build_exchange(request.id) if exchange.nil? || !exchange.blank?
|
389
|
+
request.headers.merge!(Hash(exchange.request_extra_info&.dig("headers")))
|
389
390
|
exchange.request = request
|
390
391
|
|
391
|
-
|
392
|
+
if exchange.navigation_request?(@page.main_frame.id)
|
393
|
+
@exchange = exchange
|
394
|
+
classify_pending_exchanges(exchange.loader_id)
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
@page.on("Network.requestWillBeSentExtraInfo") do |params|
|
399
|
+
exchange = select(params["requestId"]).last
|
400
|
+
exchange ||= build_exchange(params["requestId"])
|
401
|
+
exchange.request_extra_info = params
|
402
|
+
exchange.request&.headers&.merge!(params["headers"])
|
392
403
|
end
|
393
404
|
end
|
394
405
|
|
@@ -407,6 +418,8 @@ module Ferrum
|
|
407
418
|
exchange = select(params["requestId"]).last
|
408
419
|
next unless exchange
|
409
420
|
|
421
|
+
exchange.unknown = false
|
422
|
+
|
410
423
|
if (response = exchange.response)
|
411
424
|
response.loaded = true
|
412
425
|
response.body_size = params["encodedDataLength"]
|
@@ -489,5 +502,17 @@ module Ferrum
|
|
489
502
|
def whitelist?
|
490
503
|
Array(@whitelist).any?
|
491
504
|
end
|
505
|
+
|
506
|
+
# When the main frame navigates Chrome doesn't send `Network.loadingFailed`
|
507
|
+
# for pending async requests. Therefore, we mark pending connections as unknown since
|
508
|
+
# they are not relevant to the current navigation.
|
509
|
+
def classify_pending_exchanges(new_loader_id)
|
510
|
+
@traffic.each do |exchange|
|
511
|
+
break if exchange.loader_id == new_loader_id
|
512
|
+
next unless exchange.pending?
|
513
|
+
|
514
|
+
exchange.unknown = true
|
515
|
+
end
|
516
|
+
end
|
492
517
|
end
|
493
518
|
end
|
data/lib/ferrum/node.rb
CHANGED
data/lib/ferrum/page/frames.rb
CHANGED
@@ -124,7 +124,7 @@ module Ferrum
|
|
124
124
|
on("Page.frameStoppedLoading") do |params|
|
125
125
|
# `DOM.performSearch` doesn't work without getting #document node first.
|
126
126
|
# It returns node with nodeId 1 and nodeType 9 from which descend the
|
127
|
-
# tree and we save it in a variable because if we call that again root
|
127
|
+
# tree, and we save it in a variable because if we call that again root
|
128
128
|
# node will change the id and all subsequent nodes have to change id too.
|
129
129
|
if @main_frame.id == params["frameId"]
|
130
130
|
@event.set if idling?
|
@@ -177,12 +177,16 @@ module Ferrum
|
|
177
177
|
execution_id = params["executionContextId"]
|
178
178
|
frame = frame_by(execution_id: execution_id)
|
179
179
|
frame&.execution_id = nil
|
180
|
+
frame&.state = :stopped_loading
|
180
181
|
end
|
181
182
|
end
|
182
183
|
|
183
184
|
def subscribe_execution_contexts_cleared
|
184
185
|
on("Runtime.executionContextsCleared") do
|
185
|
-
@frames.each_value
|
186
|
+
@frames.each_value do |f|
|
187
|
+
f.execution_id = nil
|
188
|
+
f.state = :stopped_loading
|
189
|
+
end
|
186
190
|
end
|
187
191
|
end
|
188
192
|
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ferrum
|
4
|
+
class Page
|
5
|
+
module Screencast
|
6
|
+
# Starts sending frames to record screencast to the given block.
|
7
|
+
#
|
8
|
+
# @param [Hash{Symbol => Object}] opts
|
9
|
+
#
|
10
|
+
# @option opts [:jpeg, :png] :format
|
11
|
+
# The format the image should be returned in.
|
12
|
+
#
|
13
|
+
# @option opts [Integer] :quality
|
14
|
+
# The image quality. **Note:** 0-100 works for JPEG only.
|
15
|
+
#
|
16
|
+
# @option opts [Integer] :max_width
|
17
|
+
# Maximum screencast frame width.
|
18
|
+
#
|
19
|
+
# @option opts [Integer] :max_height
|
20
|
+
# Maximum screencast frame height.
|
21
|
+
#
|
22
|
+
# @option opts [Integer] :every_nth_frame
|
23
|
+
# Send every n-th frame.
|
24
|
+
#
|
25
|
+
# @yield [data, metadata, session_id]
|
26
|
+
# The given block receives the screencast frame along with metadata
|
27
|
+
# about the frame and the screencast session ID.
|
28
|
+
#
|
29
|
+
# @yieldparam data [String]
|
30
|
+
# Base64-encoded compressed image.
|
31
|
+
#
|
32
|
+
# @yieldparam metadata [Hash{String => Object}]
|
33
|
+
# Screencast frame metadata.
|
34
|
+
#
|
35
|
+
# @option metadata [Integer] 'offsetTop'
|
36
|
+
# Top offset in DIP.
|
37
|
+
#
|
38
|
+
# @option metadata [Integer] 'pageScaleFactor'
|
39
|
+
# Page scale factor.
|
40
|
+
#
|
41
|
+
# @option metadata [Integer] 'deviceWidth'
|
42
|
+
# Device screen width in DIP.
|
43
|
+
#
|
44
|
+
# @option metadata [Integer] 'deviceHeight'
|
45
|
+
# Device screen height in DIP.
|
46
|
+
#
|
47
|
+
# @option metadata [Integer] 'scrollOffsetX'
|
48
|
+
# Position of horizontal scroll in CSS pixels.
|
49
|
+
#
|
50
|
+
# @option metadata [Integer] 'scrollOffsetY'
|
51
|
+
# Position of vertical scroll in CSS pixels.
|
52
|
+
#
|
53
|
+
# @option metadata [Float] 'timestamp'
|
54
|
+
# (optional) Frame swap timestamp in seconds since Unix epoch.
|
55
|
+
#
|
56
|
+
# @yieldparam session_id [Integer]
|
57
|
+
# Frame number.
|
58
|
+
#
|
59
|
+
# @example
|
60
|
+
# require "base64"
|
61
|
+
#
|
62
|
+
# page.go_to("https://apple.com/ipad")
|
63
|
+
#
|
64
|
+
# page.start_screencast(format: :jpeg, quality: 75) do |data, metadata|
|
65
|
+
# timestamp = (metadata['timestamp'] * 1000).to_i
|
66
|
+
# File.binwrite("image_#{timestamp}.jpg", Base64.decode64(data))
|
67
|
+
# end
|
68
|
+
#
|
69
|
+
# sleep 10
|
70
|
+
#
|
71
|
+
# page.stop_screencast
|
72
|
+
#
|
73
|
+
def start_screencast(**opts)
|
74
|
+
options = opts.transform_keys { START_SCREENCAST_KEY_CONV.fetch(_1, _1) }
|
75
|
+
response = command("Page.startScreencast", **options)
|
76
|
+
|
77
|
+
if (error_text = response["errorText"]) # https://cs.chromium.org/chromium/src/net/base/net_error_list.h
|
78
|
+
raise "Starting screencast failed (#{error_text})"
|
79
|
+
end
|
80
|
+
|
81
|
+
on("Page.screencastFrame") do |params|
|
82
|
+
data, metadata, session_id = params.values_at("data", "metadata", "sessionId")
|
83
|
+
|
84
|
+
command("Page.screencastFrameAck", sessionId: session_id)
|
85
|
+
|
86
|
+
yield data, metadata, session_id
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Stops sending frames.
|
91
|
+
def stop_screencast
|
92
|
+
command("Page.stopScreencast")
|
93
|
+
end
|
94
|
+
|
95
|
+
START_SCREENCAST_KEY_CONV = {
|
96
|
+
max_width: :maxWidth,
|
97
|
+
max_height: :maxHeight,
|
98
|
+
every_nth_frame: :everyNthFrame
|
99
|
+
}.freeze
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
data/lib/ferrum/page.rb
CHANGED
@@ -10,6 +10,7 @@ require "ferrum/dialog"
|
|
10
10
|
require "ferrum/network"
|
11
11
|
require "ferrum/downloads"
|
12
12
|
require "ferrum/page/frames"
|
13
|
+
require "ferrum/page/screencast"
|
13
14
|
require "ferrum/page/screenshot"
|
14
15
|
require "ferrum/page/animation"
|
15
16
|
require "ferrum/page/tracing"
|
@@ -27,6 +28,7 @@ module Ferrum
|
|
27
28
|
delegate %i[base_url default_user_agent timeout timeout=] => :@options
|
28
29
|
|
29
30
|
include Animation
|
31
|
+
include Screencast
|
30
32
|
include Screenshot
|
31
33
|
include Frames
|
32
34
|
include Stream
|
@@ -393,6 +395,19 @@ module Ferrum
|
|
393
395
|
end
|
394
396
|
end
|
395
397
|
|
398
|
+
def off(name, id)
|
399
|
+
case name
|
400
|
+
when :dialog
|
401
|
+
client.off("Page.javascriptDialogOpening", id)
|
402
|
+
when :request
|
403
|
+
client.off("Fetch.requestPaused", id)
|
404
|
+
when :auth
|
405
|
+
client.off("Fetch.authRequired", id)
|
406
|
+
else
|
407
|
+
client.off(name, id)
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
396
411
|
def subscribed?(event)
|
397
412
|
client.subscribed?(event)
|
398
413
|
end
|
@@ -475,7 +490,7 @@ module Ferrum
|
|
475
490
|
# opens a new window for which `frameStoppedLoading` event never
|
476
491
|
# occurs and thus search for nodes cannot be completed. Here we check
|
477
492
|
# the history and if the transitionType for example `link` then
|
478
|
-
# content is already loaded and we can try to get the document.
|
493
|
+
# content is already loaded, and we can try to get the document.
|
479
494
|
document_node_id
|
480
495
|
end
|
481
496
|
|
data/lib/ferrum/target.rb
CHANGED
@@ -8,7 +8,8 @@ module Ferrum
|
|
8
8
|
# where we enhance page class and build page ourselves.
|
9
9
|
attr_writer :page
|
10
10
|
|
11
|
-
attr_reader :
|
11
|
+
attr_reader :options
|
12
|
+
attr_accessor :session_id
|
12
13
|
|
13
14
|
def initialize(browser_client, session_id = nil, params = nil)
|
14
15
|
@page = nil
|
@@ -67,11 +68,19 @@ module Ferrum
|
|
67
68
|
!!opener_id
|
68
69
|
end
|
69
70
|
|
71
|
+
def iframe?
|
72
|
+
type == "iframe"
|
73
|
+
end
|
74
|
+
|
70
75
|
def maybe_sleep_if_new_window
|
71
76
|
# Dirty hack because new window doesn't have events at all
|
72
77
|
sleep(NEW_WINDOW_WAIT) if window?
|
73
78
|
end
|
74
79
|
|
80
|
+
def command(...)
|
81
|
+
client.command(...)
|
82
|
+
end
|
83
|
+
|
75
84
|
private
|
76
85
|
|
77
86
|
def build_client
|
data/lib/ferrum/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ferrum
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: '0.
|
4
|
+
version: '0.17'
|
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: 2025-05-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: addressable
|
@@ -129,6 +129,7 @@ files:
|
|
129
129
|
- lib/ferrum/page.rb
|
130
130
|
- lib/ferrum/page/animation.rb
|
131
131
|
- lib/ferrum/page/frames.rb
|
132
|
+
- lib/ferrum/page/screencast.rb
|
132
133
|
- lib/ferrum/page/screenshot.rb
|
133
134
|
- lib/ferrum/page/stream.rb
|
134
135
|
- lib/ferrum/page/tracing.rb
|