ferrum 0.14 → 0.15

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.
@@ -47,6 +47,10 @@ module Ferrum
47
47
  [path] + @flags.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
48
48
  end
49
49
 
50
+ def to_s
51
+ to_a.join(" \\ \n ")
52
+ end
53
+
50
54
  private
51
55
 
52
56
  def merge_options
@@ -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, :timeout, :logger, :ws_max_receive_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, :extensions, :proxy, :port, :host, :headless,
19
- :ignore_default_browser_options, :browser_options, :xvfb
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, 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
- @ws_max_receive_size, @env, @browser_name, @browser_path,
35
- @save_path, @extensions, @ignore_default_browser_options, @xvfb = @options.values_at(
36
- :ws_max_receive_size, :env, :browser_name, :browser_path, :save_path, :extensions,
37
- :ignore_default_browser_options, :xvfb
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[:window_size] = @window_size
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 to_h
51
- @options
53
+ def base_url=(value)
54
+ @base_url = parse_base_url(value)
52
55
  end
53
56
 
54
- def parse_base_url(value)
55
- parsed = Addressable::URI.parse(value)
56
- unless BASE_URL_SCHEMA.include?(parsed&.normalized_scheme)
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 parse_proxy(options)
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
@@ -62,11 +62,15 @@ module Ferrum
62
62
  def initialize(options)
63
63
  @pid = @xvfb = @user_data_dir = nil
64
64
 
65
+ if options.ws_url
66
+ response = parse_json_version(options.ws_url)
67
+ self.ws_url = response&.[]("webSocketDebuggerUrl") || options.ws_url
68
+ return
69
+ end
70
+
65
71
  if options.url
66
- url = URI.join(options.url, "/json/version")
67
- response = JSON.parse(::Net::HTTP.get(url))
68
- self.ws_url = response["webSocketDebuggerUrl"]
69
- parse_browser_versions
72
+ response = parse_json_version(options.url)
73
+ self.ws_url = response&.[]("webSocketDebuggerUrl")
70
74
  return
71
75
  end
72
76
 
@@ -100,7 +104,7 @@ module Ferrum
100
104
  ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
101
105
 
102
106
  parse_ws_url(read_io, @process_timeout)
103
- parse_browser_versions
107
+ parse_json_version(ws_url)
104
108
  ensure
105
109
  close_io(read_io, write_io)
106
110
  end
@@ -122,6 +126,17 @@ module Ferrum
122
126
  start
123
127
  end
124
128
 
129
+ def inspect
130
+ "#<#{self.class} " \
131
+ "@user_data_dir=#{@user_data_dir.inspect} " \
132
+ "@command=#<#{@command.class}:#{@command.object_id}> " \
133
+ "@default_user_agent=#{@default_user_agent.inspect} " \
134
+ "@ws_url=#{@ws_url.inspect} " \
135
+ "@v8_version=#{@v8_version.inspect} " \
136
+ "@browser_version=#{@browser_version.inspect} " \
137
+ "@webkit_version=#{@webkit_version.inspect}>"
138
+ end
139
+
125
140
  private
126
141
 
127
142
  def kill(pid)
@@ -163,25 +178,37 @@ module Ferrum
163
178
  @port = @ws_url.port
164
179
  end
165
180
 
166
- def parse_browser_versions
167
- return unless ws_url.is_a?(Addressable::URI)
181
+ def close_io(*ios)
182
+ ios.each do |io|
183
+ io.close unless io.closed?
184
+ rescue IOError
185
+ raise unless RUBY_ENGINE == "jruby"
186
+ end
187
+ end
188
+
189
+ def parse_json_version(url)
190
+ url = URI.join(url, "/json/version")
191
+
192
+ if %w[wss ws].include?(url.scheme)
193
+ url.scheme = case url.scheme
194
+ when "ws"
195
+ "http"
196
+ when "wss"
197
+ "https"
198
+ end
199
+ end
168
200
 
169
- version_url = URI.parse(ws_url.merge(scheme: "http", path: "/json/version"))
170
- response = JSON.parse(::Net::HTTP.get(version_url))
201
+ response = JSON.parse(::Net::HTTP.get(URI(url.to_s)))
171
202
 
172
203
  @v8_version = response["V8-Version"]
173
204
  @browser_version = response["Browser"]
174
205
  @webkit_version = response["WebKit-Version"]
175
206
  @default_user_agent = response["User-Agent"]
176
207
  @protocol_version = response["Protocol-Version"]
177
- end
178
208
 
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
209
+ response
210
+ rescue StandardError
211
+ # nop
185
212
  end
186
213
  end
187
214
  end
@@ -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=] => :page
31
- delegate %i[default_user_agent] => :process
30
+ playback_rate playback_rate=
31
+ disable_javascript set_viewport resize] => :page
32
32
 
33
- attr_reader :client, :process, :contexts, :options, :window_size, :base_url
34
- attr_accessor :timeout
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.parse_proxy(proxy)
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,16 +211,13 @@ 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
- def resize(**options)
248
- @window_size = [options[:width], options[:height]]
249
- page.resize(**options)
250
- end
251
-
252
221
  def crash
253
222
  command("Browser.crash")
254
223
  end
@@ -272,11 +241,18 @@ module Ferrum
272
241
 
273
242
  def start
274
243
  Utils::ElapsedTime.start
275
- @process = Process.start(options)
276
- @client = Client.new(@process.ws_url, self,
277
- logger: options.logger,
278
- ws_max_receive_size: options.ws_max_receive_size)
279
- @contexts = Contexts.new(self)
244
+ @process = Process.new(options)
245
+
246
+ begin
247
+ @process.start
248
+ @options.default_user_agent = @process.default_user_agent
249
+
250
+ @client = Client.new(@process.ws_url, options)
251
+ @contexts = Contexts.new(@client)
252
+ rescue StandardError
253
+ @process.stop
254
+ raise
255
+ end
280
256
  end
281
257
  end
282
258
  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
@@ -5,18 +5,30 @@ require "socket"
5
5
  require "websocket/driver"
6
6
 
7
7
  module Ferrum
8
- class Browser
8
+ class Client
9
9
  class WebSocket
10
10
  WEBSOCKET_BUG_SLEEP = 0.05
11
+ DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
11
12
  SKIP_LOGGING_SCREENSHOTS = !ENV["FERRUM_LOGGING_SCREENSHOTS"]
12
13
 
13
14
  attr_reader :url, :messages
14
15
 
15
16
  def initialize(url, max_receive_size, logger)
16
- @url = url
17
- @logger = logger
18
- uri = URI.parse(@url)
19
- @sock = TCPSocket.new(uri.host, uri.port)
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
+
20
32
  max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
21
33
  @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
22
34
  @messages = Queue.new
@@ -27,21 +39,7 @@ module Ferrum
27
39
  @driver.on(:message, &method(:on_message))
28
40
  @driver.on(:close, &method(:on_close))
29
41
 
30
- @thread = Thread.new do
31
- Thread.current.abort_on_exception = true
32
- Thread.current.report_on_exception = true if Thread.current.respond_to?(:report_on_exception=)
33
-
34
- begin
35
- loop do
36
- data = @sock.readpartial(512)
37
- break unless data
38
-
39
- @driver.parse(data)
40
- end
41
- rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
42
- @messages.close
43
- end
44
- end
42
+ start
45
43
 
46
44
  @driver.start
47
45
  end
@@ -66,6 +64,7 @@ module Ferrum
66
64
 
67
65
  def on_close(_event)
68
66
  @messages.close
67
+ @sock.close
69
68
  @thread.kill
70
69
  end
71
70
 
@@ -79,13 +78,28 @@ module Ferrum
79
78
 
80
79
  def write(data)
81
80
  @sock.write(data)
82
- rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
81
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
83
82
  @messages.close
84
83
  end
85
84
 
86
85
  def close
87
86
  @driver.close
88
87
  end
88
+
89
+ private
90
+
91
+ def start
92
+ @thread = Utils::Thread.spawn do
93
+ loop do
94
+ data = @sock.readpartial(512)
95
+ break unless data
96
+
97
+ @driver.parse(data)
98
+ end
99
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
100
+ @messages.close
101
+ end
102
+ end
89
103
  end
90
104
  end
91
105
  end