ferrum 0.14 → 0.16
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 +309 -158
- data/lib/ferrum/browser/command.rb +4 -0
- data/lib/ferrum/browser/options/chrome.rb +8 -3
- data/lib/ferrum/browser/options.rb +38 -25
- data/lib/ferrum/browser/process.rb +42 -17
- data/lib/ferrum/browser.rb +38 -50
- data/lib/ferrum/client/subscriber.rb +76 -0
- data/lib/ferrum/client/web_socket.rb +126 -0
- data/lib/ferrum/client.rb +171 -0
- data/lib/ferrum/context.rb +19 -15
- data/lib/ferrum/contexts.rb +46 -12
- data/lib/ferrum/cookies.rb +28 -1
- data/lib/ferrum/downloads.rb +60 -0
- data/lib/ferrum/errors.rb +10 -3
- data/lib/ferrum/headers.rb +1 -1
- data/lib/ferrum/network/exchange.rb +10 -1
- data/lib/ferrum/network/intercepted_request.rb +5 -5
- data/lib/ferrum/network/request.rb +9 -0
- data/lib/ferrum/network.rb +36 -24
- data/lib/ferrum/node.rb +11 -0
- data/lib/ferrum/page/frames.rb +7 -9
- data/lib/ferrum/page/screenshot.rb +54 -28
- data/lib/ferrum/page.rb +192 -118
- data/lib/ferrum/proxy.rb +1 -1
- data/lib/ferrum/target.rb +25 -5
- data/lib/ferrum/utils/elapsed_time.rb +0 -2
- data/lib/ferrum/utils/event.rb +19 -0
- data/lib/ferrum/utils/platform.rb +4 -0
- data/lib/ferrum/utils/thread.rb +18 -0
- data/lib/ferrum/version.rb +1 -1
- data/lib/ferrum.rb +3 -0
- metadata +28 -17
- data/lib/ferrum/browser/client.rb +0 -103
- data/lib/ferrum/browser/subscriber.rb +0 -36
- data/lib/ferrum/browser/web_socket.rb +0 -91
@@ -6,7 +6,6 @@ module Ferrum
|
|
6
6
|
class Chrome < Base
|
7
7
|
DEFAULT_OPTIONS = {
|
8
8
|
"headless" => nil,
|
9
|
-
"disable-gpu" => nil,
|
10
9
|
"hide-scrollbars" => nil,
|
11
10
|
"mute-audio" => nil,
|
12
11
|
"enable-automation" => nil,
|
@@ -38,7 +37,8 @@ module Ferrum
|
|
38
37
|
"metrics-recording-only" => nil,
|
39
38
|
"safebrowsing-disable-auto-update" => nil,
|
40
39
|
"password-store" => "basic",
|
41
|
-
"no-startup-window" => nil
|
40
|
+
"no-startup-window" => nil,
|
41
|
+
"remote-allow-origins" => "*"
|
42
42
|
# NOTE: --no-sandbox is not needed if you properly setup a user in the container.
|
43
43
|
# https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
|
44
44
|
# "no-sandbox" => nil,
|
@@ -63,7 +63,6 @@ module Ferrum
|
|
63
63
|
def merge_required(flags, options, user_data_dir)
|
64
64
|
flags = flags.merge("remote-debugging-port" => options.port,
|
65
65
|
"remote-debugging-address" => options.host,
|
66
|
-
# Doesn't work on MacOS, so we need to set it by CDP
|
67
66
|
"window-size" => options.window_size&.join(","),
|
68
67
|
"user-data-dir" => user_data_dir)
|
69
68
|
|
@@ -84,6 +83,12 @@ module Ferrum
|
|
84
83
|
end
|
85
84
|
|
86
85
|
defaults ||= DEFAULT_OPTIONS
|
86
|
+
# On Windows, the --disable-gpu flag is a temporary work around for a few bugs.
|
87
|
+
# See https://bugs.chromium.org/p/chromium/issues/detail?id=737678 for more information.
|
88
|
+
defaults = defaults.merge("disable-gpu" => nil) if Utils::Platform.windows?
|
89
|
+
# Use Metal on Apple Silicon
|
90
|
+
# https://github.com/google/angle#platform-support-via-backing-renderers
|
91
|
+
defaults = defaults.merge("use-angle" => "metal") if Utils::Platform.mac_arm?
|
87
92
|
defaults.merge(flags)
|
88
93
|
end
|
89
94
|
end
|
@@ -3,7 +3,6 @@
|
|
3
3
|
module Ferrum
|
4
4
|
class Browser
|
5
5
|
class Options
|
6
|
-
HEADLESS = true
|
7
6
|
BROWSER_PORT = "0"
|
8
7
|
BROWSER_HOST = "127.0.0.1"
|
9
8
|
WINDOW_SIZE = [1024, 768].freeze
|
@@ -12,55 +11,56 @@ module Ferrum
|
|
12
11
|
PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
|
13
12
|
DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil?
|
14
13
|
|
15
|
-
attr_reader :window_size, :
|
14
|
+
attr_reader :window_size, :logger, :ws_max_receive_size,
|
16
15
|
:js_errors, :base_url, :slowmo, :pending_connection_errors,
|
17
|
-
:url, :env, :process_timeout, :browser_name, :browser_path,
|
18
|
-
:save_path, :
|
19
|
-
:ignore_default_browser_options, :
|
16
|
+
:url, :ws_url, :env, :process_timeout, :browser_name, :browser_path,
|
17
|
+
:save_path, :proxy, :port, :host, :headless, :browser_options,
|
18
|
+
:ignore_default_browser_options, :xvfb, :flatten
|
19
|
+
attr_accessor :timeout, :default_user_agent
|
20
20
|
|
21
21
|
def initialize(options = nil)
|
22
22
|
@options = Hash(options&.dup)
|
23
|
+
|
23
24
|
@port = @options.fetch(:port, BROWSER_PORT)
|
24
25
|
@host = @options.fetch(:host, BROWSER_HOST)
|
25
26
|
@timeout = @options.fetch(:timeout, DEFAULT_TIMEOUT)
|
26
27
|
@window_size = @options.fetch(:window_size, WINDOW_SIZE)
|
27
28
|
@js_errors = @options.fetch(:js_errors, false)
|
28
|
-
@headless = @options.fetch(:headless,
|
29
|
+
@headless = @options.fetch(:headless, true)
|
30
|
+
@flatten = @options.fetch(:flatten, true)
|
29
31
|
@pending_connection_errors = @options.fetch(:pending_connection_errors, true)
|
30
32
|
@process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
|
31
|
-
@browser_options = @options.fetch(:browser_options, {})
|
32
33
|
@slowmo = @options[:slowmo].to_f
|
33
34
|
|
34
|
-
@
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
35
|
+
@env = @options[:env]
|
36
|
+
@xvfb = @options[:xvfb]
|
37
|
+
@save_path = @options[:save_path]
|
38
|
+
@browser_name = @options[:browser_name]
|
39
|
+
@browser_path = @options[:browser_path]
|
40
|
+
@ws_max_receive_size = @options[:ws_max_receive_size]
|
41
|
+
@ignore_default_browser_options = @options[:ignore_default_browser_options]
|
39
42
|
|
40
|
-
@options[:
|
41
|
-
@proxy = parse_proxy(@options[:proxy])
|
43
|
+
@proxy = validate_proxy(@options[:proxy])
|
42
44
|
@logger = parse_logger(@options[:logger])
|
43
45
|
@base_url = parse_base_url(@options[:base_url]) if @options[:base_url]
|
44
46
|
@url = @options[:url].to_s if @options[:url]
|
47
|
+
@ws_url = @options[:ws_url].to_s if @options[:ws_url]
|
45
48
|
|
46
|
-
@options.freeze
|
47
|
-
@browser_options.freeze
|
49
|
+
@options = @options.merge(window_size: @window_size).freeze
|
50
|
+
@browser_options = @options.fetch(:browser_options, {}).freeze
|
48
51
|
end
|
49
52
|
|
50
|
-
def
|
51
|
-
@
|
53
|
+
def base_url=(value)
|
54
|
+
@base_url = parse_base_url(value)
|
52
55
|
end
|
53
56
|
|
54
|
-
def
|
55
|
-
|
56
|
-
|
57
|
-
raise ArgumentError, "`base_url` should be absolute and include schema: #{BASE_URL_SCHEMA.join(' | ')}"
|
57
|
+
def extensions
|
58
|
+
@extensions ||= Array(@options[:extensions]).map do |extension|
|
59
|
+
(extension.is_a?(Hash) && extension[:source]) || File.read(extension)
|
58
60
|
end
|
59
|
-
|
60
|
-
parsed
|
61
61
|
end
|
62
62
|
|
63
|
-
def
|
63
|
+
def validate_proxy(options)
|
64
64
|
return unless options
|
65
65
|
|
66
66
|
raise ArgumentError, "proxy options must be a Hash" unless options.is_a?(Hash)
|
@@ -72,6 +72,10 @@ module Ferrum
|
|
72
72
|
options
|
73
73
|
end
|
74
74
|
|
75
|
+
def to_h
|
76
|
+
@options
|
77
|
+
end
|
78
|
+
|
75
79
|
private
|
76
80
|
|
77
81
|
def parse_logger(logger)
|
@@ -79,6 +83,15 @@ module Ferrum
|
|
79
83
|
|
80
84
|
!logger && DEBUG_MODE ? $stdout.tap { |s| s.sync = true } : logger
|
81
85
|
end
|
86
|
+
|
87
|
+
def parse_base_url(value)
|
88
|
+
parsed = Addressable::URI.parse(value)
|
89
|
+
unless BASE_URL_SCHEMA.include?(parsed&.normalized_scheme)
|
90
|
+
raise ArgumentError, "`base_url` should be absolute and include schema: #{BASE_URL_SCHEMA.join(' | ')}"
|
91
|
+
end
|
92
|
+
|
93
|
+
parsed
|
94
|
+
end
|
82
95
|
end
|
83
96
|
end
|
84
97
|
end
|
@@ -9,6 +9,8 @@ require "ferrum/browser/options/base"
|
|
9
9
|
require "ferrum/browser/options/chrome"
|
10
10
|
require "ferrum/browser/options/firefox"
|
11
11
|
require "ferrum/browser/command"
|
12
|
+
require "ferrum/utils/elapsed_time"
|
13
|
+
require "ferrum/utils/platform"
|
12
14
|
|
13
15
|
module Ferrum
|
14
16
|
class Browser
|
@@ -62,11 +64,11 @@ module Ferrum
|
|
62
64
|
def initialize(options)
|
63
65
|
@pid = @xvfb = @user_data_dir = nil
|
64
66
|
|
65
|
-
if options.url
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
67
|
+
if options.ws_url || options.url
|
68
|
+
# `:ws_url` option is higher priority than `:url`, parse versions
|
69
|
+
# and use it as a ws_url, otherwise use what has been parsed.
|
70
|
+
response = parse_json_version(options.ws_url || options.url)
|
71
|
+
self.ws_url = options.ws_url || response&.[]("webSocketDebuggerUrl")
|
70
72
|
return
|
71
73
|
end
|
72
74
|
|
@@ -100,7 +102,7 @@ module Ferrum
|
|
100
102
|
ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
|
101
103
|
|
102
104
|
parse_ws_url(read_io, @process_timeout)
|
103
|
-
|
105
|
+
parse_json_version(ws_url)
|
104
106
|
ensure
|
105
107
|
close_io(read_io, write_io)
|
106
108
|
end
|
@@ -122,6 +124,17 @@ module Ferrum
|
|
122
124
|
start
|
123
125
|
end
|
124
126
|
|
127
|
+
def inspect
|
128
|
+
"#<#{self.class} " \
|
129
|
+
"@user_data_dir=#{@user_data_dir.inspect} " \
|
130
|
+
"@command=#<#{@command.class}:#{@command.object_id}> " \
|
131
|
+
"@default_user_agent=#{@default_user_agent.inspect} " \
|
132
|
+
"@ws_url=#{@ws_url.inspect} " \
|
133
|
+
"@v8_version=#{@v8_version.inspect} " \
|
134
|
+
"@browser_version=#{@browser_version.inspect} " \
|
135
|
+
"@webkit_version=#{@webkit_version.inspect}>"
|
136
|
+
end
|
137
|
+
|
125
138
|
private
|
126
139
|
|
127
140
|
def kill(pid)
|
@@ -163,25 +176,37 @@ module Ferrum
|
|
163
176
|
@port = @ws_url.port
|
164
177
|
end
|
165
178
|
|
166
|
-
def
|
167
|
-
|
179
|
+
def close_io(*ios)
|
180
|
+
ios.each do |io|
|
181
|
+
io.close unless io.closed?
|
182
|
+
rescue IOError
|
183
|
+
raise unless RUBY_ENGINE == "jruby"
|
184
|
+
end
|
185
|
+
end
|
168
186
|
|
169
|
-
|
170
|
-
|
187
|
+
def parse_json_version(url)
|
188
|
+
url = URI.join(url, "/json/version")
|
189
|
+
|
190
|
+
if %w[wss ws].include?(url.scheme)
|
191
|
+
url.scheme = case url.scheme
|
192
|
+
when "ws"
|
193
|
+
"http"
|
194
|
+
when "wss"
|
195
|
+
"https"
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
response = JSON.parse(::Net::HTTP.get(URI(url.to_s)))
|
171
200
|
|
172
201
|
@v8_version = response["V8-Version"]
|
173
202
|
@browser_version = response["Browser"]
|
174
203
|
@webkit_version = response["WebKit-Version"]
|
175
204
|
@default_user_agent = response["User-Agent"]
|
176
205
|
@protocol_version = response["Protocol-Version"]
|
177
|
-
end
|
178
206
|
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
rescue IOError
|
183
|
-
raise unless RUBY_ENGINE == "jruby"
|
184
|
-
end
|
207
|
+
response
|
208
|
+
rescue JSON::ParserError
|
209
|
+
# nop
|
185
210
|
end
|
186
211
|
end
|
187
212
|
end
|
data/lib/ferrum/browser.rb
CHANGED
@@ -4,11 +4,11 @@ require "base64"
|
|
4
4
|
require "forwardable"
|
5
5
|
require "ferrum/page"
|
6
6
|
require "ferrum/proxy"
|
7
|
+
require "ferrum/client"
|
7
8
|
require "ferrum/contexts"
|
8
9
|
require "ferrum/browser/xvfb"
|
9
10
|
require "ferrum/browser/options"
|
10
11
|
require "ferrum/browser/process"
|
11
|
-
require "ferrum/browser/client"
|
12
12
|
require "ferrum/browser/binary"
|
13
13
|
require "ferrum/browser/version_info"
|
14
14
|
|
@@ -20,18 +20,20 @@ module Ferrum
|
|
20
20
|
delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
|
21
21
|
at_css at_xpath css xpath current_url current_title url title
|
22
22
|
body doctype content=
|
23
|
-
headers cookies network
|
23
|
+
headers cookies network downloads
|
24
24
|
mouse keyboard
|
25
25
|
screenshot pdf mhtml viewport_size device_pixel_ratio
|
26
26
|
frames frame_by main_frame
|
27
27
|
evaluate evaluate_on evaluate_async execute evaluate_func
|
28
28
|
add_script_tag add_style_tag bypass_csp
|
29
29
|
on position position=
|
30
|
-
playback_rate playback_rate=
|
31
|
-
|
30
|
+
playback_rate playback_rate=
|
31
|
+
disable_javascript set_viewport resize] => :page
|
32
32
|
|
33
|
-
attr_reader :client, :process, :contexts, :options
|
34
|
-
|
33
|
+
attr_reader :client, :process, :contexts, :options
|
34
|
+
|
35
|
+
delegate %i[timeout timeout= base_url base_url= default_user_agent default_user_agent= extensions] => :options
|
36
|
+
delegate %i[command] => :client
|
35
37
|
|
36
38
|
#
|
37
39
|
# Initializes the browser.
|
@@ -45,6 +47,9 @@ module Ferrum
|
|
45
47
|
# @option options [Boolean] :xvfb (false)
|
46
48
|
# Run browser in a virtual framebuffer.
|
47
49
|
#
|
50
|
+
# @option options [Boolean] :flatten (true)
|
51
|
+
# Use one websocket connection to the browser and all the pages in flatten mode.
|
52
|
+
#
|
48
53
|
# @option options [(Integer, Integer)] :window_size ([1024, 768])
|
49
54
|
# The dimensions of the browser window in which to test, expressed as a
|
50
55
|
# 2-element array, e.g. `[1024, 768]`.
|
@@ -124,28 +129,9 @@ module Ferrum
|
|
124
129
|
@options = Options.new(options)
|
125
130
|
@client = @process = @contexts = nil
|
126
131
|
|
127
|
-
@timeout = @options.timeout
|
128
|
-
@window_size = @options.window_size
|
129
|
-
@base_url = @options.base_url if @options.base_url
|
130
|
-
|
131
132
|
start
|
132
133
|
end
|
133
134
|
|
134
|
-
#
|
135
|
-
# Sets the base URL.
|
136
|
-
#
|
137
|
-
# @param [String] value
|
138
|
-
# The new base URL value.
|
139
|
-
#
|
140
|
-
# @raise [ArgumentError] when path is not absolute or doesn't include schema
|
141
|
-
#
|
142
|
-
# @return [Addressable::URI]
|
143
|
-
# The parsed base URI value.
|
144
|
-
#
|
145
|
-
def base_url=(value)
|
146
|
-
@base_url = options.parse_base_url(value)
|
147
|
-
end
|
148
|
-
|
149
135
|
#
|
150
136
|
# Creates a new page.
|
151
137
|
#
|
@@ -163,7 +149,7 @@ module Ferrum
|
|
163
149
|
params = {}
|
164
150
|
|
165
151
|
if proxy
|
166
|
-
options.
|
152
|
+
options.validate_proxy(proxy)
|
167
153
|
params.merge!(proxyServer: "#{proxy[:host]}:#{proxy[:port]}")
|
168
154
|
params.merge!(proxyBypassList: proxy[:bypass]) if proxy[:bypass]
|
169
155
|
end
|
@@ -182,12 +168,6 @@ module Ferrum
|
|
182
168
|
end
|
183
169
|
end
|
184
170
|
|
185
|
-
def extensions
|
186
|
-
@extensions ||= Array(options.extensions).map do |ext|
|
187
|
-
(ext.is_a?(Hash) && ext[:source]) || File.read(ext)
|
188
|
-
end
|
189
|
-
end
|
190
|
-
|
191
171
|
#
|
192
172
|
# Evaluate JavaScript to modify things before a page load.
|
193
173
|
#
|
@@ -205,13 +185,6 @@ module Ferrum
|
|
205
185
|
extensions << expression
|
206
186
|
end
|
207
187
|
|
208
|
-
def command(*args)
|
209
|
-
@client.command(*args)
|
210
|
-
rescue DeadBrowserError
|
211
|
-
restart
|
212
|
-
raise
|
213
|
-
end
|
214
|
-
|
215
188
|
#
|
216
189
|
# Closes browser tabs opened by the `Browser` instance.
|
217
190
|
#
|
@@ -227,7 +200,6 @@ module Ferrum
|
|
227
200
|
# browser.quit
|
228
201
|
#
|
229
202
|
def reset
|
230
|
-
@window_size = options.window_size
|
231
203
|
contexts.reset
|
232
204
|
end
|
233
205
|
|
@@ -239,20 +211,29 @@ module Ferrum
|
|
239
211
|
def quit
|
240
212
|
return unless @client
|
241
213
|
|
214
|
+
contexts.close_connections
|
215
|
+
|
242
216
|
@client.close
|
243
217
|
@process.stop
|
244
218
|
@client = @process = @contexts = nil
|
245
219
|
end
|
246
220
|
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
end
|
251
|
-
|
221
|
+
#
|
222
|
+
# Crashes browser.
|
223
|
+
#
|
252
224
|
def crash
|
253
225
|
command("Browser.crash")
|
254
226
|
end
|
255
227
|
|
228
|
+
#
|
229
|
+
# Close browser gracefully.
|
230
|
+
#
|
231
|
+
# You should clean up resources/connections in ruby world manually, it's only a CDP command.
|
232
|
+
#
|
233
|
+
def close
|
234
|
+
command("Browser.close")
|
235
|
+
end
|
236
|
+
|
256
237
|
#
|
257
238
|
# Gets the version information from the browser.
|
258
239
|
#
|
@@ -272,11 +253,18 @@ module Ferrum
|
|
272
253
|
|
273
254
|
def start
|
274
255
|
Utils::ElapsedTime.start
|
275
|
-
@process = Process.
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
256
|
+
@process = Process.new(options)
|
257
|
+
|
258
|
+
begin
|
259
|
+
@process.start
|
260
|
+
@options.default_user_agent = @process.default_user_agent
|
261
|
+
|
262
|
+
@client = Client.new(@process.ws_url, options)
|
263
|
+
@contexts = Contexts.new(@client)
|
264
|
+
rescue StandardError
|
265
|
+
@process.stop
|
266
|
+
raise
|
267
|
+
end
|
280
268
|
end
|
281
269
|
end
|
282
270
|
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ferrum
|
4
|
+
class Client
|
5
|
+
class Subscriber
|
6
|
+
INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@regular = Queue.new
|
10
|
+
@priority = Queue.new
|
11
|
+
@on = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
|
12
|
+
|
13
|
+
start
|
14
|
+
end
|
15
|
+
|
16
|
+
def <<(message)
|
17
|
+
if INTERRUPTIONS.include?(message["method"])
|
18
|
+
@priority.push(message)
|
19
|
+
else
|
20
|
+
@regular.push(message)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def on(event, &block)
|
25
|
+
@on[event] << block
|
26
|
+
true
|
27
|
+
end
|
28
|
+
|
29
|
+
def subscribed?(event)
|
30
|
+
@on.key?(event)
|
31
|
+
end
|
32
|
+
|
33
|
+
def close
|
34
|
+
@regular_thread&.kill
|
35
|
+
@priority_thread&.kill
|
36
|
+
end
|
37
|
+
|
38
|
+
def clear(session_id:)
|
39
|
+
@on.delete_if { |k, _| k.match?(session_id) }
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def start
|
45
|
+
@regular_thread = Utils::Thread.spawn(abort_on_exception: false) do
|
46
|
+
loop do
|
47
|
+
message = @regular.pop
|
48
|
+
break unless message
|
49
|
+
|
50
|
+
call(message)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
@priority_thread = Utils::Thread.spawn(abort_on_exception: false) do
|
55
|
+
loop do
|
56
|
+
message = @priority.pop
|
57
|
+
break unless message
|
58
|
+
|
59
|
+
call(message)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def call(message)
|
65
|
+
method, session_id, params = message.values_at("method", "sessionId", "params")
|
66
|
+
event = SessionClient.event_name(method, session_id)
|
67
|
+
|
68
|
+
total = @on[event].size
|
69
|
+
@on[event].each_with_index do |block, index|
|
70
|
+
# In case of multiple callbacks we provide current index and total
|
71
|
+
block.call(params, index, total)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "socket"
|
5
|
+
require "websocket/driver"
|
6
|
+
|
7
|
+
module Ferrum
|
8
|
+
class Client
|
9
|
+
class WebSocket
|
10
|
+
WEBSOCKET_BUG_SLEEP = 0.05
|
11
|
+
DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
|
12
|
+
SKIP_LOGGING_SCREENSHOTS = !ENV["FERRUM_LOGGING_SCREENSHOTS"]
|
13
|
+
|
14
|
+
attr_reader :url, :messages
|
15
|
+
|
16
|
+
def initialize(url, max_receive_size, logger)
|
17
|
+
@url = url
|
18
|
+
@logger = logger
|
19
|
+
uri = URI.parse(@url)
|
20
|
+
port = uri.port || DEFAULT_PORTS[uri.scheme]
|
21
|
+
|
22
|
+
if port == 443
|
23
|
+
tcp = TCPSocket.new(uri.host, port)
|
24
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
25
|
+
@sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
|
26
|
+
@sock.sync_close = true
|
27
|
+
@sock.connect
|
28
|
+
else
|
29
|
+
@sock = TCPSocket.new(uri.host, port)
|
30
|
+
end
|
31
|
+
|
32
|
+
max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
|
33
|
+
@driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
|
34
|
+
@messages = Queue.new
|
35
|
+
|
36
|
+
@screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
|
37
|
+
|
38
|
+
@driver.on(:open, &method(:on_open))
|
39
|
+
@driver.on(:message, &method(:on_message))
|
40
|
+
@driver.on(:close, &method(:on_close))
|
41
|
+
|
42
|
+
start
|
43
|
+
|
44
|
+
@driver.start
|
45
|
+
end
|
46
|
+
|
47
|
+
def on_open(_event)
|
48
|
+
# https://github.com/faye/websocket-driver-ruby/issues/46
|
49
|
+
sleep(WEBSOCKET_BUG_SLEEP)
|
50
|
+
end
|
51
|
+
|
52
|
+
def on_message(event)
|
53
|
+
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
|
+
output = event.data
|
61
|
+
if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")]
|
62
|
+
@screenshot_commands.delete(data&.dig("id"))
|
63
|
+
output.sub!(/{"data":"[^"]*"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64"))
|
64
|
+
end
|
65
|
+
|
66
|
+
@logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
|
67
|
+
end
|
68
|
+
|
69
|
+
def on_close(_event)
|
70
|
+
@messages.close
|
71
|
+
@sock.close
|
72
|
+
@thread.kill
|
73
|
+
end
|
74
|
+
|
75
|
+
def send_message(data)
|
76
|
+
@screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
|
77
|
+
|
78
|
+
json = data.to_json
|
79
|
+
@driver.text(json)
|
80
|
+
@logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
|
81
|
+
end
|
82
|
+
|
83
|
+
def write(data)
|
84
|
+
@sock.write(data)
|
85
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
86
|
+
@messages.close
|
87
|
+
end
|
88
|
+
|
89
|
+
def close
|
90
|
+
@driver.close
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def start
|
96
|
+
@thread = Utils::Thread.spawn do
|
97
|
+
loop do
|
98
|
+
data = @sock.readpartial(512)
|
99
|
+
break unless data
|
100
|
+
|
101
|
+
@driver.parse(data)
|
102
|
+
end
|
103
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
104
|
+
@messages.close
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def safely_parse_json(data)
|
109
|
+
JSON.parse(data, max_nesting: false)
|
110
|
+
rescue JSON::NestingError
|
111
|
+
# nop
|
112
|
+
rescue JSON::ParserError
|
113
|
+
safely_parse_escaped_json(data)
|
114
|
+
end
|
115
|
+
|
116
|
+
def safely_parse_escaped_json(data)
|
117
|
+
unescaped_unicode =
|
118
|
+
data.gsub(/\\u([\da-fA-F]{4})/) { |_| [::Regexp.last_match(1)].pack("H*").unpack("n*").pack("U*") }
|
119
|
+
escaped_data = unescaped_unicode.encode("UTF-8", "UTF-8", undef: :replace, invalid: :replace, replace: "?")
|
120
|
+
JSON.parse(escaped_data, max_nesting: false)
|
121
|
+
rescue JSON::ParserError
|
122
|
+
# nop
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|