ferrum 0.17.1 → 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/LICENSE +1 -1
- data/README.md +9 -1369
- 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 +69 -2
- data/lib/ferrum/browser/options/chrome.rb +88 -26
- data/lib/ferrum/browser/options/firefox.rb +32 -0
- data/lib/ferrum/browser/options.rb +43 -3
- data/lib/ferrum/browser/process.rb +56 -4
- data/lib/ferrum/browser/xvfb.rb +24 -0
- data/lib/ferrum/browser.rb +35 -9
- data/lib/ferrum/client/subscriber.rb +58 -0
- data/lib/ferrum/client/web_socket.rb +63 -12
- data/lib/ferrum/client.rb +206 -14
- data/lib/ferrum/context.rb +99 -4
- data/lib/ferrum/contexts.rb +136 -22
- data/lib/ferrum/cookies/cookie.rb +7 -1
- 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 +62 -6
- data/lib/ferrum/frame/dom.rb +17 -0
- data/lib/ferrum/frame/runtime.rb +49 -8
- 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 +16 -1
- data/lib/ferrum/network.rb +122 -10
- data/lib/ferrum/node.rb +305 -15
- data/lib/ferrum/page/animation.rb +7 -2
- 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 +141 -46
- 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 +22 -1
- 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 +8 -21
|
@@ -2,21 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
4
|
class Browser
|
|
5
|
+
#
|
|
6
|
+
# Resolves and normalizes the options hash passed to {Browser.new},
|
|
7
|
+
# applying defaults for connection settings (host, port, timeouts),
|
|
8
|
+
# window size, and other launch behavior. Used throughout the browser to
|
|
9
|
+
# look up user-configured settings.
|
|
10
|
+
#
|
|
5
11
|
class Options
|
|
6
12
|
BROWSER_PORT = "0"
|
|
7
13
|
BROWSER_HOST = "127.0.0.1"
|
|
8
14
|
WINDOW_SIZE = [1024, 768].freeze
|
|
9
15
|
BASE_URL_SCHEMA = %w[http https].freeze
|
|
10
16
|
DEFAULT_TIMEOUT = ENV.fetch("FERRUM_DEFAULT_TIMEOUT", 5).to_i
|
|
17
|
+
DEFAULT_PROTOCOL_TIMEOUT = ENV.fetch("FERRUM_PROTOCOL_TIMEOUT", DEFAULT_TIMEOUT).to_i
|
|
11
18
|
PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
|
|
12
19
|
DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil?
|
|
13
20
|
|
|
14
21
|
attr_reader :window_size, :logger, :ws_max_receive_size,
|
|
15
22
|
:js_errors, :base_url, :slowmo, :pending_connection_errors,
|
|
16
23
|
:url, :ws_url, :env, :process_timeout, :browser_name, :browser_path,
|
|
17
|
-
:save_path, :proxy, :port, :host, :headless, :incognito, :browser_options,
|
|
24
|
+
:save_path, :proxy, :port, :host, :headless, :incognito, :dockerize, :browser_options,
|
|
18
25
|
:ignore_default_browser_options, :xvfb, :flatten
|
|
19
|
-
attr_accessor :timeout, :default_user_agent
|
|
26
|
+
attr_accessor :timeout, :protocol_timeout, :default_user_agent
|
|
20
27
|
|
|
21
28
|
def initialize(options = nil)
|
|
22
29
|
@options = Hash(options&.dup)
|
|
@@ -24,12 +31,14 @@ module Ferrum
|
|
|
24
31
|
@port = @options.fetch(:port, BROWSER_PORT)
|
|
25
32
|
@host = @options.fetch(:host, BROWSER_HOST)
|
|
26
33
|
@timeout = @options.fetch(:timeout, DEFAULT_TIMEOUT)
|
|
34
|
+
@protocol_timeout = @options.fetch(:protocol_timeout, DEFAULT_PROTOCOL_TIMEOUT)
|
|
27
35
|
@window_size = @options.fetch(:window_size, WINDOW_SIZE)
|
|
28
36
|
@js_errors = @options.fetch(:js_errors, false)
|
|
29
37
|
@headless = @options.fetch(:headless, true)
|
|
30
38
|
@incognito = @options.fetch(:incognito, true)
|
|
39
|
+
@dockerize = @options.fetch(:dockerize, false)
|
|
31
40
|
@flatten = @options.fetch(:flatten, true)
|
|
32
|
-
@pending_connection_errors = @options.fetch(:pending_connection_errors,
|
|
41
|
+
@pending_connection_errors = @options.fetch(:pending_connection_errors, false)
|
|
33
42
|
@process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
|
|
34
43
|
@slowmo = @options[:slowmo].to_f
|
|
35
44
|
|
|
@@ -51,16 +60,42 @@ module Ferrum
|
|
|
51
60
|
@browser_options = @options.fetch(:browser_options, {}).freeze
|
|
52
61
|
end
|
|
53
62
|
|
|
63
|
+
#
|
|
64
|
+
# Sets the base URL relative navigations are resolved against.
|
|
65
|
+
#
|
|
66
|
+
# @param [String] value
|
|
67
|
+
# An absolute URL including scheme, e.g. `"https://example.com"`.
|
|
68
|
+
#
|
|
69
|
+
# @return [Addressable::URI]
|
|
70
|
+
#
|
|
54
71
|
def base_url=(value)
|
|
55
72
|
@base_url = parse_base_url(value)
|
|
56
73
|
end
|
|
57
74
|
|
|
75
|
+
#
|
|
76
|
+
# JS source to preload into the browser, read from `:extensions` option.
|
|
77
|
+
#
|
|
78
|
+
# @return [Array<String>]
|
|
79
|
+
# JS source code for each configured extension.
|
|
80
|
+
#
|
|
58
81
|
def extensions
|
|
59
82
|
@extensions ||= Array(@options[:extensions]).map do |extension|
|
|
60
83
|
(extension.is_a?(Hash) && extension[:source]) || File.read(extension)
|
|
61
84
|
end
|
|
62
85
|
end
|
|
63
86
|
|
|
87
|
+
#
|
|
88
|
+
# Validates the `:proxy` option, if given.
|
|
89
|
+
#
|
|
90
|
+
# @param [Hash, nil] options
|
|
91
|
+
# The `:proxy` option as passed to {#initialize}.
|
|
92
|
+
#
|
|
93
|
+
# @return [Hash, nil]
|
|
94
|
+
# The same `options`, unchanged.
|
|
95
|
+
#
|
|
96
|
+
# @raise [ArgumentError]
|
|
97
|
+
# If `options` is not a `Hash`, or is a `Hash` without `:host` or `:port`.
|
|
98
|
+
#
|
|
64
99
|
def validate_proxy(options)
|
|
65
100
|
return unless options
|
|
66
101
|
|
|
@@ -73,6 +108,11 @@ module Ferrum
|
|
|
73
108
|
options
|
|
74
109
|
end
|
|
75
110
|
|
|
111
|
+
#
|
|
112
|
+
# Raw options hash used to initialize the browser.
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash{Symbol => Object}]
|
|
115
|
+
#
|
|
76
116
|
def to_h
|
|
77
117
|
@options
|
|
78
118
|
end
|
|
@@ -14,21 +14,40 @@ require "ferrum/utils/platform"
|
|
|
14
14
|
|
|
15
15
|
module Ferrum
|
|
16
16
|
class Browser
|
|
17
|
+
#
|
|
18
|
+
# Spawns and manages the lifecycle of the browser OS process: builds the
|
|
19
|
+
# launch {Command}, starts it (optionally under {Xvfb} for headful mode),
|
|
20
|
+
# waits for the CDP WebSocket endpoint to become available, and handles
|
|
21
|
+
# stopping/restarting the process and cleaning up its user data directory.
|
|
22
|
+
#
|
|
17
23
|
class Process
|
|
18
24
|
KILL_TIMEOUT = 2
|
|
19
25
|
WAIT_KILLED = 0.05
|
|
20
26
|
|
|
21
|
-
attr_reader :host, :port, :ws_url, :pid, :command,
|
|
22
|
-
:default_user_agent, :browser_version, :protocol_version,
|
|
23
|
-
:v8_version, :webkit_version, :xvfb
|
|
24
|
-
|
|
25
27
|
extend Forwardable
|
|
28
|
+
|
|
26
29
|
delegate path: :command
|
|
27
30
|
|
|
31
|
+
#
|
|
32
|
+
# Builds and starts a new browser process.
|
|
33
|
+
#
|
|
34
|
+
# @param [Array] args
|
|
35
|
+
# Arguments forwarded to {#initialize}.
|
|
36
|
+
#
|
|
37
|
+
# @return [Process]
|
|
38
|
+
#
|
|
28
39
|
def self.start(*args)
|
|
29
40
|
new(*args).tap(&:start)
|
|
30
41
|
end
|
|
31
42
|
|
|
43
|
+
#
|
|
44
|
+
# Builds a finalizer proc that kills the process with the given pid.
|
|
45
|
+
#
|
|
46
|
+
# @param [Integer] pid
|
|
47
|
+
# Process id to kill.
|
|
48
|
+
#
|
|
49
|
+
# @return [Proc]
|
|
50
|
+
#
|
|
32
51
|
def self.process_killer(pid)
|
|
33
52
|
proc do
|
|
34
53
|
if Utils::Platform.windows?
|
|
@@ -51,6 +70,14 @@ module Ferrum
|
|
|
51
70
|
end
|
|
52
71
|
end
|
|
53
72
|
|
|
73
|
+
#
|
|
74
|
+
# Builds a finalizer proc that removes the directory at the given path.
|
|
75
|
+
#
|
|
76
|
+
# @param [String] path
|
|
77
|
+
# Directory to remove.
|
|
78
|
+
#
|
|
79
|
+
# @return [Proc]
|
|
80
|
+
#
|
|
54
81
|
def self.directory_remover(path)
|
|
55
82
|
proc {
|
|
56
83
|
begin
|
|
@@ -61,6 +88,10 @@ module Ferrum
|
|
|
61
88
|
}
|
|
62
89
|
end
|
|
63
90
|
|
|
91
|
+
attr_reader :host, :port, :ws_url, :pid, :command,
|
|
92
|
+
:default_user_agent, :browser_version, :protocol_version,
|
|
93
|
+
:v8_version, :webkit_version, :xvfb
|
|
94
|
+
|
|
64
95
|
def initialize(options)
|
|
65
96
|
@pid = @xvfb = @user_data_dir = nil
|
|
66
97
|
|
|
@@ -82,6 +113,11 @@ module Ferrum
|
|
|
82
113
|
@command = Command.build(options, tmpdir)
|
|
83
114
|
end
|
|
84
115
|
|
|
116
|
+
#
|
|
117
|
+
# Spawns the browser process and waits for it to become reachable over CDP.
|
|
118
|
+
#
|
|
119
|
+
# @return [void]
|
|
120
|
+
#
|
|
85
121
|
def start
|
|
86
122
|
# Don't do anything as browser is already running as external process.
|
|
87
123
|
return if ws_url
|
|
@@ -108,6 +144,12 @@ module Ferrum
|
|
|
108
144
|
end
|
|
109
145
|
end
|
|
110
146
|
|
|
147
|
+
#
|
|
148
|
+
# Kills the browser process (and Xvfb, if running) and removes the user
|
|
149
|
+
# data directory.
|
|
150
|
+
#
|
|
151
|
+
# @return [void]
|
|
152
|
+
#
|
|
111
153
|
def stop
|
|
112
154
|
if @pid
|
|
113
155
|
kill(@pid)
|
|
@@ -119,11 +161,21 @@ module Ferrum
|
|
|
119
161
|
ObjectSpace.undefine_finalizer(self)
|
|
120
162
|
end
|
|
121
163
|
|
|
164
|
+
#
|
|
165
|
+
# Stops and starts the browser process again.
|
|
166
|
+
#
|
|
167
|
+
# @return [void]
|
|
168
|
+
#
|
|
122
169
|
def restart
|
|
123
170
|
stop
|
|
124
171
|
start
|
|
125
172
|
end
|
|
126
173
|
|
|
174
|
+
#
|
|
175
|
+
# Custom inspection that omits noisy internal command details.
|
|
176
|
+
#
|
|
177
|
+
# @return [String]
|
|
178
|
+
#
|
|
127
179
|
def inspect
|
|
128
180
|
"#<#{self.class} " \
|
|
129
181
|
"@user_data_dir=#{@user_data_dir.inspect} " \
|
data/lib/ferrum/browser/xvfb.rb
CHANGED
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
4
|
class Browser
|
|
5
|
+
#
|
|
6
|
+
# Manages an Xvfb virtual display server, letting a "headful" browser run
|
|
7
|
+
# in environments without a real display (e.g. CI). Spawned by {Process}
|
|
8
|
+
# when the `:xvfb` option is set, and exposes the `DISPLAY` environment
|
|
9
|
+
# variable the browser process needs to attach to it.
|
|
10
|
+
#
|
|
5
11
|
class Xvfb
|
|
6
12
|
NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \
|
|
7
13
|
"it with your package manager"
|
|
8
14
|
|
|
15
|
+
#
|
|
16
|
+
# Builds and starts a new Xvfb instance.
|
|
17
|
+
#
|
|
18
|
+
# @param [Array] args
|
|
19
|
+
# Arguments forwarded to {#initialize}.
|
|
20
|
+
#
|
|
21
|
+
# @return [Xvfb]
|
|
22
|
+
#
|
|
9
23
|
def self.start(*args)
|
|
10
24
|
new(*args).tap(&:start)
|
|
11
25
|
end
|
|
@@ -20,11 +34,21 @@ module Ferrum
|
|
|
20
34
|
@display_id = (Time.now.to_f * 1000).to_i % 100_000_000
|
|
21
35
|
end
|
|
22
36
|
|
|
37
|
+
#
|
|
38
|
+
# Spawns the Xvfb process on the configured display.
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
#
|
|
23
42
|
def start
|
|
24
43
|
@pid = ::Process.spawn("#{@path} :#{display_id} -screen 0 #{screen_size}")
|
|
25
44
|
::Process.detach(@pid)
|
|
26
45
|
end
|
|
27
46
|
|
|
47
|
+
#
|
|
48
|
+
# Environment variables needed to point the browser at this Xvfb display.
|
|
49
|
+
#
|
|
50
|
+
# @return [Hash{String => String}]
|
|
51
|
+
#
|
|
28
52
|
def to_env
|
|
29
53
|
{ "DISPLAY" => ":#{display_id}" }
|
|
30
54
|
end
|
data/lib/ferrum/browser.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "forwardable"
|
|
5
5
|
require "ferrum/page"
|
|
6
|
-
require "ferrum/
|
|
6
|
+
require "ferrum/worker"
|
|
7
7
|
require "ferrum/client"
|
|
8
8
|
require "ferrum/contexts"
|
|
9
9
|
require "ferrum/browser/xvfb"
|
|
@@ -13,14 +13,25 @@ require "ferrum/browser/binary"
|
|
|
13
13
|
require "ferrum/browser/version_info"
|
|
14
14
|
|
|
15
15
|
module Ferrum
|
|
16
|
+
#
|
|
17
|
+
# The main entry point of the library. Instantiating it spawns (or connects
|
|
18
|
+
# to) a browser process and opens a CDP connection to it, exposing a single
|
|
19
|
+
# default {Page} that most of the top-level methods (`go_to`, `at_css`,
|
|
20
|
+
# `screenshot`, etc.) are delegated to for convenience.
|
|
21
|
+
#
|
|
22
|
+
# @note A `Browser` can host multiple {Context}s (like incognito profiles)
|
|
23
|
+
# and multiple {Page}s within each of them, reachable through {#contexts}
|
|
24
|
+
# and {#create_page}.
|
|
25
|
+
#
|
|
16
26
|
class Browser
|
|
17
27
|
extend Forwardable
|
|
28
|
+
|
|
18
29
|
delegate %i[default_context] => :contexts
|
|
19
|
-
delegate %i[targets create_target page pages windows] => :default_context
|
|
30
|
+
delegate %i[targets create_target page pages windows workers service_workers attach_target] => :default_context
|
|
20
31
|
delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
|
|
21
32
|
at_css at_xpath css xpath current_url current_title url title
|
|
22
33
|
body doctype content=
|
|
23
|
-
headers cookies network downloads
|
|
34
|
+
headers cookies network accessibility downloads
|
|
24
35
|
mouse keyboard
|
|
25
36
|
screenshot pdf mhtml viewport_size device_pixel_ratio
|
|
26
37
|
start_screencast stop_screencast
|
|
@@ -33,7 +44,8 @@ module Ferrum
|
|
|
33
44
|
|
|
34
45
|
attr_reader :client, :process, :contexts, :options
|
|
35
46
|
|
|
36
|
-
delegate %i[timeout timeout=
|
|
47
|
+
delegate %i[timeout timeout= protocol_timeout protocol_timeout=
|
|
48
|
+
base_url base_url= default_user_agent default_user_agent= extensions] => :options
|
|
37
49
|
delegate %i[command] => :client
|
|
38
50
|
|
|
39
51
|
#
|
|
@@ -48,6 +60,9 @@ module Ferrum
|
|
|
48
60
|
# @option options [Boolean] :incognito (true)
|
|
49
61
|
# Create an incognito profile for the browser startup window.
|
|
50
62
|
#
|
|
63
|
+
# @option options [Boolean] :dockerize (false)
|
|
64
|
+
# Add CLI flags to a browser to run in a container.
|
|
65
|
+
#
|
|
51
66
|
# @option options [Boolean] :xvfb (false)
|
|
52
67
|
# Run browser in a virtual framebuffer.
|
|
53
68
|
#
|
|
@@ -66,13 +81,18 @@ module Ferrum
|
|
|
66
81
|
# When present, debug output is written to this object.
|
|
67
82
|
#
|
|
68
83
|
# @option options [Integer, Float] :slowmo
|
|
69
|
-
# Set a delay in seconds to wait before sending command.
|
|
70
|
-
# Useful companion of headless option, so that you have time to see
|
|
84
|
+
# Set a delay in seconds to wait before sending a command.
|
|
85
|
+
# Useful companion of a headless option, so that you have time to see
|
|
71
86
|
# changes.
|
|
72
87
|
#
|
|
73
88
|
# @option options [Numeric] :timeout (5)
|
|
74
|
-
# The number of seconds we'll wait for a response when communicating
|
|
75
|
-
# browser.
|
|
89
|
+
# The number of seconds we'll wait for a response when communicating
|
|
90
|
+
# with the browser: navigations, JS evaluation, DOM queries, dispatching input, etc.
|
|
91
|
+
#
|
|
92
|
+
# @option options [Numeric] :protocol_timeout (5)
|
|
93
|
+
# The number of seconds we'll wait for an individual internal CDP
|
|
94
|
+
# bookkeeping call to respond, e.g. `Target.createTarget`,
|
|
95
|
+
# `Target.attachToTarget`. These normally resolve in milliseconds.
|
|
76
96
|
#
|
|
77
97
|
# @option options [Boolean] :js_errors
|
|
78
98
|
# When true, JavaScript errors get re-raised in Ruby.
|
|
@@ -168,7 +188,7 @@ module Ferrum
|
|
|
168
188
|
ensure
|
|
169
189
|
if block_given?
|
|
170
190
|
page&.close
|
|
171
|
-
context
|
|
191
|
+
context&.dispose if new_context
|
|
172
192
|
end
|
|
173
193
|
end
|
|
174
194
|
|
|
@@ -207,11 +227,17 @@ module Ferrum
|
|
|
207
227
|
contexts.reset
|
|
208
228
|
end
|
|
209
229
|
|
|
230
|
+
#
|
|
231
|
+
# Restarts the browser process, keeping the same options.
|
|
232
|
+
#
|
|
210
233
|
def restart
|
|
211
234
|
quit
|
|
212
235
|
start
|
|
213
236
|
end
|
|
214
237
|
|
|
238
|
+
#
|
|
239
|
+
# Terminates the browser process and closes the client connection.
|
|
240
|
+
#
|
|
215
241
|
def quit
|
|
216
242
|
return unless @client
|
|
217
243
|
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
4
|
class Client
|
|
5
|
+
#
|
|
6
|
+
# Dispatches incoming CDP events to registered callbacks. Messages are
|
|
7
|
+
# queued and processed on dedicated threads, with `Fetch.requestPaused`
|
|
8
|
+
# and `Fetch.authRequired` given priority so request interception isn't
|
|
9
|
+
# delayed behind other events.
|
|
10
|
+
#
|
|
5
11
|
class Subscriber
|
|
6
12
|
INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze
|
|
7
13
|
|
|
@@ -13,6 +19,16 @@ module Ferrum
|
|
|
13
19
|
start
|
|
14
20
|
end
|
|
15
21
|
|
|
22
|
+
#
|
|
23
|
+
# Enqueues an incoming CDP message for dispatch to subscribers.
|
|
24
|
+
# `Fetch.requestPaused`/`Fetch.authRequired` messages jump the regular
|
|
25
|
+
# queue so request interception isn't delayed behind other events.
|
|
26
|
+
#
|
|
27
|
+
# @param [Hash] message
|
|
28
|
+
# The raw CDP message, as parsed from the websocket.
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
16
32
|
def <<(message)
|
|
17
33
|
if INTERRUPTIONS.include?(message["method"])
|
|
18
34
|
@priority.push(message)
|
|
@@ -21,26 +37,68 @@ module Ferrum
|
|
|
21
37
|
end
|
|
22
38
|
end
|
|
23
39
|
|
|
40
|
+
#
|
|
41
|
+
# Registers a callback for a CDP event.
|
|
42
|
+
#
|
|
43
|
+
# @param [String] event
|
|
44
|
+
# The event key, as built by {SessionClient.event_name}.
|
|
45
|
+
#
|
|
46
|
+
# @return [Integer]
|
|
47
|
+
# The callback's index within the event's callback list, used to
|
|
48
|
+
# unsubscribe via {#off}.
|
|
49
|
+
#
|
|
24
50
|
def on(event, &block)
|
|
25
51
|
@on[event] ||= Concurrent::Array.new
|
|
26
52
|
@on[event] << block
|
|
27
53
|
@on[event].index(block)
|
|
28
54
|
end
|
|
29
55
|
|
|
56
|
+
#
|
|
57
|
+
# Unregisters a callback for a CDP event.
|
|
58
|
+
#
|
|
59
|
+
# @param [String] event
|
|
60
|
+
# The event key, as built by {SessionClient.event_name}.
|
|
61
|
+
#
|
|
62
|
+
# @param [Integer] id
|
|
63
|
+
# The callback's index, as returned by {#on}.
|
|
64
|
+
#
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
#
|
|
30
67
|
def off(event, id)
|
|
31
68
|
@on[event].delete_at(id)
|
|
32
69
|
true
|
|
33
70
|
end
|
|
34
71
|
|
|
72
|
+
#
|
|
73
|
+
# Whether there's at least one callback registered for the event.
|
|
74
|
+
#
|
|
75
|
+
# @param [String] event
|
|
76
|
+
# The event key, as built by {SessionClient.event_name}.
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
#
|
|
35
80
|
def subscribed?(event)
|
|
36
81
|
@on.key?(event)
|
|
37
82
|
end
|
|
38
83
|
|
|
84
|
+
#
|
|
85
|
+
# Stops the regular and priority dispatch threads.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
#
|
|
39
89
|
def close
|
|
40
90
|
@regular_thread&.kill
|
|
41
91
|
@priority_thread&.kill
|
|
42
92
|
end
|
|
43
93
|
|
|
94
|
+
#
|
|
95
|
+
# Removes all callbacks registered for a given session.
|
|
96
|
+
#
|
|
97
|
+
# @param [String] session_id
|
|
98
|
+
# The session id to match against registered event keys.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
#
|
|
44
102
|
def clear(session_id:)
|
|
45
103
|
@on.delete_if { |k, _| k.match?(session_id) }
|
|
46
104
|
end
|
|
@@ -6,6 +6,12 @@ require "websocket/driver"
|
|
|
6
6
|
|
|
7
7
|
module Ferrum
|
|
8
8
|
class Client
|
|
9
|
+
#
|
|
10
|
+
# Low-level WebSocket connection to the browser's CDP endpoint. Opens
|
|
11
|
+
# the raw TCP/TLS socket, drives the `websocket-driver` handshake and
|
|
12
|
+
# framing, and exposes a queue of parsed incoming messages alongside
|
|
13
|
+
# methods to send commands and close the connection.
|
|
14
|
+
#
|
|
9
15
|
class WebSocket
|
|
10
16
|
WEBSOCKET_BUG_SLEEP = 0.05
|
|
11
17
|
DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
|
|
@@ -19,7 +25,7 @@ module Ferrum
|
|
|
19
25
|
uri = URI.parse(@url)
|
|
20
26
|
port = uri.port || DEFAULT_PORTS[uri.scheme]
|
|
21
27
|
|
|
22
|
-
if port == 443
|
|
28
|
+
if port == 443 || url.scheme == "wss"
|
|
23
29
|
tcp = TCPSocket.new(uri.host, port)
|
|
24
30
|
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
25
31
|
@sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
|
|
@@ -30,7 +36,10 @@ module Ferrum
|
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
|
|
33
|
-
@driver
|
|
39
|
+
@driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
|
|
40
|
+
# websocket-driver holds no locks and is called from many threads: commands from
|
|
41
|
+
# callers, pong/close replies from the reader. One lock keeps frames from interleaving.
|
|
42
|
+
@driver_mutex = Mutex.new
|
|
34
43
|
@messages = Queue.new
|
|
35
44
|
|
|
36
45
|
@screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
|
|
@@ -41,22 +50,28 @@ module Ferrum
|
|
|
41
50
|
|
|
42
51
|
start
|
|
43
52
|
|
|
44
|
-
@driver.start
|
|
53
|
+
@driver_mutex.synchronize { @driver.start }
|
|
45
54
|
end
|
|
46
55
|
|
|
56
|
+
#
|
|
57
|
+
# Handles the driver's `:open` event.
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
#
|
|
47
61
|
def on_open(_event)
|
|
48
62
|
# https://github.com/faye/websocket-driver-ruby/issues/46
|
|
49
63
|
sleep(WEBSOCKET_BUG_SLEEP)
|
|
50
64
|
end
|
|
51
65
|
|
|
66
|
+
#
|
|
67
|
+
# Handles the driver's `:message` event: parses the incoming frame
|
|
68
|
+
# as JSON and pushes it onto {#messages}. Malformed payloads are
|
|
69
|
+
# dropped rather than raised, to avoid crashing the reader thread.
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
#
|
|
52
73
|
def on_message(event)
|
|
53
74
|
data = safely_parse_json(event.data)
|
|
54
|
-
# If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
|
|
55
|
-
# don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
|
|
56
|
-
# anything or skip the background notification, but at least we don't crash the thread that crashes the main
|
|
57
|
-
# thread and the application.
|
|
58
|
-
@messages.push(data) if data
|
|
59
|
-
|
|
60
75
|
output = event.data
|
|
61
76
|
if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")]
|
|
62
77
|
@screenshot_commands.delete(data&.dig("id"))
|
|
@@ -64,30 +79,66 @@ module Ferrum
|
|
|
64
79
|
end
|
|
65
80
|
|
|
66
81
|
@logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
|
|
82
|
+
|
|
83
|
+
# If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
|
|
84
|
+
# don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
|
|
85
|
+
# anything or skip the background notification, but at least we don't crash the thread that crashes the main
|
|
86
|
+
# thread and the application.
|
|
87
|
+
@messages.push(data) if data
|
|
67
88
|
end
|
|
68
89
|
|
|
90
|
+
#
|
|
91
|
+
# Handles the driver's `:close` event: closes the message queue and
|
|
92
|
+
# underlying socket, then kills the reader thread.
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
#
|
|
69
96
|
def on_close(_event)
|
|
70
97
|
@messages.close
|
|
71
98
|
@sock.close
|
|
72
99
|
@thread.kill
|
|
73
100
|
end
|
|
74
101
|
|
|
102
|
+
#
|
|
103
|
+
# Serializes a CDP command to JSON and sends it as a websocket text
|
|
104
|
+
# frame.
|
|
105
|
+
#
|
|
106
|
+
# @param [Hash] data
|
|
107
|
+
# The message to send, must include an `:id` key.
|
|
108
|
+
#
|
|
109
|
+
# @return [void]
|
|
110
|
+
#
|
|
75
111
|
def send_message(data)
|
|
76
112
|
@screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
|
|
77
113
|
|
|
78
114
|
json = data.to_json
|
|
79
|
-
@driver.text(json)
|
|
115
|
+
@driver_mutex.synchronize { @driver.text(json) }
|
|
80
116
|
@logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
|
|
81
117
|
end
|
|
82
118
|
|
|
119
|
+
#
|
|
120
|
+
# Writes raw bytes to the underlying socket. Called by
|
|
121
|
+
# `websocket-driver` to emit frames. Closes {#messages} instead of
|
|
122
|
+
# raising if the connection has already been torn down.
|
|
123
|
+
#
|
|
124
|
+
# @param [String] data
|
|
125
|
+
# The raw bytes to write.
|
|
126
|
+
#
|
|
127
|
+
# @return [void]
|
|
128
|
+
#
|
|
83
129
|
def write(data)
|
|
84
130
|
@sock.write(data)
|
|
85
131
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
86
132
|
@messages.close
|
|
87
133
|
end
|
|
88
134
|
|
|
135
|
+
#
|
|
136
|
+
# Closes the websocket connection by sending a close frame.
|
|
137
|
+
#
|
|
138
|
+
# @return [void]
|
|
139
|
+
#
|
|
89
140
|
def close
|
|
90
|
-
@driver.close
|
|
141
|
+
@driver_mutex.synchronize { @driver.close }
|
|
91
142
|
end
|
|
92
143
|
|
|
93
144
|
private
|
|
@@ -98,7 +149,7 @@ module Ferrum
|
|
|
98
149
|
data = @sock.readpartial(512)
|
|
99
150
|
break unless data
|
|
100
151
|
|
|
101
|
-
@driver.parse(data)
|
|
152
|
+
@driver_mutex.synchronize { @driver.parse(data) }
|
|
102
153
|
end
|
|
103
154
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
104
155
|
@messages.close
|